Skip to main content

mati_core/graph/
edges.rs

1use serde::{Deserialize, Serialize};
2
3/// A directed edge between two knowledge graph nodes.
4/// Persisted in SurrealKV as `graph:edge:<from>:<kind>:<to>`.
5#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
6pub struct Edge {
7    pub from: String,
8    pub kind: EdgeKind,
9    pub to: String,
10}
11
12impl Edge {
13    pub fn new(from: impl Into<String>, kind: EdgeKind, to: impl Into<String>) -> Self {
14        Edge {
15            from: from.into(),
16            kind,
17            to: to.into(),
18        }
19    }
20
21    /// Encode to the SurrealKV key format: `graph:edge:<from>:<kind>:<to>`.
22    pub fn to_key(&self) -> String {
23        format!(
24            "graph:edge:{}:{}:{}",
25            self.from,
26            self.kind.as_key_segment(),
27            self.to
28        )
29    }
30
31    /// Parse an edge back from a `graph:edge:...` key.
32    ///
33    /// `from` and `to` may contain colons (e.g. `file:src/main.rs`), so the
34    /// parser scans for kind segments left-to-right and validates that both
35    /// endpoint values start with known node-key namespaces. This prevents
36    /// false matches when a slug itself equals a kind name (e.g.
37    /// `gotcha:touched`) and skips corrupt persisted keys whose `to` endpoint
38    /// is not a real node key.
39    pub fn from_key(key: &str) -> Option<Self> {
40        let rest = key.strip_prefix("graph:edge:")?;
41        let segments: Vec<&str> = rest.split(':').collect();
42        for kind_idx in 1..segments.len().saturating_sub(1) {
43            if let Some(kind) = EdgeKind::from_key_segment(segments[kind_idx]) {
44                let from = segments[..kind_idx].join(":");
45                let to = segments[kind_idx + 1..].join(":");
46                if !from.is_empty()
47                    && !to.is_empty()
48                    && is_valid_node_key(&from)
49                    && is_valid_node_key(&to)
50                {
51                    return Some(Edge { from, kind, to });
52                }
53            }
54        }
55        None
56    }
57}
58
59/// Validates that a candidate `from` value starts with a recognised node-key
60/// namespace prefix. This is the primary guard against ambiguous parses.
61fn is_valid_node_key(key: &str) -> bool {
62    const NAMESPACES: &[&str] = &[
63        "file",
64        "gotcha",
65        "decision",
66        "stage",
67        "dep",
68        "dev_note",
69        "session",
70        "analytics",
71        "graph",
72    ];
73    NAMESPACES.iter().any(|ns| {
74        key.starts_with(ns) && key[ns.len()..].starts_with(':') && key.len() > ns.len() + 1
75        // require at least one char after the colon
76    })
77}
78
79/// The 10 relationship kinds that can exist between nodes in the knowledge graph.
80/// Stored in SurrealKV as part of the key: `graph:edge:<from>:<kind>:<to>`.
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum EdgeKind {
84    /// A file node has a gotcha record attached to it.
85    HasGotcha,
86    /// A file imports another file (from static analysis).
87    Imports,
88    /// A file or gotcha is affected by an architectural decision.
89    AffectedBy,
90    /// A file or record has a developer note attached.
91    HasNote,
92    /// A gotcha or decision was discovered in a specific session.
93    DiscoveredIn,
94    /// One gotcha or issue was caused by another.
95    CausedBy,
96    /// A decision or gotcha supersedes an older one.
97    Supersedes,
98    /// A file was touched in a session (passive learning).
99    Touched,
100    /// A dependency change affects a file or module.
101    DependencyAffects,
102    /// Two files are frequently committed together (git co-change).
103    CoChanges,
104}
105
106impl EdgeKind {
107    /// Canonical slug used as the key segment, e.g. `has_gotcha`.
108    pub fn as_key_segment(&self) -> &'static str {
109        match self {
110            EdgeKind::HasGotcha => "has_gotcha",
111            EdgeKind::Imports => "imports",
112            EdgeKind::AffectedBy => "affected_by",
113            EdgeKind::HasNote => "has_note",
114            EdgeKind::DiscoveredIn => "discovered_in",
115            EdgeKind::CausedBy => "caused_by",
116            EdgeKind::Supersedes => "supersedes",
117            EdgeKind::Touched => "touched",
118            EdgeKind::DependencyAffects => "dependency_affects",
119            EdgeKind::CoChanges => "co_changes",
120        }
121    }
122
123    /// Parse a key segment back into an `EdgeKind`.
124    pub fn from_key_segment(s: &str) -> Option<Self> {
125        match s {
126            "has_gotcha" => Some(EdgeKind::HasGotcha),
127            "imports" => Some(EdgeKind::Imports),
128            "affected_by" => Some(EdgeKind::AffectedBy),
129            "has_note" => Some(EdgeKind::HasNote),
130            "discovered_in" => Some(EdgeKind::DiscoveredIn),
131            "caused_by" => Some(EdgeKind::CausedBy),
132            "supersedes" => Some(EdgeKind::Supersedes),
133            "touched" => Some(EdgeKind::Touched),
134            "dependency_affects" => Some(EdgeKind::DependencyAffects),
135            "co_changes" => Some(EdgeKind::CoChanges),
136            _ => None,
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::collections::HashSet;
145
146    #[test]
147    fn all_variants_have_a_key_segment() {
148        for v in all_variants() {
149            assert!(!v.as_key_segment().is_empty(), "{v:?} key segment is empty");
150        }
151    }
152
153    #[test]
154    fn key_segment_roundtrip_all_variants() {
155        for v in all_variants() {
156            let seg = v.as_key_segment();
157            let parsed = EdgeKind::from_key_segment(seg)
158                .unwrap_or_else(|| panic!("failed to parse segment '{seg}' for {v:?}"));
159            assert_eq!(v, parsed);
160        }
161    }
162
163    #[test]
164    fn unknown_segment_returns_none() {
165        assert!(EdgeKind::from_key_segment("nonexistent").is_none());
166        assert!(EdgeKind::from_key_segment("").is_none());
167    }
168
169    #[test]
170    fn key_segments_are_all_distinct() {
171        let segments: HashSet<&str> = all_variants().iter().map(|v| v.as_key_segment()).collect();
172        assert_eq!(segments.len(), 10, "duplicate key segments detected");
173    }
174
175    // ── Edge key encode/decode ───────────────────────────────────────────────
176
177    #[test]
178    fn edge_to_key_format() {
179        let e = Edge::new("file:src/main.rs", EdgeKind::HasGotcha, "gotcha:write-txn");
180        assert_eq!(
181            e.to_key(),
182            "graph:edge:file:src/main.rs:has_gotcha:gotcha:write-txn"
183        );
184    }
185
186    #[test]
187    fn edge_from_key_roundtrip_simple() {
188        let e = Edge::new("file:src/main.rs", EdgeKind::HasGotcha, "gotcha:write-txn");
189        assert_eq!(Edge::from_key(&e.to_key()).unwrap(), e);
190    }
191
192    #[test]
193    fn edge_from_key_roundtrip_all_kinds() {
194        for kind in all_variants() {
195            let e = Edge::new("file:src/a.rs", kind, "file:src/b.rs");
196            let key = e.to_key();
197            let parsed =
198                Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse key '{key}'"));
199            assert_eq!(parsed, e);
200        }
201    }
202
203    #[test]
204    fn edge_from_key_invalid_returns_none() {
205        assert!(Edge::from_key("not-an-edge-key").is_none());
206        assert!(Edge::from_key("graph:edge:").is_none());
207        assert!(Edge::from_key("graph:edge:from_only").is_none());
208        assert!(Edge::from_key("").is_none());
209    }
210
211    /// `from` with a valid namespace but empty slug ("file:") must be rejected.
212    /// Without the slug-length guard the parser would accept it, producing a
213    /// broken `from` value that can never match a real stored record.
214    #[test]
215    fn edge_from_key_empty_slug_rejected() {
216        // from="file:" — namespace present, slug empty
217        // key: "graph:edge:file::has_gotcha:gotcha:x"
218        let key = "graph:edge:file::has_gotcha:gotcha:x";
219        assert!(
220            Edge::from_key(key).is_none(),
221            "empty slug must not be accepted as a valid from value"
222        );
223    }
224
225    /// Keys whose from/to use an unknown namespace must return None — the parser
226    /// has no way to locate the kind boundary reliably without a known prefix.
227    #[test]
228    fn edge_from_key_unknown_namespace_returns_none() {
229        // Neither "unknown_ns" nor "xyz" is a recognised namespace.
230        let key = "graph:edge:unknown_ns:foo:has_gotcha:xyz:bar";
231        assert!(
232            Edge::from_key(key).is_none(),
233            "unknown namespace must not be accepted"
234        );
235    }
236
237    /// `to` with an unknown namespace must also be rejected — otherwise corrupt
238    /// persisted keys can create phantom graph nodes during Graph::load.
239    #[test]
240    fn edge_from_key_unknown_to_namespace_returns_none() {
241        let key = "graph:edge:file:src/main.rs:imports:unknown_ns:target";
242        assert!(
243            Edge::from_key(key).is_none(),
244            "unknown to namespace must not be accepted"
245        );
246    }
247
248    /// `to` with a known namespace but empty slug must be rejected for the same
249    /// reason as `from="file:"` — it can never match a real stored record.
250    #[test]
251    fn edge_from_key_empty_to_slug_rejected() {
252        let key = "graph:edge:file:src/main.rs:imports:file:";
253        assert!(
254            Edge::from_key(key).is_none(),
255            "empty to slug must not be accepted as a valid node key"
256        );
257    }
258
259    #[test]
260    fn edge_key_prefix_is_graph_edge() {
261        let e = Edge::new("file:a", EdgeKind::Imports, "file:b");
262        assert!(e.to_key().starts_with("graph:edge:"));
263    }
264
265    /// Regression: slug that exactly matches a kind name must not confuse the parser.
266    #[test]
267    fn edge_from_key_slug_matches_kind_name() {
268        // "touched" is both a valid gotcha slug and a kind segment name.
269        let e = Edge::new("gotcha:touched", EdgeKind::HasGotcha, "gotcha:x");
270        let key = e.to_key();
271        // key = "graph:edge:gotcha:touched:has_gotcha:gotcha:x"
272        // Without namespace validation the parser would greedily pick "touched"
273        // as the kind, returning from="gotcha" (invalid). The fix rejects that
274        // because "gotcha" alone is not a valid node key (no namespace colon).
275        let parsed = Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse key '{key}'"));
276        assert_eq!(parsed, e);
277    }
278
279    /// `to` slug is a kind name — parser must not greedily pick it as the kind boundary.
280    #[test]
281    fn edge_from_key_to_slug_matches_kind_name() {
282        // to = "gotcha:imports" — slug "imports" is also a kind segment.
283        let e = Edge::new("file:a", EdgeKind::HasGotcha, "gotcha:imports");
284        let key = e.to_key();
285        // key = "graph:edge:file:a:has_gotcha:gotcha:imports"
286        // segments = ["file", "a", "has_gotcha", "gotcha", "imports"]
287        // kind_idx=2 -> "has_gotcha", from="file:a" (valid), to="gotcha:imports" ✓
288        let parsed = Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse '{key}'"));
289        assert_eq!(parsed, e);
290    }
291
292    /// Both `from` and `to` slugs are kind names — ambiguity on both ends.
293    #[test]
294    fn edge_from_key_both_slugs_match_kind_names() {
295        // from="gotcha:touched", to="gotcha:imports", kind=AffectedBy
296        let e = Edge::new("gotcha:touched", EdgeKind::AffectedBy, "gotcha:imports");
297        let key = e.to_key();
298        // key = "graph:edge:gotcha:touched:affected_by:gotcha:imports"
299        // segments = ["gotcha", "touched", "affected_by", "gotcha", "imports"]
300        // kind_idx=1 -> "touched" IS a kind, but from="gotcha" fails is_valid_node_key → skip
301        // kind_idx=2 -> "affected_by" IS a kind, from="gotcha:touched" is valid ✓
302        let parsed = Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse '{key}'"));
303        assert_eq!(parsed, e);
304    }
305
306    /// `to` contains multiple colons — the parser must join all remaining segments.
307    #[test]
308    fn edge_from_key_to_has_multiple_colons() {
309        // Unusual but possible: to="decision:auth:v2" (slug with colon).
310        let e = Edge::new("file:src/main.rs", EdgeKind::AffectedBy, "decision:auth:v2");
311        let key = e.to_key();
312        let parsed = Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse '{key}'"));
313        assert_eq!(parsed, e);
314    }
315
316    /// `from` contains multiple colons beyond the namespace separator.
317    #[test]
318    fn edge_from_key_from_has_multiple_colons() {
319        let e = Edge::new(
320            "dep:cargo:tokio",
321            EdgeKind::DependencyAffects,
322            "file:src/main.rs",
323        );
324        let key = e.to_key();
325        let parsed = Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse '{key}'"));
326        assert_eq!(parsed, e);
327    }
328
329    #[test]
330    fn serde_roundtrip() {
331        for v in all_variants() {
332            let json = serde_json::to_string(&v).unwrap();
333            let back: EdgeKind = serde_json::from_str(&json).unwrap();
334            assert_eq!(v, back);
335        }
336    }
337
338    fn all_variants() -> [EdgeKind; 10] {
339        [
340            EdgeKind::HasGotcha,
341            EdgeKind::Imports,
342            EdgeKind::AffectedBy,
343            EdgeKind::HasNote,
344            EdgeKind::DiscoveredIn,
345            EdgeKind::CausedBy,
346            EdgeKind::Supersedes,
347            EdgeKind::Touched,
348            EdgeKind::DependencyAffects,
349            EdgeKind::CoChanges,
350        ]
351    }
352}