PomRewriter.java

package network.ike.plugin;

import org.openrewrite.xml.XPathMatcher;
import org.openrewrite.xml.XmlParser;
import org.openrewrite.xml.XmlVisitor;
import org.openrewrite.xml.tree.Content;
import org.openrewrite.xml.tree.Xml;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * AST-aware POM manipulation using OpenRewrite's XML LST.
 *
 * <p>Replaces regex-based POM editing with lossless semantic tree
 * (LST) transformations that preserve formatting, comments, and
 * whitespace. Each method parses the POM, applies a targeted change,
 * and serializes back to text.
 *
 * <p>Relocated from {@code ike-workspace-maven-plugin} (ike-platform)
 * to this module (ike-tooling) in {@code IKE-Network/ike-issues#348}
 * so the scaffold goals in {@code ike-maven-plugin} can apply
 * foundation drift without depending on a downstream artifact. The
 * earlier package-private visibility was widened to {@code public}
 * for the same reason.
 *
 * <p>Usage:
 * <pre>{@code
 * String updated = PomRewriter.updateDependencyVersion(
 *     pomContent, "network.ike", "ike-bom", "84");
 * }</pre>
 */
public final class PomRewriter {

    private PomRewriter() {}

    private static final XmlParser PARSER = new XmlParser();

    /**
     * Update the version of a specific dependency identified by
     * {@code groupId:artifactId} anywhere in the POM (both
     * {@code <dependencies>} and {@code <dependencyManagement>}).
     *
     * @param pomContent the raw POM text
     * @param groupId    dependency groupId to match
     * @param artifactId dependency artifactId to match
     * @param newVersion the version to set
     * @return updated POM text, or unchanged if no match
     */
    public static String updateDependencyVersion(String pomContent,
                                                  String groupId,
                                                  String artifactId,
                                                  String newVersion) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) return pomContent;

        Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
            @Override
            public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (!"dependency".equals(t.getName())) return t;

                String gid = t.getChildValue("groupId").orElse(null);
                String aid = t.getChildValue("artifactId").orElse(null);
                if (groupId.equals(gid) && artifactId.equals(aid)) {
                    return t.withChildValue("version", newVersion);
                }
                return t;
            }
        }.visitNonNull(doc, 0);

        return print(updated);
    }

    /**
     * Read the version of the POM's {@code <parent>} block when its
     * coordinates match {@code parentGroupId:parentArtifactId}.
     *
     * <p>Companion read to {@link #updateParentVersion}: the cascade
     * alignment path uses this to inspect the current parent version
     * before deciding whether (and to what) to bump it. Returns the
     * version text exactly as it appears in the POM — a literal, an
     * unresolved {@code ${...}} reference, whatever is declared.
     *
     * @param pomContent       the raw POM text
     * @param parentGroupId    the parent groupId to match (required)
     * @param parentArtifactId the parent artifactId to match (required)
     * @return the parent's declared version, or empty when the POM
     *         has no {@code <parent>} block matching those
     *         coordinates
     */
    public static Optional<String> readParentVersion(String pomContent,
                                                      String parentGroupId,
                                                      String parentArtifactId) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) {
            return Optional.empty();
        }
        String[] found = {null};
        new XmlVisitor<Integer>() {
            @Override
            public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (!"parent".equals(t.getName())) {
                    return t;
                }
                String gid = t.getChildValue("groupId").orElse(null);
                String aid = t.getChildValue("artifactId").orElse(null);
                if (parentGroupId.equals(gid)
                        && parentArtifactId.equals(aid)) {
                    found[0] = t.getChildValue("version").orElse(null);
                }
                return t;
            }
        }.visitNonNull(doc, 0);
        return Optional.ofNullable(found[0]);
    }

    /**
     * Update the parent version for a matching
     * {@code groupId:artifactId} in the POM's {@code <parent>} block.
     *
     * <p>Matching requires <strong>both</strong> groupId and
     * artifactId to match. This prevents cross-coordinate mutation
     * when the same artifactId lives under multiple groupIds
     * (e.g. {@code network.ike.platform:ike-parent} vs.
     * {@code network.ike.pipeline:ike-parent}) — see issue #241.
     *
     * @param pomContent       the raw POM text
     * @param parentGroupId    the parent groupId to match (required)
     * @param parentArtifactId the parent artifactId to match (required)
     * @param newVersion       the new version to set
     * @return updated POM text, or unchanged if no match
     */
    public static String updateParentVersion(String pomContent,
                                              String parentGroupId,
                                              String parentArtifactId,
                                              String newVersion) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) return pomContent;

        Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
            @Override
            public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (!"parent".equals(t.getName())) return t;

                String gid = t.getChildValue("groupId").orElse(null);
                String aid = t.getChildValue("artifactId").orElse(null);
                if (parentGroupId.equals(gid)
                        && parentArtifactId.equals(aid)) {
                    return t.withChildValue("version", newVersion);
                }
                return t;
            }
        }.visitNonNull(doc, 0);

        return print(updated);
    }

    /**
     * Update a version property value in the POM's {@code <properties>}
     * block.
     *
     * @param pomContent   the raw POM text
     * @param propertyName the property name (e.g., "tinkar-core.version")
     * @param newValue     the new property value
     * @return updated POM text, or unchanged if no match
     */
    public static String updateProperty(String pomContent,
                                         String propertyName,
                                         String newValue) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) return pomContent;

        XPathMatcher propertiesMatcher = new XPathMatcher(
                "/project/properties/" + propertyName);
        Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
            @Override
            public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (propertiesMatcher.matches(getCursor())) {
                    return t.withValue(newValue);
                }
                return t;
            }
        }.visitNonNull(doc, 0);

        return print(updated);
    }

    /**
     * Update the version of a specific plugin identified by
     * {@code groupId:artifactId} anywhere in the POM (both
     * {@code <plugins>} and {@code <pluginManagement>}).
     *
     * @param pomContent the raw POM text
     * @param groupId    plugin groupId to match
     * @param artifactId plugin artifactId to match
     * @param newVersion the version to set
     * @return updated POM text, or unchanged if no match
     */
    public static String updatePluginVersion(String pomContent,
                                              String groupId,
                                              String artifactId,
                                              String newVersion) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) return pomContent;

        Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
            @Override
            public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (!"plugin".equals(t.getName())) return t;

                String gid = t.getChildValue("groupId").orElse(null);
                String aid = t.getChildValue("artifactId").orElse(null);
                if (groupId.equals(gid) && artifactId.equals(aid)) {
                    return t.withChildValue("version", newVersion);
                }
                return t;
            }
        }.visitNonNull(doc, 0);

        return print(updated);
    }

    /**
     * Remove the {@code <version>} child from a dependency matched by
     * {@code groupId:artifactId}. Used to eliminate intra-reactor
     * version pins where the reactor resolves the version automatically.
     *
     * @param pomContent the raw POM text
     * @param groupId    dependency groupId to match
     * @param artifactId dependency artifactId to match
     * @return updated POM text with version tag removed, or unchanged if no match
     */
    public static String removeDependencyVersion(String pomContent,
                                                  String groupId,
                                                  String artifactId) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) return pomContent;

        Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
            @Override
            public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (!"dependency".equals(t.getName())) return t;

                String gid = t.getChildValue("groupId").orElse(null);
                String aid = t.getChildValue("artifactId").orElse(null);
                if (groupId.equals(gid) && artifactId.equals(aid)
                        && t.getChild("version").isPresent()) {
                    // Filter out the <version> element from content
                    List<? extends Content> filtered = t.getContent().stream()
                            .filter(c -> !(c instanceof Xml.Tag child
                                    && "version".equals(child.getName())))
                            .toList();
                    return t.withContent(filtered);
                }
                return t;
            }
        }.visitNonNull(doc, 0);

        return print(updated);
    }

    /**
     * Add a property to the POM's {@code <properties>} block. No-op
     * if the property is already declared (use {@link #updateProperty}
     * to change an existing value). No-op if the POM has no
     * {@code <properties>} block.
     *
     * <p>Introduced for the {@code __ALIAS} indirection bake step
     * (IKE-Network/ike-issues#527): release-publish reads
     * {@code __ALIAS} declarations and materializes the corresponding
     * {@code <short>${canonical}</short>} indirections into the
     * release-tagged source pom.
     *
     * @param pomContent    the raw POM text
     * @param propertyName  the property name to add
     * @param propertyValue the property value
     * @return updated POM text, or unchanged if the property is
     *         already declared or no {@code <properties>} block exists
     */
    public static String addProperty(String pomContent,
                                      String propertyName,
                                      String propertyValue) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) return pomContent;

        XPathMatcher propertiesMatcher = new XPathMatcher("/project/properties");

        Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
            @Override
            public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (propertiesMatcher.matches(getCursor())) {
                    if (t.getChild(propertyName).isPresent()) {
                        return t;
                    }
                    Xml.Tag newProp = Xml.Tag.build(
                            "<" + propertyName + ">" + propertyValue
                                    + "</" + propertyName + ">");
                    List<Content> newContent = new ArrayList<Content>(t.getContent());
                    newContent.add(newProp);
                    return t.withContent(newContent);
                }
                return t;
            }
        }.visitNonNull(doc, 0);

        return print(updated);
    }

    /**
     * Remove a property from the POM's {@code <properties>} block.
     * No-op if the property is not declared.
     *
     * <p>Introduced for the {@code __ALIAS} indirection unbake step
     * (IKE-Network/ike-issues#527): release-publish removes the
     * materialized indirections after the release tag, before the
     * merge back to main.
     *
     * @param pomContent   the raw POM text
     * @param propertyName the property name to remove
     * @return updated POM text, or unchanged if no match
     */
    public static String removeProperty(String pomContent,
                                         String propertyName) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) return pomContent;

        XPathMatcher propertiesMatcher = new XPathMatcher("/project/properties");

        Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
            @Override
            public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (propertiesMatcher.matches(getCursor())) {
                    List<? extends Content> filtered = t.getContent().stream()
                            .filter(c -> !(c instanceof Xml.Tag child
                                    && propertyName.equals(child.getName())))
                            .toList();
                    return t.withContent(filtered);
                }
                return t;
            }
        }.visitNonNull(doc, 0);

        return print(updated);
    }

    /**
     * List the properties declared in the POM's {@code <properties>}
     * block. Returns name → value pairs in declaration order.
     * Whitespace, comments, and non-tag content are skipped.
     *
     * <p>Used by the indirection bake/unbake steps
     * (IKE-Network/ike-issues#527) to scan for {@code __ALIAS}
     * declarations.
     *
     * @param pomContent the raw POM text
     * @return name → value map of declared properties; empty if no
     *         {@code <properties>} block exists
     */
    public static Map<String, String> listProperties(String pomContent) {
        Xml.Document doc = parse(pomContent);
        if (doc == null) return Map.of();

        Map<String, String> result = new LinkedHashMap<>();
        doc.getRoot().getChild("properties").ifPresent(props -> {
            for (Content c : props.getContent()) {
                if (c instanceof Xml.Tag child) {
                    String value = child.getValue().orElse("");
                    result.put(child.getName(), value);
                }
            }
        });
        return result;
    }

    /**
     * Parse POM content into an OpenRewrite XML document.
     *
     * @param pomContent the POM text
     * @return parsed document, or {@code null} if parsing failed
     */
    private static Xml.Document parse(String pomContent) {
        return PARSER.parse(pomContent)
                .findFirst()
                .map(t -> (Xml.Document) t)
                .orElse(null);
    }

    /**
     * Serialize an XML document back to a string.
     *
     * @param doc the parsed document
     * @return the document's textual form
     */
    private static String print(Xml.Document doc) {
        return doc.printAll();
    }
}