FeatureStartSupport.java

package network.ike.plugin.ws;

import network.ike.plugin.PomRewriter;

import network.ike.plugin.ReleaseSupport;
import network.ike.workspace.BomAnalysis;
import network.ike.workspace.Dependency;
import network.ike.workspace.PublishedArtifactSet;
import network.ike.workspace.Subproject;
import network.ike.workspace.VersionSupport;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Reusable helpers for {@code ws:feature-start} version qualification
 * and BOM/property cascade — extracted from {@link FeatureStartDraftMojo}
 * so they can be shared by sibling-clone work (ike-issues#201).
 *
 * <p>This is a pure mechanical extraction (ike-issues#204): every method
 * was lifted unchanged from {@code FeatureStartDraftMojo}, with
 * {@code getLog()} replaced by the injected {@link Log} field. No
 * behavior changes.
 *
 * <p>Each method takes the same parameters as the original and reads
 * the same workspace state — there is no instance state besides the
 * logger.
 */
final class FeatureStartSupport {

    private final Log log;

    /**
     * @param log Maven logger, used for all human-facing output and for
     *            forwarding to {@link ReleaseSupport} / git subprocess
     *            invocations
     */
    FeatureStartSupport(Log log) {
        this.log = log;
    }

    // ── Base-version resolution ──────────────────────────────────

    /**
     * Resolve a subproject's base version — the version feature-start
     * branch-qualifies.
     *
     * <p>The subproject's own POM {@code <version>} is authoritative. The
     * {@code workspace.yaml} {@code version:} is a checkpoint snapshot that
     * lags every post-release bump (a subproject released at {@code 6}
     * records {@code "6"} while its POM has moved to {@code 7-SNAPSHOT}),
     * so it is only the fallback for a subproject with no readable POM
     * (ike-issues#1135).
     *
     * @param sub the subproject definition
     * @param dir the subproject's directory
     * @return the base version, or {@code null} if neither the POM nor
     *         {@code workspace.yaml} yields one
     */
    String effectiveVersion(Subproject sub, File dir) {
        File pom = new File(dir, "pom.xml");
        if (pom.exists()) {
            try {
                String pomVersion = ReleaseSupport.readPomVersion(pom);
                if (pomVersion != null && !pomVersion.isEmpty()) {
                    return pomVersion;
                }
            } catch (MojoException e) {
                log.debug("Could not read POM version for " + sub.name()
                        + ": " + e.getMessage());
            }
        }
        String recorded = sub.version();
        return (recorded == null || recorded.isEmpty()) ? null : recorded;
    }

    /**
     * Resolve the base version of each named subproject via
     * {@link #effectiveVersion}. Callers resolve once, before any POM is
     * qualified, and hand the result to the qualification loop and to all
     * three cascades, so no cascade re-reads an already-qualified POM.
     *
     * @param graph the workspace dependency graph
     * @param root  the working-set root holding the subproject directories
     * @param names the subprojects to resolve
     * @return subproject name → base version, in {@code names} order;
     *         subprojects with no resolvable version are absent
     */
    Map<String, String> effectiveVersions(WorkspaceGraph graph, File root,
                                          List<String> names) {
        Map<String, String> versions = new LinkedHashMap<>();
        for (String name : names) {
            Subproject sub = graph.manifest().subprojects().get(name);
            String version = effectiveVersion(sub, new File(root, name));
            if (version != null) {
                versions.put(name, version);
            }
        }
        return versions;
    }

    /**
     * Branch-qualify every base version.
     *
     * @param baseVersions subproject name → base version
     * @param branchName   the feature branch name
     * @return subproject name → branch-qualified version
     */
    private static Map<String, String> qualifiedVersions(
            Map<String, String> baseVersions, String branchName) {
        Map<String, String> qualified = new LinkedHashMap<>();
        for (Map.Entry<String, String> entry : baseVersions.entrySet()) {
            qualified.put(entry.getKey(), VersionSupport.branchQualifiedVersion(
                    entry.getValue(), branchName));
        }
        return qualified;
    }

    // ── Version setting ──────────────────────────────────────────

    /**
     * Set the POM version, handling both simple and multi-module projects.
     * Uses ReleaseSupport's POM manipulation which skips the parent block.
     *
     * @param dir        the subproject root directory (containing {@code pom.xml})
     * @param oldVersion current POM version (used to find replacements)
     * @param newVersion target POM version
     * @throws MojoException if the rewrite or git operations fail
     */
    void setPomVersion(File dir, String oldVersion, String newVersion)
            throws MojoException {
        File pom = new File(dir, "pom.xml");
        if (!pom.exists()) {
            log.warn("    No pom.xml found in " + dir.getName());
            return;
        }

        // Set version in root POM
        ReleaseSupport.setPomVersion(pom, oldVersion, newVersion);

        // Also update any submodule POMs that reference the old version
        // in their <parent> block (for multi-module projects)
        try {
            List<File> allPoms = ReleaseSupport.findPomFiles(dir);
            for (File subPom : allPoms) {
                if (subPom.equals(pom)) continue;
                try {
                    String content = Files.readString(
                            subPom.toPath(), StandardCharsets.UTF_8);
                    if (content.contains("<version>" + oldVersion + "</version>")) {
                        String updated = content.replace(
                                "<version>" + oldVersion + "</version>",
                                "<version>" + newVersion + "</version>");
                        Files.writeString(
                                subPom.toPath(), updated,
                                StandardCharsets.UTF_8);
                        String rel = dir.toPath().relativize(subPom.toPath()).toString();
                        log.info("    updated: " + rel);
                        ReleaseSupport.exec(dir, log, "git", "add", rel);
                    }
                } catch (IOException e) {
                    log.warn("    Could not update " + subPom + ": " + e.getMessage());
                }
            }
        } catch (MojoException e) {
            log.warn("    Could not scan for submodule POMs: " + e.getMessage());
        }
    }

    /**
     * Commit the working tree's pending tracked changes in {@code dir}, or skip
     * cleanly when there is nothing to commit.
     *
     * <p>The feature-start cascade writes a POM then commits. Two failure modes
     * must both be avoided: a genuine no-op must not abort the goal with
     * "nothing to commit" (exit 1) before the aggregator is qualified
     * (ike-issues#820, same class as #636); and a real cascade edit must not be
     * silently left behind (ike-issues#821). Both stem from a prior
     * {@code git add <file>} not reliably registering across the goal's rapid
     * consecutive git invocations, so a {@code git diff --cached} check could
     * read empty and drop a real change.
     *
     * <p>This is robust against that: it detects pending <em>tracked</em> changes
     * with {@code git status --porcelain} (which reflects the working tree, not
     * just the index) and commits with {@code git commit -a}, which stages
     * tracked edits at commit time regardless of an earlier {@code git add}.
     * Untracked files are excluded so a stray report file is never swept in.
     *
     * @param dir     the git working directory to commit in
     * @param message the commit message
     * @throws MojoException if the commit itself fails
     */
    void commitIfStaged(File dir, String message) throws MojoException {
        String pending = ReleaseSupport.execCapture(
                dir, "git", "status", "--porcelain", "--untracked-files=no");
        if (pending == null || pending.isBlank()) {
            log.info("    (nothing to commit in " + dir.getName()
                    + " — skipped)");
            return;
        }
        ReleaseSupport.exec(dir, log, "git", "commit", "-a", "-m", message);
    }

    // ── Version-property cascade (declared in workspace.yaml) ────

    /**
     * Cascade version-property updates to downstream components.
     *
     * <p>When an upstream subproject's version changes (e.g., tinkar-core
     * gets a branch-qualified version), downstream components that track
     * that version via a POM property (declared as {@code version-property}
     * in workspace.yaml) need their property updated too.
     *
     * <p>For example, if rocks-kb depends on tinkar-core with
     * {@code version-property: ike-bom.version}, and tinkar-core's version
     * changed to {@code 1.127.2-feature-foo-SNAPSHOT}, then rocks-kb's
     * {@code <ike-bom.version>} property is updated to match.
     *
     * @param graph      the workspace dependency graph
     * @param root       workspace root directory
     * @param sorted       subprojects in topological order
     * @param baseVersions subproject name → base version, resolved by
     *                     {@link #effectiveVersions} before qualification
     * @param branchName   the feature branch name (e.g., {@code feature/foo})
     * @throws MojoException if a per-subproject git operation fails
     */
    void cascadeVersionProperties(WorkspaceGraph graph, File root,
                                  List<String> sorted,
                                  Map<String, String> baseVersions,
                                  String branchName)
            throws MojoException {

        Map<String, String> newVersions = qualifiedVersions(baseVersions, branchName);

        // For each subproject in topological order, update version-properties
        // that reference upstream subprojects
        for (String name : sorted) {
            Subproject sub = graph.manifest().subprojects().get(name);
            File dir = new File(root, name);
            File pomFile = new File(dir, "pom.xml");
            if (!pomFile.exists()) continue;

            try {
                String content = Files.readString(
                        pomFile.toPath(), StandardCharsets.UTF_8);
                String original = content;

                for (Dependency dep : sub.dependsOn()) {
                    String upstreamName = dep.subproject();
                    if (dep.versionProperty() == null) continue;
                    if (!newVersions.containsKey(upstreamName)) continue;

                    String upstreamVersion = newVersions.get(upstreamName);
                    String before = content;
                    content = PomRewriter.updateProperty(
                            content, dep.versionProperty(), upstreamVersion);

                    if (!content.equals(before)) {
                        log.info("    " + name + ": " + dep.versionProperty()
                                + " → " + upstreamVersion
                                + " (from " + upstreamName + ")");
                    }
                }

                if (!content.equals(original)) {
                    Files.writeString(
                            pomFile.toPath(), content,
                            StandardCharsets.UTF_8);
                    ReleaseSupport.exec(dir, log, "git", "add", "pom.xml");
                    commitIfStaged(dir,
                            "feature: update dependency versions for " + branchName);
                }
            } catch (IOException e) {
                log.warn("    Could not cascade version properties in "
                        + name + ": " + e.getMessage());
            }
        }
    }

    // ── BOM property cascade (by subproject name convention) ─────

    /**
     * Cascade branch-qualified versions into POM properties that match
     * workspace subproject names.
     *
     * <p>Scans each subproject's root POM {@code <properties>} block for
     * entries like {@code <tinkar-core.version>1.0.0-SNAPSHOT</tinkar-core.version>}
     * where "tinkar-core" matches a workspace subproject name. Updates
     * these properties to the branch-qualified version.
     *
     * <p>This complements {@link #cascadeVersionProperties} which only
     * handles properties explicitly declared via {@code version-property}
     * in workspace.yaml dependency entries.
     *
     * @param graph      the workspace dependency graph
     * @param root       workspace root directory
     * @param sorted       subprojects in topological order
     * @param baseVersions subproject name → base version, resolved by
     *                     {@link #effectiveVersions} before qualification
     * @param branchName   the feature branch name
     * @throws MojoException if a per-subproject git operation fails
     */
    void cascadeBomProperties(WorkspaceGraph graph, File root,
                              List<String> sorted,
                              Map<String, String> baseVersions,
                              String branchName)
            throws MojoException {

        Map<String, String> newVersions = qualifiedVersions(baseVersions, branchName);

        // For each subproject, check its POM properties for references
        // to other workspace subprojects (e.g., <tinkar-core.version>)
        for (String name : sorted) {
            File dir = new File(root, name);
            File pomFile = new File(dir, "pom.xml");
            if (!pomFile.exists()) continue;

            try {
                String content = Files.readString(
                        pomFile.toPath(), StandardCharsets.UTF_8);
                String original = content;

                for (Map.Entry<String, String> vEntry : newVersions.entrySet()) {
                    String subName = vEntry.getKey();
                    if (subName.equals(name)) continue;

                    String propertyName = subName + ".version";
                    String before = content;
                    content = PomRewriter.updateProperty(
                            content, propertyName, vEntry.getValue());

                    if (!content.equals(before)) {
                        log.info("    " + name + ": <" + propertyName
                                + "> → " + vEntry.getValue());
                    }
                }

                if (!content.equals(original)) {
                    Files.writeString(
                            pomFile.toPath(), content,
                            StandardCharsets.UTF_8);
                    ReleaseSupport.exec(dir, log, "git", "add", "pom.xml");
                    commitIfStaged(dir,
                            "feature: update BOM properties for " + branchName);
                }
            } catch (IOException e) {
                log.warn("    Could not cascade BOM properties in "
                        + name + ": " + e.getMessage());
            }
        }
    }

    // ── BOM-import cascade ───────────────────────────────────────

    /**
     * Cascade BOM-import version updates to downstream subprojects.
     *
     * <p>For each subproject in topological order, scans its POMs for
     * BOM imports ({@code <scope>import</scope>} + {@code <type>pom</type>})
     * whose {@code groupId:artifactId} matches an artifact published by
     * an upstream workspace subproject. When the upstream subproject's
     * version changed for the feature branch, the BOM import version is
     * rewritten to match.
     *
     * @param graph      the workspace dependency graph
     * @param root       workspace root directory
     * @param sorted       subprojects in topological order
     * @param baseVersions subproject name → base version, resolved by
     *                     {@link #effectiveVersions} before qualification
     * @param branchName   the feature branch name
     * @throws MojoException if a per-subproject git operation fails
     */
    void cascadeBomImports(WorkspaceGraph graph, File root,
                           List<String> sorted,
                           Map<String, String> baseVersions,
                           String branchName)
            throws MojoException {
        // Build published artifact sets
        Map<String, Set<PublishedArtifactSet.Artifact>> workspaceArtifacts =
                new LinkedHashMap<>();
        Map<String, String> newVersions = qualifiedVersions(baseVersions, branchName);

        for (String name : sorted) {
            Path subDir = root.toPath().resolve(name);

            if (Files.exists(subDir.resolve("pom.xml"))) {
                try {
                    workspaceArtifacts.put(name,
                            PublishedArtifactSet.scan(subDir));
                } catch (IOException e) {
                    // Skip
                }
            }
        }

        // For each subproject in topological order, check if it imports
        // a BOM published by an upstream subproject that got a new version
        for (String name : sorted) {
            File dir = new File(root, name);
            Path pomPath = dir.toPath().resolve("pom.xml");

            if (!Files.exists(pomPath)) continue;

            List<BomAnalysis.BomImport> bomImports;
            try {
                bomImports = BomAnalysis.extractBomImports(
                        pomPath, workspaceArtifacts);
            } catch (IOException e) {
                continue;
            }

            boolean pomChanged = false;
            for (BomAnalysis.BomImport bom : bomImports) {
                if (!bom.isWorkspaceInternal()) continue;

                String upstreamName = bom.publishingSubproject();
                if (!newVersions.containsKey(upstreamName)) continue;

                String newVersion = newVersions.get(upstreamName);
                try {
                    boolean updated = BomAnalysis.updateBomImportVersion(
                            pomPath, bom.groupId(), bom.artifactId(), newVersion);
                    if (updated) {
                        log.info("    " + name + ": BOM import "
                                + bom.groupId() + ":" + bom.artifactId()
                                + " → " + newVersion);
                        pomChanged = true;
                    }
                } catch (IOException e) {
                    log.warn("    Could not update BOM import in "
                            + name + ": " + e.getMessage());
                }
            }

            if (pomChanged) {
                try {
                    ReleaseSupport.exec(dir, log, "git", "add", "pom.xml");
                    commitIfStaged(dir,
                            "feature: update BOM imports for " + branchName);
                } catch (MojoException e) {
                    log.warn("    Could not commit BOM update in "
                            + name + ": " + e.getMessage());
                }
            }
        }
    }

    // ── Shallow-clone unshallowing ───────────────────────────────

    /**
     * Check if a subproject is a shallow clone and fetch full history
     * if needed. Feature branches require full history for merge-base
     * operations during feature-finish.
     *
     * @param dir  the subproject root directory
     * @param name subproject name (for log messages only)
     * @throws MojoException never (errors are logged as warnings)
     */
    void ensureFullClone(File dir, String name)
            throws MojoException {
        try {
            String isShallow = ReleaseSupport.execCapture(dir,
                    "git", "rev-parse", "--is-shallow-repository");
            if ("true".equals(isShallow.trim())) {
                log.info("    Fetching full history (shallow clone detected)...");
                ReleaseSupport.exec(dir, log,
                        "git", "fetch", "--unshallow");
            }
        } catch (MojoException e) {
            log.warn("    Could not check/unshallow " + name
                    + ": " + e.getMessage());
        }
    }

    // ── Intra-reactor version pin removal ────────────────────────

    /**
     * Detect and remove intra-reactor version pins across all
     * components. A "pin" is a {@code <version>} tag on a dependency
     * whose {@code groupId:artifactId} matches another module within
     * the same reactor — the reactor resolves versions automatically,
     * so explicit pins are redundant and cause cascade issues.
     *
     * <p>Only a {@code <dependencies>} entry in a POM Maven 4 infers
     * reactor versions for (model version 4.1.0 or later, see
     * {@link #infersReactorVersions}) is a pin. A 4.0.0 POM gets no
     * inference, so its versions are the only declaration and stay; a
     * {@code <dependencyManagement>} version is a declaration too, read
     * by whatever imports or inherits the POM, and always stays
     * (IKE-Network/ike-issues#1137).
     *
     * <p>In draft mode, reports what would be removed. In publish mode,
     * removes the pins and commits the changes.
     *
     * @param root       workspace root directory
     * @param components subproject names to scan
     * @param publish    true to actually remove; false to report only
     * @throws MojoException if a per-subproject git operation fails
     */
    void removeIntraReactorPins(File root, List<String> components,
                                boolean publish)
            throws MojoException {
        for (String name : components) {
            File subDir = new File(root, name);
            File rootPom = new File(subDir, "pom.xml");
            if (!rootPom.exists()) continue;

            try {
                // Build the set of all reactor artifactIds by walking
                // the subproject tree from the subproject root POM.
                PomModel rootModel = PomModel.parse(rootPom.toPath());
                String reactorGroupId = rootModel.groupId();
                Set<String> reactorArtifacts = new LinkedHashSet<>();
                collectReactorArtifacts(subDir.toPath(), rootModel,
                        reactorArtifacts);

                if (reactorArtifacts.size() <= 1) continue;  // no submodules

                // Scan all POMs for pinned intra-reactor dependencies
                List<File> allPoms = ReleaseSupport.findPomFiles(subDir);
                boolean anyChanged = false;

                for (File pom : allPoms) {
                    PomModel model = PomModel.parse(pom.toPath());
                    String content = model.content();
                    String updated = content;

                    if (!infersReactorVersions(model)) {
                        if (hasIntraReactorVersion(model, reactorArtifacts)) {
                            log.info("    " + name + "/" + subDir.toPath().relativize(pom.toPath())
                                    + ": model version below 4.1.0 — Maven 4 does not infer its"
                                    + " reactor versions, so they are kept");
                        }
                        continue;
                    }

                    for (org.apache.maven.api.model.Dependency dep : model.model().getDependencies()) {
                        String version = dep.getVersion();
                        if (version == null) continue;

                        // Check if this dependency is a reactor sibling
                        // — any explicit <version> is redundant, whether
                        // literal ("1.0.0-SNAPSHOT") or property-based
                        // ("${project.version}")
                        String depGroupId = dep.getGroupId();
                        if (depGroupId == null) depGroupId = reactorGroupId;
                        if (!reactorArtifacts.contains(dep.getArtifactId())) continue;

                        // Found an intra-reactor pin
                        String relPath = subDir.toPath()
                                .relativize(pom.toPath()).toString();

                        if (publish) {
                            updated = PomModel.removeDependencyVersion(
                                    updated, depGroupId, dep.getArtifactId());
                            log.info("    removed intra-reactor pin "
                                    + dep.getArtifactId() + " " + version
                                    + " from " + relPath);
                        } else {
                            log.info("  [draft] " + name + "/" + relPath
                                    + ": intra-reactor pin " + dep.getArtifactId()
                                    + " " + version
                                    + " would be removed (reactor resolves version)");
                        }
                    }

                    if (publish && !updated.equals(content)) {
                        Files.writeString(pom.toPath(), updated,
                                StandardCharsets.UTF_8);
                        anyChanged = true;
                    }
                }

                if (anyChanged) {
                    ReleaseSupport.exec(subDir, log, "git", "add", "-A");
                    commitIfStaged(subDir,
                            "build: remove intra-reactor version pins");
                }
            } catch (IOException e) {
                log.warn("    Could not scan " + name
                        + " for intra-reactor pins: " + e.getMessage());
            }
        }
    }

    /** The model version from which Maven 4 infers intra-reactor dependency versions. */
    static final int[] REACTOR_INFERENCE_MODEL_VERSION = {4, 1, 0};

    private static final Pattern POM_NAMESPACE_VERSION =
            Pattern.compile("xmlns=\"http://maven\\.apache\\.org/POM/([0-9.]+)\"");

    /**
     * Whether Maven 4 infers intra-reactor dependency versions for a POM.
     * It does so only for model version 4.1.0 and later; a 4.0.0 POM must
     * keep an explicit version on every intra-reactor dependency, or plain
     * Maven fails with {@code 'dependencies.dependency.version' ... is
     * missing} (IKE-Network/ike-issues#1137). The model version is the
     * {@code <modelVersion>} element, else the POM namespace's version; a
     * POM that declares neither is treated as 4.0.0.
     *
     * @param model the parsed POM
     * @return true if the POM's model version is 4.1.0 or later
     */
    static boolean infersReactorVersions(PomModel model) {
        String declared = model.model().getModelVersion();
        if (declared == null || declared.isBlank()) {
            Matcher namespace = POM_NAMESPACE_VERSION.matcher(model.content());
            declared = namespace.find() ? namespace.group(1) : null;
        }
        return declared != null && atLeast(declared.strip(), REACTOR_INFERENCE_MODEL_VERSION);
    }

    private static boolean atLeast(String version, int[] minimum) {
        String[] parts = version.split("\\.");
        for (int i = 0; i < minimum.length; i++) {
            int part;
            try {
                part = i < parts.length ? Integer.parseInt(parts[i]) : 0;
            } catch (NumberFormatException e) {
                return false;
            }
            if (part != minimum[i]) {
                return part > minimum[i];
            }
        }
        return true;
    }

    private static boolean hasIntraReactorVersion(PomModel model, Set<String> reactorArtifacts) {
        return model.model().getDependencies().stream()
                .anyMatch(dep -> dep.getVersion() != null && reactorArtifacts.contains(dep.getArtifactId()));
    }

    /**
     * Recursively collect all artifactIds in a reactor tree by walking
     * the {@code <subprojects>} (or {@code <modules>}) declarations.
     *
     * @param baseDir          directory of the POM being scanned
     * @param model            parsed POM model
     * @param reactorArtifacts accumulator for discovered artifactIds
     * @throws IOException if a submodule POM cannot be read
     */
    private void collectReactorArtifacts(Path baseDir,
                                         PomModel model,
                                         Set<String> reactorArtifacts)
            throws IOException {
        reactorArtifacts.add(model.artifactId());

        for (String sub : model.subprojects()) {
            Path subDir = baseDir.resolve(sub);
            Path subPom = subDir.resolve("pom.xml");
            if (Files.exists(subPom)) {
                PomModel subModel = PomModel.parse(subPom);
                collectReactorArtifacts(subDir, subModel, reactorArtifacts);
            }
        }
    }
}