CheatsheetReconciler.java

package network.ike.plugin.ws.reconcile;

import network.ike.plugin.ws.bootstrap.SubprojectInitializer;

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;

/**
 * Keeps the workspace cheatsheets — {@code GOALS.md} and
 * {@code WS-REFERENCE.md} — in lockstep with the goal set the plugin
 * actually ships (IKE-Network/ike-issues#452).
 *
 * <p>Before this reconciler, those files were written only by
 * {@link SubprojectInitializer}, which is constructed by exactly one
 * mojo ({@code ws:scaffold-init}). After a plugin upgrade the
 * cheatsheets stayed stale until someone remembered to re-run
 * {@code ws:scaffold-init} — and {@code ws:scaffold-draft} did not
 * even report the drift. Routing cheatsheet generation through the
 * reconciler chain restores the "draft reports / publish heals"
 * contract every other workspace dimension already enjoys.
 *
 * <p>Source-of-truth lives in
 * {@link SubprojectInitializer#generateGoalCheatsheet()} and
 * {@link SubprojectInitializer#generateWorkspaceReference()} — both
 * already static so this reconciler can call them without any
 * additional plumbing. A future iteration of those generators is
 * planned to iterate {@code WsGoal} so the content cannot drift from
 * the actual goal set.
 *
 * <p>Opt-out: {@code -DupdateCheatsheets=false}. Useful when the user
 * has intentionally edited the cheatsheets and does not want
 * {@code ws:scaffold-publish} to overwrite them.
 */
public class CheatsheetReconciler implements Reconciler {

    static final String GOALS_FILE = "GOALS.md";
    static final String REFERENCE_FILE = "WS-REFERENCE.md";

    @Override
    public String dimension() {
        return "Workspace cheatsheets (GOALS.md, WS-REFERENCE.md)";
    }

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

    @Override
    public DriftReport detect(WorkspaceContext ctx) {
        Path root = ctx.workspaceRoot().toPath();
        List<String> stale = staleFiles(root);
        if (stale.isEmpty()) {
            return DriftReport.noDrift(dimension());
        }
        List<String> detail = new ArrayList<>();
        for (String name : stale) {
            detail.add(name + ": content drifted from generator");
        }
        String summary = stale.size() == 1
                ? "1 cheatsheet stale"
                : stale.size() + " cheatsheets stale";
        return new DriftReport(
                dimension(),
                true,
                summary,
                detail,
                "regenerate " + String.join(" and ", stale),
                "-D" + optOutFlag() + "=false");
    }

    @Override
    public void apply(WorkspaceContext ctx) {
        if (ctx.options().isOptedOut(optOutFlag())) {
            ctx.log().info("  " + dimension()
                    + ": skipped (opted out via -D" + optOutFlag() + "=false)");
            return;
        }
        Path root = ctx.workspaceRoot().toPath();
        List<String> stale = staleFiles(root);
        if (stale.isEmpty()) {
            return;
        }
        int written = 0;
        for (String name : stale) {
            String generated = generatorFor(name);
            try {
                Files.writeString(root.resolve(name), generated,
                        StandardCharsets.UTF_8);
                written++;
            } catch (IOException e) {
                ctx.log().warn("  " + dimension()
                        + ": could not write " + name + " — " + e.getMessage());
            }
        }
        if (written > 0) {
            ctx.log().info("  " + dimension()
                    + ": regenerated " + written + " file(s)");
        }
    }

    /**
     * Return the cheatsheet filenames whose on-disk content differs
     * from the generator's current output (or that are missing
     * entirely). A missing file counts as stale so a fresh workspace
     * that never ran {@code ws:scaffold-init} still gets one written
     * by {@code ws:scaffold-publish}.
     */
    private static List<String> staleFiles(Path workspaceRoot) {
        List<String> stale = new ArrayList<>();
        if (!matchesGenerator(workspaceRoot.resolve(GOALS_FILE),
                SubprojectInitializer.generateGoalCheatsheet())) {
            stale.add(GOALS_FILE);
        }
        if (!matchesGenerator(workspaceRoot.resolve(REFERENCE_FILE),
                SubprojectInitializer.generateWorkspaceReference())) {
            stale.add(REFERENCE_FILE);
        }
        return stale;
    }

    private static boolean matchesGenerator(Path file, String expected) {
        if (!Files.exists(file)) {
            return false;
        }
        try {
            String existing = Files.readString(file, StandardCharsets.UTF_8);
            return existing.equals(expected);
        } catch (IOException e) {
            // Unreadable file is treated as drifted — apply will then
            // attempt to overwrite it (which surfaces the underlying
            // IO error to the user explicitly via the warn path above).
            return false;
        }
    }

    private static String generatorFor(String filename) {
        return switch (filename) {
            case GOALS_FILE -> SubprojectInitializer.generateGoalCheatsheet();
            case REFERENCE_FILE -> SubprojectInitializer.generateWorkspaceReference();
            default -> throw new IllegalArgumentException(
                    "Unknown cheatsheet filename: " + filename);
        };
    }
}