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 {
91 let sanitized: String = s
92 .chars()
93 .map(|c| {
94 if c.is_alphanumeric() || c == '_' || c == '-' || c == ' ' {
95 c
96 } else {
97 '_'
98 }
99 })
100 .collect();
101 graphify_core::truncate_to_bytes(&sanitized, graphify_core::MAX_FILENAME_BYTES).to_string()
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use graphify_core::confidence::Confidence;
108 use graphify_core::graph::KnowledgeGraph;
109 use graphify_core::model::{GraphEdge, GraphNode, NodeType};
110
111 fn sample_graph() -> KnowledgeGraph {
112 let mut kg = KnowledgeGraph::new();
113 for i in 0..3 {
114 kg.add_node(GraphNode {
115 id: format!("n{}", i),
116 label: format!("Node{}", i),
117 source_file: "test.rs".into(),
118 source_location: None,
119 node_type: NodeType::Class,
120 community: Some(0),
121 extra: HashMap::new(),
122 })
123 .unwrap();
124 }
125 kg.add_edge(GraphEdge {
126 source: "n0".into(),
127 target: "n1".into(),
128 relation: "calls".into(),
129 confidence: Confidence::Extracted,
130 confidence_score: 1.0,
131 source_file: "test.rs".into(),
132 source_location: None,
133 weight: 1.0,
134 extra: HashMap::new(),
135 })
136 .unwrap();
137 kg.add_edge(GraphEdge {
138 source: "n0".into(),
139 target: "n2".into(),
140 relation: "imports".into(),
141 confidence: Confidence::Extracted,
142 confidence_score: 1.0,
143 source_file: "test.rs".into(),
144 source_location: None,
145 weight: 1.0,
146 extra: HashMap::new(),
147 })
148 .unwrap();
149 kg
150 }
151
152 #[test]
153 fn export_obsidian_creates_files() {
154 let dir = tempfile::tempdir().unwrap();
155 let kg = sample_graph();
156 let communities: HashMap<usize, Vec<String>> =
157 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
158 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
159
160 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
161 assert!(vault.join("Node0.md").exists());
162 assert!(vault.join("Node1.md").exists());
163 assert!(vault.join("Node2.md").exists());
164 }
165
166 #[test]
167 fn obsidian_file_contains_wikilinks() {
168 let dir = tempfile::tempdir().unwrap();
169 let kg = sample_graph();
170 let communities: HashMap<usize, Vec<String>> =
171 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
172 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
173
174 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
175 let content = std::fs::read_to_string(vault.join("Node0.md")).unwrap();
176 assert!(content.contains("[[Node1]]"), "missing wikilink to Node1");
177 assert!(content.contains("[[Node2]]"), "missing wikilink to Node2");
178 assert!(content.contains("## Connections"));
179 }
180
181 #[test]
182 fn obsidian_frontmatter_has_community() {
183 let dir = tempfile::tempdir().unwrap();
184 let kg = sample_graph();
185 let communities: HashMap<usize, Vec<String>> =
186 [(0, vec!["n0".into(), "n1".into(), "n2".into()])].into();
187 let labels: HashMap<usize, String> = [(0, "Core".into())].into();
188
189 let vault = export_obsidian(&kg, &communities, &labels, dir.path()).unwrap();
190 let content = std::fs::read_to_string(vault.join("Node0.md")).unwrap();
191 assert!(content.contains("community: 0"));
192 assert!(content.contains("community_label: Core"));
193 }
194
195 #[test]
196 fn sanitize_filename_works() {
197 assert_eq!(sanitize_filename("my.class"), "my_class");
198 assert_eq!(sanitize_filename("hello world"), "hello world");
199 assert_eq!(sanitize_filename("a/b\\c"), "a_b_c");
200 }
201}