ScaffoldPublishMojo.java

package network.ike.plugin;

import network.ike.plugin.scaffold.DirectoryTemplateSource;
import network.ike.plugin.scaffold.FoundationBaker;
import network.ike.plugin.scaffold.FoundationDriftChecker;
import network.ike.plugin.scaffold.ModelAdapters;
import network.ike.plugin.scaffold.OrphanEntry;
import network.ike.plugin.scaffold.OrphanScanner;
import network.ike.plugin.scaffold.PathResolver;
import network.ike.plugin.scaffold.ScaffoldApplier;
import network.ike.plugin.scaffold.ScaffoldException;
import network.ike.plugin.scaffold.ScaffoldLockfile;
import network.ike.plugin.scaffold.ScaffoldLockfileIo;
import network.ike.plugin.scaffold.ScaffoldManifest;
import network.ike.plugin.scaffold.ScaffoldMojoSupport;
import network.ike.plugin.scaffold.ScaffoldMojoSupport.Counts;
import network.ike.plugin.scaffold.ScaffoldPlan;
import network.ike.plugin.scaffold.ScaffoldPlanner;
import network.ike.plugin.scaffold.ScaffoldScope;
import network.ike.plugin.scaffold.TemplateSource;
import network.ike.plugin.scaffold.TierHandlers;
import network.ike.plugin.support.AbstractGoalMojo;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.support.GoalReportSpec;
import network.ike.plugin.support.version.SessionCandidateVersionResolver;
import org.apache.maven.api.Session;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

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;

/**
 * Apply the scaffold manifest to disk and update the lockfiles.
 *
 * <p>Runs {@link ScaffoldPlanner} followed by {@link ScaffoldApplier}
 * in each applicable scope. Writes the per-file results into:
 *
 * <ul>
 *   <li>{@code {projectRoot}/.ike/scaffold.lock} — commits to the
 *       project's git history</li>
 *   <li>{@code {userHome}/.ike/scaffold.lock} — tracks user-home
 *       state on this machine</li>
 * </ul>
 *
 * <p>Write-actions land atomically (tmp file + move with
 * {@code ATOMIC_MOVE + REPLACE_EXISTING}). Skip actions — tracked
 * files the user has edited — are left alone; they are reported
 * but never overwritten.
 *
 * <p>Running with {@code projectRequired = false} means this goal
 * works both inside a project (project + user scope) and on a fresh
 * machine (user scope only, for bootstrap of git hooks,
 * {@code ~/.m2/settings.xml}, etc.).
 *
 * <p>Use {@code ike:scaffold-draft} first to preview changes.
 *
 * @see ScaffoldDraftMojo
 * @see ScaffoldRevertMojo
 */
@Mojo(name = "scaffold-publish", projectRequired = false,
      aggregator = true)
public class ScaffoldPublishMojo extends AbstractGoalMojo {

    /**
     * Path to an unpacked scaffold tree containing
     * {@code scaffold-manifest.yaml} and the template files it
     * references.
     *
     * <p>Defaults to {@code ${project.build.directory}/scaffold},
     * matching the unpack location wired into {@code ike-parent}'s
     * {@code unpack-scaffold-templates} execution (#243). Override
     * with {@code -DscaffoldDir=...} for ad-hoc invocations against
     * a custom scaffold tree.
     */
    @Parameter(property = "scaffoldDir",
               defaultValue = "${project.build.directory}/scaffold")
    String scaffoldDir;

    /**
     * Explicit override for the project root. When omitted, the goal
     * uses {@link Session#getTopDirectory()} (the directory Maven was
     * invoked from); a missing {@code pom.xml} at that location signals
     * fresh-machine mode and the project scope is skipped.
     */
    @Parameter(property = "projectRoot")
    String projectRoot;

    /**
     * Override for the user home.
     */
    @Parameter(property = "userHome",
               defaultValue = "${user.home}")
    String userHome;

    /**
     * When {@code true}, apply foundation-drift bumps to the project's
     * {@code pom.xml} (parent version + standard properties baked
     * into the scaffold manifest's {@code foundation:} section). When
     * {@code false} (default for this initial v153 ship), the
     * foundation drift is reported only — same as
     * {@code ike:scaffold-draft}. Opt-in so the apply behavior can
     * be validated over a few cascade cycles before flipping the
     * default. See {@code IKE-Network/ike-issues#348}.
     */
    @Parameter(property = "ike.scaffold.apply-foundation",
               defaultValue = "false")
    boolean applyFoundation;

    /**
     * When {@code true}, resolve the <em>latest released</em> foundation
     * versions from the remote repository and apply those, instead of
     * the snapshot baked into the scaffold zip's {@code foundation:}
     * block.
     *
     * <p>This is the escape hatch for the scaffold bootstrap loop: a
     * consumer's scaffold plugin and scaffold zip are both pinned by the
     * very {@code ike-parent} the foundation apply is meant to bump, so
     * the baked snapshot can only ever advance a consumer one
     * tested-together cycle per run. With this flag a stale consumer
     * jumps straight to the current foundation in a single run — at the
     * cost of the "tested-together snapshot" guarantee the baked block
     * carries.
     *
     * <p>Only meaningful together with
     * {@code -Dike.scaffold.apply-foundation=true}; on its own it just
     * changes what the dry-run reports. A transient resolver failure is
     * non-fatal — the goal falls back to the baked snapshot.
     */
    @Parameter(property = "ike.scaffold.resolve-foundation",
               defaultValue = "false")
    boolean resolveFoundation;

    /**
     * When {@code true}, the foundation-apply skips the {@code <parent>}
     * pin and rewrites only the property pins ({@code ike-tooling.version},
     * {@code ike-docs.version}, {@code ike-platform.version}).
     *
     * <p>{@code ws:scaffold-publish} forwards this: in a workspace the
     * {@code <parent>} version is owned by the workspace's
     * {@code ParentVersionReconciler}, which cascades one coherent
     * version across the whole reactor. Without this flag the
     * per-subproject foundation-apply would run after the reconciler
     * and overwrite its cascade with the baked snapshot's parent
     * version (IKE-Network/ike-issues#418). Standalone
     * {@code ike:scaffold-publish} leaves it {@code false} and keeps
     * writing {@code <parent>} from the snapshot.
     */
    @Parameter(property = "ike.scaffold.skip-parent",
               defaultValue = "false")
    boolean skipParent;

    /** Creates this goal instance. */
    public ScaffoldPublishMojo() {}

    @Override
    protected GoalReportSpec runGoal() throws MojoException {
        try {
            return runPublish();
        } catch (ScaffoldException e) {
            throw new MojoException(e.getMessage(), e);
        }
    }

    private GoalReportSpec runPublish() {
        Path scaffoldRoot = Path.of(scaffoldDir);
        ScaffoldManifest manifest =
                ScaffoldMojoSupport.loadManifest(scaffoldRoot);
        TemplateSource templates =
                new DirectoryTemplateSource(scaffoldRoot);
        Path home = Path.of(userHome);
        Path projRoot = ScaffoldMojoSupport.resolveProjectRoot(
                projectRoot, getSession());
        PathResolver resolver = new PathResolver(home, projRoot);
        ScaffoldPlanner planner = new ScaffoldPlanner(
                new TierHandlers(), new ModelAdapters());
        ScaffoldApplier applier = new ScaffoldApplier();

        getLog().info("");
        getLog().info("ike:scaffold-publish");
        getLog().info("  scaffold dir:      " + scaffoldRoot);
        getLog().info("  standards version: "
                + manifest.standardsVersion());
        getLog().info("  user home:         " + home);
        getLog().info("  project root:      "
                + (projRoot == null ? "(none — fresh machine)"
                        : projRoot));
        getLog().info("");

        // User scope
        Path userLock = ScaffoldMojoSupport.userLockfilePath(home);
        ScaffoldLockfile userLockfile =
                ScaffoldMojoSupport.loadLockfileOrEmpty(userLock);
        ScaffoldPlan userPlan = planner.plan(
                manifest, userLockfile, ScaffoldScope.USER,
                resolver, templates);
        ScaffoldMojoSupport.logLines(getLog(),
                ScaffoldMojoSupport.renderPlanReport(
                        userPlan, ScaffoldScope.USER));
        ScaffoldLockfile updatedUser = applier.apply(
                userPlan, userLockfile);
        List<OrphanEntry> orphans = new ArrayList<>();
        updatedUser = removeOrphans(applier, manifest, userLockfile,
                updatedUser, ScaffoldScope.USER, resolver, orphans);
        ScaffoldLockfileIo.write(updatedUser, userLock);
        getLog().info("  → " + userLock);

        // Project scope (only if we have a project)
        Counts projectCounts = null;
        if (projRoot != null) {
            Path projLock =
                    ScaffoldMojoSupport.projectLockfilePath(projRoot);
            ScaffoldLockfile projLockfile =
                    ScaffoldMojoSupport.loadLockfileOrEmpty(projLock);
            ScaffoldPlan projectPlan = planner.plan(
                    manifest, projLockfile, ScaffoldScope.PROJECT,
                    resolver, templates);
            ScaffoldMojoSupport.logLines(getLog(),
                    ScaffoldMojoSupport.renderPlanReport(
                            projectPlan, ScaffoldScope.PROJECT));
            ScaffoldLockfile updatedProj = applier.apply(
                    projectPlan, projLockfile);
            updatedProj = removeOrphans(applier, manifest, projLockfile,
                    updatedProj, ScaffoldScope.PROJECT, resolver,
                    orphans);
            ScaffoldLockfileIo.write(updatedProj, projLock);
            getLog().info("  → " + projLock);
            projectCounts = ScaffoldMojoSupport
                    .countActions(projectPlan);
        }

        Counts userCounts = ScaffoldMojoSupport.countActions(userPlan);
        getLog().info("");
        getLog().info("Publish summary:");
        getLog().info("  user:    " + userCounts.summary());
        if (projectCounts != null) {
            getLog().info("  project: " + projectCounts.summary());
        }
        getLog().info("");
        int totalSkipped = userCounts.skip()
                + (projectCounts != null ? projectCounts.skip() : 0);
        if (totalSkipped > 0) {
            getLog().info(
                    totalSkipped + " entry(ies) were skipped "
                            + "(user-edited). "
                            + "Run ike:scaffold-draft for details.");
        }
        if (!orphans.isEmpty()) {
            long removed = orphanCount(orphans,
                    OrphanEntry.Disposition.REMOVE);
            long kept = orphanCount(orphans,
                    OrphanEntry.Disposition.SKIP_USER_EDITED);
            long cleared = orphanCount(orphans,
                    OrphanEntry.Disposition.ALREADY_ABSENT);
            getLog().info(orphans.size() + " orphan(s) — files the "
                    + "scaffold no longer ships: " + removed
                    + " removed, " + kept + " kept (user-edited), "
                    + cleared + " lockfile-only.");
        }

        // #348: foundation-drift apply. Detection landed in #345's
        // scaffold-draft; this is the matching apply step. Opt-in via
        // -Dike.scaffold.apply-foundation=true for the initial v153
        // ship. The scaffold zip's foundation pins represent the
        // tested-together compatibility snapshot of ike-parent +
        // standard properties at the moment this ike-tooling version
        // was released, so applying them is a single-command "bump
        // foundation to current" operation that subsumes the routine
        // use case of ws:scaffold-publish's parent + version-upgrade
        // reconcilers.
        if (projRoot != null && manifest.foundation() != null) {
            applyFoundationDrift(projRoot, manifest.foundation());
        }

        return new GoalReportSpec(IkeGoal.SCAFFOLD_PUBLISH,
                projRoot != null ? projRoot : home,
                buildReport(manifest, userCounts, projectCounts,
                        orphans));
    }

    /**
     * Scan one scope for orphaned scaffold files — lockfile entries
     * the current manifest no longer ships — log them, and remove the
     * unedited ones from disk and the lockfile.
     *
     * @param applier   the scaffold applier
     * @param manifest  the scaffold manifest
     * @param priorLock the scope's lockfile before this publish
     * @param updated   the post-apply lockfile to prune
     * @param scope     the scope to scan
     * @param resolver  path resolver
     * @param collected accumulator the caller uses for the summary —
     *                  every orphan found is added here
     * @return the lockfile with removed/absent orphan entries dropped
     */
    private ScaffoldLockfile removeOrphans(
            ScaffoldApplier applier,
            ScaffoldManifest manifest,
            ScaffoldLockfile priorLock,
            ScaffoldLockfile updated,
            ScaffoldScope scope,
            PathResolver resolver,
            List<OrphanEntry> collected) {
        List<OrphanEntry> orphans = OrphanScanner.scan(
                manifest, priorLock, scope, resolver);
        if (orphans.isEmpty()) {
            return updated;
        }
        ScaffoldMojoSupport.logLines(getLog(),
                ScaffoldMojoSupport.renderOrphanReport(orphans, scope));
        collected.addAll(orphans);
        return applier.removeOrphans(orphans, updated);
    }

    private static long orphanCount(
            List<OrphanEntry> orphans,
            OrphanEntry.Disposition disposition) {
        return orphans.stream()
                .filter(o -> o.disposition() == disposition)
                .count();
    }

    /**
     * Build the Markdown report body for {@code ike:scaffold-publish}.
     *
     * @param manifest      the scaffold manifest
     * @param userCounts    applied-action counts for the user scope
     * @param projectCounts applied-action counts for the project
     *                      scope, or {@code null} on a fresh machine
     * @param orphans       orphaned files the manifest no longer ships
     * @return the report body
     */
    private static String buildReport(ScaffoldManifest manifest,
                                       Counts userCounts,
                                       Counts projectCounts,
                                       List<OrphanEntry> orphans) {
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("Applied the scaffold manifest to disk.");
        report.bullet("standards version: `"
                + manifest.standardsVersion() + "`");
        report.bullet("user scope: " + userCounts.summary());
        if (projectCounts != null) {
            report.bullet("project scope: " + projectCounts.summary());
        } else {
            report.bullet("project scope: (none — fresh machine)");
        }
        if (!orphans.isEmpty()) {
            report.bullet("orphans: " + orphanCount(orphans,
                    OrphanEntry.Disposition.REMOVE) + " removed, "
                    + orphanCount(orphans,
                    OrphanEntry.Disposition.SKIP_USER_EDITED)
                    + " kept (user-edited)");
        }
        int totalSkipped = userCounts.skip()
                + (projectCounts != null ? projectCounts.skip() : 0);
        if (totalSkipped > 0) {
            report.paragraph(totalSkipped
                    + " entry(ies) skipped (user-edited) — run"
                    + " `ike:scaffold-draft` for details.");
        }
        return report.build();
    }

    /**
     * Apply foundation-drift bumps to the project's {@code pom.xml}.
     *
     * <p>Reads the POM, computes drift via
     * {@link FoundationDriftChecker}, and for each
     * {@link FoundationDriftChecker.State#DIFFERS} entry, rewrites
     * the POM via {@link PomRewriter}:
     * <ul>
     *   <li>{@link FoundationDriftChecker.Kind#PARENT} —
     *       {@code PomRewriter.updateParentVersion}</li>
     *   <li>{@link FoundationDriftChecker.Kind#PROPERTY} —
     *       {@code PomRewriter.updateProperty}</li>
     * </ul>
     *
     * <p>{@code ABSENT} entries are left alone — the project inherits
     * the value from a parent POM (or simply doesn't carry it), and
     * force-declaring it here would change the structural shape of
     * the consumer's POM beyond a drift bump. {@code ALIGNED}
     * entries are no-ops.
     *
     * <p>When {@code applyFoundation} is {@code false} (the default),
     * this method just logs what would be applied without mutating
     * the POM — matching {@code ike:scaffold-draft}'s report.
     *
     * @param projRoot   the project root directory
     * @param foundation the scaffold manifest's foundation pins
     */
    private void applyFoundationDrift(
            Path projRoot,
            ScaffoldManifest.Foundation foundation) {
        if (resolveFoundation) {
            getLog().info("");
            try {
                foundation = FoundationBaker.latestFoundation(foundation,
                        new SessionCandidateVersionResolver(getSession()));
                getLog().info("Foundation: resolved latest released "
                        + "versions (resolve-foundation mode).");
            } catch (RuntimeException e) {
                getLog().warn("Foundation: could not resolve latest "
                        + "versions (" + e.getMessage() + ") — falling "
                        + "back to the baked scaffold snapshot.");
            }
        }

        Path pomPath = projRoot.resolve("pom.xml");
        List<FoundationDriftChecker.Entry> entries;
        try {
            entries = FoundationDriftChecker.checkPomFile(
                    pomPath, foundation);
        } catch (IOException e) {
            getLog().warn("Could not read POM for foundation drift "
                    + "apply: " + e.getMessage());
            return;
        }

        List<FoundationDriftChecker.Entry> toApply = new ArrayList<>();
        int parentSkipped = 0;
        for (FoundationDriftChecker.Entry e : entries) {
            if (e.state() != FoundationDriftChecker.State.DIFFERS) {
                continue;
            }
            // #418: in a workspace the <parent> cascade is owned by
            // ParentVersionReconciler; skip the parent pin so this
            // per-subproject apply does not overwrite it.
            if (skipParent
                    && e.kind() == FoundationDriftChecker.Kind.PARENT) {
                parentSkipped++;
                continue;
            }
            toApply.add(e);
        }
        if (parentSkipped > 0) {
            getLog().info("");
            getLog().info("Foundation: <parent> left to the workspace "
                    + "(ParentVersionReconciler owns the parent cascade).");
        }
        if (toApply.isEmpty()) {
            getLog().info("");
            getLog().info("Foundation: aligned with scaffold "
                    + "(no drift to apply).");
            return;
        }

        getLog().info("");
        getLog().info("IKE Foundation Apply:");
        if (!applyFoundation) {
            getLog().info("  (dry-run — pass -Dike.scaffold.apply-foundation=true to apply)");
        }
        String content;
        try {
            content = Files.readString(pomPath, StandardCharsets.UTF_8);
        } catch (IOException e) {
            getLog().warn("Could not read " + pomPath + ": "
                    + e.getMessage());
            return;
        }

        String updated = content;
        for (FoundationDriftChecker.Entry e : toApply) {
            String label;
            if (e.kind() == FoundationDriftChecker.Kind.PARENT) {
                String[] ga = e.name().split(":", 2);
                if (ga.length == 2) {
                    updated = PomRewriter.updateParentVersion(
                            updated, ga[0], ga[1], e.expected());
                }
                label = "<parent> " + e.name();
            } else {
                updated = PomRewriter.updateProperty(
                        updated, e.name(), e.expected());
                label = "${" + e.name() + "}";
            }
            getLog().info("  " + (applyFoundation ? "✓ " : "→ ")
                    + label + ": " + e.actual() + " → " + e.expected());
        }

        if (!applyFoundation) {
            return;
        }
        if (updated.equals(content)) {
            getLog().info("  (no textual change — values already "
                    + "matched at the LST level)");
            return;
        }
        // #349: capture pre-apply POM as a backup so
        // ike:scaffold-revert can restore. One-shot — the next
        // foundation apply overwrites this, and revert deletes
        // it on success.
        Path backup = projRoot.resolve(".ike")
                .resolve("foundation-revert.pom.xml");
        try {
            Files.createDirectories(backup.getParent());
            Files.writeString(backup, content, StandardCharsets.UTF_8);
            getLog().info("  → backup: " + backup);
        } catch (IOException e) {
            getLog().warn("Could not write foundation revert backup: "
                    + e.getMessage());
            // Still attempt the apply — losing revert capability is
            // worse than no foundation update.
        }
        try {
            Files.writeString(pomPath, updated, StandardCharsets.UTF_8);
            getLog().info("  → wrote " + pomPath);
        } catch (IOException e) {
            getLog().warn("Could not write updated POM: "
                    + e.getMessage());
        }
    }
}