rto_graph/links.rs
1//! Cross-repo external references (ADR-0009, the persisted `inferred` links).
2//!
3//! An inferred cross-repo link connects a config key in one repo (the *spoke*)
4//! to its counterpart in another (the *hub*). The two endpoints live in
5//! **different** graph stores, but the store's integrity rule requires both ends
6//! of an edge to resolve to a node in the *same* store. So the spoke store gets
7//! an **external-ref node** — a local placeholder standing in for the hub's node,
8//! carrying the project-qualified target key — and the inferred edge points at
9//! that placeholder. Store integrity holds locally, while the [`crate::Workspace`]
10//! resolver still follows the placeholder across repos to the real node (see
11//! [`crate::Workspace::follow_external_ref`]).
12//!
13//! These facts are not derivable from the spoke's own blobs (they need the hub),
14//! so they are persisted as an **import layer** under [`LINKS_REF`] and re-applied
15//! after every sync — dangling edges pruned when a config key is removed — reusing
16//! the same durability machinery lat.md and Graphify imports rely on.
17
18use crate::Provenance;
19use crate::model::{Node, NodeKind};
20
21/// The import-layer `src_ref` under which inferred cross-repo links are persisted
22/// (see [`crate::Store::apply_import_layer`]). Its own producer, so re-inferring
23/// can re-derive it authoritatively without touching other import layers.
24pub const LINKS_REF: &str = "import:links";
25
26/// The node-kind token for an external-ref placeholder — a stand-in, in one
27/// repo's store, for a node that actually lives in another repo's graph.
28pub const EXTERNAL_REF_KIND: &str = "external_ref";
29
30/// Build an external-ref placeholder node for a **project-qualified** target key
31/// (`<project>::<key>`, ADR-0009). The node lives in the *referring* repo's store
32/// so an inferred edge to the (foreign) target satisfies store integrity; its
33/// qualified target is recorded in `meta` so [`crate::Workspace::follow_external_ref`]
34/// can resolve it across the workspace. Tagged [`Provenance::Inferred`].
35#[must_use]
36pub fn external_ref_node(qualified: &str) -> Node {
37 let mut node = Node::new(
38 external_ref_key(qualified),
39 NodeKind::Other(EXTERNAL_REF_KIND.to_owned()),
40 qualified.to_owned(),
41 )
42 .with_provenance(Provenance::Inferred);
43 node.meta = serde_json::json!({ "qualified": qualified });
44 node
45}
46
47/// The store key of the external-ref node for `qualified` — the qualified target
48/// under an `extref:` namespace, so it never collides with a real node key.
49#[must_use]
50pub fn external_ref_key(qualified: &str) -> String {
51 format!("extref:{qualified}")
52}
53
54/// The project-qualified target of an external-ref `node`, or `None` if `node` is
55/// not one. Read from `meta.qualified`, falling back to the `extref:` key prefix
56/// so a node written by an older layer still resolves.
57#[must_use]
58pub fn external_ref_target(node: &Node) -> Option<String> {
59 // Compare by token so a hot resolver path never allocates a `NodeKind::Other`
60 // just to check the kind.
61 if node.kind.as_str() != EXTERNAL_REF_KIND {
62 return None;
63 }
64 node.meta
65 .get("qualified")
66 .and_then(serde_json::Value::as_str)
67 .map(str::to_owned)
68 .or_else(|| node.key.strip_prefix("extref:").map(str::to_owned))
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 #[test]
76 fn external_ref_round_trips_its_qualified_target() {
77 let q = "app::cfgkey:config.toml#serve.addr";
78 let node = external_ref_node(q);
79 assert_eq!(node.key, "extref:app::cfgkey:config.toml#serve.addr");
80 assert_eq!(node.kind, NodeKind::Other("external_ref".to_owned()));
81 assert_eq!(node.provenance, Provenance::Inferred);
82 assert_eq!(external_ref_target(&node).as_deref(), Some(q));
83 }
84
85 #[test]
86 fn target_falls_back_to_the_key_prefix_when_meta_is_missing() {
87 let mut node = external_ref_node("app::file:x");
88 node.meta = serde_json::Value::Null;
89 assert_eq!(external_ref_target(&node).as_deref(), Some("app::file:x"));
90 }
91
92 #[test]
93 fn non_external_ref_has_no_target() {
94 let node = Node::new("file:x", NodeKind::File, "x");
95 assert_eq!(external_ref_target(&node), None);
96 }
97}