ScaffoldRevertMojo.java

package network.ike.plugin;

import network.ike.plugin.scaffold.ModelAdapters;
import network.ike.plugin.scaffold.PathResolver;
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.ScaffoldReverter;
import network.ike.plugin.scaffold.ScaffoldScope;
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.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Undo a previous {@code ike:scaffold-publish}.
 *
 * <p>Reads the scaffold manifest to learn the tier policy for every
 * entry, then for each entry recorded in the appropriate lockfile:
 *
 * <ul>
 *   <li>{@code tool-owned} / {@code tracked} — delete the file if
 *       its on-disk hash still matches what was applied; otherwise
 *       leave it and report a skip.</li>
 *   <li>{@code tracked-block} and {@code model-managed} — not yet
 *       implemented; the goal reports these as skipped so the user
 *       can clean them up manually.</li>
 * </ul>
 *
 * <p>Conservative by design — anything the user may have touched is
 * left alone. The updated lockfiles (with reverted entries removed)
 * are written back to disk.
 *
 * <p>Runs with {@code projectRequired = false} so it can clear
 * user-scope state on a fresh machine.
 *
 * <p>Modelled after ModelAdapters + TierHandlers registries; uses
 * {@link ScaffoldReverter} under the hood. Template resolution is
 * not needed for revert (only manifest + lockfile + disk), but the
 * manifest must still be available so scope and tier information
 * are known per entry.
 *
 * @see ScaffoldDraftMojo
 * @see ScaffoldPublishMojo
 */
@Mojo(name = "scaffold-revert", projectRequired = false,
      aggregator = true)
public class ScaffoldRevertMojo extends AbstractGoalMojo {

    /**
     * Path to an unpacked scaffold tree containing
     * {@code scaffold-manifest.yaml}. Only the manifest is needed
     * for revert (templates aren't used); the parameter is kept
     * consistent with {@code ike:scaffold-draft} and
     * {@code ike:scaffold-publish} for symmetry.
     *
     * <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.
     */
    @Parameter(property = "userHome",
               defaultValue = "${user.home}")
    String userHome;

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

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

    private GoalReportSpec runRevert() {
        Path scaffoldRoot = Path.of(scaffoldDir);
        ScaffoldManifest manifest =
                ScaffoldMojoSupport.loadManifest(scaffoldRoot);
        Path home = Path.of(userHome);
        Path projRoot = ScaffoldMojoSupport.resolveProjectRoot(
                projectRoot, getSession());
        PathResolver resolver = new PathResolver(home, projRoot);

        // Instantiate the registries so adapter-specific revert
        // logic is available when (later) tracked-block and
        // model-managed revert are implemented.
        @SuppressWarnings("unused")
        TierHandlers tierHandlers = new TierHandlers();
        @SuppressWarnings("unused")
        ModelAdapters modelAdapters = new ModelAdapters();
        ScaffoldReverter reverter = new ScaffoldReverter();

        getLog().info("");
        getLog().info("ike:scaffold-revert");
        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);
        ScaffoldReverter.RevertResult userResult = reverter.revert(
                userLockfile, manifest, ScaffoldScope.USER, resolver);
        ScaffoldMojoSupport.logLines(getLog(),
                ScaffoldMojoSupport.renderRevertReport(
                        userResult, ScaffoldScope.USER));
        ScaffoldLockfileIo.write(
                userResult.updatedLockfile(), userLock);
        getLog().info("  → " + userLock);

        // Project scope (only when we have a project)
        int projectDeleted = 0, projectSkipped = 0;
        if (projRoot != null) {
            Path projLock =
                    ScaffoldMojoSupport.projectLockfilePath(projRoot);
            ScaffoldLockfile projLockfile =
                    ScaffoldMojoSupport.loadLockfileOrEmpty(projLock);
            ScaffoldReverter.RevertResult projectResult =
                    reverter.revert(
                            projLockfile, manifest,
                            ScaffoldScope.PROJECT, resolver);
            ScaffoldMojoSupport.logLines(getLog(),
                    ScaffoldMojoSupport.renderRevertReport(
                            projectResult, ScaffoldScope.PROJECT));
            ScaffoldLockfileIo.write(
                    projectResult.updatedLockfile(), projLock);
            getLog().info("  → " + projLock);
            for (ScaffoldReverter.Outcome o
                    : projectResult.outcomes()) {
                if (o.kind()
                        == ScaffoldReverter.Outcome.Kind.DELETED) {
                    projectDeleted++;
                } else if (o.kind()
                        == ScaffoldReverter.Outcome.Kind.SKIPPED) {
                    projectSkipped++;
                }
            }
        }

        int userDeleted = 0, userSkipped = 0;
        for (ScaffoldReverter.Outcome o : userResult.outcomes()) {
            if (o.kind() == ScaffoldReverter.Outcome.Kind.DELETED) {
                userDeleted++;
            } else if (o.kind()
                    == ScaffoldReverter.Outcome.Kind.SKIPPED) {
                userSkipped++;
            }
        }

        getLog().info("");
        getLog().info("Revert summary:");
        getLog().info("  user:    " + userDeleted + " deleted, "
                + userSkipped + " skipped");
        if (projRoot != null) {
            getLog().info("  project: " + projectDeleted
                    + " deleted, " + projectSkipped + " skipped");
        }

        // #349: restore POM from foundation-apply backup, if present.
        // scaffold-publish wrote .ike/foundation-revert.pom.xml with
        // the pre-apply content; replaying it restores the parent
        // version + standard properties that were rewritten.
        if (projRoot != null) {
            restoreFoundationBackup(projRoot);
        }

        return new GoalReportSpec(IkeGoal.SCAFFOLD_REVERT,
                projRoot != null ? projRoot : home,
                buildReport(manifest, userDeleted, userSkipped,
                        projRoot != null, projectDeleted,
                        projectSkipped));
    }

    /**
     * Build the Markdown report body for {@code ike:scaffold-revert}.
     *
     * @param manifest        the scaffold manifest
     * @param userDeleted     files deleted in the user scope
     * @param userSkipped     entries skipped in the user scope
     * @param hasProject      whether a project scope was processed
     * @param projectDeleted  files deleted in the project scope
     * @param projectSkipped  entries skipped in the project scope
     * @return the report body
     */
    private static String buildReport(ScaffoldManifest manifest,
                                       int userDeleted, int userSkipped,
                                       boolean hasProject,
                                       int projectDeleted,
                                       int projectSkipped) {
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("Reverted a previous `ike:scaffold-publish`.");
        report.bullet("standards version: `"
                + manifest.standardsVersion() + "`");
        report.bullet("user scope: " + userDeleted + " deleted, "
                + userSkipped + " skipped");
        if (hasProject) {
            report.bullet("project scope: " + projectDeleted
                    + " deleted, " + projectSkipped + " skipped");
        } else {
            report.bullet("project scope: (none — fresh machine)");
        }
        return report.build();
    }

    /**
     * Restore the project's {@code pom.xml} from the foundation
     * backup written by the last {@code ike:scaffold-publish} apply
     * (#349). One-shot — the backup is deleted on successful restore
     * so a second {@code scaffold-revert} is a no-op.
     *
     * @param projRoot the project root
     */
    private void restoreFoundationBackup(Path projRoot) {
        Path backup = projRoot.resolve(".ike")
                .resolve("foundation-revert.pom.xml");
        if (!Files.isRegularFile(backup)) return;

        Path pomPath = projRoot.resolve("pom.xml");
        try {
            String content = Files.readString(backup,
                    StandardCharsets.UTF_8);
            Files.writeString(pomPath, content,
                    StandardCharsets.UTF_8);
            Files.delete(backup);
            getLog().info("");
            getLog().info("Foundation: restored " + pomPath
                    + " from backup");
            getLog().info("  → removed " + backup);
        } catch (IOException e) {
            getLog().warn("Could not restore foundation backup: "
                    + e.getMessage());
        }
    }
}