use crate::config::Config;
use crate::error::VaultError;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CloudProvider {
ICloud,
Dropbox,
OneDrive,
GoogleDrive,
}
impl std::fmt::Display for CloudProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CloudProvider::ICloud => write!(f, "iCloud"),
CloudProvider::Dropbox => write!(f, "Dropbox"),
CloudProvider::OneDrive => write!(f, "OneDrive"),
CloudProvider::GoogleDrive => write!(f, "Google Drive"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VaultStrategy {
Symlink,
Copy,
Direct,
}
impl std::fmt::Display for VaultStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VaultStrategy::Symlink => write!(f, "symlink"),
VaultStrategy::Copy => write!(f, "copy"),
VaultStrategy::Direct => write!(f, "direct"),
}
}
}
#[derive(Debug)]
pub enum VaultStatus {
NotConfigured,
Healthy { strategy: String, path: PathBuf },
BrokenSymlink { link_path: PathBuf, target: PathBuf },
PermissionDenied { path: PathBuf },
MissingVaultDir { path: PathBuf },
}
#[derive(Debug, Clone)]
pub struct DetectedVault {
pub path: PathBuf,
pub kind: String,
pub cloud: Option<CloudProvider>,
pub tcc_protected: bool,
}
fn home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"))
}
pub fn is_tcc_protected(path: &Path) -> bool {
let home = home_dir();
let home_real = home.canonicalize().unwrap_or_else(|_| home.clone());
let names = ["Documents", "Desktop", "Downloads"];
let mut protected: Vec<PathBuf> = names.iter().map(|n| home_real.join(n)).collect();
if home_real != home {
protected.extend(names.iter().map(|n| home.join(n)));
}
let normalized = if let Ok(c) = path.canonicalize() {
c
} else if let Some(parent) = path.parent() {
parent
.canonicalize()
.map(|p| p.join(path.file_name().unwrap_or_default()))
.unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
protected.iter().any(|dir| {
if normalized.starts_with(dir) {
return true;
}
dir.canonicalize()
.map(|d| normalized.starts_with(&d))
.unwrap_or(false)
})
}
pub fn is_cloud_synced(path: &Path) -> Option<CloudProvider> {
let path_str = path.to_string_lossy();
if path_str.contains("Mobile Documents") || path_str.contains("com~apple~CloudDocs") {
return Some(CloudProvider::ICloud);
}
#[cfg(target_os = "macos")]
if is_tcc_protected(path) {
let mobile_docs = home_dir().join("Library/Mobile Documents");
if mobile_docs.exists() {
let icloud_docs = mobile_docs.join("com~apple~CloudDocs/Documents");
if icloud_docs.exists() {
let home = home_dir();
if path.starts_with(home.join("Documents"))
|| path
.canonicalize()
.ok()
.map(|c| c.starts_with(home.join("Documents")))
.unwrap_or(false)
{
return Some(CloudProvider::ICloud);
}
}
}
}
if path_str.contains("Dropbox") {
return Some(CloudProvider::Dropbox);
}
if path_str.contains("OneDrive") {
return Some(CloudProvider::OneDrive);
}
if path_str.contains("Google Drive") || path_str.contains("GoogleDrive") {
return Some(CloudProvider::GoogleDrive);
}
None
}
pub fn recommend_strategy(vault_path: &Path) -> VaultStrategy {
if is_cloud_synced(vault_path).is_some() {
return VaultStrategy::Copy;
}
if is_tcc_protected(vault_path) {
return VaultStrategy::Copy;
}
VaultStrategy::Symlink
}
pub fn detect_vaults() -> Vec<DetectedVault> {
let mut vaults = Vec::new();
let home = home_dir();
let scan_dirs: Vec<PathBuf> = vec![
home.join("Documents"),
home.join("Obsidian"),
home.join("notes"),
home.join("vault"),
home.join("vaults"),
];
let home_children: Vec<PathBuf> = fs::read_dir(&home)
.ok()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.map(|e| e.path())
.collect()
})
.unwrap_or_default();
let all_dirs: Vec<PathBuf> = scan_dirs
.into_iter()
.chain(home_children)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
for dir in &all_dirs {
check_vault_at(dir, &mut vaults);
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
check_vault_at(&entry.path(), &mut vaults);
}
}
}
}
vaults.sort_by(|a, b| a.path.cmp(&b.path));
vaults.dedup_by(|a, b| a.path == b.path);
vaults
}
fn check_vault_at(dir: &Path, vaults: &mut Vec<DetectedVault>) {
let markers = [
(".obsidian", "obsidian"),
(".logseq", "logseq"),
(".foam", "foam"),
];
for (marker, kind) in &markers {
if dir.join(marker).is_dir() {
vaults.push(DetectedVault {
path: dir.to_path_buf(),
kind: kind.to_string(),
cloud: is_cloud_synced(dir),
tcc_protected: is_tcc_protected(dir),
});
return; }
}
}
pub fn create_symlink(link_path: &Path, target: &Path) -> Result<(), VaultError> {
if !target.exists() {
return Err(VaultError::VaultPathNotFound(format!(
"meetings directory not found: {}",
target.display()
)));
}
if link_path.exists() || link_path.symlink_metadata().is_ok() {
if link_path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
let current_target = fs::read_link(link_path).map_err(VaultError::Io)?;
if current_target == target {
tracing::info!("symlink already exists and is correct");
return Ok(());
}
fs::remove_file(link_path).map_err(VaultError::Io)?;
} else if link_path.is_dir() {
return Err(VaultError::ExistingDirectory(format!(
"{} already exists as a directory. Move or rename it before setting up vault sync.",
link_path.display()
)));
}
}
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
VaultError::PermissionDenied(parent.display().to_string())
} else {
VaultError::Io(e)
}
})?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
VaultError::PermissionDenied(link_path.display().to_string())
} else {
VaultError::SymlinkFailed(format!("{}: {}", link_path.display(), e))
}
})?;
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(target, link_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
VaultError::PermissionDenied(link_path.display().to_string())
} else {
VaultError::SymlinkFailed(format!(
"{}: {} — try: mklink /J \"{}\" \"{}\"",
link_path.display(),
e,
link_path.display(),
target.display()
))
}
})?;
}
tracing::info!(
link = %link_path.display(),
target = %target.display(),
"vault symlink created"
);
Ok(())
}
fn effective_strategy(config: &Config) -> VaultStrategy {
match config.vault.strategy.as_str() {
"symlink" => VaultStrategy::Symlink,
"copy" => VaultStrategy::Copy,
"direct" => VaultStrategy::Direct,
_ => {
if config.vault.path.as_os_str().is_empty() {
VaultStrategy::Copy
} else {
recommend_strategy(&config.vault.path)
}
}
}
}
pub fn vault_meetings_dir(config: &Config) -> PathBuf {
config.vault.path.join(&config.vault.meetings_subdir)
}
pub fn sync_file(source: &Path, config: &Config) -> Result<Option<PathBuf>, VaultError> {
if !config.vault.enabled {
return Ok(None);
}
let strategy = effective_strategy(config);
match strategy {
VaultStrategy::Direct | VaultStrategy::Symlink => {
Ok(None)
}
VaultStrategy::Copy => {
let vault_dir = vault_meetings_dir(config);
let filename = source.file_name().ok_or_else(|| {
VaultError::CopyFailed("no filename".into(), std::io::Error::other("no filename"))
})?;
let dest_dir = if source
.parent()
.and_then(|p| p.file_name())
.map(|n| n == "memos")
.unwrap_or(false)
{
vault_dir.join("memos")
} else {
vault_dir.clone()
};
fs::create_dir_all(&dest_dir).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied {
VaultError::PermissionDenied(dest_dir.display().to_string())
} else {
VaultError::CopyFailed(dest_dir.display().to_string(), e)
}
})?;
let dest = dest_dir.join(filename);
fs::copy(source, &dest)
.map_err(|e| VaultError::CopyFailed(dest.display().to_string(), e))?;
tracing::info!(
source = %source.display(),
dest = %dest.display(),
"copied meeting to vault"
);
Ok(Some(dest))
}
}
}
pub fn sync_all(config: &Config) -> Result<Vec<PathBuf>, VaultError> {
if !config.vault.enabled {
return Err(VaultError::NotConfigured);
}
let strategy = effective_strategy(config);
if strategy != VaultStrategy::Copy {
return Ok(vec![]);
}
let mut synced = Vec::new();
let output_dir = &config.output_dir;
for entry in walkdir::WalkDir::new(output_dir)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
match sync_file(path, config) {
Ok(Some(dest)) => synced.push(dest),
Ok(None) => {}
Err(e) => {
tracing::warn!(
file = %path.display(),
error = %e,
"failed to sync file to vault"
);
}
}
}
}
Ok(synced)
}
pub fn check_health(config: &Config) -> VaultStatus {
if !config.vault.enabled {
return VaultStatus::NotConfigured;
}
let strategy = effective_strategy(config);
let vault_meetings = vault_meetings_dir(config);
match strategy {
VaultStrategy::Symlink => {
match vault_meetings.symlink_metadata() {
Ok(meta) if meta.file_type().is_symlink() => match fs::read_link(&vault_meetings) {
Ok(target) => {
if target.exists() {
VaultStatus::Healthy {
strategy: "symlink".into(),
path: vault_meetings,
}
} else {
VaultStatus::BrokenSymlink {
link_path: vault_meetings,
target,
}
}
}
Err(_) => VaultStatus::BrokenSymlink {
link_path: vault_meetings.clone(),
target: config.output_dir.clone(),
},
},
Ok(_) => {
VaultStatus::Healthy {
strategy: "symlink (directory)".into(),
path: vault_meetings,
}
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
VaultStatus::PermissionDenied {
path: vault_meetings,
}
}
Err(_) => VaultStatus::MissingVaultDir {
path: vault_meetings,
},
}
}
VaultStrategy::Copy => {
if vault_meetings.is_dir() {
VaultStatus::Healthy {
strategy: "copy".into(),
path: vault_meetings,
}
} else if config.vault.path.exists() {
VaultStatus::Healthy {
strategy: "copy (pending first sync)".into(),
path: vault_meetings,
}
} else {
VaultStatus::MissingVaultDir {
path: config.vault.path.clone(),
}
}
}
VaultStrategy::Direct => {
if config.output_dir.is_dir() {
VaultStatus::Healthy {
strategy: "direct".into(),
path: config.output_dir.clone(),
}
} else {
VaultStatus::MissingVaultDir {
path: config.output_dir.clone(),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[cfg(target_os = "macos")]
#[test]
fn tcc_protected_documents() {
let home = home_dir();
assert!(is_tcc_protected(&home.join("Documents")));
assert!(is_tcc_protected(&home.join("Documents/life")));
assert!(is_tcc_protected(
&home.join("Documents/life/areas/meetings")
));
assert!(is_tcc_protected(&home.join("Desktop")));
assert!(is_tcc_protected(&home.join("Downloads")));
}
#[test]
fn tcc_not_protected_other_dirs() {
let home = home_dir();
assert!(!is_tcc_protected(&home.join("meetings")));
assert!(!is_tcc_protected(&home.join("notes")));
assert!(!is_tcc_protected(&home.join(".minutes")));
assert!(!is_tcc_protected(&PathBuf::from("/tmp/vault")));
}
#[test]
fn cloud_detection_icloud() {
let path = PathBuf::from("/Users/test/Library/Mobile Documents/com~apple~CloudDocs/vault");
assert_eq!(is_cloud_synced(&path), Some(CloudProvider::ICloud));
}
#[test]
fn cloud_detection_dropbox() {
let path = PathBuf::from("/Users/test/Dropbox/notes");
assert_eq!(is_cloud_synced(&path), Some(CloudProvider::Dropbox));
}
#[test]
fn cloud_detection_onedrive() {
let path = PathBuf::from("/Users/test/OneDrive/vault");
assert_eq!(is_cloud_synced(&path), Some(CloudProvider::OneDrive));
}
#[test]
fn cloud_detection_google_drive() {
let path = PathBuf::from("/Users/test/Google Drive/vault");
assert_eq!(is_cloud_synced(&path), Some(CloudProvider::GoogleDrive));
}
#[test]
fn cloud_detection_none_for_local() {
let path = PathBuf::from("/tmp/vault");
assert_eq!(is_cloud_synced(&path), None);
}
#[test]
fn strategy_recommends_copy_for_cloud() {
let path = PathBuf::from("/Users/test/Dropbox/vault");
assert_eq!(recommend_strategy(&path), VaultStrategy::Copy);
}
#[test]
fn strategy_recommends_symlink_for_local() {
let path = PathBuf::from("/tmp/vault");
assert_eq!(recommend_strategy(&path), VaultStrategy::Symlink);
}
#[test]
fn sync_file_noop_when_disabled() {
let config = Config::default();
assert!(!config.vault.enabled);
let result = sync_file(Path::new("/tmp/test.md"), &config).unwrap();
assert!(result.is_none());
}
#[test]
fn sync_file_copies_to_vault() {
let tmp = TempDir::new().unwrap();
let meetings_dir = tmp.path().join("meetings");
let vault_dir = tmp.path().join("vault");
fs::create_dir_all(&meetings_dir).unwrap();
fs::create_dir_all(&vault_dir).unwrap();
let source = meetings_dir.join("2026-03-17-test.md");
fs::write(&source, "# Test Meeting\n\nHello world").unwrap();
let mut config = Config::default();
config.vault.enabled = true;
config.vault.path = vault_dir.clone();
config.vault.meetings_subdir = "meetings".into();
config.vault.strategy = "copy".into();
config.output_dir = meetings_dir;
let result = sync_file(&source, &config).unwrap();
assert!(result.is_some());
let dest = result.unwrap();
assert!(dest.exists());
assert_eq!(
fs::read_to_string(&dest).unwrap(),
"# Test Meeting\n\nHello world"
);
}
#[test]
fn sync_file_copies_memo_to_memos_subdir() {
let tmp = TempDir::new().unwrap();
let memos_dir = tmp.path().join("meetings/memos");
let vault_dir = tmp.path().join("vault");
fs::create_dir_all(&memos_dir).unwrap();
fs::create_dir_all(&vault_dir).unwrap();
let source = memos_dir.join("2026-03-17-idea.md");
fs::write(&source, "# Quick thought").unwrap();
let mut config = Config::default();
config.vault.enabled = true;
config.vault.path = vault_dir.clone();
config.vault.meetings_subdir = "meetings".into();
config.vault.strategy = "copy".into();
config.output_dir = tmp.path().join("meetings");
let result = sync_file(&source, &config).unwrap();
let dest = result.unwrap();
assert!(dest.to_string_lossy().contains("memos"));
assert!(dest.exists());
}
#[cfg(unix)]
#[test]
fn create_symlink_works() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("meetings");
let link = tmp.path().join("vault/areas/meetings");
fs::create_dir_all(&target).unwrap();
create_symlink(&link, &target).unwrap();
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
assert_eq!(fs::read_link(&link).unwrap(), target);
}
#[cfg(unix)]
#[test]
fn create_symlink_idempotent() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("meetings");
let link = tmp.path().join("vault/meetings");
fs::create_dir_all(&target).unwrap();
create_symlink(&link, &target).unwrap();
create_symlink(&link, &target).unwrap();
}
#[test]
fn create_symlink_rejects_existing_directory() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("meetings");
let link = tmp.path().join("vault/meetings");
fs::create_dir_all(&target).unwrap();
fs::create_dir_all(&link).unwrap();
let result = create_symlink(&link, &target);
assert!(matches!(result, Err(VaultError::ExistingDirectory(_))));
}
#[test]
fn check_health_not_configured() {
let config = Config::default();
assert!(matches!(check_health(&config), VaultStatus::NotConfigured));
}
#[test]
fn check_health_copy_strategy_pending() {
let tmp = TempDir::new().unwrap();
let mut config = Config::default();
config.vault.enabled = true;
config.vault.path = tmp.path().to_path_buf();
config.vault.strategy = "copy".into();
match check_health(&config) {
VaultStatus::Healthy { strategy, .. } => {
assert!(strategy.contains("pending") || strategy == "copy");
}
other => panic!("expected Healthy, got {:?}", other),
}
}
#[test]
fn sync_all_copies_existing_meetings() {
let tmp = TempDir::new().unwrap();
let meetings_dir = tmp.path().join("meetings");
let vault_dir = tmp.path().join("vault");
fs::create_dir_all(&meetings_dir).unwrap();
fs::create_dir_all(&vault_dir).unwrap();
fs::write(meetings_dir.join("meeting1.md"), "# Meeting 1").unwrap();
fs::write(meetings_dir.join("meeting2.md"), "# Meeting 2").unwrap();
fs::write(meetings_dir.join("not-a-meeting.txt"), "skip me").unwrap();
let mut config = Config::default();
config.vault.enabled = true;
config.vault.path = vault_dir.clone();
config.vault.meetings_subdir = "meetings".into();
config.vault.strategy = "copy".into();
config.output_dir = meetings_dir;
let synced = sync_all(&config).unwrap();
assert_eq!(synced.len(), 2);
assert!(vault_dir.join("meetings/meeting1.md").exists());
assert!(vault_dir.join("meetings/meeting2.md").exists());
assert!(!vault_dir.join("meetings/not-a-meeting.txt").exists());
}
}