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//! 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}