FoundationDriftChecker.java

package network.ike.plugin.scaffold;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Compare a project's POM against the IKE-foundation pins captured
 * in a scaffold manifest's {@code foundation:} section (#345).
 *
 * <p>The scaffold zip embeds the parent + property values that
 * {@code ike-tooling N} saw as the latest-released at its release
 * moment. Picking up a newer scaffold means picking up that
 * tested-together compatibility snapshot. This class identifies the
 * deltas: where the consumer's POM lags behind, where it's ahead
 * (operator manually bumped), and where it's already in sync.
 *
 * <p>Pure-function — no I/O once a {@code pomContent} string is
 * provided. The {@link #checkPomFile} convenience reads from disk
 * for callers who want path-based access.
 *
 * @see ScaffoldManifest.Foundation
 */
public final class FoundationDriftChecker {

    private FoundationDriftChecker() {}

    /**
     * Compute the drift between a POM file's parent + properties
     * and the scaffold manifest's {@code foundation:} pins.
     *
     * @param pomFile    path to the project's pom.xml
     * @param foundation the scaffold manifest's foundation pins; if
     *                   {@code null}, returns an empty drift list
     * @return ordered list of {@link Entry} records, one per
     *         drifted or aligned property/parent comparison
     * @throws IOException if the POM cannot be read
     */
    public static List<Entry> checkPomFile(
            Path pomFile,
            ScaffoldManifest.Foundation foundation) throws IOException {
        if (foundation == null || !Files.isRegularFile(pomFile)) {
            return List.of();
        }
        String content = Files.readString(pomFile, StandardCharsets.UTF_8);
        return check(content, foundation);
    }

    /**
     * Compute the drift between POM content and foundation pins.
     *
     * @param pomContent the POM XML as a string
     * @param foundation the foundation pins (non-null)
     * @return ordered drift entries
     */
    public static List<Entry> check(String pomContent,
                                     ScaffoldManifest.Foundation foundation) {
        if (pomContent == null || foundation == null) return List.of();

        List<Entry> entries = new ArrayList<>();

        // Parent comparison
        if (foundation.parent() != null) {
            String actualVersion = extractParentVersionMatching(
                    pomContent,
                    foundation.parent().groupId(),
                    foundation.parent().artifactId());
            entries.add(new Entry(
                    Kind.PARENT,
                    foundation.parent().groupId() + ":"
                            + foundation.parent().artifactId(),
                    actualVersion,
                    foundation.parent().version()));
        }

        // Property comparisons
        Map<String, String> projectProps = extractProperties(pomContent);
        for (Map.Entry<String, String> expected
                : foundation.properties().entrySet()) {
            entries.add(new Entry(
                    Kind.PROPERTY,
                    expected.getKey(),
                    projectProps.get(expected.getKey()),
                    expected.getValue()));
        }

        return entries;
    }

    /**
     * Extract the {@code <parent><version>} when the parent's GA
     * matches the given coordinates; otherwise return {@code null}
     * (parent absent OR different GA).
     */
    static String extractParentVersionMatching(String pomContent,
                                                 String groupId,
                                                 String artifactId) {
        var parentMatcher = java.util.regex.Pattern.compile(
                "(?s)<parent\\b[^>]*>(.*?)</parent>").matcher(pomContent);
        if (!parentMatcher.find()) return null;
        String block = parentMatcher.group(1);

        var gMatch = java.util.regex.Pattern.compile(
                "<groupId>\\s*([^<]+?)\\s*</groupId>").matcher(block);
        var aMatch = java.util.regex.Pattern.compile(
                "<artifactId>\\s*([^<]+?)\\s*</artifactId>").matcher(block);
        if (!gMatch.find() || !aMatch.find()) return null;
        if (!gMatch.group(1).trim().equals(groupId)
                || !aMatch.group(1).trim().equals(artifactId)) {
            return null;
        }
        var vMatch = java.util.regex.Pattern.compile(
                "<version>\\s*([^<]+?)\\s*</version>").matcher(block);
        return vMatch.find() ? vMatch.group(1).trim() : null;
    }

    /**
     * Extract project-level {@code <properties>} as a map. Repeated
     * declarations: the last one wins (matches Maven model semantics).
     */
    static Map<String, String> extractProperties(String pomContent) {
        Map<String, String> result = new LinkedHashMap<>();
        var blockMatcher = java.util.regex.Pattern.compile(
                "(?s)<properties>\\s*(.*?)\\s*</properties>")
                .matcher(pomContent);
        while (blockMatcher.find()) {
            String block = blockMatcher.group(1);
            var entryMatcher = java.util.regex.Pattern.compile(
                    "(?s)<([\\w.-]+)>\\s*([^<]*?)\\s*</\\1>")
                    .matcher(block);
            while (entryMatcher.find()) {
                result.put(entryMatcher.group(1),
                        entryMatcher.group(2).trim());
            }
        }
        return result;
    }

    /** Classification of a drift entry. */
    public enum Kind {
        /** Workspace-level {@code <parent>} declaration. */
        PARENT,
        /** Project-level {@code <properties>} declaration. */
        PROPERTY
    }

    /** Classification of a drift entry's state. */
    public enum State {
        /** Project's value equals the foundation's. */
        ALIGNED,
        /** Project's value is absent (foundation knows it; project doesn't). */
        ABSENT,
        /** Project's value differs from the foundation's. */
        DIFFERS
    }

    /**
     * One drift comparison result.
     *
     * @param kind     parent or property
     * @param name     the parent coordinates ({@code groupId:artifactId})
     *                 for {@link Kind#PARENT}, or the property name
     *                 for {@link Kind#PROPERTY}
     * @param actual   the value found in the project's POM, or
     *                 {@code null} when absent
     * @param expected the value the foundation pins to
     */
    public record Entry(Kind kind,
                         String name,
                         String actual,
                         String expected) {

        /**
         * Classify this comparison.
         *
         * @return whether the project is aligned, missing the value,
         *         or differs from the foundation
         */
        public State state() {
            if (actual == null) return State.ABSENT;
            if (actual.equals(expected)) return State.ALIGNED;
            return State.DIFFERS;
        }

        /**
         * True when this entry indicates real drift the operator
         * should act on (DIFFERS or ABSENT).
         *
         * @return true when not aligned
         */
        public boolean isDrifted() {
            return state() != State.ALIGNED;
        }
    }
}