use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
static ATTACHMENT_DIR_CACHE: LazyLock<Mutex<HashMap<PathBuf, AttachmentResolution>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
pub enum AttachmentResolution {
Fixed(PathBuf),
RelativeToFile(String),
}
#[derive(Debug, Deserialize)]
struct ObsidianAppConfig {
#[serde(default, rename = "attachmentFolderPath")]
attachment_folder_path: String,
}
pub fn find_obsidian_vault(start_path: &Path) -> Option<PathBuf> {
let mut current = if start_path.is_file() {
start_path.parent()?.to_path_buf()
} else {
start_path.to_path_buf()
};
loop {
let obsidian_dir = current.join(".obsidian");
if obsidian_dir.is_dir() {
return current.canonicalize().ok();
}
if !current.pop() {
break;
}
}
None
}
pub fn resolve_attachment_folder(start_path: &Path, file_dir: &Path) -> Option<PathBuf> {
let vault_root = find_obsidian_vault(start_path)?;
if let Ok(cache) = ATTACHMENT_DIR_CACHE.lock()
&& let Some(resolution) = cache.get(&vault_root)
{
return Some(match resolution {
AttachmentResolution::Fixed(path) => path.clone(),
AttachmentResolution::RelativeToFile(subfolder) => {
if subfolder.is_empty() {
file_dir.to_path_buf()
} else {
file_dir.join(subfolder)
}
}
});
}
let app_json_path = vault_root.join(".obsidian").join("app.json");
let attachment_folder_path = if app_json_path.exists() {
std::fs::read_to_string(&app_json_path)
.ok()
.and_then(|content| serde_json::from_str::<ObsidianAppConfig>(&content).ok())
.map(|config| config.attachment_folder_path)
.unwrap_or_default()
} else {
String::new()
};
let resolution = if attachment_folder_path.is_empty() {
AttachmentResolution::Fixed(vault_root.clone())
} else if let Some(relative) = attachment_folder_path.strip_prefix("./") {
AttachmentResolution::RelativeToFile(relative.to_string())
} else {
AttachmentResolution::Fixed(vault_root.join(&attachment_folder_path))
};
let result = match &resolution {
AttachmentResolution::Fixed(path) => path.clone(),
AttachmentResolution::RelativeToFile(subfolder) => {
if subfolder.is_empty() {
file_dir.to_path_buf()
} else {
file_dir.join(subfolder)
}
}
};
if let Ok(mut cache) = ATTACHMENT_DIR_CACHE.lock() {
cache.insert(vault_root, resolution);
}
Some(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_find_obsidian_vault() {
let temp = tempdir().unwrap();
let vault = temp.path().join("my-vault");
fs::create_dir_all(vault.join(".obsidian")).unwrap();
fs::create_dir_all(vault.join("notes/subfolder")).unwrap();
let result = find_obsidian_vault(&vault.join("test.md"));
assert!(result.is_some());
let result = find_obsidian_vault(&vault.join("notes/subfolder/deep.md"));
assert!(result.is_some());
let result = find_obsidian_vault(temp.path());
assert!(result.is_none());
}
#[test]
fn test_resolve_attachment_folder_vault_root() {
let temp = tempdir().unwrap();
let vault = temp.path().join("vault");
fs::create_dir_all(vault.join(".obsidian")).unwrap();
fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
let file_dir = vault.join("notes");
fs::create_dir_all(&file_dir).unwrap();
let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir);
assert!(result.is_some());
let resolved = result.unwrap();
assert_eq!(resolved.canonicalize().unwrap(), vault.canonicalize().unwrap());
}
#[test]
fn test_resolve_attachment_folder_named_folder() {
let temp = tempdir().unwrap();
let vault = temp.path().join("vault2");
fs::create_dir_all(vault.join(".obsidian")).unwrap();
fs::create_dir_all(vault.join("Attachments")).unwrap();
fs::write(
vault.join(".obsidian/app.json"),
r#"{"attachmentFolderPath": "Attachments"}"#,
)
.unwrap();
let file_dir = vault.join("notes");
fs::create_dir_all(&file_dir).unwrap();
let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir);
assert!(result.is_some());
let resolved = result.unwrap();
assert!(resolved.ends_with("Attachments"));
}
#[test]
fn test_resolve_attachment_folder_relative_to_file() {
let temp = tempdir().unwrap();
let vault = temp.path().join("vault3");
fs::create_dir_all(vault.join(".obsidian")).unwrap();
fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
let file_dir = vault.join("notes");
fs::create_dir_all(&file_dir).unwrap();
let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir);
assert!(result.is_some());
assert_eq!(result.unwrap(), file_dir);
}
#[test]
fn test_resolve_attachment_folder_subfolder_under_file() {
let temp = tempdir().unwrap();
let vault = temp.path().join("vault4");
fs::create_dir_all(vault.join(".obsidian")).unwrap();
fs::write(
vault.join(".obsidian/app.json"),
r#"{"attachmentFolderPath": "./assets"}"#,
)
.unwrap();
let file_dir = vault.join("notes");
fs::create_dir_all(&file_dir).unwrap();
let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir);
assert!(result.is_some());
assert!(result.unwrap().ends_with("assets"));
}
#[test]
fn test_resolve_attachment_folder_no_app_json() {
let temp = tempdir().unwrap();
let vault = temp.path().join("vault5");
fs::create_dir_all(vault.join(".obsidian")).unwrap();
let result = resolve_attachment_folder(&vault.join("test.md"), &vault);
assert!(result.is_some());
let resolved = result.unwrap();
assert_eq!(resolved.canonicalize().unwrap(), vault.canonicalize().unwrap());
}
#[test]
fn test_no_vault_returns_none() {
let temp = tempdir().unwrap();
let result = resolve_attachment_folder(&temp.path().join("test.md"), temp.path());
assert!(result.is_none());
}
}