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.
87fn 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}