PomParentSupport.java

package network.ike.plugin.ws;

import network.ike.plugin.PomRewriter;

import org.apache.maven.api.model.Parent;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Pattern;

/**
 * Utilities for reading and updating {@code <parent>} blocks in POM files.
 *
 * <p>Reading uses the Maven 4 model API via {@link PomModel}.
 * Writing uses the OpenRewrite LST via {@link PomRewriter}.
 * Thread-safe — all methods are stateless.
 */
public final class PomParentSupport {

    private PomParentSupport() {}

    /**
     * Read the parent block from a POM file using the Maven 4 model API.
     *
     * @param pomFile path to pom.xml
     * @return the parent info, or null if no parent block
     * @throws IOException if the file cannot be read or parsed
     */
    public static ParentInfo readParent(Path pomFile) throws IOException {
        PomModel model = PomModel.parse(pomFile);
        Parent parent = model.parent();
        if (parent == null) return null;
        return new ParentInfo(
                parent.getGroupId(),
                parent.getArtifactId(),
                parent.getVersion());
    }

    /**
     * Update the parent version for a matching
     * {@code groupId:artifactId}. Delegates to {@link PomRewriter}
     * for AST-aware manipulation.
     *
     * <p>Matching requires <strong>both</strong> groupId and
     * artifactId to match — see issue #241.
     *
     * @param pomContent       POM XML as a string
     * @param parentGroupId    the groupId to match in the parent block
     * @param parentArtifactId the artifactId to match in the parent block
     * @param newVersion       the new version to set
     * @return updated POM content (unchanged if no match)
     */
    public static String updateParentVersion(String pomContent,
                                              String parentGroupId,
                                              String parentArtifactId,
                                              String newVersion) {
        return PomRewriter.updateParentVersion(
                pomContent, parentGroupId, parentArtifactId, newVersion);
    }

    /**
     * Check whether a POM's {@code <parent>} block declares an
     * empty {@code <relativePath/>} (self-closing or empty content).
     *
     * <p>An empty {@code relativePath} disables Maven's filesystem
     * lookup for the parent — required to prevent the parent-cycle
     * model-builder error when a subproject's {@code <parent>} GA
     * matches the workspace aggregator's own parent GA (ike-issues#324).
     *
     * <p>Detection is text-based rather than model-based: Maven 4's
     * Parent model assigns a default value (typically
     * {@code "../pom.xml"}) to absent {@code relativePath} elements,
     * so the parsed model cannot reliably distinguish
     * "absent" from "default" from "explicit empty". The raw XML
     * preserves that information.
     *
     * @param pomFile path to pom.xml
     * @return {@code true} when the POM's {@code <parent>} block
     *         contains an empty {@code <relativePath/>}; {@code false}
     *         when absent, non-empty, or the parent block itself is
     *         missing
     * @throws IOException if the file cannot be read
     */
    public static boolean hasEmptyRelativePath(Path pomFile) throws IOException {
        String content = Files.readString(pomFile, StandardCharsets.UTF_8);
        return hasEmptyRelativePathInContent(content);
    }

    /**
     * Same as {@link #hasEmptyRelativePath(Path)} but operating on
     * raw POM content. Used directly by tests and any caller that
     * already has the content in memory.
     *
     * @param pomContent raw POM XML
     * @return {@code true} when the {@code <parent>} block contains
     *         an empty {@code <relativePath/>}
     */
    public static boolean hasEmptyRelativePathInContent(String pomContent) {
        if (pomContent == null) return false;
        // Find the <parent>...</parent> block first; an empty
        // <relativePath/> outside <parent> isn't relevant.
        var parentMatcher = PARENT_BLOCK.matcher(pomContent);
        if (!parentMatcher.find()) return false;
        String parentBlock = parentMatcher.group(1);
        return EMPTY_RELATIVE_PATH.matcher(parentBlock).find();
    }

    private static final Pattern PARENT_BLOCK = Pattern.compile(
            "(?s)<parent\\b[^>]*>(.*?)</parent>");

    /**
     * Matches both self-closing {@code <relativePath/>} and empty
     * paired form {@code <relativePath></relativePath>}, with
     * optional whitespace inside. Does not match a non-empty
     * relativePath such as {@code <relativePath>../pom.xml</relativePath>}.
     */
    private static final Pattern EMPTY_RELATIVE_PATH = Pattern.compile(
            "<relativePath\\s*/>"
                    + "|<relativePath\\s*>\\s*</relativePath>");

    /**
     * Parsed parent block from a POM file.
     *
     * @param groupId    parent groupId
     * @param artifactId parent artifactId
     * @param version    parent version
     */
    public record ParentInfo(String groupId, String artifactId, String version) {}
}