AbstractWorkspaceMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;

import network.ike.workspace.FeatureName;
import network.ike.workspace.Manifest;
import network.ike.workspace.ManifestException;
import network.ike.workspace.ManifestReader;
import network.ike.workspace.MavenVersion;
import network.ike.workspace.SubprojectName;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.support.ConsoleIkePrompter;
import network.ike.plugin.support.IkePrompter;
import org.apache.maven.api.Session;
import org.apache.maven.api.di.Inject;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.Mojo;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.io.File;
import java.nio.file.Path;
import java.util.List;

/**
 * Base class for workspace goals that read {@code workspace.yaml}.
 *
 * <p>Resolves the manifest by searching upward from the invocation
 * directory for a file named {@code workspace.yaml}. All workspace
 * goals inherit this resolution logic.
 */
abstract class AbstractWorkspaceMojo implements Mojo {

    /**
     * Maven logger, injected by the Maven 4 DI container.
     */
    @Inject
    private Log log;

    /**
     * Maven session, injected by the DI container — consulted for
     * {@code interactiveMode} when building the {@link IkePrompter}.
     */
    @Inject
    private Session session;

    /**
     * Interactive-prompt abstraction (IKE-Network/ike-issues#385).
     * Lazily built by {@link #getPrompter()} from the session's
     * interactive flag; package-private setter injects a
     * {@link network.ike.plugin.support.ScriptedIkePrompter} in tests.
     */
    private IkePrompter prompter;

    /**
     * Path to workspace.yaml. If not set, searches upward from the
     * current directory. Package-private for test access.
     */
    @Parameter(property = "workspace.manifest")
    File manifest;

    /**
     * Access the Maven logger.
     *
     * @return the logger instance
     */
    protected Log getLog() {
        return log;
    }

    /**
     * Access the Maven session injected by Maven 4's plugin DI.
     *
     * @return the injected session (may be {@code null} in unit tests)
     */
    protected Session getSession() {
        return session;
    }

    /**
     * Replace the logger. Used when a mojo is constructed directly
     * (not via Maven's DI container) and so never had a logger
     * injected — {@link WsSyncMojo} drives {@link PullWorkspaceMojo}
     * and {@link PushMojo} instances it created itself.
     *
     * @param log the replacement logger
     */
    protected void setLog(Log log) {
        this.log = log;
    }

    /**
     * Inject an {@link IkePrompter} (typically a
     * {@link network.ike.plugin.support.ScriptedIkePrompter}) for
     * tests. Production code lets {@link #getPrompter()} build one.
     *
     * @param prompter the prompter implementation to use
     */
    void setPrompter(IkePrompter prompter) {
        this.prompter = prompter;
    }

    /**
     * The {@link IkePrompter} for this goal — built lazily from the
     * session's interactive flag, or the test-injected instance.
     * Callers that pass it to a static helper (e.g.
     * {@link FeatureFinishSupport#promptStaleBranchCleanup}) use this.
     *
     * @return the prompter (never {@code null})
     */
    protected IkePrompter getPrompter() {
        if (prompter == null) {
            // No session (unit tests) or batch mode -> not interactive,
            // so the prompter declines rather than blocking on stdin.
            boolean interactive = session != null
                    && session.getSettings().isInteractiveMode();
            prompter = new ConsoleIkePrompter(getLog(), interactive);
        }
        return prompter;
    }

    /**
     * Load the manifest and build the workspace graph.
     *
     * @return the workspace dependency graph
     * @throws MojoException if the manifest cannot be read
     */
    protected WorkspaceGraph loadGraph() {
        Path manifestPath = resolveManifest();
        getLog().debug("Reading manifest: " + manifestPath);
        try {
            Manifest m = ManifestReader.read(manifestPath);
            return new WorkspaceGraph(m);
        } catch (ManifestException e) {
            throw new MojoException(
                    "Failed to read workspace manifest: " + e.getMessage(), e);
        }
    }

    /**
     * Resolve the manifest path — explicit parameter, or search upward.
     *
     * @return path to the workspace manifest file
     * @throws MojoException if the manifest cannot be found
     */
    protected Path resolveManifest() {
        if (manifest != null && manifest.exists()) {
            return manifest.toPath();
        }

        // Search upward from current directory
        Path dir = Path.of(System.getProperty("user.dir"));
        while (dir != null) {
            Path candidate = dir.resolve("workspace.yaml");
            if (candidate.toFile().exists()) {
                return candidate;
            }
            dir = dir.getParent();
        }

        throw new MojoException(
                "Cannot find workspace.yaml. Specify -Dworkspace.manifest=<path> "
                        + "or run from within a workspace directory.");
    }

    /**
     * Resolve the workspace root directory (parent of workspace.yaml).
     *
     * @return the workspace root directory
     * @throws MojoException if the manifest cannot be found
     */
    protected File workspaceRoot() {
        return resolveManifest().getParent().toFile();
    }

    /**
     * Run {@code git status --porcelain} on a subproject directory and
     * return the output (empty string = clean).
     *
     * @param subprojectDir the subproject directory to check
     * @return git status output, empty if clean
     */
    protected String gitStatus(File subprojectDir) {
        try {
            return ReleaseSupport.execCapture(subprojectDir,
                    "git", "status", "--porcelain");
        } catch (Exception e) {
            return "ERROR: " + e.getMessage();
        }
    }

    /**
     * Get the current branch of a subproject directory.
     *
     * @param subprojectDir the subproject directory to check
     * @return the current branch name
     */
    protected String gitBranch(File subprojectDir) {
        try {
            return ReleaseSupport.execCapture(subprojectDir,
                    "git", "rev-parse", "--abbrev-ref", "HEAD");
        } catch (Exception e) {
            return "unknown";
        }
    }

    /**
     * Get the short SHA of HEAD for a subproject directory.
     *
     * @param subprojectDir the subproject directory to check
     * @return the short SHA of HEAD
     */
    protected String gitShortSha(File subprojectDir) {
        try {
            return ReleaseSupport.execCapture(subprojectDir,
                    "git", "rev-parse", "--short", "HEAD");
        } catch (Exception e) {
            return "???????";
        }
    }

    /**
     * Check whether a workspace.yaml exists in the directory hierarchy.
     * Does not throw — returns false if no manifest is found.
     *
     * @return true if running inside a workspace, false for a bare repo
     */
    protected boolean isWorkspaceMode() {
        try {
            resolveManifest();
            return true;
        } catch (MojoException e) {
            return false;
        }
    }

    /**
     * Prompt the user interactively for a required parameter when it
     * was not supplied on the command line.
     *
     * <p>Delegates to the {@link IkePrompter} (IKE-Network/ike-issues#385):
     * an inline prompt on a real terminal, an own-line prompt in a
     * piped IDE runner. In batch mode it throws a clear error
     * directing the user to pass the property explicitly.
     *
     * @param currentValue the value from the {@code @Parameter} field (may be null)
     * @param propertyName the {@code -D} property name (for the error message)
     * @param promptLabel  human-readable label shown in the prompt;
     *                     callers pass it without trailing punctuation
     *                     (a {@code ": "} separator is appended here)
     * @return the resolved value — either the original or user-supplied
     * @throws MojoException if no value can be obtained
     */
    protected String requireParam(String currentValue, String propertyName,
                                  String promptLabel) {
        if (currentValue != null && !currentValue.isBlank()) {
            return currentValue.trim();
        }
        IkePrompter p = getPrompter();
        if (p.isInteractive()) {
            String input = p.prompt(promptLabel + ": ");
            if (input != null && !input.isBlank()) {
                return input.trim();
            }
        }
        throw new MojoException(
                propertyName + " is required. Specify -D" + propertyName
                        + "=<value> or run interactively.");
    }

    /**
     * Validate a feature-name string with {@link FeatureName#of} and
     * surface any rule violation as a clean {@link MojoException} (the
     * raw {@code IllegalArgumentException} would otherwise bubble up
     * as a Maven internal error). Use this anywhere a feature name
     * leaves the {@code -Dfeature=} command-line boundary
     * (ike-issues#205).
     *
     * @param feature the candidate feature name (must already be
     *                resolved — i.e. non-null after
     *                {@link #requireParam} or auto-detection)
     * @return the validated {@link FeatureName} value
     * @throws MojoException if {@code feature} fails the
     *                       {@code FeatureName} syntax rules
     */
    protected static FeatureName validateFeatureName(String feature) {
        try {
            return FeatureName.of(feature);
        } catch (IllegalArgumentException e) {
            throw new MojoException(e.getMessage());
        }
    }

    /**
     * Validate a subproject-name string with {@link SubprojectName#of}
     * and surface any rule violation as a {@link MojoException}
     * (ike-issues#295).
     *
     * @param subproject the candidate subproject name (already
     *                   resolved — non-null after
     *                   {@link #requireParam} or POM derivation)
     * @return the validated {@link SubprojectName} value
     * @throws MojoException if {@code subproject} fails the
     *                       {@code SubprojectName} syntax rules
     */
    protected static SubprojectName validateSubprojectName(String subproject) {
        try {
            return SubprojectName.of(subproject);
        } catch (IllegalArgumentException e) {
            throw new MojoException(e.getMessage());
        }
    }

    /**
     * Validate a Maven version string with {@link MavenVersion#of}
     * and surface any rule violation as a {@link MojoException}
     * (ike-issues#295). Per
     * {@code feedback_no_semver_assumption} the validator accepts
     * single-segment monotonic, semver-like, calendar-based, and
     * branch-qualified versions; it does not enforce semver.
     *
     * @param version the candidate version string
     * @return the validated {@link MavenVersion} value
     * @throws MojoException if {@code version} fails the
     *                       {@code MavenVersion} syntax rules
     */
    protected static MavenVersion validateMavenVersion(String version) {
        try {
            return MavenVersion.of(version);
        } catch (IllegalArgumentException e) {
            throw new MojoException(e.getMessage());
        }
    }

    /**
     * Prompt the user with a yes/no question, accepting "y"/"yes"/"n"/"no"
     * (case-insensitive). When invoked in a non-interactive context, the
     * default is used.
     *
     * @param label      the question to display (without trailing punctuation)
     * @param defaultYes whether {@code true} (yes) is the default
     * @return {@code true} for yes, {@code false} for no
     * @throws MojoException if no answer can be obtained
     */
    protected boolean confirm(String label, boolean defaultYes) {
        return getPrompter().confirm(label, defaultYes);
    }

    /**
     * Prompt the user to pick from a numbered list. Returns the chosen
     * option, or {@code null} when the list is empty.
     *
     * @param label   prompt header (printed via the Prompter as a message)
     * @param options ordered list of choices
     * @return the chosen option, or {@code null} if {@code options} is empty
     * @throws MojoException if no valid choice can be obtained
     */
    protected String selectFromList(String label, List<String> options) {
        if (options == null || options.isEmpty()) {
            return null;
        }
        String chosen = getPrompter().select(label, options);
        if (chosen == null) {
            throw new MojoException(
                    "Could not read a valid selection for: " + label);
        }
        return chosen;
    }

    /**
     * Read the workspace name from the root POM's artifactId.
     * Falls back to "Workspace" if the POM cannot be read.
     *
     * @return the workspace name derived from the root POM artifactId
     */
    protected String workspaceName() {
        try {
            File rootPom = new File(workspaceRoot(), "pom.xml");
            if (rootPom.exists()) {
                return ReleaseSupport.readPomArtifactId(rootPom);
            }
        } catch (Exception e) {
            // Fall through
        }
        return "Workspace";
    }

    /**
     * Format a goal header line using the workspace name.
     * Example: "komet-ws — Status"
     *
     * @param goalName the goal name to display in the header
     * @return the formatted header string
     */
    protected String header(String goalName) {
        return workspaceName() + " — " + goalName;
    }

    /**
     * Run the goal and write its report.
     *
     * <p>This method is {@code final}: every {@code ws:*} goal follows
     * the same template — do the work, then write exactly one report.
     * Subclasses supply the work and the report content by implementing
     * {@link #runGoal()}; they cannot override {@code execute()} to skip
     * the report. That is what makes report-writing structural — a goal
     * that compiles necessarily writes a report, so the #407 bug class
     * (a goal runs fine but silently writes none) becomes
     * compiler-impossible (IKE-Network/ike-issues#413).
     *
     * <p>The report lands in its per-goal file at the workspace root
     * ({@code ws꞉goal-name.md}); {@link WorkspaceReport} self-heals the
     * nearest {@code .gitignore} so reports never land in git. A
     * {@code -publish} run does not delete the matching {@code -draft}
     * report — both are timestamped history (ike-issues#413).
     *
     * @throws MojoException if the goal fails
     */
    @Override
    public final void execute() throws MojoException {
        WorkspaceReportSpec report = runGoal();
        try {
            WorkspaceReport.write(workspaceRoot().toPath(),
                    report.goal().qualified(), report.content(), getLog());
        } catch (MojoException e) {
            getLog().debug("Could not resolve workspace root for report: "
                    + e.getMessage());
        }
    }

    /**
     * Run this goal's work and return the report it produced.
     *
     * <p>Implementations do the goal's actual work here and return a
     * {@link WorkspaceReportSpec} — the goal identity and the Markdown
     * body. The base class resolves the workspace root and writes the
     * file. A goal cannot be implemented without producing a report,
     * which is the structural fix for the missing-report bug class
     * (IKE-Network/ike-issues#413 / #407).
     *
     * <p>On failure, throw {@link MojoException} as usual — a failed
     * goal produces no report, and Maven surfaces the exception.
     *
     * @return the report this goal produced (never {@code null})
     * @throws MojoException if the goal fails
     */
    protected abstract WorkspaceReportSpec runGoal() throws MojoException;
}