CascadeAssembler.java

package network.ike.workspace.cascade;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;

/**
 * Assembles the full IKE release cascade graph by traversing the
 * per-project {@code release-cascade.yaml} manifests
 * (IKE-Network/ike-issues#420).
 *
 * <p>The cascade is a loosely-coupled distributed system: each
 * project version-controls only its own {@code upstream}/{@code downstream}
 * edges. This assembler starts from one project, follows every edge
 * to its neighbours (resolving each neighbour's manifest through a
 * caller-supplied {@link CascadeResolver}), and stitches the result
 * into a single topologically ordered {@link ReleaseCascade}.
 *
 * <p>Two consistency rules are enforced during assembly:
 * <ul>
 *   <li><b>Edge reciprocity</b> — if project A names B as
 *       {@code downstream}, B must name A as {@code upstream}, and
 *       vice versa. A one-sided edge is a manifest error.</li>
 *   <li><b>Acyclicity</b> — the consume relation must be a DAG;
 *       a cycle has no release order.</li>
 * </ul>
 */
public final class CascadeAssembler {

    /**
     * Resolves the {@link ProjectCascade} for a cascade neighbour.
     *
     * <p>The caller decides how a neighbour is reached — a sibling
     * directory on disk, or a git checkout from the edge's
     * {@code url} — and reads its
     * {@code src/main/cascade/release-cascade.yaml}.
     */
    @FunctionalInterface
    public interface CascadeResolver {

        /**
         * Resolves and parses one neighbour's manifest.
         *
         * @param edge the edge naming the neighbour (carries its
         *             coordinates, {@code repo}, and {@code url})
         * @return the neighbour's parsed manifest
         * @throws RuntimeException if the neighbour's manifest cannot
         *                          be located or parsed
         */
        ProjectCascade resolve(CascadeEdge edge);
    }

    private CascadeAssembler() {}

    /**
     * Assembles the cascade graph rooted at one known project,
     * without populating {@link RepositoryKey} on the nodes.
     *
     * @param start        an edge identifying the starting project —
     *                     its coordinates and locators ({@code groupId},
     *                     {@code artifactId}, {@code repo},
     *                     {@code url}); {@code kind} is unused
     * @param startCascade the starting project's already-parsed
     *                     manifest
     * @param resolver     resolves every other project's manifest
     * @return the assembled, topologically ordered cascade
     * @throws IllegalArgumentException if an edge is one-sided or the
     *                                  graph contains a cycle
     */
    public static ReleaseCascade assemble(CascadeEdge start,
                                          ProjectCascade startCascade,
                                          CascadeResolver resolver) {
        return assemble(start, startCascade, resolver, null);
    }

    /**
     * Assembles the cascade graph rooted at one known project,
     * populating each node's {@link RepositoryKey} via
     * {@code repositoryResolver} when supplied
     * (IKE-Network/ike-issues#496 part C).
     *
     * @param start              an identifying edge for the starting
     *                           project
     * @param startCascade       the starting project's parsed manifest
     * @param resolver           resolves every other project's manifest
     * @param repositoryResolver maps a coordinate to its
     *                           {@link RepositoryKey}; may be
     *                           {@code null} to leave keys unset on
     *                           the assembled nodes
     * @return the assembled, topologically ordered cascade
     * @throws IllegalArgumentException if an edge is one-sided or the
     *                                  graph contains a cycle
     */
    public static ReleaseCascade assemble(CascadeEdge start,
                                          ProjectCascade startCascade,
                                          CascadeResolver resolver,
                                          RepositoryKeyResolver repositoryResolver) {
        // Identity is the MavenCoordinate (groupId + artifactId), NOT
        // groupId alone — two foundation members can share a groupId.
        // For example, `network.ike.tooling:ike-tooling` and
        // `network.ike.tooling:ike-workspace-extension` both live
        // under the `network.ike.tooling` group, but they are
        // independent cascade heads with different downstream edges.
        // Keying any of these maps by groupId would collide the two
        // nodes and silently drop edges, which used to surface as a
        // false-positive cycle (IKE-Network/ike-issues#466).
        //
        // Once #496 part D lands, identity collapses further onto the
        // RepositoryKey populated by repositoryResolver — multiple
        // coordinates that resolve to one repository become one node
        // — but until then MavenCoordinate remains the assembler's
        // working key.
        Map<MavenCoordinate, CascadeRepo> byCoordinate =
                new LinkedHashMap<>();
        Deque<CascadeRepo> frontier = new ArrayDeque<>();

        CascadeRepo startNode = node(start, startCascade,
                repositoryResolver);
        byCoordinate.put(startNode.coordinate(), startNode);
        frontier.add(startNode);

        while (!frontier.isEmpty()) {
            CascadeRepo current = frontier.poll();
            for (CascadeEdge edge : neighbourEdges(current)) {
                if (byCoordinate.containsKey(edge.coordinate())) {
                    continue;
                }
                ProjectCascade resolved;
                try {
                    resolved = resolver.resolve(edge);
                } catch (RuntimeException e) {
                    throw new IllegalArgumentException(
                            "cannot resolve release-cascade.yaml for"
                            + " cascade member " + edge.ga() + ": "
                            + e.getMessage(), e);
                }
                if (resolved == null) {
                    throw new IllegalArgumentException(
                            "cannot resolve release-cascade.yaml for"
                            + " cascade member " + edge.ga());
                }
                CascadeRepo neighbour = node(edge, resolved,
                        repositoryResolver);
                byCoordinate.put(neighbour.coordinate(), neighbour);
                frontier.add(neighbour);
            }
        }

        verifyReciprocity(byCoordinate);
        return new ReleaseCascade(topologicalOrder(byCoordinate));
    }

    private static CascadeRepo node(CascadeEdge identity,
                                    ProjectCascade cascade,
                                    RepositoryKeyResolver repositoryResolver) {
        RepositoryKey key = repositoryResolver == null
                ? null
                : repositoryResolver.resolve(identity.coordinate())
                        .orElse(null);
        return new CascadeRepo(identity.coordinate(), identity.repo(),
                identity.url(), key, cascade);
    }

    private static List<CascadeEdge> neighbourEdges(CascadeRepo node) {
        List<CascadeEdge> edges = new ArrayList<>(node.upstream());
        edges.addAll(node.downstream());
        return edges;
    }

    /**
     * Verifies every edge is matched by a reciprocal edge on the
     * neighbour: A→B {@code downstream} ⇔ B→A {@code upstream}.
     */
    private static void verifyReciprocity(
            Map<MavenCoordinate, CascadeRepo> byCoordinate) {
        for (CascadeRepo node : byCoordinate.values()) {
            MavenCoordinate self = node.coordinate();
            for (CascadeEdge down : node.downstream()) {
                CascadeRepo target = byCoordinate.get(down.coordinate());
                if (target == null || target.upstream().stream()
                        .map(CascadeEdge::coordinate)
                        .noneMatch(self::equals)) {
                    throw new IllegalArgumentException(
                            "one-sided cascade edge: " + self
                            + " lists " + down.ga() + " as downstream,"
                            + " but " + down.ga() + " does not list "
                            + self + " as upstream");
                }
            }
            for (CascadeEdge up : node.upstream()) {
                CascadeRepo source = byCoordinate.get(up.coordinate());
                if (source == null || source.downstream().stream()
                        .map(CascadeEdge::coordinate)
                        .noneMatch(self::equals)) {
                    throw new IllegalArgumentException(
                            "one-sided cascade edge: " + self
                            + " lists " + up.ga() + " as upstream,"
                            + " but " + up.ga() + " does not list "
                            + self + " as downstream");
                }
            }
        }
    }

    /**
     * Kahn topological sort — a node follows every node it consumes.
     * Ties break on the natural ordering of {@link MavenCoordinate}
     * so the order is deterministic.
     */
    private static List<CascadeRepo> topologicalOrder(
            Map<MavenCoordinate, CascadeRepo> byCoordinate) {
        Map<MavenCoordinate, Integer> inDegree = new HashMap<>();
        for (CascadeRepo node : byCoordinate.values()) {
            inDegree.put(node.coordinate(), node.upstream().size());
        }
        TreeSet<MavenCoordinate> ready = new TreeSet<>();
        inDegree.forEach((coordinate, degree) -> {
            if (degree == 0) {
                ready.add(coordinate);
            }
        });

        List<CascadeRepo> order = new ArrayList<>();
        while (!ready.isEmpty()) {
            MavenCoordinate coord = ready.pollFirst();
            CascadeRepo node = byCoordinate.get(coord);
            order.add(node);
            for (CascadeEdge down : node.downstream()) {
                int remaining = inDegree.merge(
                        down.coordinate(), -1, Integer::sum);
                if (remaining == 0) {
                    ready.add(down.coordinate());
                }
            }
        }
        if (order.size() != byCoordinate.size()) {
            throw new IllegalArgumentException(
                    "release cascade contains a cycle — no release"
                    + " order exists");
        }
        return order;
    }
}