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    // Pre-compute node → community mapping for frontmatter
25    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    // Collect all edges grouped by source/target for fast lookup
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 = sanitize_filename(&node.label);
46        let filepath = vault_dir.join(format!("{}.md", filename));
47
48        let mut content = String::with_capacity(512);
49
50        // --- YAML frontmatter ---
51        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        // --- Connections ---
66        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
86/// Sanitize a label for use as both a filename and a `[[wikilink]]` target.
87///
88/// Truncates to [`graphify_core::MAX_FILENAME_BYTES`] to avoid "File name too long"
89/// (ENAMETOOLONG / os error 63) on macOS and other systems with a 255-byte limit.
90fn 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}