1use std::collections::{BTreeMap, HashMap};
2use std::path::Path;
3
4#[derive(Clone, Debug, Default)]
5pub struct WikiNodeData {
6 pub label: String,
7 pub source_file: Option<String>,
8}
9
10#[derive(Clone, Debug, Default)]
11pub struct WikiEdgeData {
12 pub relation: String,
13 pub confidence: String,
14}
15
16#[derive(Debug, Default)]
17pub struct WikiGraph {
18 pub nodes: HashMap<String, WikiNodeData>,
19 adj: HashMap<String, Vec<(String, WikiEdgeData)>>,
20}
21
22impl WikiGraph {
23 pub fn new() -> Self {
24 Self::default()
25 }
26
27 pub fn add_node(&mut self, id: impl Into<String>, data: WikiNodeData) {
28 let id = id.into();
29 self.adj.entry(id.clone()).or_default();
30 self.nodes.insert(id, data);
31 }
32
33 pub fn add_edge(&mut self, u: impl Into<String>, v: impl Into<String>, data: WikiEdgeData) {
34 let u: String = u.into();
35 let v: String = v.into();
36 self.adj
37 .entry(u.clone())
38 .or_default()
39 .push((v.clone(), data.clone()));
40 self.adj.entry(v.clone()).or_default().push((u, data));
41 }
42
43 pub fn contains(&self, id: &str) -> bool {
44 self.nodes.contains_key(id)
45 }
46
47 pub fn node_count(&self) -> usize {
48 self.nodes.len()
49 }
50
51 pub fn edge_count(&self) -> usize {
52 self.adj.values().map(|v| v.len()).sum::<usize>() / 2
53 }
54
55 pub fn degree(&self, id: &str) -> usize {
56 self.adj.get(id).map(|v| v.len()).unwrap_or(0)
57 }
58
59 pub fn neighbors(&self, id: &str) -> Vec<(&str, &WikiEdgeData)> {
60 self.adj
61 .get(id)
62 .map(|v| v.iter().map(|(n, e)| (n.as_str(), e)).collect())
63 .unwrap_or_default()
64 }
65}
66
67pub fn safe_filename(name: &str) -> String {
68 let s = name.replace('/', "-").replace(' ', "_").replace(':', "-");
69 let s: String = s
70 .chars()
71 .map(|c| match c {
72 '<' | '>' | '"' | '\\' | '|' | '?' | '*' => '_',
73 c => c,
74 })
75 .collect();
76 let s = s.trim_matches(|c| c == '.' || c == ' ').to_string();
77 let s = if s.is_empty() {
78 "unnamed".to_string()
79 } else {
80 s
81 };
82 if s.len() > 200 {
83 s[..200].to_string()
84 } else {
85 s
86 }
87}
88
89fn cross_community_links(
90 graph: &WikiGraph,
91 nodes: &[String],
92 own_cid: i64,
93 labels: &HashMap<i64, String>,
94 node_community: &HashMap<String, i64>,
95) -> Vec<(String, usize)> {
96 let mut counts: HashMap<String, usize> = HashMap::new();
97 for nid in nodes {
98 for (neighbor, _) in graph.neighbors(nid) {
99 if let Some(&ncid) = node_community.get(neighbor) {
100 if ncid != own_cid {
101 let lbl = labels
102 .get(&ncid)
103 .cloned()
104 .unwrap_or_else(|| format!("Community {ncid}"));
105 *counts.entry(lbl).or_insert(0) += 1;
106 }
107 }
108 }
109 }
110 let mut result: Vec<(String, usize)> = counts.into_iter().collect();
111 result.sort_by_key(|k| std::cmp::Reverse(k.1));
112 result
113}
114
115pub fn community_article(
116 graph: &WikiGraph,
117 cid: i64,
118 nodes: &[String],
119 label: &str,
120 labels: &HashMap<i64, String>,
121 cohesion: Option<f64>,
122 node_community: &HashMap<String, i64>,
123) -> String {
124 let mut sorted_nodes = nodes.to_vec();
125 sorted_nodes.sort_by_key(|n| std::cmp::Reverse(graph.degree(n)));
126 let top_nodes = &sorted_nodes[..sorted_nodes.len().min(25)];
127
128 let cross = cross_community_links(graph, nodes, cid, labels, node_community);
129
130 let mut conf_counts: HashMap<String, usize> = HashMap::new();
131 for nid in nodes {
132 for (_, edge) in graph.neighbors(nid) {
133 *conf_counts.entry(edge.confidence.clone()).or_insert(0) += 1;
134 }
135 }
136 let total_edges = conf_counts.values().sum::<usize>().max(1);
137
138 let mut sources: Vec<String> = nodes
139 .iter()
140 .filter_map(|n| graph.nodes.get(n).and_then(|d| d.source_file.clone()))
141 .filter(|s| !s.is_empty())
142 .collect::<std::collections::BTreeSet<_>>()
143 .into_iter()
144 .collect();
145 sources.sort();
146
147 let mut lines = Vec::<String>::new();
148 lines.push(format!("# {label}"));
149 lines.push(String::new());
150
151 let mut meta_parts = vec![format!("{} nodes", nodes.len())];
152 if let Some(c) = cohesion {
153 meta_parts.push(format!("cohesion {c:.2}"));
154 }
155 lines.push(format!("> {}", meta_parts.join(" · ")));
156 lines.push(String::new());
157
158 lines.push("## Key Concepts".to_string());
159 lines.push(String::new());
160 for nid in top_nodes {
161 let d = graph.nodes.get(nid.as_str());
162 let node_label = d.map(|d| d.label.as_str()).unwrap_or(nid.as_str());
163 let src = d.and_then(|d| d.source_file.as_deref()).unwrap_or("");
164 let degree = graph.degree(nid);
165 let src_str = if src.is_empty() {
166 String::new()
167 } else {
168 format!(" — `{src}`")
169 };
170 lines.push(format!(
171 "- **{node_label}** ({degree} connections){src_str}"
172 ));
173 }
174 let remaining = nodes.len().saturating_sub(top_nodes.len());
175 if remaining > 0 {
176 lines.push(format!(
177 "- *... and {remaining} more nodes in this community*"
178 ));
179 }
180 lines.push(String::new());
181
182 lines.push("## Relationships".to_string());
183 lines.push(String::new());
184 if cross.is_empty() {
185 lines.push("- No strong cross-community connections detected".to_string());
186 } else {
187 for (other_label, count) in cross.iter().take(12) {
188 lines.push(format!("- [[{other_label}]] ({count} shared connections)"));
189 }
190 }
191 lines.push(String::new());
192
193 if !sources.is_empty() {
194 lines.push("## Source Files".to_string());
195 lines.push(String::new());
196 for src in sources.iter().take(20) {
197 lines.push(format!("- `{src}`"));
198 }
199 lines.push(String::new());
200 }
201
202 lines.push("## Audit Trail".to_string());
203 lines.push(String::new());
204 for conf in &["EXTRACTED", "INFERRED", "AMBIGUOUS"] {
205 let n = conf_counts.get(*conf).copied().unwrap_or(0);
206 let pct = (n * 100) / total_edges;
207 lines.push(format!("- {conf}: {n} ({pct}%)"));
208 }
209 lines.push(String::new());
210
211 lines.push("---".to_string());
212 lines.push(String::new());
213 lines.push("*Part of the codesynapse knowledge wiki. See [[index]] to navigate.*".to_string());
214
215 lines.join("\n")
216}
217
218pub fn god_node_article(
219 graph: &WikiGraph,
220 nid: &str,
221 labels: &HashMap<i64, String>,
222 node_community: &HashMap<String, i64>,
223) -> String {
224 let d = graph.nodes.get(nid);
225 let node_label = d.map(|d| d.label.as_str()).unwrap_or(nid);
226 let src = d.and_then(|d| d.source_file.as_deref()).unwrap_or("");
227 let cid = node_community.get(nid).copied();
228 let community_name = cid.map(|c| {
229 labels
230 .get(&c)
231 .cloned()
232 .unwrap_or_else(|| format!("Community {c}"))
233 });
234
235 let mut lines = Vec::<String>::new();
236 lines.push(format!("# {node_label}"));
237 lines.push(String::new());
238 lines.push(format!(
239 "> God node · {} connections · `{src}`",
240 graph.degree(nid)
241 ));
242 lines.push(String::new());
243
244 if let Some(ref cname) = community_name {
245 lines.push(format!("**Community:** [[{cname}]]"));
246 lines.push(String::new());
247 }
248
249 let mut by_relation: BTreeMap<String, Vec<String>> = BTreeMap::new();
250 let mut neighbors: Vec<(&str, &WikiEdgeData)> = graph.neighbors(nid);
251 neighbors.sort_by_key(|n| std::cmp::Reverse(graph.degree(n.0)));
252
253 for (neighbor, edge) in &neighbors {
254 let nd = graph.nodes.get(*neighbor);
255 let neighbor_label = nd.map(|d| d.label.as_str()).unwrap_or(neighbor);
256 let conf_str = if edge.confidence.is_empty() {
257 String::new()
258 } else {
259 format!(" `{}`", edge.confidence)
260 };
261 by_relation
262 .entry(edge.relation.clone())
263 .or_default()
264 .push(format!("[[{neighbor_label}]]{conf_str}"));
265 }
266
267 lines.push("## Connections by Relation".to_string());
268 lines.push(String::new());
269 for (rel, targets) in &by_relation {
270 lines.push(format!("### {rel}"));
271 for t in targets.iter().take(20) {
272 lines.push(format!("- {t}"));
273 }
274 lines.push(String::new());
275 }
276
277 lines.push("---".to_string());
278 lines.push(String::new());
279 lines.push("*Part of the codesynapse knowledge wiki. See [[index]] to navigate.*".to_string());
280
281 lines.join("\n")
282}
283
284pub fn index_md(
285 communities: &BTreeMap<i64, Vec<String>>,
286 labels: &HashMap<i64, String>,
287 god_nodes_data: &[HashMap<String, serde_json::Value>],
288 total_nodes: usize,
289 total_edges: usize,
290) -> String {
291 let mut lines = vec![
292 "# Knowledge Graph Index".to_string(),
293 String::new(),
294 "> Auto-generated by codesynapse. Start here — read community articles for context, then drill into god nodes for detail.".to_string(),
295 String::new(),
296 format!("**{total_nodes} nodes · {total_edges} edges · {} communities**", communities.len()),
297 String::new(),
298 "---".to_string(),
299 String::new(),
300 "## Communities".to_string(),
301 "(sorted by size, largest first)".to_string(),
302 String::new(),
303 ];
304
305 let mut sorted_communities: Vec<(i64, &Vec<String>)> =
306 communities.iter().map(|(k, v)| (*k, v)).collect();
307 sorted_communities.sort_by_key(|k| std::cmp::Reverse(k.1.len()));
308
309 for (cid, nodes) in &sorted_communities {
310 let label = labels
311 .get(cid)
312 .cloned()
313 .unwrap_or_else(|| format!("Community {cid}"));
314 lines.push(format!("- [[{label}]] — {} nodes", nodes.len()));
315 }
316 lines.push(String::new());
317
318 if !god_nodes_data.is_empty() {
319 lines.push("## God Nodes".to_string());
320 lines.push("(most connected concepts — the load-bearing abstractions)".to_string());
321 lines.push(String::new());
322 for node in god_nodes_data {
323 let lbl = node.get("label").and_then(|v| v.as_str()).unwrap_or("");
324 let degree = node.get("degree").and_then(|v| v.as_u64()).unwrap_or(0);
325 lines.push(format!("- [[{lbl}]] — {degree} connections"));
326 }
327 lines.push(String::new());
328 }
329
330 lines.push("---".to_string());
331 lines.push(String::new());
332 lines.push(
333 "*Generated by [codesynapse](https://github.com/safishamsi/codesynapse)*".to_string(),
334 );
335
336 lines.join("\n")
337}
338
339pub fn to_wiki(
340 graph: &WikiGraph,
341 communities: &HashMap<i64, Vec<String>>,
342 output_dir: &Path,
343 community_labels: Option<&HashMap<i64, String>>,
344 cohesion: Option<&HashMap<i64, f64>>,
345 god_nodes_data: Option<&[HashMap<String, serde_json::Value>]>,
346) -> Result<usize, String> {
347 if communities.is_empty() {
348 return Err("communities dict is empty — refusing to clear wiki/. \
349 Run `codesynapse extract .` or `codesynapse cluster-only .` first."
350 .to_string());
351 }
352
353 let g_nodes: std::collections::HashSet<&str> = graph.nodes.keys().map(|s| s.as_str()).collect();
354 let orig_total: usize = communities.values().map(|v| v.len()).sum();
355
356 let clean_communities: HashMap<i64, Vec<String>> = communities
357 .iter()
358 .map(|(cid, nodes)| {
359 let filtered: Vec<String> = nodes
360 .iter()
361 .filter(|n| g_nodes.contains(n.as_str()))
362 .cloned()
363 .collect();
364 (*cid, filtered)
365 })
366 .filter(|(_, nodes)| !nodes.is_empty())
367 .collect();
368
369 let kept_total: usize = clean_communities.values().map(|v| v.len()).sum();
370 if kept_total < orig_total {
371 eprintln!(
372 "wiki: dropped {} stale node ID(s) not in graph ({} communities remaining)",
373 orig_total - kept_total,
374 clean_communities.len()
375 );
376 }
377
378 if clean_communities.is_empty() {
379 return Err(
380 "all community node IDs are stale — none exist in the graph. \
381 Re-run `codesynapse extract .` to regenerate .codesynapse_analysis.json."
382 .to_string(),
383 );
384 }
385
386 std::fs::create_dir_all(output_dir).map_err(|e| format!("create_dir_all: {e}"))?;
387
388 for entry in std::fs::read_dir(output_dir).map_err(|e| e.to_string())? {
389 let entry = entry.map_err(|e| e.to_string())?;
390 let p = entry.path();
391 if p.extension().and_then(|e| e.to_str()) == Some("md") {
392 std::fs::remove_file(&p).map_err(|e| e.to_string())?;
393 }
394 }
395
396 let default_labels: HashMap<i64, String> = clean_communities
397 .keys()
398 .map(|cid| (*cid, format!("Community {cid}")))
399 .collect();
400 let labels = community_labels.unwrap_or(&default_labels);
401 let empty_cohesion = HashMap::new();
402 let cohesion = cohesion.unwrap_or(&empty_cohesion);
403 let empty_gods: Vec<HashMap<String, serde_json::Value>> = vec![];
404 let god_nodes_data = god_nodes_data.unwrap_or(&empty_gods);
405
406 let node_community: HashMap<String, i64> = clean_communities
407 .iter()
408 .flat_map(|(cid, nodes)| nodes.iter().map(|n| (n.clone(), *cid)))
409 .collect();
410
411 let mut used_slugs: std::collections::HashSet<String> = std::collections::HashSet::new();
412 let mut unique_slug = |base: &str| -> String {
413 let mut slug = base.to_string();
414 let mut n = 2;
415 while used_slugs.contains(&slug) {
416 slug = format!("{base}_{n}");
417 n += 1;
418 }
419 used_slugs.insert(slug.clone());
420 slug
421 };
422
423 let mut sorted_communities: Vec<(i64, Vec<String>)> = clean_communities.into_iter().collect();
424 sorted_communities.sort_by_key(|(cid, _)| *cid);
425
426 let mut count = 0;
427
428 for (cid, nodes) in &sorted_communities {
429 let label = labels
430 .get(cid)
431 .cloned()
432 .unwrap_or_else(|| format!("Community {cid}"));
433 let article = community_article(
434 graph,
435 *cid,
436 nodes,
437 &label,
438 labels,
439 cohesion.get(cid).copied(),
440 &node_community,
441 );
442 let slug = unique_slug(&safe_filename(&label));
443 std::fs::write(output_dir.join(format!("{slug}.md")), &article)
444 .map_err(|e| e.to_string())?;
445 count += 1;
446 }
447
448 for node_data in god_nodes_data {
449 let nid = node_data.get("id").and_then(|v| v.as_str()).unwrap_or("");
450 if !nid.is_empty() && graph.contains(nid) {
451 let article = god_node_article(graph, nid, labels, &node_community);
452 let lbl = node_data
453 .get("label")
454 .and_then(|v| v.as_str())
455 .unwrap_or(nid);
456 let slug = unique_slug(&safe_filename(lbl));
457 std::fs::write(output_dir.join(format!("{slug}.md")), &article)
458 .map_err(|e| e.to_string())?;
459 count += 1;
460 }
461 }
462
463 let btree_communities: BTreeMap<i64, Vec<String>> = sorted_communities.into_iter().collect();
464 let idx = index_md(
465 &btree_communities,
466 labels,
467 god_nodes_data,
468 graph.node_count(),
469 graph.edge_count(),
470 );
471 std::fs::write(output_dir.join("index.md"), &idx).map_err(|e| e.to_string())?;
472
473 Ok(count)
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use std::collections::HashMap;
480 use tempfile::tempdir;
481
482 fn make_graph() -> WikiGraph {
483 let mut g = WikiGraph::new();
484 g.add_node(
485 "n1",
486 WikiNodeData {
487 label: "parse".into(),
488 source_file: Some("parser.py".into()),
489 },
490 );
491 g.add_node(
492 "n2",
493 WikiNodeData {
494 label: "validate".into(),
495 source_file: Some("parser.py".into()),
496 },
497 );
498 g.add_node(
499 "n3",
500 WikiNodeData {
501 label: "render".into(),
502 source_file: Some("renderer.py".into()),
503 },
504 );
505 g.add_node(
506 "n4",
507 WikiNodeData {
508 label: "stream".into(),
509 source_file: Some("renderer.py".into()),
510 },
511 );
512 g.add_edge(
513 "n1",
514 "n2",
515 WikiEdgeData {
516 relation: "calls".into(),
517 confidence: "EXTRACTED".into(),
518 },
519 );
520 g.add_edge(
521 "n1",
522 "n3",
523 WikiEdgeData {
524 relation: "references".into(),
525 confidence: "INFERRED".into(),
526 },
527 );
528 g.add_edge(
529 "n3",
530 "n4",
531 WikiEdgeData {
532 relation: "calls".into(),
533 confidence: "EXTRACTED".into(),
534 },
535 );
536 g
537 }
538
539 fn make_communities() -> HashMap<i64, Vec<String>> {
540 let mut m = HashMap::new();
541 m.insert(0, vec!["n1".into(), "n2".into()]);
542 m.insert(1, vec!["n3".into(), "n4".into()]);
543 m
544 }
545
546 fn make_labels() -> HashMap<i64, String> {
547 let mut m = HashMap::new();
548 m.insert(0i64, "Parsing Layer".to_string());
549 m.insert(1i64, "Rendering Layer".to_string());
550 m
551 }
552
553 fn make_cohesion() -> HashMap<i64, f64> {
554 let mut m = HashMap::new();
555 m.insert(0i64, 0.85f64);
556 m.insert(1i64, 0.72f64);
557 m
558 }
559
560 fn make_god_nodes() -> Vec<HashMap<String, serde_json::Value>> {
561 vec![{
562 let mut m = HashMap::new();
563 m.insert(
564 "id".to_string(),
565 serde_json::Value::String("n1".to_string()),
566 );
567 m.insert(
568 "label".to_string(),
569 serde_json::Value::String("parse".to_string()),
570 );
571 m.insert("degree".to_string(), serde_json::Value::Number(2.into()));
572 m
573 }]
574 }
575
576 #[test]
577 fn test_to_wiki_writes_index() {
578 let g = make_graph();
579 let dir = tempdir().unwrap();
580 to_wiki(
581 &g,
582 &make_communities(),
583 dir.path(),
584 Some(&make_labels()),
585 Some(&make_cohesion()),
586 Some(&make_god_nodes()),
587 )
588 .unwrap();
589 assert!(dir.path().join("index.md").exists());
590 }
591
592 #[test]
593 fn test_to_wiki_returns_article_count() {
594 let g = make_graph();
595 let dir = tempdir().unwrap();
596 let n = to_wiki(
597 &g,
598 &make_communities(),
599 dir.path(),
600 Some(&make_labels()),
601 Some(&make_cohesion()),
602 Some(&make_god_nodes()),
603 )
604 .unwrap();
605 assert_eq!(n, 3); }
607
608 #[test]
609 fn test_to_wiki_community_articles_created() {
610 let g = make_graph();
611 let dir = tempdir().unwrap();
612 to_wiki(
613 &g,
614 &make_communities(),
615 dir.path(),
616 Some(&make_labels()),
617 None,
618 None,
619 )
620 .unwrap();
621 assert!(dir.path().join("Parsing_Layer.md").exists());
622 assert!(dir.path().join("Rendering_Layer.md").exists());
623 }
624
625 #[test]
626 fn test_to_wiki_god_node_article_created() {
627 let g = make_graph();
628 let dir = tempdir().unwrap();
629 to_wiki(
630 &g,
631 &make_communities(),
632 dir.path(),
633 Some(&make_labels()),
634 None,
635 Some(&make_god_nodes()),
636 )
637 .unwrap();
638 assert!(dir.path().join("parse.md").exists());
639 }
640
641 #[test]
642 fn test_index_links_all_communities() {
643 let g = make_graph();
644 let dir = tempdir().unwrap();
645 to_wiki(
646 &g,
647 &make_communities(),
648 dir.path(),
649 Some(&make_labels()),
650 None,
651 None,
652 )
653 .unwrap();
654 let idx = std::fs::read_to_string(dir.path().join("index.md")).unwrap();
655 assert!(idx.contains("[[Parsing Layer]]"));
656 assert!(idx.contains("[[Rendering Layer]]"));
657 }
658
659 #[test]
660 fn test_index_lists_god_nodes() {
661 let g = make_graph();
662 let dir = tempdir().unwrap();
663 to_wiki(
664 &g,
665 &make_communities(),
666 dir.path(),
667 Some(&make_labels()),
668 None,
669 Some(&make_god_nodes()),
670 )
671 .unwrap();
672 let idx = std::fs::read_to_string(dir.path().join("index.md")).unwrap();
673 assert!(idx.contains("[[parse]]"));
674 assert!(idx.contains("2 connections"));
675 }
676
677 #[test]
678 fn test_community_article_has_cross_links() {
679 let g = make_graph();
680 let dir = tempdir().unwrap();
681 to_wiki(
682 &g,
683 &make_communities(),
684 dir.path(),
685 Some(&make_labels()),
686 None,
687 None,
688 )
689 .unwrap();
690 let content = std::fs::read_to_string(dir.path().join("Parsing_Layer.md")).unwrap();
691 assert!(content.contains("[[Rendering Layer]]"));
692 }
693
694 #[test]
695 fn test_community_article_shows_cohesion() {
696 let g = make_graph();
697 let dir = tempdir().unwrap();
698 to_wiki(
699 &g,
700 &make_communities(),
701 dir.path(),
702 Some(&make_labels()),
703 Some(&make_cohesion()),
704 None,
705 )
706 .unwrap();
707 let content = std::fs::read_to_string(dir.path().join("Parsing_Layer.md")).unwrap();
708 assert!(content.contains("cohesion 0.85"));
709 }
710
711 #[test]
712 fn test_community_article_has_audit_trail() {
713 let g = make_graph();
714 let dir = tempdir().unwrap();
715 to_wiki(
716 &g,
717 &make_communities(),
718 dir.path(),
719 Some(&make_labels()),
720 None,
721 None,
722 )
723 .unwrap();
724 let content = std::fs::read_to_string(dir.path().join("Parsing_Layer.md")).unwrap();
725 assert!(content.contains("EXTRACTED"));
726 assert!(content.contains("INFERRED"));
727 }
728
729 #[test]
730 fn test_god_node_article_has_connections() {
731 let g = make_graph();
732 let dir = tempdir().unwrap();
733 to_wiki(
734 &g,
735 &make_communities(),
736 dir.path(),
737 Some(&make_labels()),
738 None,
739 Some(&make_god_nodes()),
740 )
741 .unwrap();
742 let content = std::fs::read_to_string(dir.path().join("parse.md")).unwrap();
743 assert!(content.contains("[[validate]]") || content.contains("[[render]]"));
744 }
745
746 #[test]
747 fn test_god_node_article_links_community() {
748 let g = make_graph();
749 let dir = tempdir().unwrap();
750 to_wiki(
751 &g,
752 &make_communities(),
753 dir.path(),
754 Some(&make_labels()),
755 None,
756 Some(&make_god_nodes()),
757 )
758 .unwrap();
759 let content = std::fs::read_to_string(dir.path().join("parse.md")).unwrap();
760 assert!(content.contains("[[Parsing Layer]]"));
761 }
762
763 #[test]
764 fn test_to_wiki_skips_missing_god_node_ids() {
765 let g = make_graph();
766 let dir = tempdir().unwrap();
767 let bad_gods = vec![{
768 let mut m = HashMap::new();
769 m.insert(
770 "id".to_string(),
771 serde_json::Value::String("nonexistent".to_string()),
772 );
773 m.insert(
774 "label".to_string(),
775 serde_json::Value::String("ghost".to_string()),
776 );
777 m.insert("degree".to_string(), serde_json::Value::Number(99.into()));
778 m
779 }];
780 let n = to_wiki(
781 &g,
782 &make_communities(),
783 dir.path(),
784 Some(&make_labels()),
785 None,
786 Some(&bad_gods),
787 )
788 .unwrap();
789 assert_eq!(n, 2);
790 }
791
792 #[test]
793 fn test_to_wiki_no_labels_uses_fallback() {
794 let g = make_graph();
795 let dir = tempdir().unwrap();
796 to_wiki(&g, &make_communities(), dir.path(), None, None, None).unwrap();
797 assert!(dir.path().join("Community_0.md").exists());
798 assert!(dir.path().join("Community_1.md").exists());
799 }
800
801 #[test]
802 fn test_article_navigation_footer() {
803 let g = make_graph();
804 let dir = tempdir().unwrap();
805 to_wiki(
806 &g,
807 &make_communities(),
808 dir.path(),
809 Some(&make_labels()),
810 None,
811 None,
812 )
813 .unwrap();
814 let content = std::fs::read_to_string(dir.path().join("Parsing_Layer.md")).unwrap();
815 assert!(content.contains("[[index]]"));
816 }
817
818 #[test]
819 fn test_community_article_truncation_notice() {
820 let mut g = WikiGraph::new();
821 let nodes: Vec<String> = (0..30).map(|i| format!("n{i}")).collect();
822 for nid in &nodes {
823 g.add_node(
824 nid.clone(),
825 WikiNodeData {
826 label: format!("concept_{nid}"),
827 source_file: Some("a.py".into()),
828 },
829 );
830 }
831 for i in 0..nodes.len() - 1 {
832 g.add_edge(
833 nodes[i].clone(),
834 nodes[i + 1].clone(),
835 WikiEdgeData {
836 relation: "calls".into(),
837 confidence: "EXTRACTED".into(),
838 },
839 );
840 }
841 let mut communities = HashMap::new();
842 communities.insert(0i64, nodes.clone());
843 let mut labels = HashMap::new();
844 labels.insert(0i64, "Big Community".to_string());
845 let dir = tempdir().unwrap();
846 to_wiki(&g, &communities, dir.path(), Some(&labels), None, None).unwrap();
847 let content = std::fs::read_to_string(dir.path().join("Big_Community.md")).unwrap();
848 assert!(content.contains("and 5 more nodes"));
849 }
850
851 #[test]
852 fn test_cross_community_links_without_node_community_attrs() {
853 let mut g = WikiGraph::new();
854 g.add_node(
855 "n1",
856 WikiNodeData {
857 label: "parse".into(),
858 source_file: Some("parser.py".into()),
859 },
860 );
861 g.add_node(
862 "n2",
863 WikiNodeData {
864 label: "render".into(),
865 source_file: Some("renderer.py".into()),
866 },
867 );
868 g.add_edge(
869 "n1",
870 "n2",
871 WikiEdgeData {
872 relation: "references".into(),
873 confidence: "INFERRED".into(),
874 },
875 );
876 let mut communities = HashMap::new();
877 communities.insert(0i64, vec!["n1".to_string()]);
878 communities.insert(1i64, vec!["n2".to_string()]);
879 let mut labels = HashMap::new();
880 labels.insert(0i64, "Parsing".to_string());
881 labels.insert(1i64, "Rendering".to_string());
882 let dir = tempdir().unwrap();
883 to_wiki(&g, &communities, dir.path(), Some(&labels), None, None).unwrap();
884 let content = std::fs::read_to_string(dir.path().join("Parsing.md")).unwrap();
885 assert!(content.contains("[[Rendering]]"));
886 }
887
888 #[test]
889 fn test_god_node_article_community_without_node_attr() {
890 let mut g = WikiGraph::new();
891 g.add_node(
892 "n1",
893 WikiNodeData {
894 label: "parse".into(),
895 source_file: Some("parser.py".into()),
896 },
897 );
898 g.add_node(
899 "n2",
900 WikiNodeData {
901 label: "validate".into(),
902 source_file: Some("parser.py".into()),
903 },
904 );
905 g.add_edge(
906 "n1",
907 "n2",
908 WikiEdgeData {
909 relation: "calls".into(),
910 confidence: "EXTRACTED".into(),
911 },
912 );
913 let mut communities = HashMap::new();
914 communities.insert(0i64, vec!["n1".to_string(), "n2".to_string()]);
915 let mut labels = HashMap::new();
916 labels.insert(0i64, "Core Logic".to_string());
917 let god_nodes = vec![{
918 let mut m = HashMap::new();
919 m.insert(
920 "id".to_string(),
921 serde_json::Value::String("n1".to_string()),
922 );
923 m.insert(
924 "label".to_string(),
925 serde_json::Value::String("parse".to_string()),
926 );
927 m.insert("degree".to_string(), serde_json::Value::Number(1.into()));
928 m
929 }];
930 let dir = tempdir().unwrap();
931 to_wiki(
932 &g,
933 &communities,
934 dir.path(),
935 Some(&labels),
936 None,
937 Some(&god_nodes),
938 )
939 .unwrap();
940 let content = std::fs::read_to_string(dir.path().join("parse.md")).unwrap();
941 assert!(content.contains("[[Core Logic]]"));
942 }
943
944 #[test]
945 fn test_to_wiki_drops_stale_community_nodes() {
946 let g = make_graph();
947 let mut communities = make_communities();
948 communities
949 .get_mut(&0)
950 .unwrap()
951 .push("stale_ghost".to_string());
952 let dir = tempdir().unwrap();
953 let n = to_wiki(
954 &g,
955 &communities,
956 dir.path(),
957 Some(&make_labels()),
958 None,
959 None,
960 )
961 .unwrap();
962 assert_eq!(n, 2);
963 let content = std::fs::read_to_string(dir.path().join("Parsing_Layer.md")).unwrap();
964 assert!(content.contains("parse"));
965 assert!(!content.contains("stale_ghost"));
966 }
967
968 #[test]
969 fn test_to_wiki_all_stale_raises() {
970 let g = make_graph();
971 let mut all_stale = HashMap::new();
972 all_stale.insert(0i64, vec!["ghost1".to_string(), "ghost2".to_string()]);
973 all_stale.insert(1i64, vec!["ghost3".to_string()]);
974 let dir = tempdir().unwrap();
975 let err =
976 to_wiki(&g, &all_stale, dir.path(), Some(&make_labels()), None, None).unwrap_err();
977 assert!(err.to_lowercase().contains("stale"));
978 }
979
980 #[test]
981 fn test_to_wiki_stale_nodes_prints_warning() {
982 let g = make_graph();
983 let mut communities = make_communities();
984 communities
985 .get_mut(&0)
986 .unwrap()
987 .extend(["stale1".to_string(), "stale2".to_string()]);
988 let dir = tempdir().unwrap();
989 let n = to_wiki(
991 &g,
992 &communities,
993 dir.path(),
994 Some(&make_labels()),
995 None,
996 None,
997 )
998 .unwrap();
999 assert_eq!(n, 2);
1000 }
1001
1002 #[test]
1003 fn test_community_article_handles_null_source_file() {
1004 let mut g = WikiGraph::new();
1005 g.add_node(
1006 "n1",
1007 WikiNodeData {
1008 label: "parse".into(),
1009 source_file: None,
1010 },
1011 );
1012 g.add_node(
1013 "n2",
1014 WikiNodeData {
1015 label: "validate".into(),
1016 source_file: Some("parser.py".into()),
1017 },
1018 );
1019 g.add_edge(
1020 "n1",
1021 "n2",
1022 WikiEdgeData {
1023 relation: "calls".into(),
1024 confidence: "EXTRACTED".into(),
1025 },
1026 );
1027 let mut communities = HashMap::new();
1028 communities.insert(0i64, vec!["n1".to_string(), "n2".to_string()]);
1029 let mut labels = HashMap::new();
1030 labels.insert(0i64, "Parsing Layer".to_string());
1031 let dir = tempdir().unwrap();
1032 to_wiki(&g, &communities, dir.path(), Some(&labels), None, None).unwrap();
1033 assert!(dir.path().join("index.md").exists());
1034 }
1035
1036 #[test]
1037 fn test_safe_filename_spaces_to_underscores() {
1038 assert_eq!(safe_filename("Parsing Layer"), "Parsing_Layer");
1039 }
1040
1041 #[test]
1042 fn test_safe_filename_special_chars() {
1043 assert_eq!(safe_filename("a<b>c"), "a_b_c");
1044 }
1045
1046 #[test]
1047 fn test_safe_filename_empty_becomes_unnamed() {
1048 assert_eq!(safe_filename(""), "unnamed");
1049 }
1050}