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;

/**
 * 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;
    }

    // ── 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());
        }
    }

    // ── 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 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, String branchName)
            throws MojoException {

        // Build map of upstream subproject → new branch-qualified version
        Map<String, String> newVersions = new LinkedHashMap<>();
        for (String name : sorted) {
            Subproject sub = graph.manifest().subprojects().get(name);
            if (sub.version() != null && !sub.version().isEmpty()) {
                newVersions.put(name, VersionSupport.branchQualifiedVersion(
                        sub.version(), 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");
                    ReleaseSupport.exec(dir, log, "git", "commit", "-m",
                            "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 branchName the feature branch name
     * @throws MojoException if a per-subproject git operation fails
     */
    void cascadeBomProperties(WorkspaceGraph graph, File root,
                              List<String> sorted, String branchName)
            throws MojoException {

        // Build map of subproject name → new branch-qualified version
        Map<String, String> newVersions = new LinkedHashMap<>();
        for (String name : sorted) {
            Subproject sub = graph.manifest().subprojects().get(name);
            String effectiveVersion = sub.version();
            if (effectiveVersion == null || effectiveVersion.isEmpty()) {
                File pom = new File(new File(root, name), "pom.xml");
                if (pom.exists()) {
                    try {
                        effectiveVersion = ReleaseSupport.readPomVersion(pom);
                    } catch (MojoException e) { /* skip */ }
                }
            }
            if (effectiveVersion != null && !effectiveVersion.isEmpty()) {
                newVersions.put(name, VersionSupport.branchQualifiedVersion(
                        effectiveVersion, 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");
                    ReleaseSupport.exec(dir, log, "git", "commit", "-m",
                            "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 branchName the feature branch name
     * @throws MojoException if a per-subproject git operation fails
     */
    void cascadeBomImports(WorkspaceGraph graph, File root,
                           List<String> sorted, String branchName)
            throws MojoException {
        // Build published artifact sets and new version map
        Map<String, Set<PublishedArtifactSet.Artifact>> workspaceArtifacts =
                new LinkedHashMap<>();
        Map<String, String> newVersions = new LinkedHashMap<>();

        for (String name : sorted) {
            Subproject sub = graph.manifest().subprojects().get(name);
            Path subDir = root.toPath().resolve(name);

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

            // Resolve effective version (same logic as the branching loop)
            String effectiveVersion = sub.version();
            if (effectiveVersion == null || effectiveVersion.isEmpty()) {
                File pom = new File(new File(root, name), "pom.xml");
                if (pom.exists()) {
                    try {
                        effectiveVersion = ReleaseSupport.readPomVersion(pom);
                    } catch (MojoException e) { /* skip */ }
                }
            }
            if (effectiveVersion != null && !effectiveVersion.isEmpty()) {
                newVersions.put(name, VersionSupport.branchQualifiedVersion(
                        effectiveVersion, branchName));
            }
        }

        // 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");
                    ReleaseSupport.exec(dir, log,
                            "git", "commit", "-m",
                            "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>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;

                    for (var dep : model.allDependencies()) {
                        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");
                    ReleaseSupport.exec(subDir, log,
                            "git", "commit", "-m",
                            "build: remove intra-reactor version pins");
                }
            } catch (IOException e) {
                log.warn("    Could not scan " + name
                        + " for intra-reactor pins: " + e.getMessage());
            }
        }
    }

    /**
     * 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);
            }
        }
    }
}