1use 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
11pub 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 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 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 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 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 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 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 format!("{}.md", safe)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use graphify_core::confidence::Confidence;
219 use graphify_core::graph::KnowledgeGraph;
220 use graphify_core::model::{GraphEdge, GraphNode, NodeType};
221
222 fn sample_graph() -> KnowledgeGraph {
223 let mut kg = KnowledgeGraph::new();
224 for i in 0..5 {
225 kg.add_node(GraphNode {
226 id: format!("n{}", i),
227 label: format!("Node{}", i),
228 source_file: "test.rs".into(),
229 source_location: None,
230 node_type: NodeType::Class,
231 community: Some(0),
232 extra: HashMap::new(),
233 })
234 .unwrap();
235 }
236 for i in 1..5 {
238 kg.add_edge(GraphEdge {
239 source: "n0".into(),
240 target: format!("n{}", i),
241 relation: "calls".into(),
242 confidence: Confidence::Extracted,
243 confidence_score: 1.0,
244 source_file: "test.rs".into(),
245 source_location: None,
246 weight: 1.0,
247 extra: HashMap::new(),
248 })
249 .unwrap();
250 }
251 kg
252 }
253
254 #[test]
255 fn export_wiki_creates_index() {
256 let dir = tempfile::tempdir().unwrap();
257 let kg = sample_graph();
258 let communities: HashMap<usize, Vec<String>> = [(
259 0,
260 vec![
261 "n0".into(),
262 "n1".into(),
263 "n2".into(),
264 "n3".into(),
265 "n4".into(),
266 ],
267 )]
268 .into();
269 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
270
271 let wiki_dir = export_wiki(&kg, &communities, &labels, dir.path()).unwrap();
272 assert!(wiki_dir.join("index.md").exists());
273 assert!(wiki_dir.join("community_0.md").exists());
274 }
275
276 #[test]
277 fn node_filename_sanitizes() {
278 assert_eq!(node_filename("my.class"), "my_class.md");
279 assert_eq!(node_filename("ok_name"), "ok_name.md");
280 }
281}