PomModelAdapter.java

package network.ike.plugin.scaffold;

import org.openrewrite.Tree;
import org.openrewrite.xml.XmlParser;
import org.openrewrite.xml.XmlVisitor;
import org.openrewrite.xml.tree.Xml;

import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Model adapter for {@code pom.xml} via OpenRewrite's XML LST.
 *
 * <p>POM writes always go through OpenRewrite (not regex, not the
 * Maven 4 model API) so formatting, comments, and whitespace survive
 * round-trips.
 *
 * <p>Supported {@code ensure} subtree:
 * <pre>{@code
 * ensure:
 *   pluginManagement:
 *     - groupId: network.ike.tooling
 *       artifactId: ike-maven-plugin
 *       version: 127-SNAPSHOT
 * }</pre>
 *
 * <p>Semantics:
 * <ul>
 *   <li>If the destination POM is missing, publish skips with an
 *       informational message — creating a POM from scratch is
 *       outside scaffold's responsibility.</li>
 *   <li>For each ensured plugin, the adapter checks whether a matching
 *       {@code <plugin>} with the same {@code groupId + artifactId}
 *       exists anywhere under {@code /project/build/pluginManagement/plugins}.
 *       If absent it is appended (the whole {@code pluginManagement}
 *       scaffold is also created if missing). If present, the version
 *       is <em>not</em> changed — that is the job of
 *       {@code ws:align-publish}, not scaffold.</li>
 *   <li>Each ensured plugin is recorded as a {@link ManagedElement}
 *       with path
 *       {@code "/project/build/pluginManagement/plugins/plugin[groupId='G' and artifactId='A']"}.</li>
 * </ul>
 */
public final class PomModelAdapter implements ModelAdapter {

    /** Model name matching {@link ManifestEntry#model()}. */
    public static final String MODEL_NAME = "pom-openrewrite";

    private static final XmlParser PARSER = new XmlParser();

    /**
     * Construct a stateless POM adapter. Instances are safe to share
     * across planning calls; the underlying OpenRewrite parser is held
     * in a {@code static final} field.
     */
    public PomModelAdapter() {
    }

    @Override
    public String modelName() {
        return MODEL_NAME;
    }

    @Override
    public ModelPlanResult plan(
            ManifestEntry entry,
            Path resolvedDest,
            byte[] currentContent,
            LockfileEntry priorEntry,
            String currentStandardsVersion) {

        List<PluginEnsure> ensured = readEnsuredPlugins(entry);

        if (currentContent == null || currentContent.length == 0) {
            // Creating a POM from scratch is not in scope.
            return new ModelPlanResult(
                    new TierAction.Skip(
                            entry, resolvedDest,
                            "POM does not exist; scaffold will not "
                                    + "create a pom.xml",
                            ""),
                    List.of());
        }

        String pomText = new String(
                currentContent, StandardCharsets.UTF_8);
        Xml.Document doc = parse(pomText);
        if (doc == null) {
            throw new ScaffoldException(
                    "Cannot parse POM at " + resolvedDest);
        }

        List<String> currentlyPresent =
                collectPluginCoordinates(doc);
        Map<String, ManagedElement> priorByPath =
                indexByPath(priorEntry);

        boolean changed = false;
        List<ManagedElement> managed = new ArrayList<>();
        Xml.Document updated = doc;
        for (PluginEnsure p : ensured) {
            String coord = p.groupId() + ":" + p.artifactId();
            String path = pluginPath(p);
            if (!currentlyPresent.contains(coord)) {
                updated = addPluginToPluginManagement(updated, p);
                changed = true;
                managed.add(new ManagedElement(
                        path, Instant.now(),
                        currentStandardsVersion));
            } else {
                ManagedElement prior = priorByPath.get(path);
                managed.add(prior != null
                        ? prior
                        : new ManagedElement(
                                path, Instant.now(),
                                currentStandardsVersion));
            }
        }

        if (!changed) {
            String sha = Sha256.of(currentContent);
            return new ModelPlanResult(
                    new TierAction.UpToDate(
                            entry, resolvedDest, sha, sha,
                            "up to date"),
                    managed);
        }

        byte[] newBytes = updated.printAll()
                .getBytes(StandardCharsets.UTF_8);
        String sha = Sha256.of(newBytes);
        int priorCount = priorEntry == null
                ? 0
                : priorEntry.managedElements().size();
        int added = managed.size() - priorCount;
        return new ModelPlanResult(
                new TierAction.Write(
                        entry, resolvedDest, newBytes,
                        sha, sha,
                        TierAction.Write.Kind.UPDATE,
                        "ensure " + Math.max(added, 0)
                                + " plugin(s) in pluginManagement"),
                managed);
    }

    // ── helpers ────────────────────────────────────────────────────

    private record PluginEnsure(
            String groupId, String artifactId, String version) {
    }

    private static String pluginPath(PluginEnsure p) {
        return "/project/build/pluginManagement/plugins/"
                + "plugin[groupId='" + p.groupId()
                + "' and artifactId='" + p.artifactId() + "']";
    }

    private static Map<String, ManagedElement> indexByPath(
            LockfileEntry priorEntry) {
        if (priorEntry == null
                || priorEntry.managedElements().isEmpty()) {
            return Collections.emptyMap();
        }
        Map<String, ManagedElement> out = new LinkedHashMap<>();
        for (ManagedElement e : priorEntry.managedElements()) {
            out.put(e.path(), e);
        }
        return out;
    }

    private static List<PluginEnsure> readEnsuredPlugins(
            ManifestEntry entry) {
        Object ensure = entry.extras().get("ensure");
        if (ensure == null) {
            return Collections.emptyList();
        }
        if (!(ensure instanceof Map<?, ?> ensureMap)) {
            throw new ScaffoldException(
                    "pom-openrewrite entry '" + entry.dest()
                            + "': 'ensure' must be a mapping");
        }
        Object pm = ensureMap.get("pluginManagement");
        if (pm == null) {
            return Collections.emptyList();
        }
        if (!(pm instanceof List<?> pmList)) {
            throw new ScaffoldException(
                    "pom-openrewrite entry '" + entry.dest()
                            + "': 'ensure.pluginManagement' must be "
                            + "a list");
        }
        List<PluginEnsure> out = new ArrayList<>();
        int idx = 0;
        for (Object o : pmList) {
            if (!(o instanceof Map<?, ?> m)) {
                throw new ScaffoldException(
                        "pom-openrewrite entry '" + entry.dest()
                                + "': ensure.pluginManagement["
                                + idx + "] must be a mapping");
            }
            String g = stringVal(m, "groupId");
            String a = stringVal(m, "artifactId");
            String v = stringVal(m, "version");
            if (g == null || a == null) {
                throw new ScaffoldException(
                        "pom-openrewrite entry '" + entry.dest()
                                + "': ensure.pluginManagement["
                                + idx + "] missing groupId/artifactId");
            }
            out.add(new PluginEnsure(g, a, v == null ? "" : v));
            idx++;
        }
        return out;
    }

    private static String stringVal(Map<?, ?> m, String k) {
        Object v = m.get(k);
        return v == null ? null : v.toString();
    }

    private static Xml.Document parse(String text) {
        return PARSER.parse(text)
                .findFirst()
                .map(t -> (Xml.Document) t)
                .orElse(null);
    }

    private static List<String> collectPluginCoordinates(
            Xml.Document doc) {
        List<String> found = new ArrayList<>();
        new XmlVisitor<List<String>>() {
            @Override
            public Xml visitTag(Xml.Tag tag, List<String> ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (!"plugin".equals(t.getName())) {
                    return t;
                }
                Optional<String> g = t.getChildValue("groupId");
                Optional<String> a = t.getChildValue("artifactId");
                g.ifPresent(gid -> a.ifPresent(aid ->
                        ctx.add(gid + ":" + aid)));
                return t;
            }
        }.visit(doc, found);
        return found;
    }

    /**
     * Append a {@code <plugin>} entry under
     * {@code /project/build/pluginManagement/plugins}, creating any
     * missing ancestor tags.
     */
    private static Xml.Document addPluginToPluginManagement(
            Xml.Document doc, PluginEnsure p) {
        Xml.Tag pluginTag = buildPluginTag(p);

        return (Xml.Document) new XmlVisitor<Integer>() {
            @Override
            public Xml visitTag(Xml.Tag tag, Integer ctx) {
                Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
                if (!"project".equals(t.getName())) {
                    return t;
                }
                Xml.Tag build = childOrCreate(t, "build");
                Xml.Tag pluginManagement =
                        childOrCreate(build, "pluginManagement");
                Xml.Tag plugins =
                        childOrCreate(pluginManagement, "plugins");
                Xml.Tag appendedPlugins = appendChild(
                        plugins, pluginTag);
                Xml.Tag newPluginManagement = replaceChild(
                        pluginManagement, plugins, appendedPlugins);
                Xml.Tag newBuild = replaceChild(
                        build, pluginManagement, newPluginManagement);
                return replaceChild(t, build, newBuild);
            }
        }.visitNonNull(doc, 0);
    }

    private static Xml.Tag buildPluginTag(PluginEnsure p) {
        StringBuilder sb = new StringBuilder();
        sb.append("<plugin>\n");
        sb.append("    <groupId>").append(p.groupId())
                .append("</groupId>\n");
        sb.append("    <artifactId>").append(p.artifactId())
                .append("</artifactId>\n");
        if (!p.version().isBlank()) {
            sb.append("    <version>").append(p.version())
                    .append("</version>\n");
        }
        sb.append("</plugin>");
        Xml.Document frag = PARSER.parse(sb.toString())
                .findFirst()
                .map(t -> (Xml.Document) t)
                .orElseThrow(() -> new ScaffoldException(
                        "cannot build <plugin> fragment"));
        return frag.getRoot();
    }

    private static Xml.Tag childOrCreate(
            Xml.Tag parent, String name) {
        return parent.getChild(name)
                .orElseGet(() -> emptyTag(name));
    }

    private static Xml.Tag emptyTag(String name) {
        Xml.Document frag = PARSER.parse(
                "<" + name + "/>").findFirst()
                .map(t -> (Xml.Document) t)
                .orElseThrow(() -> new ScaffoldException(
                        "cannot build empty <" + name + "/> tag"));
        return frag.getRoot();
    }

    private static Xml.Tag appendChild(Xml.Tag parent, Xml.Tag child) {
        List<org.openrewrite.xml.tree.Content> content =
                new ArrayList<>();
        if (parent.getContent() != null) {
            content.addAll(parent.getContent());
        }
        content.add(child);
        return parent.withContent(content);
    }

    private static Xml.Tag replaceChild(
            Xml.Tag parent,
            Xml.Tag oldChild,
            Xml.Tag newChild) {
        if (oldChild == newChild) {
            if (parent.getContent() == null
                    || !parent.getContent().contains(oldChild)) {
                return appendChild(parent, newChild);
            }
            return parent;
        }
        List<? extends org.openrewrite.xml.tree.Content> oldContent =
                parent.getContent() == null
                        ? List.of()
                        : parent.getContent();
        if (!oldContent.contains(oldChild)) {
            return appendChild(parent, newChild);
        }
        List<org.openrewrite.xml.tree.Content> newContent =
                new ArrayList<>(oldContent.size());
        for (org.openrewrite.xml.tree.Content c : oldContent) {
            newContent.add(c == oldChild ? newChild : c);
        }
        return parent.withContent(newContent);
    }

    /** Unused reference to silence analyzers. */
    @SuppressWarnings("unused")
    private static final Class<?> TREE_REF = Tree.class;
}