FieldNormalizationReconciler.java

package network.ike.plugin.ws.reconcile;

import network.ike.plugin.ReleaseSupport;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
import org.apache.maven.api.plugin.MojoException;

import java.io.File;
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;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Reconciler that keeps the denormalized {@code workspace.yaml}
 * fields ({@code version}, {@code groupId}) in sync with each cloned
 * subproject's POM.
 *
 * <p>Subsumes the retired {@code ws:fix} goal (IKE-Network/ike-issues#393).
 * The Maven coordinates in {@code workspace.yaml} are denormalized
 * convenience — the authoritative source is each subproject's
 * {@code pom.xml}. This reconciler detects when the manifest has
 * drifted from POM truth and applies the corrections.
 *
 * <p>Only {@code version} and {@code groupId} are touched. Novel
 * fields ({@code repo}, {@code branch}, {@code type}, {@code depends-on})
 * are never modified by this reconciler.
 *
 * <p>Subprojects that are not yet cloned are silently skipped — they
 * have no POM to compare against.
 */
public class FieldNormalizationReconciler implements Reconciler {

    private static final Pattern GROUP_ID_PATTERN =
            Pattern.compile("<groupId>([^<]+)</groupId>");

    @Override
    public String dimension() {
        return "Denormalized YAML fields (version, groupId)";
    }

    @Override
    public String optOutFlag() {
        return "updateFields";
    }

    @Override
    public DriftReport detect(WorkspaceContext ctx) {
        Drift drift = computeDrift(ctx);
        boolean hasDuplicates = hasDuplicateKeys(ctx);
        if (drift.versionUpdates.isEmpty() && drift.groupIdUpdates.isEmpty()
                && !hasDuplicates) {
            return DriftReport.noDrift(dimension());
        }

        List<String> detail = new ArrayList<>();
        for (Map.Entry<String, FieldChange> e : drift.versionUpdates.entrySet()) {
            FieldChange c = e.getValue();
            detail.add(e.getKey() + ": version "
                    + (c.before() == null ? "(null)" : c.before())
                    + " → " + c.after());
        }
        for (Map.Entry<String, FieldChange> e : drift.groupIdUpdates.entrySet()) {
            FieldChange c = e.getValue();
            detail.add(e.getKey() + ": groupId "
                    + (c.before() == null || c.before().isEmpty()
                            ? "(empty)" : c.before())
                    + " → " + c.after());
        }
        if (hasDuplicates) {
            detail.add("workspace.yaml has duplicate subproject field "
                    + "keys — will collapse to last-wins (#387)");
        }

        int driftCount = drift.versionUpdates.size()
                + drift.groupIdUpdates.size();
        String summary;
        if (driftCount > 0 && hasDuplicates) {
            summary = driftCount + " field(s) drift from POM truth; "
                    + "duplicate keys to collapse";
        } else if (driftCount > 0) {
            summary = driftCount + " field(s) drift from POM truth";
        } else {
            summary = "duplicate subproject field keys to collapse";
        }
        String action = "sync from POM truth on scaffold-publish";
        String optOut = "mvn ws:scaffold-publish -D" + optOutFlag() + "=false";

        return new DriftReport(dimension(), true, summary, detail, action, optOut);
    }

    @Override
    public void apply(WorkspaceContext ctx) {
        if (ctx.options().isOptedOut(optOutFlag())) {
            ctx.log().info("  " + dimension() + ": skipped (opted out via -D"
                    + optOutFlag() + "=false)");
            return;
        }
        Drift drift = computeDrift(ctx);
        try {
            if (!drift.versionUpdates.isEmpty()) {
                Map<String, String> versionValues = new LinkedHashMap<>();
                for (Map.Entry<String, FieldChange> e : drift.versionUpdates.entrySet()) {
                    versionValues.put(e.getKey(), e.getValue().after());
                }
                writeVersionFields(ctx.manifestPath(), versionValues);
            }
            if (!drift.groupIdUpdates.isEmpty()) {
                Map<String, String> groupIdValues = new LinkedHashMap<>();
                for (Map.Entry<String, FieldChange> e : drift.groupIdUpdates.entrySet()) {
                    groupIdValues.put(e.getKey(), e.getValue().after());
                }
                writeGroupIdFields(ctx.manifestPath(), groupIdValues);
            }
            int total = drift.versionUpdates.size() + drift.groupIdUpdates.size();
            if (total > 0) {
                ctx.log().info("  " + dimension() + ": updated " + total
                        + " field(s)");
            }
            // #387 safety net (wired by #399): collapse any pre-existing
            // duplicate subproject keys. Runs after the field sync — the
            // writer-side fix means updateSubprojectField no longer
            // creates duplicates, so this only cleans up duplicates that
            // predate that fix. Idempotent: a no-op on a clean manifest.
            collapseDuplicateKeys(ctx);
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to update workspace.yaml: " + e.getMessage(), e);
        }
    }

    /**
     * Tests whether the workspace manifest contains duplicate
     * subproject field keys (the pre-#387 duplicate-key bug).
     *
     * @param ctx the workspace context
     * @return true if collapsing would change the manifest
     */
    private static boolean hasDuplicateKeys(WorkspaceContext ctx) {
        try {
            String content = Files.readString(
                    ctx.manifestPath(), StandardCharsets.UTF_8);
            return !ManifestWriter.collapseDuplicateSubprojectFields(content)
                    .equals(content);
        } catch (IOException e) {
            ctx.log().warn("  could not check workspace.yaml for "
                    + "duplicate keys — " + e.getMessage());
            return false;
        }
    }

    /**
     * Collapses pre-existing duplicate subproject field keys in the
     * workspace manifest, keeping the last (YAML last-wins) occurrence.
     *
     * @param ctx the workspace context
     * @throws IOException if the manifest cannot be read or written
     */
    private void collapseDuplicateKeys(WorkspaceContext ctx)
            throws IOException {
        Path manifestPath = ctx.manifestPath();
        String before = Files.readString(manifestPath, StandardCharsets.UTF_8);
        String after =
                ManifestWriter.collapseDuplicateSubprojectFields(before);
        if (!after.equals(before)) {
            Files.writeString(manifestPath, after, StandardCharsets.UTF_8);
            ctx.log().info("  " + dimension()
                    + ": collapsed duplicate keys in workspace.yaml");
        }
    }

    // ── Drift computation (shared by detect and apply) ──────────────

    /**
     * A single field's "before / after" values when its YAML value
     * has drifted from POM truth. Used inside {@link Drift} so the
     * pair is compiler-visible rather than a positional
     * {@code String[]}.
     */
    private record FieldChange(String before, String after) {}

    /**
     * Per-subproject before/after for each denormalized field that
     * drifts from POM truth. Used as a shared intermediate between
     * {@link #detect} and {@link #apply} so they agree on exactly
     * which subprojects are affected.
     */
    private record Drift(
            Map<String, FieldChange> versionUpdates,
            Map<String, FieldChange> groupIdUpdates) {}

    private Drift computeDrift(WorkspaceContext ctx) {
        Map<String, FieldChange> versionUpdates = new LinkedHashMap<>();
        Map<String, FieldChange> groupIdUpdates = new LinkedHashMap<>();
        for (Map.Entry<String, Subproject> entry
                : ctx.graph().manifest().subprojects().entrySet()) {
            String name = entry.getKey();
            Subproject subproject = entry.getValue();
            File subprojectDir = new File(ctx.workspaceRoot(), name);
            File pomFile = new File(subprojectDir, "pom.xml");
            if (!pomFile.exists()) continue;

            try {
                String pomVersion = ReleaseSupport.readPomVersion(pomFile);
                String yamlVersion = subproject.version();
                if (yamlVersion != null && !yamlVersion.equals(pomVersion)) {
                    versionUpdates.put(name, new FieldChange(yamlVersion, pomVersion));
                } else if (yamlVersion == null && pomVersion != null) {
                    versionUpdates.put(name, new FieldChange(null, pomVersion));
                }
            } catch (MojoException e) {
                ctx.log().warn("  " + name + ": could not read POM version — "
                        + e.getMessage());
            }

            try {
                String pomGroupId = readPomGroupId(pomFile);
                String yamlGroupId = subproject.groupId();
                if (pomGroupId != null && !pomGroupId.isEmpty()) {
                    if (yamlGroupId == null || yamlGroupId.isEmpty()
                            || !yamlGroupId.equals(pomGroupId)) {
                        groupIdUpdates.put(name,
                                new FieldChange(yamlGroupId, pomGroupId));
                    }
                }
            } catch (MojoException e) {
                ctx.log().warn("  " + name + ": could not read POM groupId — "
                        + e.getMessage());
            }
        }
        return new Drift(versionUpdates, groupIdUpdates);
    }

    // ── POM groupId reader (lifted from WsFixMojo) ──────────────────

    /**
     * Read the project's own {@code <groupId>} from a POM file,
     * skipping any {@code <groupId>} inside the {@code <parent>} block.
     * Falls back to the parent's groupId if the project POM does not
     * declare its own.
     *
     * @param pomFile the POM file to read
     * @return the groupId, or null if not found
     * @throws MojoException if the file cannot be read
     */
    static String readPomGroupId(File pomFile) throws MojoException {
        try {
            String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
            String stripped = content.replaceFirst(
                    "(?s)<parent>.*?</parent>", "");
            Matcher matcher = GROUP_ID_PATTERN.matcher(stripped);
            if (matcher.find()) {
                return matcher.group(1);
            }
            Matcher parentMatcher = Pattern.compile(
                    "(?s)<parent>.*?<groupId>([^<]+)</groupId>.*?</parent>"
            ).matcher(content);
            if (parentMatcher.find()) {
                return parentMatcher.group(1);
            }
            return null;
        } catch (IOException e) {
            throw new MojoException("Failed to read " + pomFile, e);
        }
    }

    // ── ManifestWriter wrappers (preserves WsFixMojo semantics) ─────

    private static void writeVersionFields(Path manifestPath,
                                            Map<String, String> updates)
            throws IOException {
        String content = Files.readString(manifestPath, StandardCharsets.UTF_8);
        for (Map.Entry<String, String> entry : updates.entrySet()) {
            content = ManifestWriter.updateSubprojectField(
                    content, entry.getKey(), "version", entry.getValue());
        }
        Files.writeString(manifestPath, content, StandardCharsets.UTF_8);
    }

    private static void writeGroupIdFields(Path manifestPath,
                                            Map<String, String> updates)
            throws IOException {
        String content = Files.readString(manifestPath, StandardCharsets.UTF_8);
        for (Map.Entry<String, String> entry : updates.entrySet()) {
            content = ManifestWriter.updateSubprojectField(
                    content, entry.getKey(), "groupId", entry.getValue());
        }
        Files.writeString(manifestPath, content, StandardCharsets.UTF_8);
    }
}