Skip to main content

grex_core/tree/
graph.rs

1//! Read-only pack graph produced by the [`crate::tree::Walker`].
2//!
3//! The graph is a value type: once the walker returns a [`PackGraph`] the
4//! structure is immutable. Callers (validators, schedulers, renderers) only
5//! ever see the already-assembled graph — they never participate in its
6//! construction. This decoupling lets us swap the walker for, say, an IPC
7//! driver or a snapshot-replay harness without touching any downstream
8//! consumer.
9//!
10//! # Ownership model
11//!
12//! * Nodes live in a `Vec`; node id == vector index.
13//! * Edges are a flat vector for cheap iteration; the few lookups we perform
14//!   on a walked tree do not justify an adjacency-map yet.
15//! * The root is always at index `0` by construction.
16//!
17//! # Non-goals
18//!
19//! * No mutation API. The graph cannot grow or shrink after walker exit.
20//! * No topological sort here — that belongs to the scheduler slice.
21//! * No serialisation — persistence is a later slice.
22
23use std::path::PathBuf;
24
25use crate::pack::PackManifest;
26
27/// Node-edge relationship kind.
28///
29/// `Child` edges are *owned*: the walker cloned the target repo and recursed
30/// into it. `DependsOn` edges are *referential*: the walker recorded that the
31/// parent named this dep but did not hydrate it — resolution happens at
32/// validate time via `DependsOnValidator`.
33///
34/// Marked `#[non_exhaustive]` so new edge relations (e.g. `Provides`,
35/// `Conflicts`) can land without breaking external match sites.
36#[non_exhaustive]
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum EdgeKind {
39    /// Parent owns/walks this child (cloned + recursed).
40    Child,
41    /// Parent merely references this pack by name or url.
42    DependsOn,
43}
44
45/// A pack in the walked graph.
46///
47/// Every field is `pub` by design: the graph is a read-only value type, and
48/// exposing the full record here is simpler than hand-curating accessors for
49/// each field.
50///
51/// Marked `#[non_exhaustive]` so audit fields (resolved ref SHA, hydration
52/// timestamps) can land without breaking library consumers who destructure
53/// the struct.
54#[non_exhaustive]
55#[derive(Debug, Clone)]
56pub struct PackNode {
57    /// Stable index inside the graph (equal to the `Vec` position).
58    pub id: usize,
59    /// `name` copied from the pack manifest for O(1) lookup.
60    pub name: String,
61    /// On-disk location of the pack's working tree.
62    pub path: PathBuf,
63    /// Source URL the walker used to hydrate this node, or `None` for the
64    /// root / nodes that were loaded directly from an on-disk path.
65    pub source_url: Option<String>,
66    /// Full parsed manifest.
67    pub manifest: PackManifest,
68    /// Parent id; `None` for the root.
69    pub parent: Option<usize>,
70    /// Resolved commit SHA of the pack's working tree, when the walker
71    /// could obtain one. `None` for the root (local path, no clone step)
72    /// or when `head_sha` probing fails. Mixed into
73    /// [`crate::lockfile::compute_actions_hash`] so ref drift invalidates
74    /// the skip-on-hash short-circuit (M4-D spec §M4 req 4a).
75    pub commit_sha: Option<String>,
76    /// `true` when the walker synthesised the manifest in-memory because
77    /// the on-disk child had no `.grex/pack.yaml` but did carry a `.git/`
78    /// (v1.1.1 plain-git children, see
79    /// `openspec/changes/feat-v1.1.1-plain-git-children/`). Threaded
80    /// through to the lockfile (`LockEntry::synthetic`) and downstream
81    /// surfaces (doctor, ls). Default `false` for every declared pack.
82    pub synthetic: bool,
83    /// Verbatim copy of the parent manifest's `ref:` field (the
84    /// originating `crate::pack::ChildRef::r#ref`) — `None` for the
85    /// root and for children whose manifest declared no `ref:`. Threaded
86    /// from the walker down to [`crate::sync::run`] so the lockfile
87    /// `branch` slot can mirror the manifest verbatim, per the v1.3.1
88    /// B14 fix and the Lean theorem
89    /// `Grex.Lockfile.lockfile_branch_mirrors_manifest_ref`.
90    pub manifest_ref: Option<String>,
91}
92
93/// An edge in the walked graph.
94///
95/// Marked `#[non_exhaustive]` so future edge-level metadata (priority,
96/// guard expression) is non-breaking for library consumers.
97#[non_exhaustive]
98#[derive(Debug, Clone)]
99pub struct PackEdge {
100    /// Origin node id.
101    pub from: usize,
102    /// Target node id.
103    pub to: usize,
104    /// Relationship kind.
105    pub kind: EdgeKind,
106}
107
108/// Fully-walked pack graph. Immutable post-construction.
109///
110/// Nodes are owned; callers borrow via [`PackGraph::nodes`] or the dedicated
111/// lookup helpers.
112#[derive(Debug)]
113pub struct PackGraph {
114    nodes: Vec<PackNode>,
115    edges: Vec<PackEdge>,
116}
117
118impl PackGraph {
119    /// Construct a graph from raw node and edge vectors.
120    ///
121    /// Pub-crate visibility keeps mutation ownership with the walker while
122    /// allowing an empty graph or a manually-assembled fixture inside tests
123    /// (via `pub(crate)` helpers invoked from the integration test harness).
124    ///
125    /// # Panics
126    ///
127    /// Panics if `nodes` is empty — a walk always produces at least the
128    /// root. This is a programming-error guard rather than a user-facing
129    /// failure mode.
130    #[must_use]
131    pub(crate) fn new(nodes: Vec<PackNode>, edges: Vec<PackEdge>) -> Self {
132        assert!(!nodes.is_empty(), "PackGraph must contain at least the root node");
133        Self { nodes, edges }
134    }
135
136    /// The root node (id == 0).
137    #[must_use]
138    pub fn root(&self) -> &PackNode {
139        &self.nodes[0]
140    }
141
142    /// All nodes in insertion order.
143    #[must_use]
144    pub fn nodes(&self) -> &[PackNode] {
145        &self.nodes
146    }
147
148    /// All edges in insertion order.
149    #[must_use]
150    pub fn edges(&self) -> &[PackEdge] {
151        &self.edges
152    }
153
154    /// Iterate the `Child`-kind neighbours of `id` (in insertion order).
155    pub fn children_of(&self, id: usize) -> impl Iterator<Item = &PackNode> {
156        self.neighbours(id, EdgeKind::Child)
157    }
158
159    /// Iterate the `DependsOn`-kind neighbours of `id`.
160    pub fn depends_on_of(&self, id: usize) -> impl Iterator<Item = &PackNode> {
161        self.neighbours(id, EdgeKind::DependsOn)
162    }
163
164    /// Find a node by its manifest name. Returns the first match in
165    /// insertion order; names are not guaranteed unique across a graph,
166    /// though per-pack validators may reject duplicates in future slices.
167    #[must_use]
168    pub fn find_by_name(&self, name: &str) -> Option<&PackNode> {
169        self.nodes.iter().find(|n| n.name == name)
170    }
171
172    /// Borrow a node by id.
173    #[must_use]
174    pub fn node(&self, id: usize) -> Option<&PackNode> {
175        self.nodes.get(id)
176    }
177
178    fn neighbours(&self, id: usize, kind: EdgeKind) -> impl Iterator<Item = &PackNode> {
179        self.edges
180            .iter()
181            .filter(move |e| e.from == id && e.kind == kind)
182            .filter_map(|e| self.nodes.get(e.to))
183    }
184}