Skip to main content

graphify_export/
wiki.rs

1//! Wikipedia-style markdown export.
2
3use std::collections::HashMap;
4use std::fmt::Write;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use graphify_core::graph::KnowledgeGraph;
9use tracing::info;
10
11/// Export wiki-style markdown documentation.
12///
13/// Generates a `wiki/` directory with:
14/// - `index.md` — table of contents
15/// - One `.md` file per community
16/// - One `.md` file per god node (nodes with degree > average * 2)
17pub fn export_wiki(
18    graph: &KnowledgeGraph,
19    communities: &HashMap<usize, Vec<String>>,
20    community_labels: &HashMap<usize, String>,
21    output_dir: &Path,
22) -> anyhow::Result<PathBuf> {
23    let wiki_dir = output_dir.join("wiki");
24    fs::create_dir_all(&wiki_dir)?;
25
26    // --- index.md ---
27    let mut index = String::with_capacity(2048);
28    writeln!(index, "# Knowledge Graph Wiki").unwrap();
29    writeln!(index).unwrap();
30    writeln!(index, "## Communities").unwrap();
31    writeln!(index).unwrap();
32
33    let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
34    sorted_cids.sort();
35
36    for &cid in &sorted_cids {
37        let members = &communities[&cid];
38        let label = community_labels
39            .get(&cid)
40            .map(|s| s.as_str())
41            .unwrap_or("Unnamed");
42        let filename = community_filename(cid);
43        writeln!(
44            index,
45            "- [[{}|{}]] ({} nodes)",
46            filename.trim_end_matches(".md"),
47            label,
48            members.len()
49        )
50        .unwrap();
51    }
52    writeln!(index).unwrap();
53
54    // Find god nodes: degree > average * 2
55    let degrees: Vec<(String, usize)> = graph
56        .nodes()
57        .iter()
58        .map(|n| (n.id.clone(), graph.degree(&n.id)))
59        .collect();
60    let avg_degree = if degrees.is_empty() {
61        0.0
62    } else {
63        degrees.iter().map(|(_, d)| *d).sum::<usize>() as f64 / degrees.len() as f64
64    };
65    let threshold = (avg_degree * 2.0).max(3.0) as usize;
66    let god_nodes: Vec<&(String, usize)> =
67        degrees.iter().filter(|(_, d)| *d >= threshold).collect();
68
69    if !god_nodes.is_empty() {
70        writeln!(index, "## Key Entities").unwrap();
71        writeln!(index).unwrap();
72        for (nid, degree) in &god_nodes {
73            let node = graph.get_node(nid);
74            let label = node.map(|n| n.label.as_str()).unwrap_or(nid.as_str());
75            let filename = node_filename(nid);
76            writeln!(
77                index,
78                "- [[{}|{}]] (degree: {})",
79                filename.trim_end_matches(".md"),
80                label,
81                degree
82            )
83            .unwrap();
84        }
85        writeln!(index).unwrap();
86    }
87
88    fs::write(wiki_dir.join("index.md"), &index)?;
89
90    // --- Community pages ---
91    for &cid in &sorted_cids {
92        let members = &communities[&cid];
93        let label = community_labels
94            .get(&cid)
95            .map(|s| s.as_str())
96            .unwrap_or("Unnamed");
97        let mut page = String::with_capacity(1024);
98        writeln!(page, "# Community {}: {}", cid, label).unwrap();
99        writeln!(page).unwrap();
100        writeln!(page, "**Members:** {}", members.len()).unwrap();
101        writeln!(page).unwrap();
102
103        writeln!(page, "## Nodes").unwrap();
104        writeln!(page).unwrap();
105        for nid in members {
106            let node = graph.get_node(nid);
107            let node_label = node.map(|n| n.label.as_str()).unwrap_or(nid.as_str());
108            let node_type = node
109                .map(|n| format!("{:?}", n.node_type))
110                .unwrap_or_default();
111            let degree = graph.degree(nid);
112            writeln!(
113                page,
114                "- **{}** (`{}`, {}, degree: {})",
115                node_label, nid, node_type, degree
116            )
117            .unwrap();
118        }
119        writeln!(page).unwrap();
120
121        // Internal edges
122        let member_set: std::collections::HashSet<&str> =
123            members.iter().map(|s| s.as_str()).collect();
124        let all_edges = graph.edges();
125        let internal_edges: Vec<_> = all_edges
126            .iter()
127            .filter(|e| {
128                member_set.contains(e.source.as_str()) && member_set.contains(e.target.as_str())
129            })
130            .collect();
131
132        if !internal_edges.is_empty() {
133            writeln!(page, "## Relationships").unwrap();
134            writeln!(page).unwrap();
135            for edge in &internal_edges {
136                writeln!(
137                    page,
138                    "- {} → {} ({})",
139                    edge.source, edge.target, edge.relation
140                )
141                .unwrap();
142            }
143            writeln!(page).unwrap();
144        }
145
146        fs::write(wiki_dir.join(community_filename(cid)), &page)?;
147    }
148
149    // --- God node pages ---
150    for (nid, _) in &god_nodes {
151        let node = match graph.get_node(nid) {
152            Some(n) => n,
153            None => continue,
154        };
155        let mut page = String::with_capacity(512);
156        writeln!(page, "# {}", node.label).unwrap();
157        writeln!(page).unwrap();
158        writeln!(page, "- **ID:** `{}`", node.id).unwrap();
159        writeln!(page, "- **Type:** {:?}", node.node_type).unwrap();
160        writeln!(page, "- **File:** `{}`", node.source_file).unwrap();
161        if let Some(loc) = &node.source_location {
162            writeln!(page, "- **Location:** {}", loc).unwrap();
163        }
164        if let Some(c) = node.community {
165            let clabel = community_labels.get(&c).map(|s| s.as_str()).unwrap_or("?");
166            writeln!(page, "- **Community:** {} ({})", c, clabel).unwrap();
167        }
168        writeln!(page).unwrap();
169
170        // Related edges
171        let all_edges = graph.edges();
172        let related: Vec<_> = all_edges
173            .iter()
174            .filter(|e| e.source.as_str() == nid.as_str() || e.target.as_str() == nid.as_str())
175            .collect();
176        if !related.is_empty() {
177            writeln!(page, "## Relationships").unwrap();
178            writeln!(page).unwrap();
179            for edge in &related {
180                writeln!(
181                    page,
182                    "- {} → {} ({}, {:?})",
183                    edge.source, edge.target, edge.relation, edge.confidence
184                )
185                .unwrap();
186            }
187            writeln!(page).unwrap();
188        }
189
190        fs::write(wiki_dir.join(node_filename(nid)), &page)?;
191    }
192
193    info!(path = %wiki_dir.display(), "exported wiki documentation");
194    Ok(wiki_dir)
195}
196
197fn community_filename(cid: usize) -> String {
198    format!("community_{}.md", cid)
199}
200
201fn node_filename(id: &str) -> String {
202    let safe: String = id
203        .chars()
204        .map(|c| {
205            if c.is_alphanumeric() || c == '_' || c == '-' {
206                c
207            } else {
208                '_'
209            }
210        })
211        .collect();
212    let truncated = graphify_core::truncate_to_bytes(&safe, graphify_core::MAX_FILENAME_BYTES);
213    format!("{}.md", truncated)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use graphify_core::confidence::Confidence;
220    use graphify_core::graph::KnowledgeGraph;
221    use graphify_core::model::{GraphEdge, GraphNode, NodeType};
222
223    fn sample_graph() -> KnowledgeGraph {
224        let mut kg = KnowledgeGraph::new();
225        for i in 0..5 {
226            kg.add_node(GraphNode {
227                id: format!("n{}", i),
228                label: format!("Node{}", i),
229                source_file: "test.rs".into(),
230                source_location: None,
231                node_type: NodeType::Class,
232                community: Some(0),
233                extra: HashMap::new(),
234            })
235            .unwrap();
236        }
237        // Make n0 a hub
238        for i in 1..5 {
239            kg.add_edge(GraphEdge {
240                source: "n0".into(),
241                target: format!("n{}", i),
242                relation: "calls".into(),
243                confidence: Confidence::Extracted,
244                confidence_score: 1.0,
245                source_file: "test.rs".into(),
246                source_location: None,
247                weight: 1.0,
248                extra: HashMap::new(),
249            })
250            .unwrap();
251        }
252        kg
253    }
254
255    #[test]
256    fn export_wiki_creates_index() {
257        let dir = tempfile::tempdir().unwrap();
258        let kg = sample_graph();
259        let communities: HashMap<usize, Vec<String>> = [(
260            0,
261            vec![
262                "n0".into(),
263                "n1".into(),
264                "n2".into(),
265                "n3".into(),
266                "n4".into(),
267            ],
268        )]
269        .into();
270        let labels: HashMap<usize, String> = [(0, "Core".into())].into();
271
272        let wiki_dir = export_wiki(&kg, &communities, &labels, dir.path()).unwrap();
273        assert!(wiki_dir.join("index.md").exists());
274        assert!(wiki_dir.join("community_0.md").exists());
275    }
276
277    #[test]
278    fn node_filename_sanitizes() {
279        assert_eq!(node_filename("my.class"), "my_class.md");
280        assert_eq!(node_filename("ok_name"), "ok_name.md");
281    }
282}