Skip to main content

graphify_export/
obsidian.rs

1//! Obsidian vault export — one `.md` file per node with `[[wikilinks]]`.
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 graph as an Obsidian vault (folder of `.md` files with `[[wikilinks]]`).
12///
13/// Each node becomes a markdown file with YAML frontmatter and a **Connections**
14/// section listing all neighbours as `[[wikilinks]]`.
15pub 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
111/// Sanitize a label for use as both a filename and a `[[wikilink]]` target.
112///
113/// Truncates to [`graphify_core::MAX_FILENAME_BYTES`] to avoid "File name too long"
114/// (ENAMETOOLONG / os error 63) on macOS and other systems with a 255-byte limit.
115fn 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}