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