Skip to main content

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}