Skip to main content

rumdl_lib/utils/
obsidian_config.rs

1//! Obsidian vault configuration utilities.
2//!
3//! Provides discovery and parsing of `.obsidian/app.json` files,
4//! with caching for efficient repeated lookups.
5//!
6//! Mirrors the pattern used by `mkdocs_config.rs` for MkDocs projects.
7
8use serde::Deserialize;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{LazyLock, Mutex};
12
13/// Cache: canonicalized vault root -> resolved attachment folder (absolute)
14static ATTACHMENT_DIR_CACHE: LazyLock<Mutex<HashMap<PathBuf, AttachmentResolution>>> =
15    LazyLock::new(|| Mutex::new(HashMap::new()));
16
17/// Result of resolving the Obsidian attachment folder configuration.
18#[derive(Debug, Clone)]
19pub enum AttachmentResolution {
20    /// Absolute path to a fixed attachment folder (vault root or named folder)
21    Fixed(PathBuf),
22    /// Relative to each file's directory (`./ ` prefix in config)
23    RelativeToFile(String),
24}
25
26/// Minimal `.obsidian/app.json` structure for extracting attachment settings.
27#[derive(Debug, Deserialize)]
28struct ObsidianAppConfig {
29    #[serde(default, rename = "attachmentFolderPath")]
30    attachment_folder_path: String,
31}
32
33/// Find an Obsidian vault root by walking up from `start_path`.
34///
35/// Returns the vault root directory (parent of `.obsidian/`), or None if not found.
36pub fn find_obsidian_vault(start_path: &Path) -> Option<PathBuf> {
37    let mut current = if start_path.is_file() {
38        start_path.parent()?.to_path_buf()
39    } else {
40        start_path.to_path_buf()
41    };
42
43    loop {
44        let obsidian_dir = current.join(".obsidian");
45        if obsidian_dir.is_dir() {
46            return current.canonicalize().ok();
47        }
48
49        if !current.pop() {
50            break;
51        }
52    }
53
54    None
55}
56
57/// Resolve the attachment folder for a given file in an Obsidian vault.
58///
59/// Reads `.obsidian/app.json` to determine the `attachmentFolderPath` setting:
60/// - `""` (empty/absent): vault root
61/// - `"FolderName"`: `<vault-root>/FolderName/`
62/// - `"./"`: same folder as current file
63/// - `"./subfolder"`: `<file-dir>/subfolder/`
64///
65/// Results are cached by vault root path.
66///
67/// `start_path` should be the markdown file being checked or its parent directory.
68/// `file_dir` is the directory containing the file being checked (for `./` resolution).
69///
70/// Returns the absolute path to the attachment folder, or None if no vault is found.
71pub fn resolve_attachment_folder(start_path: &Path, file_dir: &Path) -> Option<PathBuf> {
72    let vault_root = find_obsidian_vault(start_path)?;
73
74    // Check cache first
75    if let Ok(cache) = ATTACHMENT_DIR_CACHE.lock()
76        && let Some(resolution) = cache.get(&vault_root)
77    {
78        return Some(match resolution {
79            AttachmentResolution::Fixed(path) => path.clone(),
80            AttachmentResolution::RelativeToFile(subfolder) => {
81                if subfolder.is_empty() {
82                    file_dir.to_path_buf()
83                } else {
84                    file_dir.join(subfolder)
85                }
86            }
87        });
88    }
89
90    // Parse .obsidian/app.json
91    let app_json_path = vault_root.join(".obsidian").join("app.json");
92    let attachment_folder_path = if app_json_path.exists() {
93        std::fs::read_to_string(&app_json_path)
94            .ok()
95            .and_then(|content| serde_json::from_str::<ObsidianAppConfig>(&content).ok())
96            .map(|config| config.attachment_folder_path)
97            .unwrap_or_default()
98    } else {
99        String::new()
100    };
101
102    // Resolve and cache
103    let resolution = if attachment_folder_path.is_empty() {
104        AttachmentResolution::Fixed(vault_root.clone())
105    } else if let Some(relative) = attachment_folder_path.strip_prefix("./") {
106        AttachmentResolution::RelativeToFile(relative.to_string())
107    } else {
108        AttachmentResolution::Fixed(vault_root.join(&attachment_folder_path))
109    };
110
111    let result = match &resolution {
112        AttachmentResolution::Fixed(path) => path.clone(),
113        AttachmentResolution::RelativeToFile(subfolder) => {
114            if subfolder.is_empty() {
115                file_dir.to_path_buf()
116            } else {
117                file_dir.join(subfolder)
118            }
119        }
120    };
121
122    if let Ok(mut cache) = ATTACHMENT_DIR_CACHE.lock() {
123        cache.insert(vault_root, resolution);
124    }
125
126    Some(result)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::fs;
133    use tempfile::tempdir;
134
135    #[test]
136    fn test_find_obsidian_vault() {
137        let temp = tempdir().unwrap();
138        let vault = temp.path().join("my-vault");
139        fs::create_dir_all(vault.join(".obsidian")).unwrap();
140        fs::create_dir_all(vault.join("notes/subfolder")).unwrap();
141
142        // From a file in the vault root
143        let result = find_obsidian_vault(&vault.join("test.md"));
144        assert!(result.is_some());
145
146        // From a nested subfolder
147        let result = find_obsidian_vault(&vault.join("notes/subfolder/deep.md"));
148        assert!(result.is_some());
149
150        // From outside the vault
151        let result = find_obsidian_vault(temp.path());
152        assert!(result.is_none());
153    }
154
155    #[test]
156    fn test_resolve_attachment_folder_vault_root() {
157        let temp = tempdir().unwrap();
158        let vault = temp.path().join("vault");
159        fs::create_dir_all(vault.join(".obsidian")).unwrap();
160        fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
161
162        let file_dir = vault.join("notes");
163        fs::create_dir_all(&file_dir).unwrap();
164
165        let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir);
166        assert!(result.is_some());
167        let resolved = result.unwrap();
168        assert_eq!(resolved.canonicalize().unwrap(), vault.canonicalize().unwrap());
169    }
170
171    #[test]
172    fn test_resolve_attachment_folder_named_folder() {
173        let temp = tempdir().unwrap();
174        let vault = temp.path().join("vault2");
175        fs::create_dir_all(vault.join(".obsidian")).unwrap();
176        fs::create_dir_all(vault.join("Attachments")).unwrap();
177        fs::write(
178            vault.join(".obsidian/app.json"),
179            r#"{"attachmentFolderPath": "Attachments"}"#,
180        )
181        .unwrap();
182
183        let file_dir = vault.join("notes");
184        fs::create_dir_all(&file_dir).unwrap();
185
186        let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir);
187        assert!(result.is_some());
188        let resolved = result.unwrap();
189        assert!(resolved.ends_with("Attachments"));
190    }
191
192    #[test]
193    fn test_resolve_attachment_folder_relative_to_file() {
194        let temp = tempdir().unwrap();
195        let vault = temp.path().join("vault3");
196        fs::create_dir_all(vault.join(".obsidian")).unwrap();
197        fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
198
199        let file_dir = vault.join("notes");
200        fs::create_dir_all(&file_dir).unwrap();
201
202        let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir);
203        assert!(result.is_some());
204        assert_eq!(result.unwrap(), file_dir);
205    }
206
207    #[test]
208    fn test_resolve_attachment_folder_subfolder_under_file() {
209        let temp = tempdir().unwrap();
210        let vault = temp.path().join("vault4");
211        fs::create_dir_all(vault.join(".obsidian")).unwrap();
212        fs::write(
213            vault.join(".obsidian/app.json"),
214            r#"{"attachmentFolderPath": "./assets"}"#,
215        )
216        .unwrap();
217
218        let file_dir = vault.join("notes");
219        fs::create_dir_all(&file_dir).unwrap();
220
221        let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir);
222        assert!(result.is_some());
223        assert!(result.unwrap().ends_with("assets"));
224    }
225
226    #[test]
227    fn test_resolve_attachment_folder_no_app_json() {
228        let temp = tempdir().unwrap();
229        let vault = temp.path().join("vault5");
230        fs::create_dir_all(vault.join(".obsidian")).unwrap();
231        // No app.json - should default to vault root
232
233        let result = resolve_attachment_folder(&vault.join("test.md"), &vault);
234        assert!(result.is_some());
235        let resolved = result.unwrap();
236        assert_eq!(resolved.canonicalize().unwrap(), vault.canonicalize().unwrap());
237    }
238
239    #[test]
240    fn test_no_vault_returns_none() {
241        let temp = tempdir().unwrap();
242        let result = resolve_attachment_folder(&temp.path().join("test.md"), temp.path());
243        assert!(result.is_none());
244    }
245}