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 file_names = build_unique_filenames(graph);
25
26 let node_community: HashMap<&str, usize> = communities
27 .iter()
28 .flat_map(|(&cid, members)| members.iter().map(move |nid| (nid.as_str(), cid)))
29 .collect();
30
31 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 = file_names
46 .get(&node.id)
47 .map(|s| s.as_str())
48 .unwrap_or_else(|| "unnamed");
49 let filepath = vault_dir.join(format!("{filename}.md"));
50
51 let mut content = String::with_capacity(512);
52
53 content.push_str("---\n");
54 writeln!(content, "id: {}", node.id)?;
55 writeln!(content, "type: {}", node.node_type)?;
56 if !node.source_file.is_empty() {
57 writeln!(content, "source: {}", node.source_file)?;
58 }
59 if let Some(&cid) = node_community.get(node.id.as_str()) {
60 writeln!(content, "community: {cid}")?;
61 if let Some(clabel) = community_labels.get(&cid) {
62 writeln!(content, "community_label: {clabel}")?;
63 }
64 }
65 content.push_str("---\n\n");
66
67 if let Some(neighbours) = edges_for.get(node.id.as_str())
68 && !neighbours.is_empty()
69 {
70 content.push_str("## Connections\n\n");
71 for &(neighbor_id, relation) in neighbours {
72 let fallback = sanitize_filename(neighbor_id);
73 let link_label = file_names
74 .get(neighbor_id)
75 .map(|s| s.as_str())
76 .unwrap_or_else(|| fallback.as_str());
77 writeln!(content, "- [[{link_label}]] ({relation})")?;
78 }
79 }
80
81 fs::write(&filepath, &content)?;
82 }
83
84 info!(path = %vault_dir.display(), "exported Obsidian vault");
85 Ok(vault_dir)
86}
87
88fn build_unique_filenames(graph: &KnowledgeGraph) -> HashMap<String, String> {
89 let mut name_to_ids: HashMap<String, Vec<String>> = HashMap::new();
90 for node in graph.nodes() {
91 let sanitized = sanitize_filename(&node.label);
92 name_to_ids
93 .entry(sanitized)
94 .or_default()
95 .push(node.id.clone());
96 }
97
98 let mut result = HashMap::new();
99 for (sanitized, mut ids) in name_to_ids {
100 if ids.len() == 1 {
101 result.insert(ids.pop().unwrap(), sanitized);
102 } else {
103 for (i, id) in ids.into_iter().enumerate() {
104 result.insert(id, format!("{sanitized}_{i}"));
105 }
106 }
107 }
108 result
109}
110
111fn sanitize_filename(s: &str) -> String {
116 let sanitized: String = s
117 .chars()
118 .map(|c| {
119 if c.is_alphanumeric() || c == '_' || c == '-' || c == ' ' {
120 c
121 } else {
122 '_'
123 }
124 })
125 .collect();
126 graphify_core::truncate_to_bytes(&sanitized, graphify_core::MAX_FILENAME_BYTES).to_string()
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use graphify_core::confidence::Confidence;
133 use graphify_core::graph::KnowledgeGraph;
134 use graphify_core::model::{GraphEdge, GraphNode, NodeType};
135
136 fn sample_graph() -> KnowledgeGraph {
137 let mut kg = KnowledgeGraph::new();
138 for i in 0..3 {
139 kg.add_node(GraphNode {
140 id: format!("n{}", i),
141 label: format!("Node{}", i),
142 source_file: "test.rs".into(),
143 source_location: None,
144 node_type: NodeType::Class,
145 community: Some(0),
146 extra: HashMap::new(),
147 })
148 .unwrap();
149 }
150 kg.add_edge(GraphEdge {
151 source: "n0".into(),
152 target: "n1".into(),
153 relation: "calls".into(),
154 confidence: Confidence::Extracted,
155 confidence_score: 1.0,
156 source_file: "test.rs".into(),
157 source_location: None,
158 weight: 1.0,
159 extra: HashMap::new(),
160 })
161 .unwrap();
162 kg.add_edge(GraphEdge {
163 source: "n0".into(),
164 target: "n2".into(),
165 relation: "imports".into(),
166 confidence: Confidence::Extracted,
167 confidence_score: 1.0,
168 source_file: "test.rs".into(),
169 source_location: None,
170 weight: 1.0,
171 extra: HashMap::new(),
172 })
173 .unwrap();
174 kg
175 }
176
177 #[test]
178 fn export_obsidian_creates_files() {
179 let dir = tempfile::tempdir().unwrap();
180 let kg = sample_graph();
181 let communities: HashMap<usize, Vec<String>> =
182 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
183 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
184
185 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
186 assert!(vault.join("Node0.md").exists());
187 assert!(vault.join("Node1.md").exists());
188 assert!(vault.join("Node2.md").exists());
189 }
190
191 #[test]
192 fn obsidian_file_contains_wikilinks() {
193 let dir = tempfile::tempdir().unwrap();
194 let kg = sample_graph();
195 let communities: HashMap<usize, Vec<String>> =
196 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
197 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
198
199 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
200 let content = std::fs::read_to_string(vault.join("Node0.md")).unwrap();
201 assert!(content.contains("[[Node1]]"), "missing wikilink to Node1");
202 assert!(content.contains("[[Node2]]"), "missing wikilink to Node2");
203 assert!(content.contains("## Connections"));
204 }
205
206 #[test]
207 fn obsidian_frontmatter_has_community() {
208 let dir = tempfile::tempdir().unwrap();
209 let kg = sample_graph();
210 let communities: HashMap<usize, Vec<String>> =
211 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
212 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
213
214 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
215 let content = std::fs::read_to_string(vault.join("Node0.md")).unwrap();
216 assert!(content.contains("community: 0"));
217 assert!(content.contains("community_label: Core"));
218 }
219
220 #[test]
221 fn sanitize_filename_works() {
222 assert_eq!(sanitize_filename("my.class"), "my_class");
223 assert_eq!(sanitize_filename("hello world"), "hello world");
224 assert_eq!(sanitize_filename("a/b\\c"), "a_b_c");
225 }
226}