FeatureVersionReconciler.java

package network.ike.plugin.ws.reconcile;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.QualificationCascade;
import network.ike.plugin.ws.WsGoal;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.Subproject;
import network.ike.workspace.VersionSupport;
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.List;
import java.util.Map;

/**
 * Reconciler that branch-qualifies each subproject's <em>own</em> Maven
 * version to match the branch it tracks (IKE-Network/ike-issues#574).
 *
 * <p>On a feature branch every subproject's version must carry the
 * branch slug ({@code <base>-<slug>-SNAPSHOT}) so feature builds are
 * isolated from main SNAPSHOTs. {@code ws:feature-start} applies this
 * once, at branch-creation; a subproject added <em>later</em> (via
 * {@code ws:add}) — or otherwise left un-qualified — stays un-isolated
 * and its feature build collides with its own main SNAPSHOT. This
 * reconciler self-heals that on {@code scaffold-publish}.
 *
 * <p>Scope is each subproject's own version across its <em>whole module
 * tree</em> — the root POM's {@code <version>}, every child's in-tree
 * {@code <parent><version>}, and any child's redundant own
 * {@code <version>} (IKE-Network/ike-issues#1051, via
 * {@link QualificationCascade}) — plus the denormalized {@code version}
 * field in {@code workspace.yaml}. Detection likewise sees a tree whose
 * root is already qualified but whose children were left at the base
 * version, the state a root-only read could not see (#1051).
 * Propagating the new version into <em>consumers'</em> dependency
 * references is left to {@link AlignmentReconciler}, which runs after
 * this one in {@link ReconcilerRegistry}.
 *
 * <p>The target branch is read from the manifest ({@code branch:} per
 * subproject), not git, so the reconciler is deterministic and
 * branch-coherent. It is a no-op for {@code main}-tracking subprojects
 * and for members already qualified —
 * {@link VersionSupport#branchQualifiedVersion} strips any existing slug
 * before re-applying, so repeated runs converge.
 */
public class FeatureVersionReconciler implements Reconciler {

    @Override
    public String dimension() {
        return "Feature-branch version qualification";
    }

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

    @Override
    public DriftReport detect(WorkspaceContext ctx) {
        List<Change> changes = computeChanges(ctx);
        if (changes.isEmpty()) {
            return DriftReport.noDrift(dimension());
        }
        List<String> detail = new ArrayList<>();
        for (Change c : changes) {
            detail.add(c.name() + ": " + c.before() + " → " + c.after()
                    + " (" + c.branch() + ")");
        }
        String summary = changes.size()
                + " subproject version(s) not branch-qualified";
        String action = "qualify each to its branch slug on scaffold-publish";
        String optOut = "mvn " + WsGoal.SCAFFOLD_PUBLISH.qualified()
                + " -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;
        }
        List<Change> changes = computeChanges(ctx);
        if (changes.isEmpty()) {
            return;
        }
        try {
            // Rewrite each subproject's own POM <version>, then mirror the
            // new value into workspace.yaml's denormalized version field in
            // a single pass (FieldNormalizationReconciler runs *before* this
            // one, so it won't re-sync the manifest from the rewritten POM).
            Path manifestPath = ctx.manifestPath();
            String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);
            for (Change c : changes) {
                File dir = new File(ctx.workspaceRoot(), c.name());
                QualificationCascade.apply(dir.toPath(), c.before(),
                        c.after(), ctx.log());
                yaml = ManifestWriter.updateSubprojectField(
                        yaml, c.name(), "version", c.after());
            }
            Files.writeString(manifestPath, yaml, StandardCharsets.UTF_8);
            ctx.log().info("  " + dimension() + ": qualified "
                    + changes.size() + " subproject version(s)");
        } catch (IOException e) {
            throw new MojoException(
                    "Feature-version qualification failed: " + e.getMessage(), e);
        }
    }

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

    /**
     * One subproject's version qualification.
     *
     * @param name   the subproject name
     * @param branch the branch it tracks (from the manifest)
     * @param before its current POM version
     * @param after  the branch-qualified version
     */
    private record Change(String name, String branch,
                          String before, String after) {}

    private List<Change> computeChanges(WorkspaceContext ctx) {
        List<Change> changes = new ArrayList<>();
        for (Map.Entry<String, Subproject> entry
                : ctx.graph().manifest().subprojects().entrySet()) {
            String name = entry.getKey();
            String branch = entry.getValue().branch();
            // Only feature branches qualify; main keeps the plain version.
            if (branch == null || branch.isBlank() || "main".equals(branch)) {
                continue;
            }
            File pom = new File(new File(ctx.workspaceRoot(), name), "pom.xml");
            if (!pom.exists()) {
                continue;
            }
            String current;
            try {
                current = ReleaseSupport.readPomVersion(pom);
            } catch (MojoException e) {
                ctx.log().warn("  " + name + ": cannot read version — "
                        + e.getMessage());
                continue;
            }
            if (current == null || current.isBlank()) {
                continue;
            }
            String qualified =
                    VersionSupport.branchQualifiedVersion(current, branch);
            if (!qualified.equals(current)) {
                changes.add(new Change(name, branch, current, qualified));
                continue;
            }
            // Root already qualified — the children may still be at the
            // base version if qualification ran before it cascaded
            // (#1051). Detect through the tree, not the root alone.
            String base = VersionSupport.extractNumericBase(
                    VersionSupport.stripSnapshot(current)) + "-SNAPSHOT";
            if (!base.equals(current) && QualificationCascade.hasStaleTree(
                    new File(ctx.workspaceRoot(), name).toPath(), base,
                    ctx.log())) {
                changes.add(new Change(name, branch, base, current));
            }
        }
        return changes;
    }

}