BuildReportRatchetDraftMojo.java

package network.ike.plugin;

import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;

import network.ike.plugin.support.AbstractGoalMojo;
import network.ike.plugin.support.GoalReportSpec;
import network.ike.tooling.buildreport.Ledger;
import network.ike.tooling.buildreport.LedgerWriter;
import network.ike.tooling.buildreport.ObservationsFile;
import network.ike.tooling.buildreport.RatchetPlanner;
import network.ike.tooling.buildreport.ReportSession;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;

/**
 * Preview tightening the build-report acceptance ledger against the
 * latest session's observations (ike-issues#989).
 *
 * <p>Reads {@code .mvn/build-report.yaml} and the machine-readable
 * sidecar the build-report extension writes at session end
 * ({@code target/build-report-observations.yaml} — data, not scraped
 * receipt Markdown), and reports which accepted counts would move
 * <em>down</em>. Counts never loosen mechanically: accepting new or
 * grown findings is a deliberate human edit. Side-effect-free; the
 * {@code -publish} counterpart applies the plan.
 *
 * @see BuildReportRatchetPublishMojo
 */
@Mojo(name = IkeGoal.NAME_BUILD_REPORT_RATCHET_DRAFT, projectRequired = false,
      aggregator = true)
public class BuildReportRatchetDraftMojo extends AbstractGoalMojo {

    private static final DateTimeFormatter STAMP =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    /**
     * When {@code true}, the plan is applied and the ledger rewritten
     * canonically; set by {@link BuildReportRatchetPublishMojo}.
     */
    protected boolean publish;

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

    /**
     * Computes (and in publish mode applies) the downward ratchet plan.
     *
     * @return the goal report
     * @throws MojoException when the ledger or sidecar cannot be read,
     *                       or the rewritten ledger cannot be written
     */
    @Override
    protected GoalReportSpec runGoal() throws MojoException {
        IkeGoal goal = publish
                ? IkeGoal.BUILD_REPORT_RATCHET_PUBLISH
                : IkeGoal.BUILD_REPORT_RATCHET_DRAFT;
        Path root = getSession().getTopDirectory();
        Path ledgerPath = root.resolve(ReportSession.LEDGER_RELATIVE_PATH);
        Path sidecarPath = root.resolve(ReportSession.OBSERVATIONS_RELATIVE_PATH);

        StringBuilder body = new StringBuilder(512);
        body.append('_').append(STAMP.format(LocalDateTime.now())).append("_\n\n");

        Ledger ledger;
        try {
            ledger = Ledger.load(ledgerPath);
        } catch (IOException | IllegalArgumentException e) {
            throw new MojoException("Cannot read " + ledgerPath + ": " + e.getMessage(), e);
        }
        if (ledger.entries().isEmpty()) {
            body.append("No accepted entries in `")
                    .append(ReportSession.LEDGER_RELATIVE_PATH)
                    .append("` — nothing to ratchet.\n");
            getLog().info("build-report ratchet: no accepted entries — nothing to do");
            return new GoalReportSpec(goal, root, body.toString());
        }

        Map<String, Long> observed;
        try {
            observed = ObservationsFile.readObserved(sidecarPath);
        } catch (IOException | IllegalArgumentException e) {
            throw new MojoException("Cannot read " + sidecarPath + ": " + e.getMessage(), e);
        }
        if (observed.isEmpty()) {
            body.append("No observations sidecar at `")
                    .append(ReportSession.OBSERVATIONS_RELATIVE_PATH)
                    .append("` — run a build with the build-report extension "
                            + "active, then re-run this goal.\n");
            getLog().warn("build-report ratchet: no observations sidecar — run a build first");
            return new GoalReportSpec(goal, root, body.toString());
        }

        RatchetPlanner.Plan plan = RatchetPlanner.plan(ledger, observed);
        if (!plan.changesAnything()) {
            body.append("Ledger is already tight: every accepted count is at "
                    + "or below its latest observation.\n");
            getLog().info("build-report ratchet: ledger already tight");
            return new GoalReportSpec(goal, root, body.toString());
        }

        body.append(publish ? "## Tightened\n\n" : "## Would tighten\n\n");
        for (RatchetPlanner.Tightening tightening : plan.tightenings()) {
            body.append("- `").append(tightening.entry().key()).append("` — count ")
                    .append(tightening.entry().count()).append(" → ")
                    .append(tightening.observed());
            if (tightening.observed() == 0) {
                body.append(" (kept at 0 as a documented tripwire)");
            }
            body.append('\n');
            getLog().info("  " + (publish ? "tightened " : "would tighten ")
                    + tightening.entry().key() + ": " + tightening.entry().count()
                    + " -> " + tightening.observed());
        }
        body.append('\n');

        if (publish) {
            try {
                LedgerWriter.write(ledgerPath, plan.result());
            } catch (IOException e) {
                throw new MojoException("Cannot write " + ledgerPath + ": " + e.getMessage(), e);
            }
            body.append("Ledger rewritten canonically at `")
                    .append(ReportSession.LEDGER_RELATIVE_PATH)
                    .append("`. Counts only move down; loosening is a human edit.\n");
        } else {
            body.append("Apply with `mvn ike:")
                    .append(IkeGoal.NAME_BUILD_REPORT_RATCHET_PUBLISH).append("`.\n");
        }
        return new GoalReportSpec(goal, root, body.toString());
    }
}