CloudFormationTemplateDiff.java

package com.cloudforge.core.local;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/** Semantic resource-level diff for canonical CloudFormation templates. */
public final class CloudFormationTemplateDiff {
    private CloudFormationTemplateDiff() {
    }

    public enum Action {
        ADD,
        MODIFY,
        REMOVE
    }

    public record ResourceChange(Action action, String logicalId, String resourceType) {
    }

    public static List<ResourceChange> diff(ObjectNode before, ObjectNode after) {
        ObjectNode beforeResources = resources(before);
        ObjectNode afterResources = resources(after);
        Set<String> logicalIds = new LinkedHashSet<>();
        beforeResources.fieldNames().forEachRemaining(logicalIds::add);
        afterResources.fieldNames().forEachRemaining(logicalIds::add);

        List<ResourceChange> changes = new ArrayList<>();
        for (String logicalId : logicalIds) {
            // logicalId is drawn from the union of both resource maps' field names, so exactly
            // one of oldResource/newResource can be null here, never the one being read below —
            // guarded defensively anyway since ObjectNode#get(String) returning null on a
            // missing key isn't obvious from this method's own signature alone.
            JsonNode oldResource = beforeResources.get(logicalId);
            JsonNode newResource = afterResources.get(logicalId);
            if (oldResource == null) {
                changes.add(new ResourceChange(
                    Action.ADD, logicalId, orMissing(newResource).path("Type").asText()));
            } else if (newResource == null) {
                changes.add(new ResourceChange(
                    Action.REMOVE, logicalId, orMissing(oldResource).path("Type").asText()));
            } else if (!oldResource.equals(newResource)) {
                changes.add(new ResourceChange(
                    Action.MODIFY, logicalId, newResource.path("Type").asText()));
            }
        }
        return List.copyOf(changes);
    }

    private static ObjectNode resources(ObjectNode template) {
        JsonNode node = template.get("Resources");
        return node instanceof ObjectNode objectNode
            ? objectNode
            : template.objectNode();
    }

    private static JsonNode orMissing(JsonNode node) {
        return node == null ? com.fasterxml.jackson.databind.node.MissingNode.getInstance() : node;
    }
}