rto_graph/topology.rs
1//! The **project-level shape** of a workspace: who depends on whom, derived once
2//! and read by everything that needs to know.
3//!
4//! # Why this is here rather than in a caller
5//!
6//! Issue #623 asked for two things. The first — that a project can be a spoke of
7//! one project and the hub of others — landed in the served topology view. The
8//! second did not: *lift the hub rule into one shared home, because there are
9//! already two and pins would add a third.*
10//!
11//! That second half is this module, and it was not optional for long. The
12//! consolidated rule lived in `roteiro`'s `graph_api`, which is
13//! `#[cfg(feature = "explorer")]`, while the workspace **vault** renderer is not
14//! gated at all. So the first caller outside the web API — version pins in the
15//! shareable manifest (#442) — could not legally call the rule it needed, and its
16//! only alternatives were to write a third one or to gate a Markdown export
17//! behind a web-API feature.
18//!
19//! Living in `rto-graph` puts it below every caller: the explorer's JSON API, the
20//! vault renderer, and the `links` views all depend on this crate unconditionally,
21//! which is the same argument [`crate::slugify`] and [`crate::markdown_dialect`]
22//! already make for themselves.
23//!
24//! # What it is built from, and what it is deliberately not built from
25//!
26//! **Persisted external-ref edges only** — those declared as authored `[[links]]`,
27//! and those a previous `links --write` wrote to the store. Not the merged link
28//! list a topology view renders: that also
29//! carries the correspondences inferred *live* against the hub, which are a
30//! config-key **matching heuristic**, not a declared dependency. Deriving the shape
31//! from those would make every project a child of the hub by construction, and in a
32//! chain (`infra → chart → app`) would invent a `chart ↔ app` cycle out of a name
33//! match.
34
35use std::collections::{BTreeMap, BTreeSet, HashSet};
36
37use crate::links::{EXTERNAL_REF_KIND, external_ref_target};
38use crate::model::{Node, NodeKind};
39use crate::store::{Store, StoreError};
40use crate::workspace::{Workspace, WorkspaceError, parse_qualified};
41
42/// Where a project sits in the workspace hierarchy, from its own in/out degree.
43///
44/// Four values, replacing the `hub`/`spoke` pair that could not describe a project
45/// which is both — see #623. A cycle has no root: every project in it reports
46/// [`Self::Intermediate`], which is a truthful report of a workspace that declares
47/// one rather than an error. Nothing here recurses, so a cycle cannot hang a
48/// caller.
49///
50/// `#[non_exhaustive]` because this is a published crate and the set is a
51/// description of shapes we have met, not a proof that no other exists — a
52/// workspace form nobody has modelled yet would add a variant, and that must not
53/// be a breaking change (#431).
54#[non_exhaustive]
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
56pub enum ProjectRole {
57 /// Depends on nothing hosted, and something depends on it — the end of every
58 /// chain; the application a deployment tree ultimately deploys.
59 Root,
60 /// A **sub-hub**: depends on something *and* is depended upon. The case a
61 /// two-valued label had no room for.
62 Intermediate,
63 /// Depends on something hosted, and nothing depends on it. An ordinary spoke,
64 /// and still the common case.
65 Leaf,
66 /// Neither. A project in the workspace with no declared cross-repo links yet,
67 /// which a two-valued label reported as a spoke of a hub it never named.
68 Isolated,
69}
70
71impl ProjectRole {
72 /// The wire spelling, as the served topology publishes it.
73 #[must_use]
74 pub fn as_str(self) -> &'static str {
75 match self {
76 Self::Root => "root",
77 Self::Intermediate => "intermediate",
78 Self::Leaf => "leaf",
79 Self::Isolated => "isolated",
80 }
81 }
82}
83
84/// A workspace's project-level dependency shape.
85#[derive(Debug, Clone, Default)]
86pub struct ProjectGraph {
87 /// For each project, the hosted projects it points **into** — the hubs it
88 /// depends on. A project is never its own parent, so a self-reference (a repo
89 /// whose link targets its own project name) is dropped.
90 parents: BTreeMap<String, BTreeSet<String>>,
91 /// For each project, how many external-ref **edges** point into it. Counts
92 /// edges, not distinct projects: five keys in one repo referencing the hub are
93 /// five, which is what has always decided the hub tiebreak.
94 inbound_edges: BTreeMap<String, usize>,
95 /// For each project, how many **other projects** name it as a parent.
96 ///
97 /// Not the same number as [`Self::inbound_edges`] and not derivable from it:
98 /// that counts edges and includes self-references, while this counts distinct
99 /// dependent projects and excludes them. Only this answers "is anything
100 /// downstream of me".
101 children: BTreeMap<String, usize>,
102 /// Whether **any** project carries a persisted external-ref edge at all — set
103 /// before the hosted-target filter, so a workspace whose links all point at
104 /// unhosted repos still reports `true`.
105 ///
106 /// That distinction is why it is a flag rather than `!parents.is_empty()`:
107 /// "nothing has been linked yet" falls back to inference, while "links exist
108 /// but dangle" keeps a `None` hub, and both leave `parents` empty.
109 has_any_external_refs: bool,
110}
111
112impl ProjectGraph {
113 /// The hosted projects `name` depends on, in name order. Empty for a root or an
114 /// isolated project.
115 #[must_use]
116 pub fn parents_of(&self, name: &str) -> &BTreeSet<String> {
117 static NONE: std::sync::LazyLock<BTreeSet<String>> =
118 std::sync::LazyLock::new(BTreeSet::new);
119 self.parents.get(name).unwrap_or(&NONE)
120 }
121
122 /// How many external-ref **edges** point into `name`.
123 ///
124 /// Exposed because it is what [`Self::busiest_hub`] reduces, and because it is
125 /// the number [`Self::children_of`] is most likely to be confused with: this
126 /// counts edges and includes self-references, that counts distinct dependent
127 /// projects and excludes them. A spoke referencing the hub from twenty config
128 /// keys contributes twenty here and one there.
129 #[must_use]
130 pub fn inbound_edges_of(&self, name: &str) -> usize {
131 self.inbound_edges.get(name).copied().unwrap_or(0)
132 }
133
134 /// How many hosted projects depend on `name`.
135 ///
136 /// A lookup rather than a scan: it is called once per project, and scanning
137 /// every `parents` set each time made role assignment O(n²) in the number of
138 /// projects for an answer already known while the map was built.
139 #[must_use]
140 pub fn children_of(&self, name: &str) -> usize {
141 self.children.get(name).copied().unwrap_or(0)
142 }
143
144 /// Where `name` sits in the hierarchy, from its own in/out degree.
145 #[must_use]
146 pub fn role_of(&self, name: &str) -> ProjectRole {
147 let has_parents = !self.parents_of(name).is_empty();
148 match (has_parents, self.children_of(name) > 0) {
149 (false, true) => ProjectRole::Root,
150 (true, true) => ProjectRole::Intermediate,
151 (true, false) => ProjectRole::Leaf,
152 (false, false) => ProjectRole::Isolated,
153 }
154 }
155
156 /// Whether any project carries a persisted external-ref edge at all.
157 #[must_use]
158 pub fn has_any_external_refs(&self) -> bool {
159 self.has_any_external_refs
160 }
161
162 /// The **hosted** project most external-ref edges point into, or `None` when
163 /// nothing references a hosted project (a single-repo or unlinked workspace).
164 ///
165 /// Note what this is *not*: in a snowflake it names the busiest node, which
166 /// need not be the chain's root. `infra1`/`infra2` → `chart` → `app` makes
167 /// `chart` the hub on two inbound edges while `app` is what everything
168 /// ultimately depends on. That is the right answer for this function's one job
169 /// — picking the config-key baseline the override matrix pivots on — and the
170 /// wrong answer for "where does the chain end", which is what [`Self::role_of`]
171 /// reports instead. In a star the two coincided, which is why one field used to
172 /// serve both.
173 #[must_use]
174 pub fn busiest_hub(&self) -> Option<String> {
175 self.inbound_edges
176 .iter()
177 .max_by_key(|(_, count)| **count)
178 .map(|(p, _)| p.clone())
179 }
180}
181
182/// Build the [`ProjectGraph`] by walking every hosted project's persisted external
183/// refs once.
184///
185/// `names` is the set of **hosted** projects: a ref naming a project outside it is
186/// counted towards [`ProjectGraph::has_any_external_refs`] but contributes no edge,
187/// so the shape never contains a project the workspace cannot read.
188///
189/// # Errors
190///
191/// Propagates any [`WorkspaceError`] from selecting a member, and any
192/// [`StoreError`] from reading its store — the latter converted, since a store
193/// that will not open must not be reported as a project with no dependencies.
194/// Swallowing it would silently move the hub and change every role.
195pub fn project_graph(ws: &Workspace, names: &[String]) -> Result<ProjectGraph, WorkspaceError> {
196 let hosted: HashSet<&str> = names.iter().map(String::as_str).collect();
197 let mut graph = ProjectGraph::default();
198 for name in names {
199 for node in ws.with_store(Some(name), external_ref_nodes)?? {
200 // Before the target filter, deliberately: a ref that names an unhosted
201 // project is still a ref, and the caller distinguishing "never linked"
202 // from "linked but dangling" depends on seeing it.
203 graph.has_any_external_refs = true;
204 let Some(qualified) = external_ref_target(&node) else {
205 continue;
206 };
207 let Some((project, _)) = parse_qualified(&qualified) else {
208 continue;
209 };
210 if !hosted.contains(project) {
211 continue;
212 }
213 *graph.inbound_edges.entry(project.to_owned()).or_default() += 1;
214 if project != name.as_str()
215 // Only a *newly* inserted parent is a new dependent: a spoke
216 // referencing the hub from twenty config keys is one child of it,
217 // not twenty.
218 && graph
219 .parents
220 .entry(name.clone())
221 .or_default()
222 .insert(project.to_owned())
223 {
224 *graph.children.entry(project.to_owned()).or_default() += 1;
225 }
226 }
227 }
228 Ok(graph)
229}
230
231/// Every external-ref placeholder node in `store` that something actually points
232/// at, with `Authored` or `Inferred` provenance.
233///
234/// A *derived* edge never targets an external-ref placeholder, so it is excluded;
235/// and a placeholder with no incoming edge is a leftover, not a dependency.
236///
237/// **One entry per incoming edge, not per node** — a placeholder pointed at by
238/// three config keys appears three times. That is deliberate and load-bearing:
239/// [`ProjectGraph::inbound_edges_of`] counts edges, which is what decides the hub
240/// tiebreak, and de-duplicating here would silently turn it into a count of
241/// distinct placeholders and move the hub. The *distinct* count callers usually
242/// want is [`ProjectGraph::children_of`], which is derived separately.
243fn external_ref_nodes(store: &Store) -> Result<Vec<Node>, StoreError> {
244 let mut out = Vec::new();
245 for node in store.nodes_by_kind(&NodeKind::Other(EXTERNAL_REF_KIND.to_owned()))? {
246 for edge in store.edges_to(&node.key)? {
247 if matches!(
248 edge.provenance,
249 crate::provenance::Provenance::Inferred | crate::provenance::Provenance::Authored
250 ) {
251 out.push(node.clone());
252 }
253 }
254 }
255 Ok(out)
256}
257
258#[cfg(test)]
259mod tests {
260 use super::{ProjectRole, project_graph};
261 use crate::links::{external_ref_key, external_ref_node};
262 use crate::model::{Edge, EdgeKind, Node, NodeKind};
263 use crate::store::Store;
264 use crate::workspace::Workspace;
265
266 /// One repo's store, holding an authored external-ref edge per target.
267 ///
268 /// The edge is `authored`, matching what `roteiro links --write` actually
269 /// persists: a fixture pairing an authored edge with an inferred layer would be
270 /// a state the product never produces.
271 fn repo(own: &str, targets: &[&str]) -> Store {
272 let store = Store::open_in_memory().expect("store");
273 // The edge's own end must exist: the store enforces referential integrity,
274 // so a fixture that only creates the placeholder is rejected rather than
275 // quietly storing a half-edge.
276 let src_key = format!("cfgkey:cfg.toml#{own}");
277 store
278 .upsert_node(&Node::new(
279 src_key.clone(),
280 NodeKind::Other("config_key".to_owned()),
281 own.to_owned(),
282 ))
283 .expect("src node");
284 for target in targets {
285 let node = external_ref_node(target);
286 store.upsert_node(&node).expect("node");
287 let edge = Edge::authored(
288 src_key.clone(),
289 external_ref_key(target),
290 EdgeKind::References,
291 );
292 store.insert_edge(&edge).expect("edge");
293 }
294 store
295 }
296
297 fn names(list: &[&str]) -> Vec<String> {
298 list.iter().map(|s| (*s).to_owned()).collect()
299 }
300
301 /// The shape #623 exists for: `infra1,infra2 → chart → app`, where `chart` is a
302 /// spoke of `app` and the hub of both infra repos.
303 #[test]
304 fn a_chain_has_a_root_a_sub_hub_and_leaves() {
305 let ws = Workspace::from_stores([
306 (
307 "infra1".to_owned(),
308 repo("a", &["chart::cfgkey:cfg.toml#c"]),
309 ),
310 (
311 "infra2".to_owned(),
312 repo("b", &["chart::cfgkey:cfg.toml#c"]),
313 ),
314 ("chart".to_owned(), repo("c", &["app::cfgkey:cfg.toml#d"])),
315 ("app".to_owned(), repo("d", &[])),
316 ]);
317 let g = project_graph(&ws, &names(&["infra1", "infra2", "chart", "app"])).expect("graph");
318
319 assert_eq!(g.role_of("app"), ProjectRole::Root, "nothing is downstream");
320 assert_eq!(
321 g.role_of("chart"),
322 ProjectRole::Intermediate,
323 "a spoke of app AND the hub of both infra repos"
324 );
325 assert_eq!(g.role_of("infra1"), ProjectRole::Leaf);
326 assert_eq!(g.role_of("infra2"), ProjectRole::Leaf);
327
328 assert_eq!(
329 g.parents_of("chart").iter().collect::<Vec<_>>(),
330 ["app"],
331 "the sub-hub names its own hub"
332 );
333 assert!(g.parents_of("app").is_empty());
334
335 // The busiest node is `chart`, which is NOT the root — the two questions
336 // that coincide in a star and diverge in a chain.
337 assert_eq!(g.busiest_hub().as_deref(), Some("chart"));
338 }
339
340 /// A project with no cross-repo links is `Isolated`, not a spoke of a hub it
341 /// never named.
342 #[test]
343 fn a_project_with_no_links_is_isolated() {
344 let ws = Workspace::from_stores([
345 ("solo".to_owned(), repo("a", &[])),
346 ("other".to_owned(), repo("b", &[])),
347 ]);
348 let g = project_graph(&ws, &names(&["solo", "other"])).expect("graph");
349 assert_eq!(g.role_of("solo"), ProjectRole::Isolated);
350 assert_eq!(g.busiest_hub(), None, "nothing references anything hosted");
351 assert!(!g.has_any_external_refs());
352 }
353
354 /// Links that exist but name an **unhosted** project: no edge, yet the
355 /// workspace is not "never linked".
356 ///
357 /// The distinction decides whether a caller falls back to inference or keeps a
358 /// `None` hub, and both cases leave `parents` empty — so it cannot be recovered
359 /// from the maps afterwards.
360 #[test]
361 fn a_dangling_link_is_still_a_link() {
362 let ws = Workspace::from_stores([(
363 "spoke".to_owned(),
364 repo("a", &["ghost::cfgkey:cfg.toml#z"]),
365 )]);
366 let g = project_graph(&ws, &names(&["spoke"])).expect("graph");
367 assert!(
368 g.has_any_external_refs(),
369 "the ref exists even though its target is not hosted"
370 );
371 assert_eq!(g.busiest_hub(), None, "nothing hosted is referenced");
372 assert_eq!(g.role_of("spoke"), ProjectRole::Isolated);
373 }
374
375 /// A repo referencing the hub from many keys is **one** dependent of it, while
376 /// the hub tiebreak still counts every edge. Asserted directly because both
377 /// numbers are `> 0` and so produce the same role.
378 #[test]
379 fn many_links_from_one_repo_are_one_dependent_but_many_edges() {
380 let ws = Workspace::from_stores([
381 (
382 "spoke".to_owned(),
383 repo("a", &["hub::cfgkey:cfg.toml#x", "hub::cfgkey:cfg.toml#y"]),
384 ),
385 ("hub".to_owned(), repo("h", &[])),
386 ]);
387 let g = project_graph(&ws, &names(&["spoke", "hub"])).expect("graph");
388 assert_eq!(g.children_of("hub"), 1, "one dependent project");
389 assert_eq!(g.inbound_edges_of("hub"), 2, "two edges");
390 }
391
392 /// A repo whose link targets its own project is not its own parent — otherwise
393 /// it would report as `Intermediate` on the strength of pointing at itself.
394 #[test]
395 fn a_self_reference_is_not_a_dependency() {
396 let ws =
397 Workspace::from_stores([("solo".to_owned(), repo("a", &["solo::cfgkey:cfg.toml#a"]))]);
398 let g = project_graph(&ws, &names(&["solo"])).expect("graph");
399 assert!(g.parents_of("solo").is_empty());
400 assert_eq!(g.children_of("solo"), 0);
401 assert_eq!(g.role_of("solo"), ProjectRole::Isolated);
402 // …but the edge is still counted where edges are counted.
403 assert_eq!(g.inbound_edges_of("solo"), 1);
404 }
405}