1use serde::{Deserialize, Serialize};
2
3#[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 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 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
59fn 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 })
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum EdgeKind {
84 HasGotcha,
86 Imports,
88 AffectedBy,
90 HasNote,
92 DiscoveredIn,
94 CausedBy,
96 Supersedes,
98 Touched,
100 DependencyAffects,
102 CoChanges,
104}
105
106impl EdgeKind {
107 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 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 #[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 #[test]
215 fn edge_from_key_empty_slug_rejected() {
216 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 #[test]
228 fn edge_from_key_unknown_namespace_returns_none() {
229 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 #[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 #[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 #[test]
267 fn edge_from_key_slug_matches_kind_name() {
268 let e = Edge::new("gotcha:touched", EdgeKind::HasGotcha, "gotcha:x");
270 let key = e.to_key();
271 let parsed = Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse key '{key}'"));
276 assert_eq!(parsed, e);
277 }
278
279 #[test]
281 fn edge_from_key_to_slug_matches_kind_name() {
282 let e = Edge::new("file:a", EdgeKind::HasGotcha, "gotcha:imports");
284 let key = e.to_key();
285 let parsed = Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse '{key}'"));
289 assert_eq!(parsed, e);
290 }
291
292 #[test]
294 fn edge_from_key_both_slugs_match_kind_names() {
295 let e = Edge::new("gotcha:touched", EdgeKind::AffectedBy, "gotcha:imports");
297 let key = e.to_key();
298 let parsed = Edge::from_key(&key).unwrap_or_else(|| panic!("failed to parse '{key}'"));
303 assert_eq!(parsed, e);
304 }
305
306 #[test]
308 fn edge_from_key_to_has_multiple_colons() {
309 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 #[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}