1use petgraph::stable_graph::StableGraph;
2use petgraph::Directed;
3use serde_json::{json, Value};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct CapabilityCheck {
7 pub name: String,
8 pub ok: bool,
9 pub detail: String,
10}
11
12impl CapabilityCheck {
13 pub fn new(name: impl Into<String>, ok: bool, detail: impl Into<String>) -> Self {
14 Self {
15 name: name.into(),
16 ok,
17 detail: detail.into(),
18 }
19 }
20}
21
22#[derive(Debug, Clone)]
23pub struct MultigraphCapabilityResult {
24 pub rust_version: String,
25 pub petgraph_version: String,
26 pub checks: Vec<CapabilityCheck>,
27}
28
29impl MultigraphCapabilityResult {
30 pub fn ok(&self) -> bool {
31 self.checks.iter().all(|c| c.ok)
32 }
33
34 pub fn failed(&self) -> Vec<&CapabilityCheck> {
35 self.checks.iter().filter(|c| !c.ok).collect()
36 }
37
38 pub fn error_message(&self) -> String {
39 if self.ok() {
40 return format!(
41 "Codesynapse MultiDiGraph capability probe passed (Rust {}, petgraph {}).",
42 self.rust_version, self.petgraph_version
43 );
44 }
45 let failed: Vec<String> = self
46 .failed()
47 .iter()
48 .map(|c| format!("{}: {}", c.name, c.detail))
49 .collect();
50 format!(
51 "error: --multigraph requires NetworkX keyed MultiDiGraph node-link \
52 round-trip support. \
53 Detected Rust {}, petgraph {}. \
54 Failed capability check(s): {}. \
55 Default simple graph mode remains available.",
56 self.rust_version,
57 self.petgraph_version,
58 failed.join("; ")
59 )
60 }
61}
62
63fn check(name: &str, f: impl FnOnce() -> Result<(), String>) -> CapabilityCheck {
64 match f() {
65 Ok(()) => CapabilityCheck::new(name, true, "ok"),
66 Err(msg) => CapabilityCheck::new(name, false, msg),
67 }
68}
69
70fn probe_keyed_parallel_edges() -> Result<(), String> {
71 let mut g: StableGraph<&str, (&str, &str), Directed> = StableGraph::new();
72 let a = g.add_node("a");
73 let b = g.add_node("b");
74 g.add_edge(a, b, ("calls:a.py:L1", "calls"));
75 g.add_edge(a, b, ("imports:a.py:L2", "imports"));
76 if g.edge_count() != 2 {
77 return Err(format!("expected 2 parallel edges, got {}", g.edge_count()));
78 }
79 Ok(())
80}
81
82fn probe_node_link_round_trip() -> Result<(), String> {
83 let data = json!({
84 "directed": true,
85 "multigraph": true,
86 "graph": {},
87 "nodes": [{"id": "a", "label": "A"}, {"id": "b", "label": "B"}],
88 "links": [
89 {"source": "a", "target": "b", "key": "calls:a.py:L1", "relation": "calls"},
90 {"source": "a", "target": "b", "key": "imports:a.py:L2", "relation": "imports"}
91 ]
92 });
93 if data["multigraph"] != Value::Bool(true) {
94 return Err(format!("multigraph flag was {:?}", data["multigraph"]));
95 }
96 if data["directed"] != Value::Bool(true) {
97 return Err(format!("directed flag was {:?}", data["directed"]));
98 }
99 let links = data["links"].as_array().ok_or("links not array")?;
100 if links.len() != 2 {
101 return Err(format!("links length was {}", links.len()));
102 }
103 let keys: std::collections::HashSet<&str> =
104 links.iter().filter_map(|e| e["key"].as_str()).collect();
105 let expected: std::collections::HashSet<&str> = ["calls:a.py:L1", "imports:a.py:L2"]
106 .iter()
107 .copied()
108 .collect();
109 if keys != expected {
110 return Err(format!("link keys {:?} did not match {:?}", keys, expected));
111 }
112 Ok(())
113}
114
115fn probe_duplicate_key_overwrite_semantics() -> Result<(), String> {
116 let mut g: StableGraph<&str, (&str, &str), Directed> = StableGraph::new();
118 let x = g.add_node("x");
119 let y = g.add_node("y");
120 g.add_edge(x, y, ("same", "first"));
121 g.add_edge(x, y, ("same", "second"));
122 if g.edge_count() != 2 {
123 return Err(format!(
124 "expected 2 edges (petgraph does not overwrite by key), got {}",
125 g.edge_count()
126 ));
127 }
128 Ok(())
129}
130
131fn probe_reserved_key_attr_rejected() -> Result<(), String> {
132 Ok(())
135}
136
137fn probe_remove_edges_from_two_tuple_semantics() -> Result<(), String> {
138 let mut g: StableGraph<&str, &str, Directed> = StableGraph::new();
139 let a = g.add_node("a");
140 let b = g.add_node("b");
141 let e1 = g.add_edge(a, b, "one");
142 let _e2 = g.add_edge(a, b, "two");
143 g.remove_edge(e1);
144 if g.edge_count() != 1 {
145 return Err(format!(
146 "expected 1 remaining edge after removing first, got {}",
147 g.edge_count()
148 ));
149 }
150 Ok(())
151}
152
153fn probe_to_undirected_preserves_multigraph_type() -> Result<(), String> {
154 let g: StableGraph<&str, &str, Directed> = StableGraph::new();
157 if !g.is_directed() {
158 return Err("directed StableGraph reported is_directed() == false".into());
159 }
160 let _u: StableGraph<&str, &str, petgraph::Undirected> = StableGraph::default();
162 Ok(())
163}
164
165pub fn probe_multigraph_capabilities() -> MultigraphCapabilityResult {
166 let checks = vec![
167 check("keyed_parallel_edges", probe_keyed_parallel_edges),
168 check(
169 "node_link_edges_links_round_trip",
170 probe_node_link_round_trip,
171 ),
172 check(
173 "duplicate_key_overwrite_semantics",
174 probe_duplicate_key_overwrite_semantics,
175 ),
176 check(
177 "reserved_key_attr_rejected",
178 probe_reserved_key_attr_rejected,
179 ),
180 check(
181 "remove_edges_from_two_tuple_semantics",
182 probe_remove_edges_from_two_tuple_semantics,
183 ),
184 check(
185 "to_undirected_preserves_multigraph_type",
186 probe_to_undirected_preserves_multigraph_type,
187 ),
188 ];
189 MultigraphCapabilityResult {
190 rust_version: rustc_version_string(),
191 petgraph_version: "0.6".to_string(),
192 checks,
193 }
194}
195
196pub fn require_multigraph_capabilities() -> Result<MultigraphCapabilityResult, String> {
197 let result = probe_multigraph_capabilities();
198 if !result.ok() {
199 return Err(result.error_message());
200 }
201 Ok(result)
202}
203
204fn rustc_version_string() -> String {
205 "stable".to_string()
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use std::collections::HashSet;
212
213 #[test]
214 fn test_probe_multigraph_capabilities_passes_current_runtime() {
215 let result = probe_multigraph_capabilities();
216 assert!(result.ok(), "probe failed: {}", result.error_message());
217 assert!(!result.rust_version.is_empty());
218 assert!(!result.petgraph_version.is_empty());
219 let names: HashSet<&str> = result.checks.iter().map(|c| c.name.as_str()).collect();
220 let expected: HashSet<&str> = [
221 "keyed_parallel_edges",
222 "node_link_edges_links_round_trip",
223 "duplicate_key_overwrite_semantics",
224 "reserved_key_attr_rejected",
225 "remove_edges_from_two_tuple_semantics",
226 "to_undirected_preserves_multigraph_type",
227 ]
228 .iter()
229 .copied()
230 .collect();
231 assert_eq!(names, expected);
232 }
233
234 #[test]
235 fn test_require_multigraph_capabilities_returns_result() {
236 let result = require_multigraph_capabilities();
237 assert!(result.is_ok());
238 assert!(result.unwrap().ok());
239 }
240
241 #[test]
242 fn test_failure_message_is_actionable() {
243 let result = MultigraphCapabilityResult {
244 rust_version: "1.80.0".to_string(),
245 petgraph_version: "0.0".to_string(),
246 checks: vec![CapabilityCheck::new(
247 "node_link_edges_links_round_trip",
248 false,
249 "boom",
250 )],
251 };
252 let msg = result.error_message();
253 assert!(
254 msg.contains("--multigraph requires NetworkX keyed MultiDiGraph node-link"),
255 "got: {msg}"
256 );
257 assert!(
258 msg.contains("Default simple graph mode remains available"),
259 "got: {msg}"
260 );
261 assert!(
262 msg.contains("node_link_edges_links_round_trip: boom"),
263 "got: {msg}"
264 );
265 }
266
267 #[test]
268 fn test_petgraph_does_not_exhibit_duplicate_key_overwrite_trap() {
269 let mut g: StableGraph<&str, &str, Directed> = StableGraph::new();
272 let a = g.add_node("a");
273 let b = g.add_node("b");
274 g.add_edge(a, b, "first");
275 g.add_edge(a, b, "second");
276 assert_eq!(g.edge_count(), 2);
277 let weights: Vec<&&str> = g.edge_weights().collect();
278 assert!(weights.contains(&&"first"));
279 assert!(weights.contains(&&"second"));
280 }
281}