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_obsidian(
16 graph: &KnowledgeGraph,
17 communities: &HashMap<usize, Vec<String>>,
18 community_labels: &HashMap<usize, String>,
19 output_dir: &Path,
20) -> anyhow::Result<PathBuf> {
21 let vault_dir = output_dir.join("obsidian");
22 fs::create_dir_all(&vault_dir)?;
23
24 let node_community: HashMap<&str, usize> = communities
26 .iter()
27 .flat_map(|(&cid, members)| members.iter().map(move |nid| (nid.as_str(), cid)))
28 .collect();
29
30 let all_edges = graph.edges();
32 let mut edges_for: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
33 for edge in &all_edges {
34 edges_for
35 .entry(edge.source.as_str())
36 .or_default()
37 .push((edge.target.as_str(), edge.relation.as_str()));
38 edges_for
39 .entry(edge.target.as_str())
40 .or_default()
41 .push((edge.source.as_str(), edge.relation.as_str()));
42 }
43
44 for node in graph.nodes() {
45 let filename = sanitize_filename(&node.label);
46 let filepath = vault_dir.join(format!("{}.md", filename));
47
48 let mut content = String::with_capacity(512);
49
50 content.push_str("---\n");
52 writeln!(content, "id: {}", node.id).unwrap();
53 writeln!(content, "type: {:?}", node.node_type).unwrap();
54 if !node.source_file.is_empty() {
55 writeln!(content, "source: {}", node.source_file).unwrap();
56 }
57 if let Some(&cid) = node_community.get(node.id.as_str()) {
58 writeln!(content, "community: {}", cid).unwrap();
59 if let Some(clabel) = community_labels.get(&cid) {
60 writeln!(content, "community_label: {}", clabel).unwrap();
61 }
62 }
63 content.push_str("---\n\n");
64
65 if let Some(neighbours) = edges_for.get(node.id.as_str())
67 && !neighbours.is_empty()
68 {
69 content.push_str("## Connections\n\n");
70 for &(neighbor_id, relation) in neighbours {
71 let link_label = graph
72 .get_node(neighbor_id)
73 .map(|n| sanitize_filename(&n.label))
74 .unwrap_or_else(|| sanitize_filename(neighbor_id));
75 writeln!(content, "- [[{}]] ({})", link_label, relation).unwrap();
76 }
77 }
78
79 fs::write(&filepath, &content)?;
80 }
81
82 info!(path = %vault_dir.display(), "exported Obsidian vault");
83 Ok(vault_dir)
84}
85
86fn sanitize_filename(s: &str) -> String {
88 s.chars()
89 .map(|c| {
90 if c.is_alphanumeric() || c == '_' || c == '-' || c == ' ' {
91 c
92 } else {
93 '_'
94 }
95 })
96 .collect()
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use graphify_core::confidence::Confidence;
103 use graphify_core::graph::KnowledgeGraph;
104 use graphify_core::model::{GraphEdge, GraphNode, NodeType};
105
106 fn sample_graph() -> KnowledgeGraph {
107 let mut kg = KnowledgeGraph::new();
108 for i in 0..3 {
109 kg.add_node(GraphNode {
110 id: format!("n{}", i),
111 label: format!("Node{}", i),
112 source_file: "test.rs".into(),
113 source_location: None,
114 node_type: NodeType::Class,
115 community: Some(0),
116 extra: HashMap::new(),
117 })
118 .unwrap();
119 }
120 kg.add_edge(GraphEdge {
121 source: "n0".into(),
122 target: "n1".into(),
123 relation: "calls".into(),
124 confidence: Confidence::Extracted,
125 confidence_score: 1.0,
126 source_file: "test.rs".into(),
127 source_location: None,
128 weight: 1.0,
129 extra: HashMap::new(),
130 })
131 .unwrap();
132 kg.add_edge(GraphEdge {
133 source: "n0".into(),
134 target: "n2".into(),
135 relation: "imports".into(),
136 confidence: Confidence::Extracted,
137 confidence_score: 1.0,
138 source_file: "test.rs".into(),
139 source_location: None,
140 weight: 1.0,
141 extra: HashMap::new(),
142 })
143 .unwrap();
144 kg
145 }
146
147 #[test]
148 fn export_obsidian_creates_files() {
149 let dir = tempfile::tempdir().unwrap();
150 let kg = sample_graph();
151 let communities: HashMap<usize, Vec<String>> =
152 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
153 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
154
155 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
156 assert!(vault.join("Node0.md").exists());
157 assert!(vault.join("Node1.md").exists());
158 assert!(vault.join("Node2.md").exists());
159 }
160
161 #[test]
162 fn obsidian_file_contains_wikilinks() {
163 let dir = tempfile::tempdir().unwrap();
164 let kg = sample_graph();
165 let communities: HashMap<usize, Vec<String>> =
166 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
167 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
168
169 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
170 let content = std::fs::read_to_string(vault.join("Node0.md")).unwrap();
171 assert!(content.contains("[[Node1]]"), "missing wikilink to Node1");
172 assert!(content.contains("[[Node2]]"), "missing wikilink to Node2");
173 assert!(content.contains("## Connections"));
174 }
175
176 #[test]
177 fn obsidian_frontmatter_has_community() {
178 let dir = tempfile::tempdir().unwrap();
179 let kg = sample_graph();
180 let communities: HashMap<usize, Vec<String>> =
181 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
182 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
183
184 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
185 let content = std::fs::read_to_string(vault.join("Node0.md")).unwrap();
186 assert!(content.contains("community: 0"));
187 assert!(content.contains("community_label: Core"));
188 }
189
190 #[test]
191 fn sanitize_filename_works() {
192 assert_eq!(sanitize_filename("my.class"), "my_class");
193 assert_eq!(sanitize_filename("hello world"), "hello world");
194 assert_eq!(sanitize_filename("a/b\\c"), "a_b_c");
195 }
196}