rumdl_lib/utils/
obsidian_config.rs1use serde::Deserialize;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{LazyLock, Mutex};
12
13static ATTACHMENT_DIR_CACHE: LazyLock<Mutex<HashMap<PathBuf, AttachmentResolution>>> =
15 LazyLock::new(|| Mutex::new(HashMap::new()));
16
17#[derive(Debug, Clone)]
19pub enum AttachmentResolution {
20 Fixed(PathBuf),
22 RelativeToFile(String),
24}
25
26#[derive(Debug, Deserialize)]
28struct ObsidianAppConfig {
29 #[serde(default, rename = "attachmentFolderPath")]
30 attachment_folder_path: String,
31}
32
33pub 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
57pub fn resolve_attachment_folder(start_path: &Path, file_dir: &Path) -> Option<PathBuf> {
72 let vault_root = find_obsidian_vault(start_path)?;
73
74 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 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 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 let result = find_obsidian_vault(&vault.join("test.md"));
144 assert!(result.is_some());
145
146 let result = find_obsidian_vault(&vault.join("notes/subfolder/deep.md"));
148 assert!(result.is_some());
149
150 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 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}