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 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 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 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 #[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 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 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 #[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 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 assert!(json.contains("\"node_type\""));
606 assert!(json.contains("\"EXTRACTED\""));
607
608 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}