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