use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use globset::Glob;
use walkdir::WalkDir;
use crate::error::{VaultError, VaultResult};
use crate::models::FileStat;
fn normalize_relative(path: &Path) -> VaultResult<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(c) => normalized.push(c),
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
return Err(VaultError::OutsideVault(path.to_path_buf()));
}
}
Component::RootDir | Component::Prefix(_) => {
return Err(VaultError::InvalidPath(format!(
"absolute paths not allowed: {}",
path.display()
)));
}
}
}
Ok(normalized)
}
fn is_hidden(name: &str) -> bool {
name.starts_with('.')
}
fn map_not_found(path: &Path) -> impl FnOnce(std::io::Error) -> VaultError + '_ {
|e| match e.kind() {
std::io::ErrorKind::NotFound => VaultError::NoteNotFound(path.to_path_buf()),
_ => VaultError::Io(e),
}
}
pub fn resolve_path(vault_root: &Path, relative: &Path) -> VaultResult<PathBuf> {
let normalized = normalize_relative(relative)?;
let absolute = vault_root.join(&normalized);
if absolute.exists() {
let canonical_root = vault_root
.canonicalize()
.map_err(|_| VaultError::InvalidPath(vault_root.display().to_string()))?;
let canonical = absolute.canonicalize()?;
if !canonical.starts_with(&canonical_root) {
return Err(VaultError::OutsideVault(relative.to_path_buf()));
}
Ok(canonical)
} else {
Ok(absolute)
}
}
pub fn file_exists(vault_root: &Path, path: &Path) -> bool {
resolve_path(vault_root, path)
.map(|p| p.is_file())
.unwrap_or(false)
}
pub fn file_stat(vault_root: &Path, path: &Path) -> VaultResult<FileStat> {
let abs = resolve_path(vault_root, path)?;
let meta = fs::metadata(&abs).map_err(map_not_found(path))?;
let created = meta
.created()
.ok()
.map(chrono::DateTime::<chrono::Utc>::from);
let modified = meta
.modified()
.ok()
.map(chrono::DateTime::<chrono::Utc>::from);
Ok(FileStat {
size: meta.len(),
created,
modified,
})
}
pub fn read_file(vault_root: &Path, path: &Path) -> VaultResult<String> {
let abs = resolve_path(vault_root, path)?;
fs::read_to_string(&abs).map_err(map_not_found(path))
}
pub fn write_file(vault_root: &Path, path: &Path, content: &str) -> VaultResult<()> {
let abs = resolve_path(vault_root, path)?;
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&abs, content)?;
Ok(())
}
pub fn append_file(vault_root: &Path, path: &Path, content: &str) -> VaultResult<()> {
let abs = resolve_path(vault_root, path)?;
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(&abs)?;
file.write_all(content.as_bytes())?;
Ok(())
}
pub fn delete_file(vault_root: &Path, path: &Path) -> VaultResult<()> {
let abs = resolve_path(vault_root, path)?;
if abs.is_dir() {
fs::remove_dir(&abs).map_err(map_not_found(path))
} else {
fs::remove_file(&abs).map_err(map_not_found(path))
}
}
pub fn move_file(vault_root: &Path, from: &Path, to: &Path) -> VaultResult<PathBuf> {
let abs_from = resolve_path(vault_root, from)?;
if !abs_from.exists() {
return Err(VaultError::NoteNotFound(from.to_path_buf()));
}
let abs_to = resolve_path(vault_root, to)?;
if abs_to.exists() {
return Err(VaultError::AlreadyExists(to.to_path_buf()));
}
if let Some(parent) = abs_to.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(&abs_from, &abs_to)?;
let normalized_to = normalize_relative(to)?;
Ok(normalized_to)
}
pub fn list_files(
vault_root: &Path,
dir: &Path,
recursive: bool,
glob: Option<&str>,
) -> VaultResult<Vec<PathBuf>> {
let abs_dir = resolve_path(vault_root, dir)?;
if !abs_dir.is_dir() {
return Err(VaultError::DirectoryNotFound(dir.to_path_buf()));
}
let canonical_root = vault_root
.canonicalize()
.map_err(|_| VaultError::InvalidPath(vault_root.display().to_string()))?;
let glob_matcher = glob
.map(|pattern| {
Glob::new(pattern)
.map(|g| g.compile_matcher())
.map_err(|e| VaultError::InvalidPath(format!("invalid glob pattern: {e}")))
})
.transpose()?;
let mut results = Vec::new();
let mut try_add = |entry_path: &Path| -> VaultResult<()> {
let canonical = entry_path.canonicalize()?;
if let Ok(rel) = canonical.strip_prefix(&canonical_root) {
let rel = rel.to_path_buf();
if let Some(ref matcher) = glob_matcher
&& !matcher.is_match(&rel)
{
return Ok(());
}
results.push(rel);
}
Ok(())
};
if recursive {
for entry in WalkDir::new(&abs_dir)
.min_depth(1)
.into_iter()
.filter_entry(|e| {
e.file_name()
.to_str()
.map(|name| !is_hidden(name))
.unwrap_or(false)
})
{
let entry = entry.map_err(|e| VaultError::Io(std::io::Error::other(e.to_string())))?;
try_add(entry.path())?;
}
} else {
for entry in fs::read_dir(&abs_dir)? {
let entry = entry?;
let name = entry.file_name();
if is_hidden(&name.to_string_lossy()) {
continue;
}
try_add(&entry.path())?;
}
}
results.sort();
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_vault() -> TempDir {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("note1.md"), "# Note 1\nHello").unwrap();
fs::write(dir.path().join("note2.md"), "# Note 2\nWorld").unwrap();
fs::create_dir_all(dir.path().join("subfolder")).unwrap();
fs::write(dir.path().join("subfolder/nested.md"), "# Nested\nContent").unwrap();
fs::create_dir_all(dir.path().join(".obsidian")).unwrap();
fs::write(
dir.path().join(".obsidian/config.json"),
r#"{"key":"value"}"#,
)
.unwrap();
fs::write(dir.path().join(".hidden_file"), "secret").unwrap();
dir
}
#[test]
fn resolve_simple_relative_path() {
let vault = setup_vault();
let result = resolve_path(vault.path(), Path::new("note1.md")).unwrap();
assert!(result.is_file());
assert!(result.ends_with("note1.md"));
}
#[test]
fn resolve_rejects_parent_escape() {
let vault = setup_vault();
let err = resolve_path(vault.path(), Path::new("../etc/passwd")).unwrap_err();
assert!(matches!(err, VaultError::OutsideVault(_)));
}
#[test]
fn resolve_rejects_nested_parent_escape() {
let vault = setup_vault();
let err = resolve_path(vault.path(), Path::new("subfolder/../../etc/passwd")).unwrap_err();
assert!(matches!(err, VaultError::OutsideVault(_)));
}
#[test]
fn resolve_rejects_absolute_path() {
let vault = setup_vault();
let err = resolve_path(vault.path(), Path::new("/etc/passwd")).unwrap_err();
assert!(matches!(err, VaultError::InvalidPath(_)));
}
#[test]
fn resolve_normalizes_dot() {
let vault = setup_vault();
let result = resolve_path(vault.path(), Path::new("./note1.md")).unwrap();
assert!(result.is_file());
}
#[test]
fn resolve_allows_valid_parent_dir() {
let vault = setup_vault();
let result = resolve_path(vault.path(), Path::new("subfolder/../note1.md")).unwrap();
assert!(result.is_file());
}
#[test]
fn exists_returns_true_for_file() {
let vault = setup_vault();
assert!(file_exists(vault.path(), Path::new("note1.md")));
}
#[test]
fn exists_returns_false_for_missing() {
let vault = setup_vault();
assert!(!file_exists(vault.path(), Path::new("no_such.md")));
}
#[test]
fn exists_returns_false_for_directory() {
let vault = setup_vault();
assert!(!file_exists(vault.path(), Path::new("subfolder")));
}
#[test]
fn exists_returns_false_for_traversal() {
let vault = setup_vault();
assert!(!file_exists(vault.path(), Path::new("../../etc/passwd")));
}
#[test]
fn stat_returns_correct_size() {
let vault = setup_vault();
let stat = file_stat(vault.path(), Path::new("note1.md")).unwrap();
assert_eq!(stat.size, "# Note 1\nHello".len() as u64);
assert!(stat.modified.is_some());
}
#[test]
fn stat_not_found() {
let vault = setup_vault();
let err = file_stat(vault.path(), Path::new("missing.md")).unwrap_err();
assert!(matches!(err, VaultError::NoteNotFound(_)));
}
#[test]
fn read_existing_file() {
let vault = setup_vault();
let content = read_file(vault.path(), Path::new("note1.md")).unwrap();
assert_eq!(content, "# Note 1\nHello");
}
#[test]
fn read_missing_file() {
let vault = setup_vault();
let err = read_file(vault.path(), Path::new("nope.md")).unwrap_err();
assert!(matches!(err, VaultError::NoteNotFound(_)));
}
#[test]
fn write_and_read_round_trip() {
let vault = setup_vault();
let path = Path::new("new_note.md");
write_file(vault.path(), path, "fresh content").unwrap();
let content = read_file(vault.path(), path).unwrap();
assert_eq!(content, "fresh content");
}
#[test]
fn write_creates_parent_dirs() {
let vault = setup_vault();
let path = Path::new("deep/nested/dir/note.md");
write_file(vault.path(), path, "deep").unwrap();
assert_eq!(read_file(vault.path(), path).unwrap(), "deep");
}
#[test]
fn write_overwrites_existing() {
let vault = setup_vault();
let path = Path::new("note1.md");
write_file(vault.path(), path, "overwritten").unwrap();
assert_eq!(read_file(vault.path(), path).unwrap(), "overwritten");
}
#[test]
fn append_creates_new_file() {
let vault = setup_vault();
let path = Path::new("appended.md");
append_file(vault.path(), path, "line1\n").unwrap();
assert_eq!(read_file(vault.path(), path).unwrap(), "line1\n");
}
#[test]
fn append_adds_to_existing() {
let vault = setup_vault();
let path = Path::new("note1.md");
append_file(vault.path(), path, "\nappended").unwrap();
assert_eq!(
read_file(vault.path(), path).unwrap(),
"# Note 1\nHello\nappended"
);
}
#[test]
fn delete_existing_file() {
let vault = setup_vault();
let path = Path::new("note1.md");
assert!(file_exists(vault.path(), path));
delete_file(vault.path(), path).unwrap();
assert!(!file_exists(vault.path(), path));
}
#[test]
fn delete_empty_directory() {
let vault = setup_vault();
let dir_path = Path::new("empty_dir");
fs::create_dir(vault.path().join("empty_dir")).unwrap();
delete_file(vault.path(), dir_path).unwrap();
assert!(!vault.path().join("empty_dir").exists());
}
#[test]
fn delete_missing_file() {
let vault = setup_vault();
let err = delete_file(vault.path(), Path::new("missing.md")).unwrap_err();
assert!(matches!(err, VaultError::NoteNotFound(_)));
}
#[test]
fn move_renames_file() {
let vault = setup_vault();
let from = Path::new("note1.md");
let to = Path::new("renamed.md");
let result = move_file(vault.path(), from, to).unwrap();
assert_eq!(result, PathBuf::from("renamed.md"));
assert!(!file_exists(vault.path(), from));
assert!(file_exists(vault.path(), to));
assert_eq!(read_file(vault.path(), to).unwrap(), "# Note 1\nHello");
}
#[test]
fn move_creates_parent_dirs() {
let vault = setup_vault();
let from = Path::new("note1.md");
let to = Path::new("new_dir/note1.md");
move_file(vault.path(), from, to).unwrap();
assert!(file_exists(vault.path(), to));
}
#[test]
fn move_rejects_existing_destination() {
let vault = setup_vault();
let err =
move_file(vault.path(), Path::new("note1.md"), Path::new("note2.md")).unwrap_err();
assert!(matches!(err, VaultError::AlreadyExists(_)));
}
#[test]
fn move_rejects_missing_source() {
let vault = setup_vault();
let err = move_file(vault.path(), Path::new("ghost.md"), Path::new("dest.md")).unwrap_err();
assert!(matches!(err, VaultError::NoteNotFound(_)));
}
#[test]
fn list_root_non_recursive() {
let vault = setup_vault();
let files = list_files(vault.path(), Path::new(""), false, None).unwrap();
let names: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
assert!(names.contains(&"note1.md".to_string()));
assert!(names.contains(&"note2.md".to_string()));
assert!(names.contains(&"subfolder".to_string()));
assert!(!names.iter().any(|n| n.contains(".obsidian")));
assert!(!names.iter().any(|n| n.contains(".hidden")));
}
#[test]
fn list_recursive() {
let vault = setup_vault();
let files = list_files(vault.path(), Path::new(""), true, None).unwrap();
let names: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
assert!(names.contains(&"note1.md".to_string()));
assert!(names.iter().any(|n| n.contains("nested.md")));
assert!(!names.iter().any(|n| n.contains(".obsidian")));
}
#[test]
fn list_with_glob() {
let vault = setup_vault();
let files = list_files(vault.path(), Path::new(""), true, Some("**/*.md")).unwrap();
for f in &files {
assert!(f.display().to_string().ends_with(".md"));
}
assert!(files.len() >= 3);
}
#[test]
fn list_excludes_obsidian_dir() {
let vault = setup_vault();
let files = list_files(vault.path(), Path::new(""), true, None).unwrap();
for f in &files {
assert!(
!f.display().to_string().contains(".obsidian"),
"should exclude .obsidian: {}",
f.display()
);
}
}
#[test]
fn list_excludes_hidden_files() {
let vault = setup_vault();
let files = list_files(vault.path(), Path::new(""), false, None).unwrap();
for f in &files {
let name = f.file_name().unwrap().to_string_lossy();
assert!(!name.starts_with('.'), "should exclude hidden: {name}");
}
}
#[test]
fn list_nonexistent_dir() {
let vault = setup_vault();
let err = list_files(vault.path(), Path::new("no_such_dir"), false, None).unwrap_err();
assert!(matches!(err, VaultError::DirectoryNotFound(_)));
}
#[test]
fn list_subdirectory() {
let vault = setup_vault();
let files = list_files(vault.path(), Path::new("subfolder"), false, None).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].display().to_string().contains("nested.md"));
}
}