ParentVersionReconciler.java

package network.ike.plugin.ws.reconcile;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.PomParentSupport;
import network.ike.plugin.ws.PomParentSupport.ParentInfo;
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;

/**
 * Reconciler that cascades the workspace root POM's
 * {@code <parent>} version across every cloned subproject (and any
 * nested submodule POMs whose parent block matches the same
 * {@code groupId:artifactId}).
 *
 * <p>Subsumes the retired {@code ws:set-parent-{draft,publish}}
 * goals (IKE-Network/ike-issues#393). The source of truth for the
 * target parent version is the workspace root POM's
 * {@code <parent><version>}. To pin to a specific (non-root-declared)
 * version, pass {@code -DparentVersion=<v>}; the reconciler then
 * also updates the root POM before cascading.
 *
 * <p>External-trigger drift dimension: a new ike-parent release
 * lands → the user updates the root POM (or passes
 * {@code -DparentVersion=}) → next scaffold-publish cascades to
 * every subproject.
 */
public class ParentVersionReconciler implements Reconciler {

    @Override
    public String dimension() {
        return "Parent version cascade";
    }

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

    @Override
    public String pinFlag() {
        return "parentVersion";
    }

    @Override
    public DriftReport detect(WorkspaceContext ctx) {
        Plan plan = computePlan(ctx);
        if (plan == null || plan.changes().isEmpty()) {
            return DriftReport.noDrift(dimension());
        }
        List<String> detail = new ArrayList<>();
        for (Map.Entry<String, FieldChange> e : plan.changes().entrySet()) {
            detail.add(e.getKey() + ": "
                    + plan.parentArtifactId() + ":" + e.getValue().before()
                    + " → " + e.getValue().after());
        }
        String summary = plan.changes().size()
                + " POM(s) need parent " + plan.parentArtifactId()
                + " cascaded to " + plan.targetVersion();
        String action = "cascade to " + plan.targetVersion()
                + " 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;
        }
        Plan plan = computePlan(ctx);
        if (plan == null || plan.changes().isEmpty()) {
            return;
        }
        try {
            // Update root POM first if its current parent version doesn't
            // already match the target — covers the pin (-DparentVersion=)
            // case where root POM also needs to be updated.
            Path rootPomPath = ctx.workspaceRoot().toPath().resolve("pom.xml");
            ParentInfo rootParent = PomParentSupport.readParent(rootPomPath);
            if (rootParent != null
                    && !plan.targetVersion().equals(rootParent.version())) {
                updateOnePom(rootPomPath, plan.parentGroupId(),
                        plan.parentArtifactId(), plan.targetVersion());
            }
            // Then cascade to each subproject (and its submodules).
            for (String name : plan.changes().keySet()) {
                File subDir = new File(ctx.workspaceRoot(), name);
                cascadeIntoSubproject(subDir, plan);
            }
            ctx.log().info("  " + dimension() + ": cascaded to "
                    + plan.changes().size() + " subproject(s)");
        } catch (IOException e) {
            throw new MojoException(
                    "Parent cascade failed: " + e.getMessage(), e);
        }
    }

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

    /**
     * "Before → after" pair for one subproject's parent version.
     * Compiler-visible alternative to a positional {@code String[]}.
     */
    private record FieldChange(String before, String after) {}

    /**
     * The full cascade plan for one reconciliation pass.
     *
     * @param parentGroupId    workspace root's parent groupId
     * @param parentArtifactId workspace root's parent artifactId
     * @param targetVersion    target parent version to cascade
     * @param changes          subproject name → before/after for those
     *                         whose parent version doesn't match target
     */
    private record Plan(
            String parentGroupId,
            String parentArtifactId,
            String targetVersion,
            Map<String, FieldChange> changes) {}

    private Plan computePlan(WorkspaceContext ctx) {
        Path rootPomPath = ctx.workspaceRoot().toPath().resolve("pom.xml");
        if (!Files.exists(rootPomPath)) {
            return null;
        }
        ParentInfo rootParent;
        try {
            rootParent = PomParentSupport.readParent(rootPomPath);
        } catch (IOException e) {
            ctx.log().warn("  Cannot read root POM parent: " + e.getMessage());
            return null;
        }
        if (rootParent == null) {
            return null;
        }

        // Target version: -DparentVersion=<v> pin overrides root POM.
        String targetVersion = ctx.options().pin(pinFlag())
                .orElse(rootParent.version());

        Map<String, FieldChange> changes = new LinkedHashMap<>();
        for (Map.Entry<String, Subproject> entry
                : ctx.graph().manifest().subprojects().entrySet()) {
            String name = entry.getKey();
            Path subprojectPom = new File(ctx.workspaceRoot(), name)
                    .toPath().resolve("pom.xml");
            if (!Files.exists(subprojectPom)) continue;
            ParentInfo subParent;
            try {
                subParent = PomParentSupport.readParent(subprojectPom);
            } catch (IOException e) {
                ctx.log().warn("  " + name + ": cannot read parent — "
                        + e.getMessage());
                continue;
            }
            if (subParent == null) continue;
            // Skip subprojects with an unrelated parent GAV (see #241).
            if (!rootParent.groupId().equals(subParent.groupId())
                    || !rootParent.artifactId().equals(subParent.artifactId())) {
                continue;
            }
            if (!targetVersion.equals(subParent.version())) {
                changes.put(name, new FieldChange(
                        subParent.version(), targetVersion));
            }
        }
        return new Plan(rootParent.groupId(), rootParent.artifactId(),
                targetVersion, changes);
    }

    private static void cascadeIntoSubproject(File subDir, Plan plan)
            throws IOException {
        Path subprojectPom = subDir.toPath().resolve("pom.xml");
        if (!Files.exists(subprojectPom)) return;

        // Update the subproject's root POM.
        updateOnePom(subprojectPom, plan.parentGroupId(),
                plan.parentArtifactId(), plan.targetVersion());

        // Then update any submodule POMs that reference the same parent
        // GAV (matches WsSetParentDraftMojo's submodule cascade).
        List<File> subPoms;
        try {
            subPoms = ReleaseSupport.findPomFiles(subDir);
        } catch (MojoException e) {
            // Non-fatal — submodule scan failures don't roll back the
            // root POM update.
            return;
        }
        for (File subPom : subPoms) {
            if (subPom.toPath().equals(subprojectPom)) continue;
            String content = Files.readString(subPom.toPath(),
                    StandardCharsets.UTF_8);
            String updated = PomParentSupport.updateParentVersion(
                    content, plan.parentGroupId(),
                    plan.parentArtifactId(), plan.targetVersion());
            if (!updated.equals(content)) {
                Files.writeString(subPom.toPath(), updated,
                        StandardCharsets.UTF_8);
            }
        }
    }

    private static void updateOnePom(Path pomPath, String parentGroupId,
                                      String parentArtifactId,
                                      String newVersion) throws IOException {
        String content = Files.readString(pomPath, StandardCharsets.UTF_8);
        String updated = PomParentSupport.updateParentVersion(
                content, parentGroupId, parentArtifactId, newVersion);
        if (!updated.equals(content)) {
            Files.writeString(pomPath, updated, StandardCharsets.UTF_8);
        }
    }
}