ReleaseStatusInspector.java

package network.ike.plugin.ws;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/**
 * Pure inference logic for {@link WsReleaseStatusMojo} — given a
 * snapshot of git observations for one subproject, classifies the
 * subproject's release state.
 *
 * <p>This class is git-only and side-effect free. The mojo is
 * responsible for collecting an {@link Observation} from the live
 * repository (via {@code git} subprocesses); this class encapsulates
 * the rules that turn that observation into a {@link Finding}.
 *
 * <p>The split exists so that the classification rules can be
 * exercised without building a real git repository on disk for every
 * scenario — see {@code WsReleaseStatusInspectorTest}. End-to-end
 * coverage that spans real {@code git tag}, {@code git branch}, and
 * remote interaction lives in {@code WsReleaseStatusIntegrationTest}.
 *
 * <p>See issue #187.
 */
public final class ReleaseStatusInspector {

    private ReleaseStatusInspector() {}

    /**
     * High-level summary of a subproject's release state, derived from
     * git artifacts alone. Cycle 2+ of #187 will refine these states
     * once a {@code .ike/release-state.json} file is also available.
     */
    public enum Status {
        /**
         * No in-flight release artifacts. The subproject is either
         * fully released or has never been released.
         */
        CLEAN("✓", "clean"),

        /**
         * The subproject has at least one of: a {@code release/*}
         * branch left behind by an interrupted release, a local
         * {@code v*} tag that was never pushed to {@code origin}.
         * A previous release attempt did not run to completion.
         */
        IN_FLIGHT("⚠", "in-flight"),

        /**
         * Contradictory signals: a {@code release/*} branch is still
         * present locally, AND the corresponding {@code v*} tag is
         * already on {@code origin}. The release likely completed on
         * another machine (or via a manual recovery), and the local
         * branch is stale debris.
         */
        DIVERGED("✗", "diverged"),

        /**
         * The subproject directory is not present in the workspace
         * checkout. No inference possible.
         */
        ABSENT("─", "not checked out");

        private final String badge;
        private final String label;

        Status(String badge, String label) {
            this.badge = badge;
            this.label = label;
        }

        /** Single-character glyph suitable for status-line output. */
        public String badge() {
            return badge;
        }

        /** Lowercase human label. */
        public String label() {
            return label;
        }
    }

    /**
     * Raw git observations for a single subproject. Built by the mojo
     * from real {@code git} subprocesses (or by a test fixture).
     *
     * @param subprojectName        the subproject name from {@code workspace.yaml}
     * @param checkedOut            whether the subproject directory exists
     *                              and contains a git repository
     * @param currentVersion        the {@code <version>} value read from
     *                              the subproject's root POM, or
     *                              {@code "unknown"} if it cannot be read
     * @param currentBranch         the current branch name, or
     *                              {@code "unknown"}
     * @param releaseBranches       local branch names matching
     *                              {@code release/*} (typically empty
     *                              when no release is in flight)
     * @param localTags             local tags matching {@code v*}
     * @param remoteTags            tags present on {@code origin}
     *                              matching {@code v*} (best-effort —
     *                              empty when {@code origin} cannot be
     *                              reached)
     * @param remoteReachable       whether the {@code origin} remote
     *                              could be queried; {@code false}
     *                              suppresses the local-only-tag
     *                              warning to avoid false positives
     */
    public record Observation(
            String subprojectName,
            boolean checkedOut,
            String currentVersion,
            String currentBranch,
            List<String> releaseBranches,
            Set<String> localTags,
            Set<String> remoteTags,
            boolean remoteReachable) {}

    /**
     * Classification result for a single subproject. The
     * {@link #details()} list captures the raw signals that led to
     * the chosen {@link #status()}, suitable for direct rendering as
     * indented bullet lines in the goal output.
     *
     * @param subprojectName            the subproject name
     * @param status                    classification verdict
     * @param currentVersion            the {@code <version>} from POM
     * @param currentBranch             the checked-out branch
     * @param inFlightReleaseBranches   {@code release/*} branches still
     *                                  present locally
     * @param localOnlyTags             local {@code v*} tags not yet on
     *                                  {@code origin}
     * @param details                   ordered diagnostic lines
     *                                  describing the inputs that led
     *                                  to {@code status}
     */
    public record Finding(
            String subprojectName,
            Status status,
            String currentVersion,
            String currentBranch,
            List<String> inFlightReleaseBranches,
            List<String> localOnlyTags,
            List<String> details) {}

    /**
     * Apply the classification rules to a single observation.
     *
     * <p>Rules, in order of precedence:
     * <ol>
     *   <li>If the subproject is not checked out, return
     *       {@link Status#ABSENT}.</li>
     *   <li>If a {@code release/*} branch is present locally <em>and</em>
     *       the corresponding {@code v<version>} tag is already on
     *       {@code origin}, return {@link Status#DIVERGED} — the
     *       release happened elsewhere; the local branch is debris.</li>
     *   <li>If any {@code release/*} branch exists locally, or any
     *       local {@code v*} tag is missing from the remote (and the
     *       remote was reachable), return {@link Status#IN_FLIGHT}.</li>
     *   <li>Otherwise return {@link Status#CLEAN}.</li>
     * </ol>
     *
     * @param obs the git observation snapshot for one subproject
     * @return the classification result
     */
    public static Finding classify(Observation obs) {
        if (!obs.checkedOut()) {
            return new Finding(
                    obs.subprojectName(),
                    Status.ABSENT,
                    obs.currentVersion(),
                    obs.currentBranch(),
                    List.of(),
                    List.of(),
                    List.of("Subproject directory not present."));
        }

        List<String> details = new ArrayList<>();

        // Local-only tags = local tags missing on origin (only meaningful
        // when origin was reachable; otherwise the absence is noise).
        List<String> localOnlyTags;
        if (obs.remoteReachable()) {
            localOnlyTags = new ArrayList<>();
            for (String t : obs.localTags()) {
                if (!obs.remoteTags().contains(t)) {
                    localOnlyTags.add(t);
                }
            }
        } else {
            localOnlyTags = List.of();
            details.add("origin unreachable — local-only tag check skipped.");
        }

        List<String> releaseBranches = new ArrayList<>(obs.releaseBranches());

        // DIVERGED: release branch present AND its tag is on origin.
        // The release was carried to completion elsewhere and the
        // branch debris should be cleaned up locally.
        Set<String> remoteVersions = new LinkedHashSet<>();
        for (String t : obs.remoteTags()) {
            if (t.startsWith("v")) {
                remoteVersions.add(t.substring(1));
            }
        }
        boolean diverged = false;
        for (String branch : releaseBranches) {
            // branch shape is "release/<version>"
            if (branch.startsWith("release/")) {
                String version = branch.substring("release/".length());
                if (remoteVersions.contains(version)) {
                    diverged = true;
                    details.add("Release branch '" + branch
                            + "' is local-only, but origin already has tag v"
                            + version + ".");
                }
            }
        }
        if (diverged) {
            return new Finding(
                    obs.subprojectName(),
                    Status.DIVERGED,
                    obs.currentVersion(),
                    obs.currentBranch(),
                    releaseBranches,
                    localOnlyTags,
                    details);
        }

        if (!releaseBranches.isEmpty()) {
            details.add("Release branch(es) still present locally: "
                    + String.join(", ", releaseBranches));
        }
        if (!localOnlyTags.isEmpty()) {
            details.add("Local tag(s) not on origin: "
                    + String.join(", ", localOnlyTags));
        }

        if (!releaseBranches.isEmpty() || !localOnlyTags.isEmpty()) {
            return new Finding(
                    obs.subprojectName(),
                    Status.IN_FLIGHT,
                    obs.currentVersion(),
                    obs.currentBranch(),
                    releaseBranches,
                    localOnlyTags,
                    details);
        }

        details.add("No in-flight release artifacts.");
        return new Finding(
                obs.subprojectName(),
                Status.CLEAN,
                obs.currentVersion(),
                obs.currentBranch(),
                List.of(),
                List.of(),
                details);
    }
}