BomAnalysis.java

package network.ike.workspace;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Analyzes BOM imports in workspace subproject POMs to detect
 * version cascade gaps and support feature-start BOM updates.
 *
 * <p>Key concepts:
 * <ul>
 *   <li><strong>Workspace-internal BOM:</strong> a BOM (pom import)
 *       whose groupId:artifactId is in the published artifact set
 *       of a workspace subproject</li>
 *   <li><strong>External BOM:</strong> a BOM not published by any
 *       workspace subproject</li>
 *   <li><strong>Cascade gap:</strong> when subproject A depends on
 *       subproject B, but A has no version-property or workspace-internal
 *       BOM import that tracks B's version</li>
 *   <li><strong>External pin:</strong> an external BOM manages
 *       artifacts published by a workspace subproject, potentially
 *       overriding the workspace version</li>
 * </ul>
 */
public final class BomAnalysis {

    private BomAnalysis() {}

    /**
     * A BOM import found in a subproject's {@code <dependencyManagement>}.
     *
     * @param groupId    BOM groupId
     * @param artifactId BOM artifactId
     * @param version    declared version (may contain ${property} refs)
     * @param isWorkspaceInternal true if published by a workspace subproject
     * @param publishingSubproject name of the workspace subproject that
     *                            publishes this BOM (null if external)
     * @param orderIndex position in the import list (0-based, for
     *                   precedence analysis)
     */
    public record BomImport(String groupId, String artifactId, String version,
                            boolean isWorkspaceInternal,
                            String publishingSubproject,
                            int orderIndex) {}

    /**
     * A detected cascade issue for a subproject.
     *
     * @param subprojectName   the subproject with the issue
     * @param dependsOn        the upstream subproject it depends on
     * @param hasVersionProperty whether a version-property tracks upstream
     * @param hasWorkspaceBom  whether a workspace-internal BOM import exists
     * @param externalBomPins  external BOMs that manage upstream's artifacts
     */
    public record CascadeIssue(String subprojectName, String dependsOn,
                                boolean hasVersionProperty,
                                boolean hasWorkspaceBom,
                                List<BomImport> externalBomPins) {

        /**
         * True if feature-start can cascade versions for this edge.
         *
         * @return true if a version-property or workspace BOM exists
         */
        public boolean canCascade() {
            return hasVersionProperty || hasWorkspaceBom;
        }
    }

    private static final DocumentBuilderFactory DBF;
    static {
        DBF = DocumentBuilderFactory.newInstance();
        try {
            DBF.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
            DBF.setFeature("http://xml.org/sax/features/external-general-entities", false);
            DBF.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        } catch (Exception e) { /* non-fatal */ }
    }

    /**
     * Extract all BOM imports from a subproject's root POM's
     * {@code <dependencyManagement>} section.
     *
     * @param pomFile the root POM to analyze
     * @param workspaceArtifacts map of workspace subproject name to
     *        its published artifact set
     * @return list of BOM imports in declaration order
     * @throws IOException if the POM cannot be read or parsed
     */
    public static List<BomImport> extractBomImports(
            Path pomFile,
            Map<String, Set<PublishedArtifactSet.Artifact>> workspaceArtifacts)
            throws IOException {
        List<BomImport> imports = new ArrayList<>();
        if (!Files.exists(pomFile)) return imports;

        Document doc;
        try {
            DocumentBuilder db = DBF.newDocumentBuilder();
            doc = db.parse(pomFile.toFile());
        } catch (Exception e) {
            return imports;
        }

        Element project = doc.getDocumentElement();

        // Read properties for ${...} resolution
        Map<String, String> properties = readProperties(project);

        Element depMgmt = firstChild(project, "dependencyManagement");
        if (depMgmt == null) return imports;

        Element deps = firstChild(depMgmt, "dependencies");
        if (deps == null) return imports;

        int index = 0;
        for (Element dep : children(deps, "dependency")) {
            String type = childText(dep, "type");
            String scope = childText(dep, "scope");

            if ("pom".equals(type) && "import".equals(scope)) {
                String gid = resolve(childText(dep, "groupId"), properties);
                String aid = resolve(childText(dep, "artifactId"), properties);
                String ver = resolve(childText(dep, "version"), properties);

                if (gid == null || aid == null) continue;

                // Check if this BOM is published by a workspace subproject
                String publishingSubproject = null;
                for (var entry : workspaceArtifacts.entrySet()) {
                    for (var artifact : entry.getValue()) {
                        if (artifact.groupId().equals(gid)
                                && artifact.artifactId().equals(aid)) {
                            publishingSubproject = entry.getKey();
                            break;
                        }
                    }
                    if (publishingSubproject != null) break;
                }

                imports.add(new BomImport(gid, aid, ver,
                        publishingSubproject != null,
                        publishingSubproject, index));
                index++;
            }
        }

        return imports;
    }

    /**
     * Analyze cascade issues for all subprojects in the workspace.
     *
     * @param wsDir         workspace root directory
     * @param manifest      the workspace manifest
     * @param workspaceArtifacts published artifacts per subproject
     * @return list of cascade issues (empty if all edges can cascade)
     * @throws IOException if a subproject POM cannot be read
     */
    public static List<CascadeIssue> analyzeCascadeIssues(
            Path wsDir, Manifest manifest,
            Map<String, Set<PublishedArtifactSet.Artifact>> workspaceArtifacts)
            throws IOException {

        List<CascadeIssue> issues = new ArrayList<>();

        for (var entry : manifest.subprojects().entrySet()) {
            String subprojectName = entry.getKey();
            Subproject sub = entry.getValue();

            if (sub.dependsOn() == null || sub.dependsOn().isEmpty()) continue;

            Path pomFile = wsDir.resolve(subprojectName).resolve("pom.xml");
            List<BomImport> bomImports = extractBomImports(
                    pomFile, workspaceArtifacts);

            for (Dependency dep : sub.dependsOn()) {
                String upstream = dep.subproject();
                boolean hasVersionProp = dep.versionProperty() != null;

                // Check if any workspace-internal BOM import tracks upstream
                boolean hasWorkspaceBom = bomImports.stream()
                        .anyMatch(b -> b.isWorkspaceInternal
                                && upstream.equals(b.publishingSubproject));

                // Find external BOMs that manage upstream's artifacts
                Set<PublishedArtifactSet.Artifact> upstreamArtifacts =
                        workspaceArtifacts.getOrDefault(upstream, Set.of());
                List<BomImport> externalPins = new ArrayList<>();

                for (BomImport bom : bomImports) {
                    if (bom.isWorkspaceInternal) continue;

                    // We can't resolve the BOM's managed deps without
                    // downloading it. But we can flag external BOMs that
                    // share a groupId prefix with upstream artifacts as
                    // potential pins.
                    // For a more precise check, we'd need the BOM's
                    // effective dependencyManagement — future enhancement.
                    externalPins.add(bom);
                }

                if (!hasVersionProp && !hasWorkspaceBom) {
                    issues.add(new CascadeIssue(subprojectName, upstream,
                            hasVersionProp, hasWorkspaceBom, externalPins));
                }
            }
        }

        return issues;
    }

    /**
     * Update a BOM import version in a POM file.
     * Finds the {@code <dependency>} block in {@code <dependencyManagement>}
     * with matching groupId:artifactId and type=pom/scope=import,
     * and rewrites its {@code <version>} element.
     *
     * @param pomFile    the POM to modify
     * @param groupId    BOM groupId to match
     * @param artifactId BOM artifactId to match
     * @param newVersion the new version to set
     * @return true if the file was modified
     * @throws IOException if the POM cannot be read or written
     */
    public static boolean updateBomImportVersion(Path pomFile,
                                                  String groupId,
                                                  String artifactId,
                                                  String newVersion)
            throws IOException {
        String content = Files.readString(pomFile);
        String original = content;

        // Find the BOM import block and update its version.
        // This uses text manipulation (not DOM) because we need to
        // preserve the exact formatting of the POM.
        //
        // Strategy: find <dependency> blocks inside <dependencyManagement>
        // that match groupId, artifactId, type=pom, scope=import,
        // then rewrite the <version> element.
        String depMgmtBlock = extractBlock(content,
                "<dependencyManagement>", "</dependencyManagement>");
        if (depMgmtBlock == null) return false;

        String searchGid = "<groupId>" + groupId + "</groupId>";
        String searchAid = "<artifactId>" + artifactId + "</artifactId>";

        int searchFrom = 0;
        while (true) {
            int depStart = depMgmtBlock.indexOf("<dependency>", searchFrom);
            if (depStart < 0) break;
            int depEnd = depMgmtBlock.indexOf("</dependency>", depStart);
            if (depEnd < 0) break;

            String depBlock = depMgmtBlock.substring(depStart, depEnd + "</dependency>".length());
            searchFrom = depEnd + 1;

            if (!depBlock.contains(searchGid) || !depBlock.contains(searchAid)) continue;
            if (!depBlock.contains("<type>pom</type>")) continue;
            if (!depBlock.contains("<scope>import</scope>")) continue;

            // Found the matching BOM import — update its version
            String versionPattern = "<version>[^<]+</version>";
            String updatedBlock = depBlock.replaceFirst(
                    versionPattern, "<version>" + newVersion + "</version>");

            if (!updatedBlock.equals(depBlock)) {
                content = content.replace(depBlock, updatedBlock);
                Files.writeString(pomFile, content);
                return true;
            }
        }

        return false;
    }

    // ── DOM helpers (same pattern as PublishedArtifactSet) ──────

    private static String extractBlock(String content, String startTag, String endTag) {
        int start = content.indexOf(startTag);
        if (start < 0) return null;
        int end = content.indexOf(endTag, start);
        if (end < 0) return null;
        return content.substring(start, end + endTag.length());
    }

    private static Map<String, String> readProperties(Element project) {
        Map<String, String> props = new LinkedHashMap<>();
        Element propsEl = firstChild(project, "properties");
        if (propsEl != null) {
            NodeList children = propsEl.getChildNodes();
            for (int i = 0; i < children.getLength(); i++) {
                Node node = children.item(i);
                if (node.getNodeType() == Node.ELEMENT_NODE) {
                    String value = node.getTextContent().trim();
                    if (!value.isEmpty()) {
                        props.put(node.getNodeName(), value);
                    }
                }
            }
        }
        return props;
    }

    private static String resolve(String value, Map<String, String> properties) {
        if (value == null || !value.contains("${")) return value;
        for (var entry : properties.entrySet()) {
            value = value.replace("${" + entry.getKey() + "}", entry.getValue());
        }
        return value;
    }

    private static String childText(Element parent, String tagName) {
        Element child = firstChild(parent, tagName);
        if (child == null) return null;
        String text = child.getTextContent().trim();
        return text.isEmpty() ? null : text;
    }

    private static Element firstChild(Element parent, String tagName) {
        NodeList children = parent.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE
                    && tagName.equals(node.getNodeName())) {
                return (Element) node;
            }
        }
        return null;
    }

    private static List<Element> children(Element parent, String tagName) {
        List<Element> result = new ArrayList<>();
        NodeList children = parent.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            Node node = children.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE
                    && tagName.equals(node.getNodeName())) {
                result.add((Element) node);
            }
        }
        return result;
    }
}