ScaffoldManifestIo.java

package network.ike.plugin.scaffold;

import org.yaml.snakeyaml.Yaml;

import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
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;

/**
 * Read a scaffold manifest from YAML.
 *
 * <p>The manifest is shipped by {@code ike-build-standards}; the
 * scaffold plugin never writes it, only reads it. Parsing is lenient
 * about extra top-level keys (future extensions) but strict about
 * every key that drives behaviour.
 *
 * <p>Raw adapter-specific configuration (under keys like
 * {@code ensure}, {@code never-touch}, {@code block-begin},
 * {@code block-end}) is preserved verbatim in
 * {@link ManifestEntry#extras()} so tier handlers and model adapters
 * can parse their own subtrees.
 */
public final class ScaffoldManifestIo {

    /**
     * Keys consumed by the core manifest parser; every other key in
     * a file entry is carried through into
     * {@link ManifestEntry#extras()} for adapter consumption.
     */
    private static final Set<String> CORE_ENTRY_KEYS = Set.of(
            "dest", "scope", "tier", "source", "model");

    private ScaffoldManifestIo() {}

    /**
     * Parse a manifest from a file path.
     *
     * @param path path to the YAML manifest
     * @return the parsed manifest
     * @throws ScaffoldException if the file cannot be read, is empty,
     *                           has an unsupported schema version, or
     *                           contains an invalid entry
     */
    public static ScaffoldManifest read(Path path) {
        try (Reader reader = Files.newBufferedReader(
                path, StandardCharsets.UTF_8)) {
            return read(reader);
        } catch (IOException e) {
            throw new ScaffoldException(
                    "Cannot read scaffold manifest " + path, e);
        }
    }

    /**
     * Parse a manifest from a {@link Reader}.
     *
     * @param reader source of YAML text
     * @return the parsed manifest
     */
    public static ScaffoldManifest read(Reader reader) {
        Object raw = new Yaml().load(reader);
        if (raw == null) {
            throw new ScaffoldException(
                    "Empty scaffold manifest");
        }
        if (!(raw instanceof Map<?, ?> rootMap)) {
            throw new ScaffoldException(
                    "Scaffold manifest root must be a YAML mapping, "
                            + "got " + raw.getClass().getSimpleName());
        }
        @SuppressWarnings("unchecked")
        Map<String, Object> root = (Map<String, Object>) rootMap;

        int schema = intField(root, "schema",
                ScaffoldManifest.CURRENT_SCHEMA);
        if (schema != ScaffoldManifest.CURRENT_SCHEMA) {
            throw new ScaffoldException(
                    "Unsupported scaffold manifest schema: " + schema
                            + " (supported: "
                            + ScaffoldManifest.CURRENT_SCHEMA + ")");
        }

        String standardsVersion = stringField(
                root, "standards-version", null);
        if (standardsVersion == null) {
            throw new ScaffoldException(
                    "Scaffold manifest missing required "
                            + "'standards-version'");
        }

        Object filesObj = root.get("files");
        if (filesObj == null) {
            throw new ScaffoldException(
                    "Scaffold manifest missing required 'files' list");
        }
        if (!(filesObj instanceof List<?> filesList)) {
            throw new ScaffoldException(
                    "'files' must be a YAML list");
        }

        List<ManifestEntry> entries = new ArrayList<>();
        int idx = 0;
        for (Object o : filesList) {
            entries.add(parseEntry(o, idx++));
        }

        // #345: optional foundation: section. Picking up scaffold
        // version N gives the consumer the parent + property pins
        // that ike-tooling N saw as the latest-released at its
        // release time — a tested-together compatibility snapshot.
        ScaffoldManifest.Foundation foundation = parseFoundation(
                root.get("foundation"));

        return new ScaffoldManifest(schema, standardsVersion,
                entries, foundation);
    }

    @SuppressWarnings("unchecked")
    private static ScaffoldManifest.Foundation parseFoundation(Object raw) {
        if (raw == null) return null;
        if (!(raw instanceof Map<?, ?> map)) {
            throw new ScaffoldException(
                    "'foundation' must be a YAML mapping");
        }
        Map<String, Object> foundationMap = (Map<String, Object>) map;

        Object parentObj = foundationMap.get("parent");
        ScaffoldManifest.ParentRef parent = null;
        if (parentObj != null) {
            if (!(parentObj instanceof Map<?, ?> pm)) {
                throw new ScaffoldException(
                        "'foundation.parent' must be a YAML mapping");
            }
            Map<String, Object> parentMap = (Map<String, Object>) pm;
            String g = stringField(parentMap, "groupId", null);
            String a = stringField(parentMap, "artifactId", null);
            String v = stringField(parentMap, "version", null);
            if (g == null || a == null || v == null) {
                throw new ScaffoldException(
                        "'foundation.parent' requires groupId, "
                                + "artifactId, and version");
            }
            parent = new ScaffoldManifest.ParentRef(g, a, v);
        }

        Object propsObj = foundationMap.get("properties");
        Map<String, String> properties = new java.util.LinkedHashMap<>();
        if (propsObj != null) {
            if (!(propsObj instanceof Map<?, ?> pm)) {
                throw new ScaffoldException(
                        "'foundation.properties' must be a YAML mapping");
            }
            Map<String, Object> propsMap = (Map<String, Object>) pm;
            for (Map.Entry<String, Object> e : propsMap.entrySet()) {
                if (e.getValue() == null) continue;
                properties.put(e.getKey(), e.getValue().toString());
            }
        }

        return new ScaffoldManifest.Foundation(parent, properties);
    }

    private static ManifestEntry parseEntry(Object raw, int index) {
        if (!(raw instanceof Map<?, ?> entryMap)) {
            throw new ScaffoldException(
                    "files[" + index + "] must be a YAML mapping");
        }
        @SuppressWarnings("unchecked")
        Map<String, Object> entry = (Map<String, Object>) entryMap;

        String dest = stringField(entry, "dest", null);
        String scopeStr = stringField(entry, "scope", null);
        String tierStr = stringField(entry, "tier", null);
        String source = stringField(entry, "source", null);
        String model = stringField(entry, "model", null);

        if (dest == null) {
            throw new ScaffoldException(
                    "files[" + index + "] missing 'dest'");
        }
        if (scopeStr == null) {
            throw new ScaffoldException(
                    "files[" + index + "] ('" + dest
                            + "') missing 'scope'");
        }
        if (tierStr == null) {
            throw new ScaffoldException(
                    "files[" + index + "] ('" + dest
                            + "') missing 'tier'");
        }

        ScaffoldScope scope;
        ScaffoldTier tier;
        try {
            scope = ScaffoldScope.fromManifestValue(scopeStr);
            tier = ScaffoldTier.fromManifestValue(tierStr);
        } catch (IllegalArgumentException e) {
            throw new ScaffoldException(
                    "files[" + index + "] ('" + dest + "'): "
                            + e.getMessage(), e);
        }

        Map<String, Object> extras = new LinkedHashMap<>();
        for (Map.Entry<String, Object> e : entry.entrySet()) {
            if (!CORE_ENTRY_KEYS.contains(e.getKey())) {
                extras.put(e.getKey(), e.getValue());
            }
        }

        try {
            return new ManifestEntry(
                    dest, scope, tier, source, model, extras);
        } catch (IllegalArgumentException e) {
            throw new ScaffoldException(
                    "files[" + index + "] ('" + dest + "'): "
                            + e.getMessage(), e);
        }
    }

    // ── YAML helpers ────────────────────────────────────────────────

    private static String stringField(
            Map<String, Object> map, String key, String fallback) {
        Object v = map.get(key);
        if (v == null) {
            return fallback;
        }
        return v.toString();
    }

    private static int intField(
            Map<String, Object> map, String key, int fallback) {
        Object v = map.get(key);
        if (v == null) {
            return fallback;
        }
        if (v instanceof Number n) {
            return n.intValue();
        }
        try {
            return Integer.parseInt(v.toString().trim());
        } catch (NumberFormatException e) {
            throw new ScaffoldException(
                    "Expected integer for '" + key + "', got " + v);
        }
    }
}