Skip to main content

codesynapse_core/
wiki.rs

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); // 2 communities + 1 god node
606    }
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        // stderr warning is tested implicitly — just verify it doesn't crash and drops stale
990        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}