Skip to main content

codesynapse_core/
export.rs

1use crate::error::Result;
2use crate::types::{Edge, GraphData, HyperEdge, NetworkXGraph, Node};
3use std::collections::HashMap;
4use std::path::Path;
5
6pub struct Exporter;
7
8impl Exporter {
9    /// NetworkX node_link_data format (default for compat mode)
10    pub fn to_json_compat(&self, nodes: &[Node], edges: &[Edge]) -> Result<String> {
11        let nx = NetworkXGraph::from_graph_data(nodes, edges);
12        serde_json::to_string_pretty(&nx).map_err(Into::into)
13    }
14
15    pub fn to_json_compat_file(&self, nodes: &[Node], edges: &[Edge], path: &Path) -> Result<()> {
16        let json = self.to_json_compat(nodes, edges)?;
17        std::fs::write(path, json)?;
18        Ok(())
19    }
20
21    pub fn load_json_compat(&self, path: &Path) -> Result<NetworkXGraph> {
22        let content = std::fs::read_to_string(path)?;
23        let nx: NetworkXGraph = serde_json::from_str(&content)?;
24        Ok(nx)
25    }
26
27    /// Legacy GraphData format (our original, not NetworkX-compatible)
28    pub fn to_json(
29        &self,
30        nodes: &[Node],
31        edges: &[Edge],
32        hyperedges: Option<&[HyperEdge]>,
33    ) -> Result<String> {
34        let graph_data = GraphData {
35            nodes: nodes.to_vec(),
36            edges: edges.to_vec(),
37            hyperedges: hyperedges.map(|h| h.to_vec()),
38        };
39        serde_json::to_string_pretty(&graph_data).map_err(Into::into)
40    }
41
42    pub fn to_json_file(
43        &self,
44        nodes: &[Node],
45        edges: &[Edge],
46        hyperedges: Option<&[HyperEdge]>,
47        path: &Path,
48    ) -> Result<()> {
49        let json = self.to_json(nodes, edges, hyperedges)?;
50        std::fs::write(path, json)?;
51        Ok(())
52    }
53
54    pub fn load_json(&self, path: &Path) -> Result<GraphData> {
55        let content = std::fs::read_to_string(path)?;
56        let data: GraphData = serde_json::from_str(&content)?;
57        Ok(data)
58    }
59
60    pub fn to_svg(&self, nodes: &[Node], edges: &[Edge]) -> Result<String> {
61        let n = nodes.len();
62        let center = 300.0;
63        let radius = 200.0;
64        let r = 20.0;
65        let h = "#";
66
67        let mut svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600">
68<style>text { font-family: sans-serif; font-size: 10px; }</style>
69"#
70        .to_string();
71
72        let positions: Vec<(f64, f64)> = (0..n)
73            .map(|i| {
74                let angle = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
75                (center + radius * angle.cos(), center + radius * angle.sin())
76            })
77            .collect();
78
79        for edge in edges {
80            let si = nodes.iter().position(|n| n.id == edge.source);
81            let ti = nodes.iter().position(|n| n.id == edge.target);
82            if let (Some(s), Some(t)) = (si, ti) {
83                svg.push_str(&format!(
84                    "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"{h}999\" stroke-width=\"1\"/>",
85                    positions[s].0, positions[s].1, positions[t].0, positions[t].1
86                ));
87            }
88        }
89
90        for (i, node) in nodes.iter().enumerate() {
91            let dy = "0.3em";
92            svg.push_str(&format!(
93                "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{h}4A90D9\" stroke=\"{h}2C5F8A\" stroke-width=\"1\"/>\n\
94                 <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dy=\"{}\" fill=\"{h}fff\">{}</text>",
95                positions[i].0,
96                positions[i].1,
97                r,
98                positions[i].0,
99                positions[i].1,
100                dy,
101                node.label.chars().take(4).collect::<String>()
102            ));
103        }
104
105        svg.push_str("</svg>");
106        Ok(svg)
107    }
108
109    pub fn to_svg_file(&self, nodes: &[Node], edges: &[Edge], path: &Path) -> Result<()> {
110        let svg = self.to_svg(nodes, edges)?;
111        std::fs::write(path, svg)?;
112        Ok(())
113    }
114
115    pub fn to_cypher(&self, nodes: &[Node], edges: &[Edge]) -> Result<String> {
116        let mut out = String::new();
117        for node in nodes {
118            let label = node.label.replace('\'', "\\'");
119            let file_type = &node.file_type;
120            out.push_str(&format!(
121                "CREATE (:`{}` {{id: '{}', label: '{}', file_type: '{}'}})\n",
122                file_type, node.id, label, file_type
123            ));
124        }
125        for edge in edges {
126            let rel = edge.relation.to_uppercase();
127            out.push_str(&format!(
128                "CREATE (:`_` {{id: '{}'}})-[:{} {{confidence: '{}'}}]->(:`_` {{id: '{}'}})\n",
129                edge.source, rel, edge.confidence, edge.target
130            ));
131        }
132        Ok(out)
133    }
134
135    pub fn to_cypher_file(&self, nodes: &[Node], edges: &[Edge], path: &Path) -> Result<()> {
136        let cypher = self.to_cypher(nodes, edges)?;
137        std::fs::write(path, cypher)?;
138        Ok(())
139    }
140
141    pub fn to_wiki(&self, nodes: &[Node], _edges: &[Edge], output_dir: &Path) -> Result<()> {
142        let index_path = output_dir.join("index.md");
143        let mut index = "# Codesynapse Knowledge Graph\n\n".to_string();
144        for node in nodes {
145            index.push_str(&format!("- [[{}]]\n", node.label));
146        }
147        std::fs::write(&index_path, index)?;
148
149        let community_map: HashMap<Option<usize>, Vec<&Node>> =
150            nodes.iter().fold(HashMap::new(), |mut acc, node| {
151                acc.entry(node.community).or_default().push(node);
152                acc
153            });
154
155        for (community_id, members) in &community_map {
156            let filename = format!(
157                "Community-{}.md",
158                community_id.map_or("none".to_string(), |i| i.to_string())
159            );
160            let mut content = format!(
161                "# Community {}\n\n",
162                community_id.map_or("none".to_string(), |i| i.to_string())
163            );
164            for member in members {
165                content.push_str(&format!("- [[{}]]\n", member.label));
166            }
167            std::fs::write(output_dir.join(&filename), content)?;
168        }
169        Ok(())
170    }
171
172    pub fn to_html(&self, nodes: &[Node], _edges: &[Edge]) -> Result<String> {
173        let mut html = String::from("<html><body><h1>Codesynapse Graph</h1><ul>");
174        for node in nodes {
175            html.push_str(&format!(
176                "<li><strong>{}</strong> ({})</li>",
177                node.label, node.file_type
178            ));
179        }
180        html.push_str("</ul></body></html>");
181        Ok(html)
182    }
183
184    pub fn to_html_file(&self, nodes: &[Node], edges: &[Edge], path: &Path) -> Result<()> {
185        let html = self.to_html(nodes, edges)?;
186        std::fs::write(path, html)?;
187        Ok(())
188    }
189
190    pub fn to_graphml(&self, nodes: &[Node], edges: &[Edge]) -> Result<String> {
191        let mut xml = String::from(
192            r#"<?xml version="1.0" encoding="UTF-8"?>
193<graphml xmlns="http://graphml.graphdrawing.org/xmlns">
194<graph id="G" edgedefault="directed">
195<key id="label" for="node" attr.name="label" attr.type="string"/>
196<key id="file_type" for="node" attr.name="file_type" attr.type="string"/>
197<key id="relation" for="edge" attr.name="relation" attr.type="string"/>
198"#,
199        );
200
201        for node in nodes {
202            xml.push_str(&format!(
203                r#"<node id="{}"><data key="label">{}</data><data key="file_type">{}</data></node>"#,
204                node.id, node.label, node.file_type
205            ));
206        }
207
208        for edge in edges {
209            xml.push_str(&format!(
210                r#"<edge source="{}" target="{}"><data key="relation">{}</data></edge>"#,
211                edge.source, edge.target, edge.relation
212            ));
213        }
214
215        xml.push_str("</graph></graphml>");
216        Ok(xml)
217    }
218
219    pub fn to_obsidian_vault(
220        &self,
221        nodes: &[Node],
222        _edges: &[Edge],
223        output_dir: &Path,
224    ) -> Result<()> {
225        let index_path = output_dir.join("index.md");
226        let mut index = "# Codesynapse Knowledge Graph\n\n".to_string();
227
228        for node in nodes {
229            index.push_str(&format!("[[{}]] ", node.label));
230        }
231
232        std::fs::write(&index_path, index)?;
233
234        let community_map: HashMap<Option<usize>, Vec<&Node>> =
235            nodes.iter().fold(HashMap::new(), |mut acc, node| {
236                acc.entry(node.community).or_default().push(node);
237                acc
238            });
239
240        for (community_id, members) in &community_map {
241            let filename = format!(
242                "Community {}.md",
243                community_id.map_or("none".to_string(), |i| i.to_string())
244            );
245            let mut content = format!(
246                "# Community {}\n\n",
247                community_id.map_or("none".to_string(), |i| i.to_string())
248            );
249            for member in members {
250                content.push_str(&format!("[[{}]]\n", member.label));
251            }
252            std::fs::write(output_dir.join(&filename), content)?;
253        }
254
255        Ok(())
256    }
257
258    pub fn to_obsidian_vault_from_data(&self, data: &GraphData, output_dir: &Path) -> Result<()> {
259        self.to_obsidian_vault(&data.nodes, &data.edges, output_dir)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use std::collections::HashMap;
267
268    fn make_node(id: &str, label: &str, file_type: &str) -> Node {
269        Node {
270            id: id.to_string(),
271            label: label.to_string(),
272            file_type: file_type.to_string(),
273            source_file: "test.py".to_string(),
274            source_location: None,
275            community: Some(0),
276            rationale: None,
277            docstring: None,
278            metadata: HashMap::new(),
279        }
280    }
281
282    fn make_edge(src: &str, tgt: &str, relation: &str) -> Edge {
283        Edge {
284            source: src.to_string(),
285            target: tgt.to_string(),
286            relation: relation.to_string(),
287            confidence: "EXTRACTED".to_string(),
288            source_file: Some("test.py".to_string()),
289            weight: 1.0,
290            context: None,
291        }
292    }
293
294    #[test]
295    fn test_export_json_roundtrip() {
296        let nodes = vec![make_node("a", "A", "code"), make_node("b", "B", "code")];
297        let edges = vec![make_edge("a", "b", "imports")];
298
299        let exporter = Exporter;
300        let json = exporter.to_json(&nodes, &edges, None).unwrap();
301        let loaded: GraphData = serde_json::from_str(&json).unwrap();
302
303        assert_eq!(loaded.nodes.len(), 2);
304        assert_eq!(loaded.edges.len(), 1);
305    }
306
307    #[test]
308    fn test_export_json_node_attrs() {
309        let node = make_node("a", "A", "code");
310        let exporter = Exporter;
311        let json = exporter.to_json(&[node], &[], None).unwrap();
312        assert!(json.contains("\"label\": \"A\""));
313        assert!(json.contains("\"file_type\": \"code\""));
314    }
315
316    #[test]
317    fn test_export_json_edge_attrs() {
318        let edge = make_edge("a", "b", "imports");
319        let exporter = Exporter;
320        let json = exporter.to_json(&[], &[edge], None).unwrap();
321        assert!(json.contains("\"relation\": \"imports\""));
322        assert!(json.contains("\"confidence\": \"EXTRACTED\""));
323    }
324
325    #[test]
326    fn test_export_html_creates_file() {
327        let dir = std::env::temp_dir().join("codesynapse_test_html");
328        let _ = std::fs::remove_dir_all(&dir);
329        std::fs::create_dir_all(&dir).unwrap();
330
331        let path = dir.join("graph.html");
332        let exporter = Exporter;
333        exporter
334            .to_html_file(&[make_node("a", "A", "code")], &[], &path)
335            .unwrap();
336
337        assert!(path.exists());
338        let _ = std::fs::remove_dir_all(&dir);
339    }
340
341    #[test]
342    fn test_export_html_node_count() {
343        let nodes = vec![make_node("a", "A", "code"), make_node("b", "B", "code")];
344        let exporter = Exporter;
345        let html = exporter.to_html(&nodes, &[]).unwrap();
346        // Count <li> tags
347        let count = html.matches("<li>").count();
348        assert_eq!(count, 2);
349    }
350
351    #[test]
352    fn test_export_obsidian_vault() {
353        let dir = std::env::temp_dir().join("codesynapse_test_obsidian");
354        let _ = std::fs::remove_dir_all(&dir);
355        std::fs::create_dir_all(&dir).unwrap();
356
357        let nodes = vec![make_node("a", "A", "code"), make_node("b", "B", "code")];
358        let edges = vec![make_edge("a", "b", "imports")];
359
360        let exporter = Exporter;
361        exporter.to_obsidian_vault(&nodes, &edges, &dir).unwrap();
362
363        assert!(dir.join("index.md").exists());
364        assert!(dir.join("Community 0.md").exists());
365
366        let _ = std::fs::remove_dir_all(&dir);
367    }
368
369    #[test]
370    fn test_export_graphml() {
371        let nodes = vec![make_node("a", "A", "code"), make_node("b", "B", "code")];
372        let edges = vec![make_edge("a", "b", "imports")];
373
374        let exporter = Exporter;
375        let graphml = exporter.to_graphml(&nodes, &edges).unwrap();
376
377        assert!(graphml.contains("<node id=\"a\">"));
378        assert!(graphml.contains("<edge source=\"a\" target=\"b\">"));
379    }
380
381    #[test]
382    fn test_export_json_hyperedges() {
383        let nodes = vec![make_node("a", "A", "code")];
384        let edges = vec![];
385        let hyperedges = vec![HyperEdge {
386            id: "hyper1".to_string(),
387            members: vec!["a".to_string()],
388            label: "Group".to_string(),
389        }];
390        let exporter = Exporter;
391        let json = exporter.to_json(&nodes, &edges, Some(&hyperedges)).unwrap();
392        assert!(json.contains("\"hyperedges\""));
393        assert!(json.contains("\"hyper1\""));
394        assert!(json.contains("\"Group\""));
395    }
396
397    #[test]
398    fn test_export_svg_creates_file() {
399        let dir = std::env::temp_dir().join("codesynapse_test_svg");
400        let _ = std::fs::remove_dir_all(&dir);
401        std::fs::create_dir_all(&dir).unwrap();
402
403        let path = dir.join("graph.svg");
404        let nodes = vec![make_node("a", "A", "code"), make_node("b", "B", "code")];
405        let edges = vec![make_edge("a", "b", "imports")];
406        let exporter = Exporter;
407        exporter.to_svg_file(&nodes, &edges, &path).unwrap();
408        assert!(path.exists());
409        let content = std::fs::read_to_string(&path).unwrap();
410        assert!(content.contains("<svg"));
411        assert!(content.contains("</svg>"));
412
413        let _ = std::fs::remove_dir_all(&dir);
414    }
415
416    #[test]
417    fn test_export_wiki() {
418        let dir = std::env::temp_dir().join("codesynapse_test_wiki");
419        let _ = std::fs::remove_dir_all(&dir);
420        std::fs::create_dir_all(&dir).unwrap();
421
422        let nodes = vec![
423            make_node("a", "AuthService", "code"),
424            make_node("b", "UserService", "code"),
425        ];
426        let edges = vec![make_edge("a", "b", "calls")];
427        let exporter = Exporter;
428        exporter.to_wiki(&nodes, &edges, &dir).unwrap();
429
430        assert!(dir.join("index.md").exists());
431        let content = std::fs::read_to_string(dir.join("index.md")).unwrap();
432        assert!(content.contains("AuthService"));
433        assert!(content.contains("UserService"));
434
435        let _ = std::fs::remove_dir_all(&dir);
436    }
437
438    #[test]
439    fn test_export_neo4j_cypher() {
440        let nodes = vec![
441            make_node("auth", "AuthService", "code"),
442            make_node("user", "UserService", "code"),
443        ];
444        let edges = vec![make_edge("auth", "user", "calls")];
445        let exporter = Exporter;
446        let cypher = exporter.to_cypher(&nodes, &edges).unwrap();
447
448        assert!(cypher.contains("CREATE"));
449        assert!(cypher.contains("AuthService"));
450        assert!(cypher.contains("UserService"));
451        assert!(cypher.contains(":CALLS"));
452        assert!(cypher.contains("confidence"));
453    }
454
455    // --- Edge case tests ---
456
457    #[test]
458    fn test_export_json_empty() {
459        let exporter = Exporter;
460        let json = exporter.to_json(&[], &[], None).unwrap();
461        assert!(json.contains("\"nodes\""));
462        assert!(json.contains("\"edges\""));
463        let data: GraphData = serde_json::from_str(&json).unwrap();
464        assert!(data.nodes.is_empty());
465        assert!(data.edges.is_empty());
466    }
467
468    #[test]
469    fn test_export_html_empty() {
470        let exporter = Exporter;
471        let html = exporter.to_html(&[], &[]).unwrap();
472        assert!(html.contains("<ul>"));
473        assert!(html.contains("</ul>"));
474    }
475
476    #[test]
477    fn test_export_graphml_empty() {
478        let exporter = Exporter;
479        let graphml = exporter.to_graphml(&[], &[]).unwrap();
480        assert!(graphml.contains("<graph"));
481        assert!(graphml.contains("</graphml>"));
482    }
483
484    #[test]
485    fn test_export_json_special_chars() {
486        let node = Node {
487            id: "a".to_string(),
488            label: "Test \"Label\" & <tag>".to_string(),
489            file_type: "code".to_string(),
490            source_file: "test.py".to_string(),
491            source_location: None,
492            community: None,
493            rationale: None,
494            docstring: None,
495            metadata: HashMap::new(),
496        };
497        let exporter = Exporter;
498        let json = exporter.to_json(&[node], &[], None).unwrap();
499        assert!(json.contains("Test"));
500        let data: GraphData = serde_json::from_str(&json).unwrap();
501        assert_eq!(data.nodes[0].label, "Test \"Label\" & <tag>");
502    }
503
504    #[test]
505    fn test_export_json_unicode_label() {
506        let node = Node {
507            id: "fn".to_string(),
508            label: "función".to_string(),
509            file_type: "code".to_string(),
510            source_file: "test.py".to_string(),
511            source_location: None,
512            community: None,
513            rationale: None,
514            docstring: None,
515            metadata: HashMap::new(),
516        };
517        let exporter = Exporter;
518        let json = exporter.to_json(&[node], &[], None).unwrap();
519        assert!(json.contains("función"));
520    }
521
522    #[test]
523    fn test_export_html_escaped() {
524        let node = Node {
525            id: "a".to_string(),
526            label: "<script>alert('xss')</script>".to_string(),
527            file_type: "code".to_string(),
528            source_file: "test.py".to_string(),
529            source_location: None,
530            community: None,
531            rationale: None,
532            docstring: None,
533            metadata: HashMap::new(),
534        };
535        let exporter = Exporter;
536        let html = exporter.to_html(&[node], &[]).unwrap();
537        // HTML should escape the label (might escape via serde or template)
538        assert!(html.contains("alert") || !html.contains("<script>"));
539    }
540
541    #[test]
542    fn test_export_json_file_error() {
543        let dir = std::env::temp_dir().join("codesynapse_test_export_err");
544        let _ = std::fs::remove_dir_all(&dir);
545        // A path inside a nonexistent directory
546        let bad_path = dir.join("nonexistent").join("output.json");
547        let exporter = Exporter;
548        let result = exporter.to_json_file(&[], &[], None, &bad_path);
549        assert!(result.is_err(), "writing to nonexistent dir should fail");
550    }
551
552    #[test]
553    fn test_export_svg_empty() {
554        let exporter = Exporter;
555        let svg = exporter.to_svg(&[], &[]).unwrap();
556        assert!(svg.contains("<svg"));
557        assert!(svg.contains("</svg>"));
558    }
559
560    #[test]
561    fn test_export_wiki_empty() {
562        let dir = std::env::temp_dir().join("codesynapse_test_wiki_empty");
563        let _ = std::fs::remove_dir_all(&dir);
564        std::fs::create_dir_all(&dir).unwrap();
565
566        let exporter = Exporter;
567        exporter.to_wiki(&[], &[], &dir).unwrap();
568        assert!(dir.join("index.md").exists());
569
570        let _ = std::fs::remove_dir_all(&dir);
571    }
572
573    #[test]
574    fn test_export_obsidian_empty() {
575        let dir = std::env::temp_dir().join("codesynapse_test_obsidian_empty");
576        let _ = std::fs::remove_dir_all(&dir);
577        std::fs::create_dir_all(&dir).unwrap();
578
579        let exporter = Exporter;
580        exporter.to_obsidian_vault(&[], &[], &dir).unwrap();
581        assert!(dir.join("index.md").exists());
582
583        let _ = std::fs::remove_dir_all(&dir);
584    }
585
586    // --- Compat (NetworkX) export tests ---
587
588    #[test]
589    fn test_export_json_compat_format() {
590        let nodes = vec![make_node("a", "A", "code"), make_node("b", "B", "code")];
591        let edges = vec![make_edge("a", "b", "imports")];
592
593        let exporter = Exporter;
594        let json = exporter.to_json_compat(&nodes, &edges).unwrap();
595
596        // NetworkX format markers
597        assert!(json.contains("\"directed\""));
598        assert!(json.contains("\"multigraph\""));
599        assert!(json.contains("\"graph\""));
600        assert!(json.contains("\"nodes\""));
601        assert!(json.contains("\"links\""));
602        assert!(!json.contains("\"edges\""));
603
604        // Compat fields
605        assert!(json.contains("\"node_type\""));
606        assert!(json.contains("\"EXTRACTED\""));
607
608        // Roundtrip
609        let nx: crate::types::NetworkXGraph = serde_json::from_str(&json).unwrap();
610        assert_eq!(nx.nodes.len(), 2);
611        assert_eq!(nx.links.len(), 1);
612    }
613
614    #[test]
615    fn test_export_json_compat_file_roundtrip() {
616        let dir = std::env::temp_dir().join("codesynapse_test_nx_export");
617        let _ = std::fs::remove_dir_all(&dir);
618        std::fs::create_dir_all(&dir).unwrap();
619
620        let path = dir.join("graph.json");
621        let nodes = vec![
622            make_node("a", "NodeA", "function"),
623            make_node("b", "NodeB", "class"),
624        ];
625        let edges = vec![make_edge("a", "b", "calls")];
626
627        let exporter = Exporter;
628        exporter.to_json_compat_file(&nodes, &edges, &path).unwrap();
629        assert!(path.exists());
630
631        let loaded = exporter.load_json_compat(&path).unwrap();
632        assert_eq!(loaded.nodes.len(), 2);
633        assert_eq!(loaded.links.len(), 1);
634        assert_eq!(loaded.nodes[0].label, "NodeA");
635        assert_eq!(loaded.nodes[0].node_type, crate::types::NodeType::Function);
636        assert_eq!(loaded.nodes[1].node_type, crate::types::NodeType::Class);
637
638        let _ = std::fs::remove_dir_all(&dir);
639    }
640
641    #[test]
642    fn test_export_json_compat_empty() {
643        let exporter = Exporter;
644        let json = exporter.to_json_compat(&[], &[]).unwrap();
645        assert!(json.contains("\"nodes\":"));
646        assert!(json.contains("\"links\":"));
647        let nx: crate::types::NetworkXGraph = serde_json::from_str(&json).unwrap();
648        assert!(nx.nodes.is_empty());
649        assert!(nx.links.is_empty());
650    }
651
652    #[test]
653    fn test_export_json_compat_inferred_confidence() {
654        let edge = Edge {
655            source: "a".to_string(),
656            target: "b".to_string(),
657            relation: "depends".to_string(),
658            confidence: "INFERRED".to_string(),
659            source_file: Some("f.rs".to_string()),
660            weight: 1.0,
661            context: None,
662        };
663        let exporter = Exporter;
664        let json = exporter.to_json_compat(&[], &[edge]).unwrap();
665        assert!(json.contains("\"INFERRED\""));
666        assert!(json.contains("confidence_score"));
667    }
668
669    #[test]
670    fn test_export_json_compat_unicode() {
671        let node = Node {
672            id: "fn".to_string(),
673            label: "función".to_string(),
674            file_type: "code".to_string(),
675            source_file: "test.py".to_string(),
676            source_location: None,
677            community: None,
678            rationale: None,
679            docstring: None,
680            metadata: HashMap::new(),
681        };
682        let exporter = Exporter;
683        let json = exporter.to_json_compat(&[node], &[]).unwrap();
684        assert!(json.contains("función"));
685    }
686
687    #[test]
688    fn test_export_json_compat_file_error() {
689        let dir = std::env::temp_dir().join("codesynapse_test_nx_export_err");
690        let _ = std::fs::remove_dir_all(&dir);
691        let bad_path = dir.join("nonexistent").join("output.json");
692        let exporter = Exporter;
693        let result = exporter.to_json_compat_file(&[], &[], &bad_path);
694        assert!(result.is_err(), "writing to nonexistent dir should fail");
695    }
696}