ScaffoldLockfileIo.java

package network.ike.plugin.scaffold;

import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Read and write {@link ScaffoldLockfile} instances in the on-disk
 * YAML format documented in the
 * {@code dev-ike-scaffold-architecture} design note.
 *
 * <p>Uses SnakeYAML for parsing; emits a stable, human-friendly
 * representation via {@link DumperOptions.FlowStyle#BLOCK block
 * style} with insertion-order preserved. The writer writes the
 * schema version first, then the top-level stamps, then the
 * {@code files:} map — so a hand-read diff of
 * {@code .ike/scaffold.lock} stays easy to follow across publishes.
 *
 * <p>All emitted timestamps are in UTC ISO-8601 ({@code "Z"}).
 */
public final class ScaffoldLockfileIo {

    private ScaffoldLockfileIo() {}

    // ── Read ────────────────────────────────────────────────────────

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

    /**
     * Parse a lockfile from a Reader.
     *
     * @param reader source of YAML text
     * @return the parsed lockfile
     * @throws ScaffoldException if the YAML is empty or has an
     *                           unsupported schema version
     */
    public static ScaffoldLockfile read(Reader reader) {
        Object raw = new Yaml().load(reader);
        if (raw == null) {
            throw new ScaffoldException(
                    "Empty scaffold lockfile");
        }
        if (!(raw instanceof Map<?, ?> rootMap)) {
            throw new ScaffoldException(
                    "Scaffold lockfile 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", ScaffoldLockfile.CURRENT_SCHEMA);
        if (schema != ScaffoldLockfile.CURRENT_SCHEMA) {
            throw new ScaffoldException(
                    "Unsupported scaffold lockfile schema: " + schema
                            + " (supported: "
                            + ScaffoldLockfile.CURRENT_SCHEMA + ")");
        }

        String standardsVersion = stringField(
                root, "standards-version", null);
        Instant applied = instantField(root, "applied");

        Map<String, Object> filesYaml = mapField(root, "files");
        Map<String, LockfileEntry> files = new LinkedHashMap<>();
        if (filesYaml != null) {
            for (Map.Entry<String, Object> e : filesYaml.entrySet()) {
                files.put(e.getKey(),
                        parseEntry(e.getKey(), e.getValue()));
            }
        }

        return new ScaffoldLockfile(
                schema, standardsVersion, applied, files);
    }

    private static LockfileEntry parseEntry(String key, Object raw) {
        if (!(raw instanceof Map<?, ?> entryMap)) {
            throw new ScaffoldException(
                    "Lockfile entry '" + key
                            + "' must be a YAML mapping");
        }
        @SuppressWarnings("unchecked")
        Map<String, Object> entry = (Map<String, Object>) entryMap;

        String tierValue = stringField(entry, "tier", null);
        if (tierValue == null) {
            throw new ScaffoldException(
                    "Lockfile entry '" + key
                            + "' missing required 'tier' field");
        }
        ScaffoldTier tier = ScaffoldTier.fromManifestValue(tierValue);

        String templateSha = stringField(entry, "template-sha", null);
        String appliedSha = stringField(entry, "applied-sha", null);

        List<ManagedElement> managed = new ArrayList<>();
        Object me = entry.get("managed-elements");
        if (me instanceof List<?> list) {
            for (Object o : list) {
                if (!(o instanceof Map<?, ?> itemMap)) {
                    throw new ScaffoldException(
                            "managed-elements entry in '" + key
                                    + "' must be a YAML mapping");
                }
                @SuppressWarnings("unchecked")
                Map<String, Object> item = (Map<String, Object>) itemMap;
                String elemPath = stringField(item, "path", null);
                Instant installedAt = instantField(item, "installed-at");
                String stdVer = stringField(
                        item, "standards-version", null);
                if (elemPath == null || installedAt == null
                        || stdVer == null) {
                    throw new ScaffoldException(
                            "managed-elements entry in '" + key
                                    + "' requires path, installed-at, "
                                    + "and standards-version");
                }
                managed.add(new ManagedElement(
                        elemPath, installedAt, stdVer));
            }
        }

        return new LockfileEntry(tier, templateSha, appliedSha, managed);
    }

    // ── Write ───────────────────────────────────────────────────────

    /**
     * Serialise a lockfile to YAML text. Does not touch disk.
     *
     * @param lockfile the lockfile to serialise
     * @return its YAML representation
     */
    public static String writeToString(ScaffoldLockfile lockfile) {
        StringWriter out = new StringWriter();
        write(lockfile, out);
        return out.toString();
    }

    /**
     * Serialise a lockfile to a file path, creating parent
     * directories if needed.
     *
     * @param lockfile the lockfile to serialise
     * @param path     destination path
     * @throws ScaffoldException if the file cannot be written
     */
    public static void write(ScaffoldLockfile lockfile, Path path) {
        try {
            Path parent = path.getParent();
            if (parent != null) {
                Files.createDirectories(parent);
            }
            try (BufferedWriter out = Files.newBufferedWriter(
                    path, StandardCharsets.UTF_8)) {
                write(lockfile, out);
            }
        } catch (IOException e) {
            throw new ScaffoldException(
                    "Cannot write scaffold lockfile " + path, e);
        }
    }

    /**
     * Serialise a lockfile through any {@link Writer}.
     *
     * @param lockfile the lockfile to serialise
     * @param writer   destination writer (not closed)
     */
    public static void write(ScaffoldLockfile lockfile, Writer writer) {
        Map<String, Object> root = new LinkedHashMap<>();
        root.put("schema", lockfile.schema());
        if (lockfile.standardsVersion() != null) {
            root.put("standards-version", lockfile.standardsVersion());
        }
        if (lockfile.applied() != null) {
            root.put("applied", toIso(lockfile.applied()));
        }
        Map<String, Object> filesOut = new LinkedHashMap<>();
        for (Map.Entry<String, LockfileEntry> e
                : lockfile.files().entrySet()) {
            filesOut.put(e.getKey(), entryToMap(e.getValue()));
        }
        root.put("files", filesOut);

        DumperOptions opts = new DumperOptions();
        opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
        opts.setPrettyFlow(true);
        opts.setIndent(2);
        opts.setIndicatorIndent(0);
        opts.setLineBreak(DumperOptions.LineBreak.UNIX);
        try {
            new Yaml(opts).dump(root, writer);
        } catch (Exception e) {
            throw new ScaffoldException(
                    "Failed to serialise scaffold lockfile", e);
        }
    }

    private static Map<String, Object> entryToMap(LockfileEntry entry) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("tier", entry.tier().manifestValue());
        if (entry.templateSha() != null) {
            m.put("template-sha", entry.templateSha());
        }
        if (entry.appliedSha() != null) {
            m.put("applied-sha", entry.appliedSha());
        }
        if (!entry.managedElements().isEmpty()) {
            List<Map<String, Object>> list = new ArrayList<>();
            for (ManagedElement e : entry.managedElements()) {
                Map<String, Object> em = new LinkedHashMap<>();
                em.put("path", e.path());
                em.put("installed-at", toIso(e.installedAt()));
                em.put("standards-version", e.standardsVersion());
                list.add(em);
            }
            m.put("managed-elements", list);
        }
        return m;
    }

    // ── 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);
        }
    }

    @SuppressWarnings("unchecked")
    private static Map<String, Object> mapField(
            Map<String, Object> map, String key) {
        Object v = map.get(key);
        if (v == null) {
            return null;
        }
        if (!(v instanceof Map<?, ?>)) {
            throw new ScaffoldException(
                    "Expected mapping for '" + key + "'");
        }
        return (Map<String, Object>) v;
    }

    private static Instant instantField(
            Map<String, Object> map, String key) {
        Object v = map.get(key);
        if (v == null) {
            return null;
        }
        if (v instanceof Instant i) {
            return i;
        }
        if (v instanceof java.util.Date d) {
            return d.toInstant();
        }
        String s = v.toString().trim();
        try {
            return DateTimeFormatter.ISO_INSTANT.parse(s, Instant::from);
        } catch (DateTimeParseException e) {
            throw new ScaffoldException(
                    "Invalid timestamp for '" + key + "': " + s, e);
        }
    }

    private static String toIso(Instant i) {
        return DateTimeFormatter.ISO_INSTANT.format(i);
    }
}