OverviewWorkspaceMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.workspace.Subproject;
import network.ike.workspace.Dependency;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.vcs.VcsOperations;
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.File;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Consolidated workspace overview — manifest, graph, status, cascade.
 *
 * <p>Replaces the former separate {@code ws:dashboard}, {@code ws:status},
 * and {@code ws:graph} goals with a single command. Loads the manifest
 * once and presents four sections:
 *
 * <ol>
 *   <li><b>Manifest</b> — subproject count, consistency check</li>
 *   <li><b>Graph</b> — dependency order with direct dependencies</li>
 *   <li><b>Status</b> — branch, SHA, clean/uncommitted per subproject</li>
 *   <li><b>Cascade</b> — downstream rebuild impact of components with
 *       uncommitted changes</li>
 * </ol>
 *
 * <p>Use {@code -Dformat=dot} to output Graphviz DOT format instead
 * of the overview (delegates to graph rendering).
 *
 * <pre>{@code
 * mvn ws:overview
 * mvn ws:overview -Dformat=dot
 * }</pre>
 */
@Mojo(name = "overview", projectRequired = false, aggregator = true)
public class OverviewWorkspaceMojo extends AbstractWorkspaceMojo {

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

    /** Output format: "overview" (default) or "dot" (Graphviz DOT). */
    @Parameter(property = "format", defaultValue = "overview")
    String format;

    /**
     * Kroki server base URL used to render the dependency graph in
     * the markdown report (#533). Defaults to {@code https://kroki.io}.
     * Pass an empty string to disable Kroki rendering and emit only
     * the raw DOT block (useful for fully air-gapped runs).
     * Self-hosted Kroki users override with their endpoint.
     */
    @Parameter(property = "ws.overview.krokiUrl",
            defaultValue = "https://kroki.io")
    String krokiUrl;

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        WorkspaceGraph graph = loadGraph();

        // DOT mode — delegate to graph-only output
        if ("dot".equalsIgnoreCase(format)) {
            printDot(graph);
            return new WorkspaceReportSpec(WsGoal.OVERVIEW,
                    "DOT format emitted to console — no report generated.\n");
        }

        File root = workspaceRoot();

        getLog().info("");
        getLog().info(header("Overview"));
        getLog().info("══════════════════════════════════════════════════════════════");

        // ── Section 1: Manifest ─────────────────────────────────────
        List<String> errors = graph.verify();
        getLog().info("");
        if (errors.isEmpty()) {
            getLog().info(Ansi.green("  ✓ ") + "Manifest: "
                    + graph.manifest().subprojects().size()
                    + " components — consistent");
        } else {
            getLog().warn(Ansi.red("  ✗ ") + "Manifest: "
                    + errors.size() + " error(s)");
            for (String error : errors) {
                getLog().warn("    " + error);
            }
        }

        // ── Section 2: Dependency Graph ─────────────────────────────
        List<String> sorted = graph.topologicalSort();
        getLog().info("");
        getLog().info("  Graph (dependency order)");
        getLog().info("  ──────────────────────────────────────────────────────");

        List<String[]> graphRows = new ArrayList<>();
        for (int i = 0; i < sorted.size(); i++) {
            String name = sorted.get(i);
            Subproject sub = graph.manifest().subprojects().get(name);
            String deps = sub.dependsOn().isEmpty() ? "—"
                    : sub.dependsOn().stream()
                        .map(Dependency::subproject)
                        .collect(Collectors.joining(", "));

            getLog().info(String.format("  %2d. %-24s → %s",
                    i + 1, name, deps));
            graphRows.add(new String[]{String.valueOf(i + 1), name, deps});
        }

        // ── Section 3: Working-Set Status ────────────────────────────
        // #763/#767: iterate the resolved working set — the declared
        // subprojects PLUS the workspace root (aggregator) — so the
        // aggregator's own pom version, branch, and tree state surface
        // in the report. A subproject-only loop hid the root left on a
        // stale {@code 1-<feature>-SNAPSHOT}; the aggregator is now a
        // first-class member row.
        getLog().info("");
        getLog().info("  Status");
        getLog().info("  ──────────────────────────────────────────────────────");
        getLog().info(String.format("  %-24s %-16s %-24s %-8s %s",
                "COMPONENT", "VERSION", "BRANCH", "SHA", ""));

        List<String> modifiedComponents = new ArrayList<>();
        List<WorkingSetReportTable.Row> statusRows = new ArrayList<>();
        int cloned = 0;
        int notCloned = 0;

        for (WorkingSet.Member member : resolveWorkingSet().members()) {
            String name = member.name();
            File dir = member.directory().toFile();
            // A subproject may not be cloned yet; the aggregator's root
            // directory always exists. The manifest entry is present for
            // subprojects only — the aggregator has none.
            Subproject sub = graph.manifest().subprojects().get(name);

            if (!dir.exists()) {
                notCloned++;
                getLog().info(String.format("  %-24s %-16s %-24s %-8s %s",
                        name, "—", "—", "—", "not cloned"));
                statusRows.add(new WorkingSetReportTable.Row(
                        member, "—", "—", "—", "not cloned"));
                continue;
            }

            cloned++;
            String branch = gitBranch(dir);
            String sha = gitShortSha(dir);
            String status = gitStatus(dir);
            // #533: POM version surfaces stale -SNAPSHOTs, missed
            // version-reset on a feature branch, etc. For the aggregator
            // this is the #763 fix — the root pom version is surfaced.
            String version = readPomVersion(dir);

            // #533: also surface ahead/behind against the upstream
            // tracking branch ({@code @{u}}), so unpushed commits and
            // unfetched remote work both show up in the Status column
            // rather than hiding behind a "clean" tree.
            Optional<int[]> aheadBehind = VcsOperations.aheadBehindUpstream(dir);

            String marker;
            String statusText;
            boolean clean = status.isEmpty();
            if (clean) {
                marker = Ansi.green("✓");
                statusText = "clean";
            } else {
                long count = status.lines().count();
                marker = Ansi.red("✗") + " " + count + " changed";
                statusText = "uncommitted (" + count + " files)";
                // Cascade impact is graph-driven; only declared
                // subprojects have downstream rebuild edges. The
                // aggregator is not a graph node, so it never seeds
                // the cascade even when its tree is dirty.
                if (!member.isAggregator()) {
                    modifiedComponents.add(name);
                }
            }
            if (aheadBehind.isPresent()) {
                int ahead = aheadBehind.get()[0];
                int behind = aheadBehind.get()[1];
                if (ahead > 0 || behind > 0) {
                    StringBuilder delta = new StringBuilder();
                    if (ahead > 0) delta.append(ahead).append(" ahead");
                    if (behind > 0) {
                        if (delta.length() > 0) delta.append(", ");
                        delta.append(behind).append(" behind");
                    }
                    statusText += "; " + delta + " origin";
                    // When the tree itself is clean, promote the
                    // ahead/behind delta to the CLI marker so the
                    // operator notices unpushed or unfetched work
                    // without scanning the status column.
                    if (clean) {
                        if (ahead > 0 && behind == 0) {
                            marker = Ansi.yellow("↑") + " " + ahead + " unpushed";
                        } else if (behind > 0 && ahead == 0) {
                            marker = Ansi.yellow("↓") + " " + behind + " unfetched";
                        } else {
                            marker = Ansi.yellow("⇅") + " " + ahead + "↑/" + behind + "↓";
                        }
                    }
                }
            }

            String branchCol = branch;
            if (sub != null && sub.branch() != null
                    && !branch.equals(sub.branch())) {
                branchCol = branch + Ansi.yellow(" ⚠");
            }

            getLog().info(String.format("  %-24s %-16s %-24s %-8s %s",
                    name, version, branchCol, sha, marker));
            statusRows.add(new WorkingSetReportTable.Row(
                    member, version, branch, sha, statusText));
        }

        getLog().info("");
        getLog().info("  " + cloned + " cloned, " + notCloned + " not cloned, "
                + modifiedComponents.size() + " with uncommitted changes");

        // ── Section 4: Feature Branch Divergence ────────────────────
        // If any subproject is on a feature branch, show how far it has
        // diverged from main — helps developers keep long-lived branches
        // up to date. This stays subproject-scoped: divergence-from-main
        // is a per-subproject concern, distinct from the working-set
        // Status table above.
        Set<String> targets = graph.manifest().subprojects().keySet();
        List<String[]> divergenceRows = new ArrayList<>();
        boolean anyOnFeature = false;
        int warnThreshold = 20;

        for (String name : targets) {
            File dir = new File(root, name);
            if (!dir.exists() || !new File(dir, ".git").exists()) continue;

            String branch = gitBranch(dir);
            if (!branch.startsWith("feature/")) continue;

            anyOnFeature = true;
            try {
                List<String> behind = VcsOperations.commitLog(dir, branch, "main");
                List<String> ahead = VcsOperations.commitLog(dir, "main", branch);

                String divergence;
                String marker;
                if (behind.isEmpty()) {
                    divergence = "up to date";
                    marker = Ansi.green("✓");
                } else if (behind.size() >= warnThreshold) {
                    divergence = behind.size() + " behind, "
                            + ahead.size() + " ahead";
                    marker = Ansi.red("⚠") + " consider ws:update-feature";
                } else {
                    divergence = behind.size() + " behind, "
                            + ahead.size() + " ahead";
                    marker = Ansi.yellow("·");
                }

                divergenceRows.add(new String[]{name, branch,
                        String.valueOf(behind.size()),
                        String.valueOf(ahead.size()), divergence});

                if (!anyOnFeature) continue; // skip printing header until first
            } catch (MojoException e) {
                // Can't determine divergence (no main branch, etc.) — skip
                divergenceRows.add(new String[]{name, branch,
                        "?", "?", "unknown"});
            }
        }

        if (anyOnFeature) {
            getLog().info("");
            getLog().info("  Feature Branch Divergence (from main)");
            getLog().info("  ──────────────────────────────────────────────────────");

            for (String[] row : divergenceRows) {
                String name = row[0];
                int behind = "?".equals(row[2]) ? -1 : Integer.parseInt(row[2]);
                int ahead = "?".equals(row[3]) ? -1 : Integer.parseInt(row[3]);

                String marker;
                String detail;
                if (behind == 0) {
                    marker = Ansi.green("✓");
                    detail = "up to date";
                } else if (behind < 0) {
                    marker = "?";
                    detail = "unknown";
                } else if (behind >= warnThreshold) {
                    marker = Ansi.red("⚠");
                    detail = behind + " commit(s) behind main, "
                            + ahead + " ahead — consider ws:update-feature";
                } else {
                    marker = Ansi.yellow("·");
                    detail = behind + " commit(s) behind main, " + ahead + " ahead";
                }

                getLog().info(String.format("  %-24s %s %s",
                        name, marker, detail));
            }
        }

        // ── Section 5: Cascade ──────────────────────────────────────
        List<String[]> cascadeRows = new ArrayList<>();
        if (!modifiedComponents.isEmpty()) {
            Set<String> allAffected = new LinkedHashSet<>();
            for (String sub : modifiedComponents) {
                allAffected.addAll(graph.cascade(sub));
            }
            allAffected.removeAll(modifiedComponents);

            if (!allAffected.isEmpty()) {
                getLog().info("");
                getLog().info("  Cascade — components needing rebuild:");
                getLog().info("  ──────────────────────────────────────────────────────");

                List<String> buildOrder = graph.topologicalSort(
                        new LinkedHashSet<>(allAffected));
                for (String name : buildOrder) {
                    List<String> triggeredBy = new ArrayList<>();
                    Subproject sub = graph.manifest().subprojects().get(name);
                    if (sub != null) {
                        for (Dependency dep : sub.dependsOn()) {
                            if (modifiedComponents.contains(dep.subproject())
                                    || allAffected.contains(dep.subproject())) {
                                triggeredBy.add(dep.subproject());
                            }
                        }
                    }
                    String triggers = String.join(", ", triggeredBy);
                    getLog().info("    " + name + " ← " + triggers);
                    cascadeRows.add(new String[]{name, triggers});
                }
            }
        }

        getLog().info("");

        // Structured markdown report
        return new WorkspaceReportSpec(WsGoal.OVERVIEW, buildMarkdownReport(
                errors, graphRows, statusRows, divergenceRows, cascadeRows,
                cloned, notCloned, modifiedComponents.size(), graph));
    }

    // ── Markdown report ─────────────────────────────────────────────

    private String buildMarkdownReport(List<String> manifestErrors,
                                        List<String[]> graphRows,
                                        List<WorkingSetReportTable.Row> statusRows,
                                        List<String[]> divergenceRows,
                                        List<String[]> cascadeRows,
                                        int cloned, int notCloned,
                                        int modified,
                                        WorkspaceGraph graph) {
        GoalReportBuilder report = new GoalReportBuilder();

        report.paragraph(manifestErrors.isEmpty()
                ? "**Manifest:** consistent."
                : "**Manifest:** " + manifestErrors.size() + " error(s).");

        // GraphViz dependency graph — IKE-DIAGRAMS.md mandates
        // GraphViz for dependency graphs (IKE-Network/ike-issues#406).
        // #533: Render via Kroki + collapsed DOT source, since most
        // markdown viewers (GitHub web, VS Code preview, Claude Code's
        // session viewer) showed the bare ```dot``` block as raw text.
        report.section("Dependency Graph")
                .table(List.of("#", "Subproject", "Dependencies"), graphRows)
                .raw(DotGraphSupport.buildDotReportSection(graph, krokiUrl));

        // #763/#767: the working-set Status table — one row per member,
        // the workspace-root aggregator included, so its (possibly stale)
        // pom version is visible. Read-only goal ⇒ final column "Status".
        // The summary lead-in precedes the section that render() opens.
        report.paragraph(cloned + " cloned, " + notCloned + " not cloned, "
                + modified + " with uncommitted changes.");
        WorkingSetReportTable.render(report, "Status", "Status", statusRows);

        if (!divergenceRows.isEmpty()) {
            report.section("Feature Branch Divergence")
                    .table(List.of("Subproject", "Branch", "Behind", "Ahead",
                            "Status"), divergenceRows);
        }

        if (!cascadeRows.isEmpty()) {
            report.section("Cascade")
                    .table(List.of("Subproject", "Triggered By"), cascadeRows);
        }

        return report.build();
    }

    /**
     * Read the {@code <version>} from {@code <dir>/pom.xml}. Returns
     * {@code "—"} if the POM is missing or unreadable so the overview
     * never fails on a partially-cloned subproject. The POM is the
     * source of truth for #533 — the workspace.yaml's version field
     * can drift if a feature branch was version-bumped but the
     * manifest didn't get a corresponding update.
     *
     * @param dir subproject directory
     * @return the POM {@code <version>} or {@code "—"} when unavailable
     */
    private String readPomVersion(File dir) {
        File pom = new File(dir, "pom.xml");
        if (!pom.isFile()) return "—";
        try {
            return ReleaseSupport.readPomVersion(pom);
        } catch (MojoException e) {
            return "—";
        }
    }

    // ── DOT output ──────────────────────────────────────────────────

    private void printDot(WorkspaceGraph graph) {
        for (String line
                : DotGraphSupport.dotFromGraph(graph).split("\n")) {
            getLog().info(line);
        }
    }
}