ScaffoldDraftMojo.java

package network.ike.plugin;

import network.ike.plugin.scaffold.DirectoryTemplateSource;
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.ScaffoldException;
import network.ike.plugin.scaffold.ScaffoldLockfile;
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 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.nio.file.Path;
import java.util.List;

/**
 * Preview the changes {@code ike:scaffold-publish} would make.
 *
 * <p>Reads the scaffold manifest shipped by
 * {@code ike-build-standards}, compares each entry to the current
 * disk state and scaffold lockfile, and prints a per-entry summary:
 *
 * <ul>
 *   <li>{@code [INSTALL]} — file does not yet exist, will be created</li>
 *   <li>{@code [UPDATE]} — file exists, will be refreshed</li>
 *   <li>{@code [SKIP]} — file has been user-edited and will be left
 *       alone (a unified diff is included for tracked/tracked-block
 *       entries)</li>
 *   <li>{@code [OK]} — file already matches the current template</li>
 * </ul>
 *
 * <p>The goal runs with {@code projectRequired = false} so it can
 * preview user-scope changes (git hooks, {@code ~/.m2/settings.xml})
 * on a fresh machine. In a project it previews both scopes.
 *
 * <p>This goal never touches disk — it's safe to run anytime.
 *
 * <p>Usage:
 * <pre>
 * # From inside a project (previews project + user scopes):
 * mvn ike:scaffold-draft -DscaffoldDir=/path/to/unpacked/scaffold
 *
 * # Fresh-machine bootstrap (user scope only):
 * mvn ike:scaffold-draft -DscaffoldDir=/path/to/unpacked/scaffold
 * </pre>
 *
 * @see ScaffoldPublishMojo
 * @see ScaffoldRevertMojo
 */
@Mojo(name = "scaffold-draft", projectRequired = false,
      aggregator = true)
public class ScaffoldDraftMojo extends AbstractGoalMojo {

    /**
     * Path to an unpacked scaffold tree containing
     * {@code scaffold-manifest.yaml} and the template files it
     * references. Typically the result of unpacking the
     * {@code ike-build-standards} scaffold zip.
     *
     * <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 bootstrap and the project scope is skipped.
     */
    @Parameter(property = "projectRoot")
    String projectRoot;

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

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

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

    private GoalReportSpec runDraft() {
        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());

        getLog().info("");
        getLog().info("ike:scaffold-draft");
        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 — always planned.
        Path userLock =
                ScaffoldMojoSupport.userLockfilePath(home);
        ScaffoldLockfile userLockfile =
                ScaffoldMojoSupport.loadLockfileOrEmpty(userLock);
        ScaffoldPlan userPlan = planner.plan(
                manifest, userLockfile, ScaffoldScope.USER,
                resolver, templates);
        logPlan(userPlan, ScaffoldScope.USER);
        int orphanCount = reportOrphans(
                manifest, userLockfile, ScaffoldScope.USER, resolver);

        // Project scope — only when invoked inside 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);
            logPlan(projectPlan, ScaffoldScope.PROJECT);
            orphanCount += reportOrphans(manifest, projLockfile,
                    ScaffoldScope.PROJECT, resolver);
            projectCounts =
                    ScaffoldMojoSupport.countActions(projectPlan);
        }

        Counts userCounts =
                ScaffoldMojoSupport.countActions(userPlan);
        getLog().info("");
        getLog().info("Summary:");
        getLog().info("  user:    " + userCounts.summary());
        if (projectCounts != null) {
            getLog().info("  project: " + projectCounts.summary());
        }
        if (orphanCount > 0) {
            getLog().info("  orphans: " + orphanCount
                    + " — file(s) the scaffold no longer ships; "
                    + "ike:scaffold-publish removes the unedited ones");
        }

        // #345: report IKE-foundation drift when the manifest carries
        // a foundation: section AND we're in a project context. 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.
        if (projRoot != null && manifest.foundation() != null) {
            reportFoundationDrift(projRoot, manifest.foundation());
        }

        getLog().info("");
        getLog().info(
                "Run ike:scaffold-publish to apply these changes.");

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

    /**
     * Scan one scope for orphaned scaffold files — lockfile entries
     * the current manifest no longer ships — and log them.
     *
     * @param manifest the scaffold manifest
     * @param lockfile the lockfile for {@code scope}
     * @param scope    the scope to scan
     * @param resolver path resolver
     * @return the number of orphans found
     */
    private int reportOrphans(ScaffoldManifest manifest,
                               ScaffoldLockfile lockfile,
                               ScaffoldScope scope,
                               PathResolver resolver) {
        List<OrphanEntry> orphans = OrphanScanner.scan(
                manifest, lockfile, scope, resolver);
        if (!orphans.isEmpty()) {
            ScaffoldMojoSupport.logLines(getLog(),
                    ScaffoldMojoSupport.renderOrphanReport(
                            orphans, scope));
        }
        return orphans.size();
    }

    /**
     * Build the Markdown report body for {@code ike:scaffold-draft}.
     *
     * @param manifest      the scaffold manifest
     * @param userCounts    planned-action counts for the user scope
     * @param projectCounts planned-action counts for the project
     *                      scope, or {@code null} on a fresh machine
     * @param orphanCount   number of orphaned files the manifest no
     *                      longer ships
     * @return the report body
     */
    private static String buildReport(ScaffoldManifest manifest,
                                       Counts userCounts,
                                       Counts projectCounts,
                                       int orphanCount) {
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("Preview of the changes `ike:scaffold-publish`"
                + " would apply — no files were written.");
        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 (orphanCount > 0) {
            report.bullet("orphans: " + orphanCount + " file(s) the "
                    + "scaffold no longer ships");
        }
        report.paragraph("Run `ike:scaffold-publish` to apply.");
        return report.build();
    }

    /**
     * Compute and log foundation drift for the project at
     * {@code projRoot}.
     *
     * @param projRoot   the project root
     * @param foundation manifest's foundation section
     */
    private void reportFoundationDrift(
            Path projRoot,
            ScaffoldManifest.Foundation foundation) {
        java.util.List<FoundationDriftChecker.Entry> entries;
        try {
            entries = FoundationDriftChecker.checkPomFile(
                    projRoot.resolve("pom.xml"), foundation);
        } catch (java.io.IOException e) {
            getLog().debug("Could not check foundation drift: " + e.getMessage());
            return;
        }
        if (entries.isEmpty()) return;

        getLog().info("");
        getLog().info("IKE Foundation Drift:");
        getLog().info("  Compares this POM against the foundation snapshot");
        getLog().info("  pinned in the unpacked ike-build-standards ("
                + foundation.parent() + ").");

        int behind = 0;
        int ahead = 0;
        int absent = 0;
        for (FoundationDriftChecker.Entry e : entries) {
            String label = e.kind() == FoundationDriftChecker.Kind.PARENT
                    ? "<parent> " + e.name()
                    : "${" + e.name() + "}";
            switch (e.state()) {
                case ALIGNED ->
                    getLog().info("  ✓ " + label + ": " + e.actual());
                case ABSENT -> {
                    absent++;
                    getLog().info("  · " + label + ": not declared here"
                            + " (snapshot: " + e.expected() + ") — likely"
                            + " inherited from the parent");
                }
                case DIFFERS -> {
                    int dir = versionDirection(e.actual(), e.expected());
                    if (dir < 0) {
                        behind++;
                        getLog().info("  ⬆ " + label + ": " + e.actual()
                                + " → " + e.expected() + " (behind — upgrade)");
                    } else if (dir > 0) {
                        ahead++;
                        getLog().info("  ℹ " + label + ": " + e.actual()
                                + " (snapshot pins " + e.expected() + ") —"
                                + " AHEAD; the scaffold is stale, not this POM");
                    } else {
                        behind++;
                        getLog().info("  ✗ " + label + ": " + e.actual()
                                + " → " + e.expected() + " (differs)");
                    }
                }
                default -> getLog().info("  ? " + label);
            }
        }

        if (behind > 0 || ahead > 0 || absent > 0) {
            getLog().info("");
            getLog().info("  " + behind + " behind, " + ahead + " ahead, "
                    + absent + " not declared.");
            if (behind > 0 || absent > 0) {
                getLog().info("  Apply behind/absent values with"
                        + " ike:scaffold-publish"
                        + " -Dike.scaffold.apply-foundation=true.");
            }
            if (ahead > 0) {
                getLog().info("  Ahead values mean the unpacked"
                        + " ike-build-standards is stale — refresh the"
                        + " foundation; do not downgrade this POM.");
            }
        }
    }

    /**
     * Compare two foundation version strings.
     *
     * @param actual   the project's POM value
     * @param expected the scaffold snapshot's pinned value
     * @return negative when {@code actual} is behind {@code expected},
     *         positive when ahead, {@code 0} when the direction cannot
     *         be determined (non-numeric versions). IKE foundation
     *         versions are single-segment integers, so this is a
     *         numeric comparison.
     */
    private static int versionDirection(String actual, String expected) {
        try {
            return Integer.signum(Long.compare(
                    Long.parseLong(actual.trim()),
                    Long.parseLong(expected.trim())));
        } catch (NumberFormatException | NullPointerException ex) {
            return 0;
        }
    }

    private void logPlan(ScaffoldPlan plan, ScaffoldScope scope) {
        ScaffoldMojoSupport.logLines(getLog(),
                ScaffoldMojoSupport.renderPlanReport(plan, scope));
    }
}