ProjectCascadeIo.java

package network.ike.workspace.cascade;

import org.yaml.snakeyaml.Yaml;

import java.io.IOException;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Reads a project's own {@code src/main/cascade/release-cascade.yaml}
 * into a {@link ProjectCascade} (IKE-Network/ike-issues#420).
 *
 * <p>Each foundation project version-controls this file in its own
 * git tree, so resolution is a plain on-disk read of a fixed path —
 * no artifact resolution, no central manifest. {@link CascadeAssembler}
 * stitches the per-project files into the full ordered graph.
 *
 * <p>Parsing is lenient about unknown top-level keys (forward
 * compatibility) but strict about edge shape.
 */
public final class ProjectCascadeIo {

    /** Conventional manifest file name. */
    public static final String MANIFEST_NAME = "release-cascade.yaml";

    /**
     * Conventional manifest location relative to a repository root —
     * {@code src/main/cascade/release-cascade.yaml}. Every foundation
     * project carries the file at this same relative path.
     */
    public static final String MANIFEST_RELATIVE_PATH =
            "src/main/cascade/" + MANIFEST_NAME;

    private ProjectCascadeIo() {}

    /**
     * Parses a per-project manifest from a file path.
     *
     * @param path path to the YAML manifest
     * @return the parsed manifest
     * @throws UncheckedIOException if the file cannot be read
     * @throws IllegalArgumentException if the manifest is malformed
     */
    public static ProjectCascade read(Path path) {
        try (Reader reader = Files.newBufferedReader(
                path, StandardCharsets.UTF_8)) {
            return read(reader);
        } catch (IOException e) {
            throw new UncheckedIOException(
                    "Cannot read release-cascade manifest: " + path, e);
        }
    }

    /**
     * Parses a per-project manifest from an open reader.
     *
     * @param reader the YAML source
     * @return the parsed manifest
     * @throws IllegalArgumentException if the manifest is malformed
     */
    public static ProjectCascade read(Reader reader) {
        Object root = new Yaml().load(reader);
        if (!(root instanceof Map<?, ?> map)) {
            throw new IllegalArgumentException(
                    "release-cascade.yaml must be a YAML mapping");
        }
        int schema = map.get("schema") instanceof Number n
                ? n.intValue() : 1;
        boolean head = Boolean.TRUE.equals(map.get("head"));
        boolean terminal = Boolean.TRUE.equals(map.get("terminal"));
        List<CascadeEdge> upstream = readEdges(map.get("upstream"));
        List<CascadeEdge> downstream = readEdges(map.get("downstream"));
        return new ProjectCascade(schema, head, upstream,
                terminal, downstream);
    }

    /**
     * Loads a per-project manifest from a path, degrading gracefully
     * when no manifest is present.
     *
     * @param manifestPath the manifest path; may be {@code null}
     * @return the parsed manifest, or empty if {@code manifestPath} is
     *         {@code null} or does not point at a regular file
     * @throws IllegalArgumentException if the manifest exists but is
     *                                  malformed
     */
    public static Optional<ProjectCascade> load(Path manifestPath) {
        if (manifestPath == null || !Files.isRegularFile(manifestPath)) {
            return Optional.empty();
        }
        return Optional.of(read(manifestPath));
    }

    private static List<CascadeEdge> readEdges(Object raw) {
        List<CascadeEdge> edges = new ArrayList<>();
        if (!(raw instanceof List<?> entries)) {
            return edges;
        }
        for (Object entry : entries) {
            if (!(entry instanceof Map<?, ?> e)) {
                throw new IllegalArgumentException(
                        "each cascade edge must be a mapping");
            }
            edges.add(new CascadeEdge(
                    stringOrNull(e.get("groupId")),
                    stringOrNull(e.get("artifactId")),
                    stringOrNull(e.get("repo")),
                    stringOrNull(e.get("url")),
                    stringOrNull(e.get("version-property"))));
        }
        return edges;
    }

    private static String stringOrNull(Object value) {
        return value == null ? null : String.valueOf(value);
    }
}