MavenSettingsAdapter.java

package network.ike.plugin.scaffold;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.XMLConstants;
import java.io.ByteArrayInputStream;
import java.io.StringReader;
import java.io.StringWriter;
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;

/**
 * Model adapter for {@code ~/.m2/settings.xml} (Maven Settings 1.2.0).
 *
 * <p>Supported {@code ensure} subtree:
 * <pre>{@code
 * ensure:
 *   pluginGroups:
 *     - network.ike.tooling
 * }</pre>
 *
 * <p>Semantics:
 * <ul>
 *   <li>If the file does not exist, a minimal
 *       {@code <settings xmlns="...">} document is created.</li>
 *   <li>Each {@code pluginGroup} in {@code ensure.pluginGroups} that
 *       is not already present is appended. Existing entries — and any
 *       unrelated elements (servers, profiles, …) — are left
 *       untouched.</li>
 *   <li>Each installed or re-confirmed {@code pluginGroup} is recorded
 *       as a {@link ManagedElement} with path
 *       {@code "/settings/pluginGroups/pluginGroup[text()='G']"}.</li>
 * </ul>
 *
 * <p>DOM-based so unmanaged content (comments, whitespace outside the
 * managed region, unrelated child elements) is preserved on round-trip
 * to the extent the Transformer supports it.
 */
public final class MavenSettingsAdapter implements ModelAdapter {

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

    static final String SETTINGS_NS =
            "http://maven.apache.org/SETTINGS/1.2.0";

    /**
     * Construct a stateless Maven-settings adapter. Instances are safe
     * to share across planning calls; all per-invocation state lives
     * on method parameters.
     */
    public MavenSettingsAdapter() {
    }

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

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

        List<String> ensuredGroups = readEnsuredPluginGroups(entry);

        boolean fresh = currentContent == null
                || currentContent.length == 0;
        Document doc = fresh
                ? newSettingsDocument()
                : parseXml(currentContent);
        Element root = doc.getDocumentElement();

        Element pluginGroupsEl = findOrCreateChild(
                doc, root, "pluginGroups");

        List<String> existingGroups = readChildValues(
                pluginGroupsEl, "pluginGroup");
        Map<String, ManagedElement> priorByPath =
                indexByPath(priorEntry);

        boolean changed = fresh;
        List<ManagedElement> managed = new ArrayList<>();
        for (String group : ensuredGroups) {
            String path = pluginGroupPath(group);
            if (!existingGroups.contains(group)) {
                Element pg = doc.createElementNS(
                        SETTINGS_NS, "pluginGroup");
                pg.setTextContent(group);
                pluginGroupsEl.appendChild(pg);
                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) {
            byte[] currentBytes = currentContent;
            String currentSha = Sha256.of(currentBytes);
            return new ModelPlanResult(
                    new TierAction.UpToDate(
                            entry, resolvedDest,
                            currentSha, currentSha,
                            "up to date"),
                    managed);
        }

        byte[] newBytes = serialize(doc);
        String newSha = Sha256.of(newBytes);
        TierAction.Write.Kind kind = fresh
                ? TierAction.Write.Kind.INSTALL
                : TierAction.Write.Kind.UPDATE;
        int added = managed.size()
                - (priorEntry == null
                        ? 0
                        : priorEntry.managedElements().size());
        String reason = fresh
                ? "install settings.xml with " + managed.size()
                        + " pluginGroup(s)"
                : (added > 0
                        ? "ensure " + added + " pluginGroup(s)"
                        : "refresh settings.xml");

        return new ModelPlanResult(
                new TierAction.Write(
                        entry, resolvedDest, newBytes,
                        newSha, newSha, kind, reason),
                managed);
    }

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

    private static String pluginGroupPath(String group) {
        return "/settings/pluginGroups/pluginGroup[text()='"
                + group + "']";
    }

    private static List<String> readEnsuredPluginGroups(
            ManifestEntry entry) {
        Object ensure = entry.extras().get("ensure");
        if (ensure == null) {
            return Collections.emptyList();
        }
        if (!(ensure instanceof Map<?, ?> ensureMap)) {
            throw new ScaffoldException(
                    "maven-settings-4 entry '" + entry.dest()
                            + "': 'ensure' must be a mapping");
        }
        Object groups = ensureMap.get("pluginGroups");
        if (groups == null) {
            return Collections.emptyList();
        }
        if (!(groups instanceof List<?> groupList)) {
            throw new ScaffoldException(
                    "maven-settings-4 entry '" + entry.dest()
                            + "': 'ensure.pluginGroups' must be a list");
        }
        List<String> out = new ArrayList<>();
        for (Object g : groupList) {
            if (g == null || g.toString().isBlank()) {
                throw new ScaffoldException(
                        "maven-settings-4 entry '" + entry.dest()
                                + "': blank pluginGroup entry");
            }
            out.add(g.toString());
        }
        return out;
    }

    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 Document newSettingsDocument() {
        try {
            DocumentBuilder builder = newDocumentBuilder();
            Document doc = builder.newDocument();
            Element root = doc.createElementNS(
                    SETTINGS_NS, "settings");
            root.setAttributeNS(
                    XMLConstants.XMLNS_ATTRIBUTE_NS_URI,
                    "xmlns:xsi",
                    "http://www.w3.org/2001/XMLSchema-instance");
            root.setAttributeNS(
                    "http://www.w3.org/2001/XMLSchema-instance",
                    "xsi:schemaLocation",
                    SETTINGS_NS + " https://maven.apache.org/xsd/"
                            + "settings-1.2.0.xsd");
            doc.appendChild(root);
            return doc;
        } catch (ParserConfigurationException e) {
            throw new ScaffoldException(
                    "cannot create settings document", e);
        }
    }

    private static Document parseXml(byte[] bytes) {
        try {
            DocumentBuilder builder = newDocumentBuilder();
            return builder.parse(new InputSource(
                    new StringReader(new String(
                            bytes, StandardCharsets.UTF_8))));
        } catch (Exception e) {
            throw new ScaffoldException(
                    "cannot parse settings.xml: " + e.getMessage(), e);
        }
    }

    private static DocumentBuilder newDocumentBuilder()
            throws ParserConfigurationException {
        DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
        f.setNamespaceAware(true);
        f.setFeature(
                XMLConstants.FEATURE_SECURE_PROCESSING, true);
        f.setFeature(
                "http://apache.org/xml/features/"
                        + "disallow-doctype-decl", true);
        f.setXIncludeAware(false);
        f.setExpandEntityReferences(false);
        f.setAttribute(
                XMLConstants.ACCESS_EXTERNAL_DTD, "");
        f.setAttribute(
                XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
        return f.newDocumentBuilder();
    }

    private static Element findOrCreateChild(
            Document doc, Element parent, String localName) {
        NodeList kids = parent.getChildNodes();
        for (int i = 0; i < kids.getLength(); i++) {
            Node n = kids.item(i);
            if (n.getNodeType() == Node.ELEMENT_NODE
                    && localName.equals(n.getLocalName())) {
                return (Element) n;
            }
        }
        Element created = doc.createElementNS(
                SETTINGS_NS, localName);
        parent.appendChild(created);
        return created;
    }

    private static List<String> readChildValues(
            Element parent, String localName) {
        List<String> out = new ArrayList<>();
        NodeList kids = parent.getChildNodes();
        for (int i = 0; i < kids.getLength(); i++) {
            Node n = kids.item(i);
            if (n.getNodeType() == Node.ELEMENT_NODE
                    && localName.equals(n.getLocalName())) {
                String text = n.getTextContent();
                out.add(text == null ? "" : text.trim());
            }
        }
        return out;
    }

    private static byte[] serialize(Document doc) {
        try {
            Transformer t = TransformerFactory.newInstance()
                    .newTransformer();
            t.setOutputProperty(OutputKeys.INDENT, "yes");
            t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
            t.setOutputProperty(
                    "{http://xml.apache.org/xslt}indent-amount", "2");
            t.setOutputProperty(
                    OutputKeys.ENCODING, "UTF-8");
            StringWriter sw = new StringWriter();
            t.transform(new DOMSource(doc), new StreamResult(sw));
            String out = sw.toString();
            if (!out.endsWith("\n")) {
                out = out + "\n";
            }
            return out.getBytes(StandardCharsets.UTF_8);
        } catch (TransformerException e) {
            throw new ScaffoldException(
                    "cannot serialize settings.xml", e);
        }
    }
}