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    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}