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 import-layer `src_ref` for **authored** cross-repo links — a repo's
27/// `[[links]]` declarations (ADR-0009), as opposed to [`LINKS_REF`]'s inferred
28/// matches.
29///
30/// A **separate** ref, and that is the load-bearing part. `apply_import_layer`
31/// is authoritative per ref: it clears the ref's prior edges before re-applying.
32/// Sharing one ref would therefore make `links --write` delete every inferred
33/// edge and `links --infer --write` delete every authored one — each command
34/// silently reclassifying the other's work on every run.
35///
36/// It is also what lets "authored → gold, inferred → slate" mean anything: the
37/// two provenances have to be independently replaceable, or re-running one
38/// changes the colour of the other.
39pub const LINKS_AUTHORED_REF: &str = "import:links/authored";
40
41/// The node-kind token for an external-ref placeholder — a stand-in, in one
42/// repo's store, for a node that actually lives in another repo's graph.
43pub const EXTERNAL_REF_KIND: &str = "external_ref";
44
45/// Build an external-ref placeholder node for a **project-qualified** target key
46/// (`<project>::<key>`, ADR-0009). The node lives in the *referring* repo's store
47/// so an inferred edge to the (foreign) target satisfies store integrity; its
48/// qualified target is recorded in `meta` so [`crate::Workspace::follow_external_ref`]
49/// can resolve it across the workspace. Tagged [`Provenance::Inferred`].
50#[must_use]
51pub fn external_ref_node(qualified: &str) -> Node {
52 external_ref_node_with(qualified, Provenance::Inferred)
53}
54
55/// [`external_ref_node`], with the placeholder's provenance chosen by the caller.
56///
57/// The placeholder carries the provenance of the *claim that the target exists*:
58/// [`Provenance::Inferred`] for a confidence-scored match, [`Provenance::Authored`]
59/// for a `[[links]]` declaration someone wrote.
60///
61/// # The placeholder's provenance is not the link's
62///
63/// Both flavours share a key, so a repo that both declares *and* infers the same
64/// target has **one** placeholder — and since each layer upserts it, the node's
65/// own provenance is whichever layer was applied last. Do not read it as the
66/// link's provenance. The **edges** carry that, one per ref, and a consumer
67/// asking "is this link authored?" must look there:
68///
69/// ```text
70/// "incoming": [
71/// { "provenance": "authored", "confidence": null },
72/// { "provenance": "inferred", "confidence": 0.9 }
73/// ]
74/// ```
75#[must_use]
76pub fn external_ref_node_with(qualified: &str, provenance: Provenance) -> Node {
77 let mut node = Node::new(
78 external_ref_key(qualified),
79 NodeKind::Other(EXTERNAL_REF_KIND.to_owned()),
80 qualified.to_owned(),
81 )
82 .with_provenance(provenance);
83 node.meta = serde_json::json!({ "qualified": qualified });
84 node
85}
86
87/// The store key of the external-ref node for `qualified` — the qualified target
88/// under an `extref:` namespace, so it never collides with a real node key.
89#[must_use]
90pub fn external_ref_key(qualified: &str) -> String {
91 format!("extref:{qualified}")
92}
93
94/// The project-qualified target of an external-ref `node`, or `None` if `node` is
95/// not one. Read from `meta.qualified`, falling back to the `extref:` key prefix
96/// so a node written by an older layer still resolves.
97#[must_use]
98pub fn external_ref_target(node: &Node) -> Option<String> {
99 // Compare by token so a hot resolver path never allocates a `NodeKind::Other`
100 // just to check the kind.
101 if node.kind.as_str() != EXTERNAL_REF_KIND {
102 return None;
103 }
104 node.meta
105 .get("qualified")
106 .and_then(serde_json::Value::as_str)
107 .map(str::to_owned)
108 .or_else(|| node.key.strip_prefix("extref:").map(str::to_owned))
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn external_ref_round_trips_its_qualified_target() {
117 let q = "app::cfgkey:config.toml#serve.addr";
118 let node = external_ref_node(q);
119 assert_eq!(node.key, "extref:app::cfgkey:config.toml#serve.addr");
120 assert_eq!(node.kind, NodeKind::Other("external_ref".to_owned()));
121 assert_eq!(node.provenance, Provenance::Inferred);
122 assert_eq!(external_ref_target(&node).as_deref(), Some(q));
123 }
124
125 #[test]
126 fn target_falls_back_to_the_key_prefix_when_meta_is_missing() {
127 let mut node = external_ref_node("app::file:x");
128 node.meta = serde_json::Value::Null;
129 assert_eq!(external_ref_target(&node).as_deref(), Some("app::file:x"));
130 }
131
132 #[test]
133 fn non_external_ref_has_no_target() {
134 let node = Node::new("file:x", NodeKind::File, "x");
135 assert_eq!(external_ref_target(&node), None);
136 }
137}