Skip to main content

codesynapse_core/
multigraph_compat.rs

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    // petgraph adds both edges (no overwrite) — document this differs from NetworkX
117    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    // Rust type system prevents accidental key duplication at compile time.
133    // This probe always passes.
134    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    // petgraph StableGraph can be constructed as undirected; type is compile-time.
155    // Verify: directed StableGraph is directed.
156    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    // undirected variant exists and compiles
161    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        // In NetworkX MultiDiGraph, add_edge with same key overwrites (1 edge).
270        // In petgraph StableGraph, each add_edge gets its own EdgeIndex (2 edges).
271        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}