Skip to main content

kimun_core/nfs/
mod.rs

1pub mod filename;
2pub mod saved_searches;
3use std::{
4    fmt::Display,
5    hash::Hash,
6    path::{Path, PathBuf},
7    str::FromStr,
8    sync::LazyLock,
9    time::UNIX_EPOCH,
10};
11
12use ignore::{WalkBuilder, WalkParallel};
13use log::warn;
14use regex::Regex;
15use serde::{de::Visitor, Deserialize, Serialize};
16use twox_hash::XxHash64;
17
18use super::{error::FSError, DirectoryDetails, NoteDetails};
19
20use super::utilities::path_to_string;
21
22/// The vault-internal path separator. Always `/`, independent of the host OS:
23/// a [`VaultPath`] is logical and portable, and is only translated to native
24/// OS separators when resolved to a real on-disk location.
25pub const PATH_SEPARATOR: char = '/';
26const NOTE_EXTENSION: &str = ".md";
27
28/// Appends the note extension to `name` if it is not already present, without
29/// sanitizing the rest of the string. Unlike [`VaultPath::note_path_from`] this
30/// leaves wildcards and other non-path characters intact, so search patterns
31/// (e.g. `proj*`) keep their meaning. Use it only for building match patterns,
32/// never for constructing real vault paths.
33pub fn with_note_extension<S: AsRef<str>>(name: S) -> String {
34    let name = name.as_ref();
35    if name.ends_with(NOTE_EXTENSION) {
36        name.to_string()
37    } else {
38        format!("{name}{NOTE_EXTENSION}")
39    }
40}
41
42static RX_INCREMENT_SUFFIX: LazyLock<Regex> =
43    LazyLock::new(|| Regex::new(r"_(?P<number>[0-9]+)$").unwrap());
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub(crate) struct VaultEntry {
47    pub path: VaultPath,
48    pub path_string: String,
49    pub data: EntryData,
50}
51
52impl AsRef<str> for VaultEntry {
53    fn as_ref(&self) -> &str {
54        self.path_string.as_ref()
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Hash)]
59pub(crate) enum EntryData {
60    Note(NoteEntryData),
61    Directory(DirectoryEntryData),
62    Attachment,
63}
64
65/// Lightweight metadata for an indexed note: enough to detect changes without
66/// reading the note's contents. Produced from filesystem metadata as the vault
67/// is walked.
68#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
69pub struct NoteEntryData {
70    /// The note's vault path, stored flattened (no `.`/`..` components).
71    pub path: VaultPath,
72    /// File size in bytes. Cheap first-pass signal that a note changed.
73    pub size: u64,
74    /// Last-modified time, in whole seconds since the Unix epoch.
75    pub modified_secs: u64,
76}
77
78impl NoteEntryData {
79    #[cfg(test)]
80    pub async fn load_details<P: AsRef<Path>>(
81        &self,
82        workspace_path: P,
83        path: &VaultPath,
84    ) -> Result<NoteDetails, FSError> {
85        let content = load_note(workspace_path, path).await?;
86        Ok(NoteDetails::new(path, content))
87    }
88
89    /// Reads the file at `os_path` directly (no case-insensitive resolution).
90    /// Use when the real on-disk path is already known (e.g. from the walker).
91    pub(crate) fn load_details_from_os_path(&self, os_path: &Path) -> Result<NoteDetails, FSError> {
92        let bytes = std::fs::read(os_path)?;
93        let text = String::from_utf8(bytes)?;
94        Ok(NoteDetails::new(&self.path, text))
95    }
96
97    async fn from_os_path(path: &VaultPath, file_path: &Path) -> Result<NoteEntryData, FSError> {
98        let metadata = tokio::fs::metadata(file_path).await?;
99        Ok(Self::from_metadata(path, &metadata))
100    }
101
102    fn from_metadata(path: &VaultPath, metadata: &std::fs::Metadata) -> NoteEntryData {
103        let size = metadata.len();
104        let modified_secs = metadata
105            .modified()
106            .map(|t| t.duration_since(UNIX_EPOCH).unwrap().as_secs())
107            .unwrap_or(0);
108        NoteEntryData {
109            path: path.flatten(),
110            size,
111            modified_secs,
112        }
113    }
114}
115
116/// Metadata for an indexed directory. A directory carries no content of its
117/// own, so its vault path is all that needs tracking.
118#[derive(Debug, Clone, PartialEq, Eq, Hash)]
119pub struct DirectoryEntryData {
120    /// The directory's vault path.
121    pub path: VaultPath,
122}
123impl DirectoryEntryData {
124    /// Builds the public [`DirectoryDetails`] view of this directory entry.
125    pub fn get_details<P: AsRef<Path>>(&self) -> DirectoryDetails {
126        DirectoryDetails {
127            path: self.path.clone(),
128        }
129    }
130}
131
132#[cfg(test)]
133#[derive(Debug, Clone)]
134pub(crate) enum VaultEntryDetails {
135    Note(NoteDetails),
136    #[allow(dead_code)]
137    Directory(DirectoryDetails),
138    None,
139}
140
141#[cfg(test)]
142impl VaultEntryDetails {
143    pub fn get_title(&mut self) -> String {
144        match self {
145            VaultEntryDetails::Note(note_details) => note_details.get_title(),
146            VaultEntryDetails::Directory(_) => String::new(),
147            VaultEntryDetails::None => String::new(),
148        }
149    }
150}
151
152impl VaultEntry {
153    #[cfg(test)]
154    pub async fn new<P: AsRef<Path>>(workspace_path: P, path: VaultPath) -> Result<Self, FSError> {
155        let os_path = resolve_path_on_disk(&workspace_path, &path).await;
156        let metadata = tokio::fs::metadata(&os_path)
157            .await
158            .map_err(|e| Self::map_metadata_err(e, &os_path))?;
159        Self::assemble(path, &metadata)
160    }
161
162    #[cfg(test)]
163    pub async fn from_path<P: AsRef<Path>, F: AsRef<Path>>(
164        workspace_path: P,
165        full_path: F,
166    ) -> Result<Self, FSError> {
167        let note_path = VaultPath::from_path(&workspace_path, &full_path)?;
168        let os_path = full_path.as_ref();
169        let metadata = tokio::fs::metadata(os_path)
170            .await
171            .map_err(|e| Self::map_metadata_err(e, os_path))?;
172        Self::assemble(note_path, &metadata)
173    }
174
175    /// Sync sibling of `from_path`. Used by the parallel-walker visitor where
176    /// the OS path is already known and the calling thread is synchronous.
177    pub(crate) fn from_path_sync<P: AsRef<Path>, F: AsRef<Path>>(
178        workspace_path: P,
179        full_path: F,
180    ) -> Result<Self, FSError> {
181        let note_path = VaultPath::from_path(&workspace_path, &full_path)?;
182        let os_path = full_path.as_ref();
183        let metadata =
184            std::fs::metadata(os_path).map_err(|e| Self::map_metadata_err(e, os_path))?;
185        Self::assemble(note_path, &metadata)
186    }
187
188    fn map_metadata_err(e: std::io::Error, os_path: &Path) -> FSError {
189        match e.kind() {
190            std::io::ErrorKind::NotFound => FSError::NoFileOrDirectoryFound {
191                path: path_to_string(os_path),
192            },
193            _ => FSError::ReadFileError(e),
194        }
195    }
196
197    fn assemble(path: VaultPath, metadata: &std::fs::Metadata) -> Result<Self, FSError> {
198        let data = if metadata.is_dir() {
199            EntryData::Directory(DirectoryEntryData { path: path.clone() })
200        } else if path.is_note() {
201            EntryData::Note(NoteEntryData::from_metadata(&path, metadata))
202        } else {
203            EntryData::Attachment
204        };
205        let path_string = path.to_string();
206        Ok(VaultEntry {
207            path,
208            path_string,
209            data,
210        })
211    }
212}
213
214impl Display for VaultEntry {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        match &self.data {
217            EntryData::Note(_details) => write!(f, "[NOT] {}", self.path),
218            EntryData::Directory(_details) => write!(f, "[DIR] {}", self.path),
219            EntryData::Attachment => write!(f, "[ATT]"),
220        }
221    }
222}
223
224pub(crate) fn hash_text<S: AsRef<str>>(text: S) -> u64 {
225    XxHash64::oneshot(42, text.as_ref().as_bytes())
226}
227
228/// Resolves a VaultPath to the real PathBuf on disk by matching each component
229/// case-insensitively. When a component doesn't exist on disk yet, the stored
230/// (lowercase) name is used for the remainder of the path.
231///
232/// Fast path: stored paths are always lowercase, so `vault_path.to_pathbuf` is
233/// the canonical form. We try it directly first; only fall back to the
234/// per-slice walk when something exists on disk under a different case
235/// (legacy mixed-case files imported from outside Kimun).
236pub(crate) async fn resolve_path_on_disk<P: AsRef<Path>>(
237    workspace_path: P,
238    vault_path: &VaultPath,
239) -> PathBuf {
240    let canonical = vault_path.to_pathbuf(&workspace_path);
241    if matches!(tokio::fs::try_exists(&canonical).await, Ok(true)) {
242        return canonical;
243    }
244    let mut current = workspace_path.as_ref().to_path_buf();
245    for slice in &vault_path.flatten().slices {
246        let name = slice.to_string();
247        let real_name = async {
248            let mut entries = tokio::fs::read_dir(&current).await.ok()?;
249            while let Ok(Some(entry)) = entries.next_entry().await {
250                if entry.file_name().to_string_lossy().to_lowercase() == name {
251                    return Some(entry.file_name().to_string_lossy().into_owned());
252                }
253            }
254            None
255        }
256        .await
257        .unwrap_or(name);
258        current = current.join(real_name);
259    }
260    current
261}
262
263/// Sync variant of `resolve_path_on_disk` for use in non-async contexts.
264pub(crate) fn resolve_path_on_disk_sync<P: AsRef<Path>>(
265    workspace_path: P,
266    vault_path: &VaultPath,
267) -> PathBuf {
268    let canonical = vault_path.to_pathbuf(&workspace_path);
269    if canonical.exists() {
270        return canonical;
271    }
272    let mut current = workspace_path.as_ref().to_path_buf();
273    for slice in &vault_path.flatten().slices {
274        let name = slice.to_string();
275        let real_name = std::fs::read_dir(&current)
276            .ok()
277            .and_then(|entries| {
278                entries
279                    .filter_map(|e| e.ok())
280                    .find(|e| e.file_name().to_string_lossy().to_lowercase() == name)
281                    .map(|e| e.file_name().to_string_lossy().into_owned())
282            })
283            .unwrap_or(name);
284        current = current.join(real_name);
285    }
286    current
287}
288
289/// Walks the vault directory tree and returns a human-readable description of
290/// every pair of entries that collide when lowercased (e.g. "note.md" vs "Note.md").
291/// Returns an empty Vec if the vault is clean.
292pub(crate) fn check_case_conflicts<P: AsRef<Path>>(workspace_path: P) -> Vec<String> {
293    let root = workspace_path.as_ref();
294    check_conflicts_in_dir(root, root)
295}
296
297fn check_conflicts_in_dir(workspace_root: &Path, dir: &Path) -> Vec<String> {
298    let mut conflicts = Vec::new();
299    let mut seen: std::collections::HashMap<String, std::ffi::OsString> =
300        std::collections::HashMap::new();
301
302    let entries = match std::fs::read_dir(dir) {
303        Ok(e) => e,
304        Err(_) => return conflicts,
305    };
306
307    let mut subdirs = Vec::new();
308    for entry in entries.flatten() {
309        let name = entry.file_name();
310        let name_str = name.to_string_lossy().to_string();
311        // skip hidden entries, consistent with the vault's filter_files behaviour
312        if name_str.starts_with('.') {
313            continue;
314        }
315        let lower = name_str.to_lowercase();
316        if let Some(existing) = seen.get(&lower) {
317            let rel = dir.strip_prefix(workspace_root).unwrap_or(dir);
318            let rel_str = rel.to_string_lossy();
319            let location = if rel_str.is_empty() {
320                PATH_SEPARATOR.to_string()
321            } else {
322                format!("{}{}", PATH_SEPARATOR, rel_str)
323            };
324            conflicts.push(format!(
325                "\"{}\" conflicts with \"{}\" in {}",
326                name_str,
327                existing.to_string_lossy(),
328                location
329            ));
330        } else {
331            seen.insert(lower, name);
332        }
333        // Use file_type() rather than is_dir() to avoid following symlinks,
334        // which could cause unbounded recursion on symlink loops.
335        if let Ok(ft) = entry.file_type() {
336            if ft.is_dir() {
337                subdirs.push(entry.path());
338            }
339        }
340    }
341
342    // Recurse into all subdirectories, including both sides of a conflicting pair,
343    // so that deeper conflicts inside them are also surfaced.
344    for subdir in subdirs {
345        conflicts.extend(check_conflicts_in_dir(workspace_root, &subdir));
346    }
347
348    conflicts
349}
350
351/// Loads a note from disk, if the file doesn't exist, returns a FSError::NotePathNotFound
352/// Returns the note's text. If you want the details, use NoteDetails::from_content
353pub(crate) async fn load_note<P: AsRef<Path>>(
354    workspace_path: P,
355    path: &VaultPath,
356) -> Result<String, FSError> {
357    let os_path = resolve_path_on_disk(&workspace_path, path).await;
358    match tokio::fs::read(&os_path).await {
359        Ok(file) => {
360            let text = String::from_utf8(file)?;
361            Ok(text)
362        }
363        Err(e) => match e.kind() {
364            std::io::ErrorKind::NotFound => Err(FSError::VaultPathNotFound {
365                path: path.to_owned(),
366            }),
367            _ => Err(FSError::ReadFileError(e)),
368        },
369    }
370}
371
372/// Creates a new directory at `path`. Returns `FSError::AlreadyExists` if the
373/// directory (or any case-insensitive variant) is already present.
374pub(crate) async fn create_directory<P: AsRef<Path>>(
375    workspace_path: P,
376    path: &VaultPath,
377) -> Result<DirectoryEntryData, FSError> {
378    path.ensure_directory()?;
379
380    let full_path = resolve_path_on_disk(&workspace_path, path).await;
381    if let Some(parent) = full_path.parent() {
382        tokio::fs::create_dir_all(parent).await?;
383    }
384    match tokio::fs::create_dir(&full_path).await {
385        Ok(()) => Ok(DirectoryEntryData {
386            path: path.to_owned(),
387        }),
388        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(FSError::AlreadyExists {
389            path: path.to_owned(),
390        }),
391        Err(e) => Err(FSError::ReadFileError(e)),
392    }
393}
394
395/// Writes raw bytes (e.g. an image attachment) at `path` under the workspace,
396/// creating parent directories as needed. Unlike [`save_note`], does not require
397/// the path to be a note file and bypasses the case-insensitive note resolver.
398pub(crate) async fn save_attachment<P: AsRef<Path>>(
399    workspace_path: P,
400    path: &VaultPath,
401    bytes: &[u8],
402) -> Result<(), FSError> {
403    let full_path = path.flatten().to_pathbuf(workspace_path);
404    if let Some(parent) = full_path.parent() {
405        tokio::fs::create_dir_all(parent).await?;
406    }
407    tokio::fs::write(&full_path, bytes).await?;
408    Ok(())
409}
410
411pub(crate) async fn save_note<P: AsRef<Path>, S: AsRef<str>>(
412    workspace_path: P,
413    path: &VaultPath,
414    text: S,
415) -> Result<NoteEntryData, FSError> {
416    path.ensure_note()?;
417    // Resolve the full path case-insensitively so an existing `MyNote.md` is
418    // written in place rather than creating a new lowercase `mynote.md` alongside it.
419    let full_path = resolve_path_on_disk(&workspace_path, path).await;
420    if let Some(base_path) = full_path.parent() {
421        tokio::fs::create_dir_all(base_path).await?;
422    }
423    tokio::fs::write(&full_path, text.as_ref().as_bytes()).await?;
424
425    let entry = NoteEntryData::from_os_path(path, &full_path).await?;
426    Ok(entry)
427}
428
429/// Creates a new note at `path` exclusively. Returns `FSError::AlreadyExists` if
430/// any file (case-insensitive) already occupies the resolved path.
431pub(crate) async fn create_note_exclusive<P: AsRef<Path>, S: AsRef<str>>(
432    workspace_path: P,
433    path: &VaultPath,
434    text: S,
435) -> Result<NoteEntryData, FSError> {
436    path.ensure_note()?;
437    let full_path = resolve_path_on_disk(&workspace_path, path).await;
438    if let Some(base_path) = full_path.parent() {
439        tokio::fs::create_dir_all(base_path).await?;
440    }
441    let mut file = match tokio::fs::OpenOptions::new()
442        .write(true)
443        .create_new(true)
444        .open(&full_path)
445        .await
446    {
447        Ok(f) => f,
448        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
449            return Err(FSError::AlreadyExists {
450                path: path.to_owned(),
451            });
452        }
453        Err(e) => return Err(FSError::ReadFileError(e)),
454    };
455    use tokio::io::AsyncWriteExt;
456    file.write_all(text.as_ref().as_bytes()).await?;
457    file.flush().await?;
458    drop(file);
459
460    NoteEntryData::from_os_path(path, &full_path).await
461}
462
463pub(crate) async fn rename_note<P: AsRef<Path>>(
464    workspace_path: P,
465    from: &VaultPath,
466    to: &VaultPath,
467) -> Result<(), FSError> {
468    from.ensure_note()?;
469    to.ensure_note()?;
470    rename_path(workspace_path, from, to).await
471}
472
473pub(crate) async fn rename_directory<P: AsRef<Path>>(
474    workspace_path: P,
475    from: &VaultPath,
476    to: &VaultPath,
477) -> Result<(), FSError> {
478    from.ensure_directory()?;
479    to.ensure_directory()?;
480    rename_path(workspace_path, from, to).await
481}
482
483/// Resolves both endpoints, ensures the destination's parent directory exists,
484/// and renames atomically. Returns `FSError::AlreadyExists` if the destination
485/// is occupied (the OS rename would silently overwrite on Linux otherwise).
486async fn rename_path<P: AsRef<Path>>(
487    workspace_path: P,
488    from: &VaultPath,
489    to: &VaultPath,
490) -> Result<(), FSError> {
491    let full_from_path = resolve_path_on_disk(&workspace_path, from).await;
492    let (to_parent, to_name) = to.get_parent_path();
493    let to_base = resolve_path_on_disk(&workspace_path, &to_parent).await;
494    let full_to_path = to_base.join(&to_name);
495
496    if matches!(tokio::fs::try_exists(&full_to_path).await, Ok(true)) {
497        return Err(FSError::AlreadyExists {
498            path: to.to_owned(),
499        });
500    }
501
502    match tokio::fs::metadata(&to_base).await {
503        Ok(m) if m.is_dir() => {}
504        _ => {
505            tokio::fs::create_dir_all(&to_base).await?;
506        }
507    }
508    tokio::fs::rename(full_from_path, full_to_path).await?;
509    Ok(())
510}
511/// How long automated-edit backups are retained before the lazy purge reclaims
512/// them. Counted in whole days against the UTC backup date.
513const BACKUP_RETENTION_DAYS: i64 = 30;
514
515/// The last `(backups_root, date)` purged in this process. The sweep is
516/// de-duplicated against this so it runs at most once per vault per UTC day
517/// rather than on every backup write — a single hub-note rename can back up
518/// thousands of victims in a row, and each would otherwise re-scan the root.
519static LAST_PURGE: std::sync::LazyLock<
520    std::sync::Mutex<Option<(std::path::PathBuf, chrono::NaiveDate)>>,
521> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
522
523/// Best-effort sweep of the backups root: removes every `<YYYY-MM-DD>` directory
524/// whose date is older than [`BACKUP_RETENTION_DAYS`]. Runs at most once per
525/// backups root per UTC day per process (see [`LAST_PURGE`]). Never fails the
526/// caller — backups are housekeeping, and a purge error must not block (and
527/// thereby abort) the edit that triggered it.
528async fn purge_old_backups(backups_root: &Path) {
529    let today = chrono::Utc::now().date_naive();
530    // Skip if we already swept this root today (in this process). The marker is
531    // only stamped AFTER a successful sweep below, so a transient failure (e.g.
532    // the dir not existing yet, or a read error) is retried on the next backup.
533    if LAST_PURGE
534        .lock()
535        .unwrap()
536        .as_ref()
537        .is_some_and(|(root, day)| root == backups_root && *day == today)
538    {
539        return;
540    }
541    let cutoff = today - chrono::Duration::days(BACKUP_RETENTION_DAYS);
542    let mut entries = match tokio::fs::read_dir(backups_root).await {
543        Ok(e) => e,
544        Err(_) => return,
545    };
546    while let Ok(Some(entry)) = entries.next_entry().await {
547        let name = entry.file_name();
548        if let Ok(date) = chrono::NaiveDate::parse_from_str(&name.to_string_lossy(), "%Y-%m-%d") {
549            if date < cutoff {
550                let _ = tokio::fs::remove_dir_all(entry.path()).await;
551            }
552        }
553    }
554    *LAST_PURGE.lock().unwrap() = Some((backups_root.to_path_buf(), today));
555}
556
557/// Atomically reserves a free backup destination: tries the mirrored name first,
558/// then time-and-counter-suffixed variants, each via `create_new` so two writers
559/// racing on the same note get distinct files and no pre-image is ever clobbered.
560/// Returns the reserved (now-empty) path for the caller to copy into.
561async fn reserve_backup_dest(base: &Path) -> Result<std::path::PathBuf, FSError> {
562    let mut candidate = base.to_path_buf();
563    let mut attempt: u32 = 0;
564    loop {
565        match tokio::fs::OpenOptions::new()
566            .write(true)
567            .create_new(true)
568            .open(&candidate)
569            .await
570        {
571            Ok(_) => return Ok(candidate),
572            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
573                let ts = chrono::Utc::now().format("%H%M%S%6f");
574                let mut name = base.file_name().unwrap_or_default().to_os_string();
575                name.push(format!(".{ts}.{attempt}"));
576                candidate = base.with_file_name(name);
577                attempt = attempt.wrapping_add(1);
578            }
579            Err(e) => return Err(FSError::ReadFileError(e)),
580        }
581    }
582}
583
584/// Copies the current on-disk content of the note at `path` into a hidden, dated
585/// backup directory inside the vault, before the note is overwritten or deleted.
586/// The backup lives at `<workspace>/.kimun/backups/<YYYY-MM-DD>/<note>` — the
587/// note's on-disk path mirrored under the (UTC) date. The destination is claimed
588/// atomically (see [`reserve_backup_dest`]); a repeat edit on the same day gets a
589/// time-suffixed sibling, and concurrent writers never overwrite each other's
590/// pre-image. Returns `Ok(())` without writing when the source note does not
591/// exist (nothing to back up). `.kimun` is hidden, so the indexer's walker skips
592/// it and backups never appear in search.
593pub(crate) async fn backup_note<P: AsRef<Path>>(
594    workspace_path: P,
595    path: &VaultPath,
596) -> Result<(), FSError> {
597    let workspace_path = workspace_path.as_ref();
598    let src = resolve_path_on_disk(workspace_path, path).await;
599    // Fail closed: only skip the backup when the source is genuinely absent.
600    // A probe error (FS unhealthy) must abort the edit, not silently proceed
601    // without a pre-image.
602    match tokio::fs::try_exists(&src).await {
603        Ok(true) => {}
604        Ok(false) => return Ok(()),
605        Err(e) => return Err(FSError::ReadFileError(e)),
606    }
607
608    let rel = src
609        .strip_prefix(workspace_path)
610        .map_err(|_| FSError::InvalidPath {
611            path: src.to_string_lossy().into_owned(),
612            message: "note path escapes the workspace".to_string(),
613        })?;
614    let backups_root = workspace_path.join(".kimun").join("backups");
615    purge_old_backups(&backups_root).await;
616    let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
617    let base = backups_root.join(date).join(rel);
618    if let Some(parent) = base.parent() {
619        tokio::fs::create_dir_all(parent).await?;
620    }
621    // Reserve a unique name, then stream the source into it — no full read into
622    // memory, and the reserved name can't be clobbered by a concurrent backup.
623    let dest = reserve_backup_dest(&base).await?;
624    tokio::fs::copy(&src, &dest).await?;
625    Ok(())
626}
627
628pub(crate) async fn delete_note<P: AsRef<Path>>(
629    workspace_path: P,
630    path: &VaultPath,
631) -> Result<(), FSError> {
632    let full_path = resolve_path_on_disk(&workspace_path, path).await;
633    tokio::fs::remove_file(full_path).await?;
634    Ok(())
635}
636
637/// Create `dir` and all missing parents. No-op if it already exists.
638pub(crate) fn ensure_dir(dir: &Path) -> Result<(), FSError> {
639    std::fs::create_dir_all(dir).map_err(FSError::ReadFileError)
640}
641
642/// Returns true if anything (file or directory) exists at the resolved
643/// disk path for `path`. Cheaper than `load_note` when the contents are
644/// not needed.
645pub(crate) async fn path_exists<P: AsRef<Path>>(
646    workspace_path: P,
647    path: &VaultPath,
648) -> Result<bool, FSError> {
649    let full_path = resolve_path_on_disk(&workspace_path, path).await;
650    Ok(tokio::fs::try_exists(&full_path).await?)
651}
652
653pub(crate) async fn delete_directory<P: AsRef<Path>>(
654    workspace_path: P,
655    path: &VaultPath,
656) -> Result<(), FSError> {
657    let full_path = resolve_path_on_disk(&workspace_path, path).await;
658    tokio::fs::remove_dir_all(full_path).await?;
659    Ok(())
660}
661
662/// A logical, vault-internal path to a note or directory.
663///
664/// `VaultPath` is the core's single currency for everything inside a vault: it
665/// never refers to a location outside the workspace, and it is portable across
666/// Windows, macOS, and Linux. Components are sanitized and lowercased on
667/// construction (see [`VaultPath::new`]) so that only characters valid on all
668/// three filesystems survive and equality is effectively case-insensitive. The
669/// separator is always [`PATH_SEPARATOR`] (`/`); translation to native OS paths
670/// happens only at the filesystem boundary in `nfs`.
671///
672/// A path may be absolute (rooted at the vault root, rendered with a leading
673/// `/`) or relative, and may contain `.`/`..` components until [`flatten`]ed.
674///
675/// [`flatten`]: VaultPath::flatten
676#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
677pub struct VaultPath {
678    absolute: bool,
679    slices: Vec<VaultPathSlice>,
680}
681
682impl FromStr for VaultPath {
683    type Err = FSError;
684
685    fn from_str(s: &str) -> Result<Self, Self::Err> {
686        Self::from_string(s)
687    }
688}
689
690impl TryFrom<String> for VaultPath {
691    type Error = FSError;
692
693    fn try_from(value: String) -> Result<Self, Self::Error> {
694        Self::from_string(value)
695    }
696}
697
698impl From<&VaultPath> for VaultPath {
699    fn from(value: &VaultPath) -> Self {
700        value.to_owned()
701    }
702}
703
704impl TryFrom<&str> for VaultPath {
705    type Error = FSError;
706    fn try_from(value: &str) -> Result<Self, Self::Error> {
707        VaultPath::from_string(value)
708    }
709}
710
711impl TryFrom<&String> for VaultPath {
712    type Error = FSError;
713
714    fn try_from(value: &String) -> Result<Self, Self::Error> {
715        VaultPath::from_string(value)
716    }
717}
718
719impl Serialize for VaultPath {
720    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
721    where
722        S: serde::Serializer,
723    {
724        let string = self.to_string();
725        serializer.serialize_str(string.as_ref())
726    }
727}
728
729struct DeserializeVaultPathVisitor;
730impl Visitor<'_> for DeserializeVaultPathVisitor {
731    type Value = VaultPath;
732
733    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
734        formatter.write_str("A valid path with `/` separators, no need of starting `/`")
735    }
736    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
737        let path = VaultPath::new(value);
738        Ok(path)
739    }
740}
741
742impl<'de> Deserialize<'de> for VaultPath {
743    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
744    where
745        D: serde::Deserializer<'de>,
746    {
747        deserializer.deserialize_str(DeserializeVaultPathVisitor)
748    }
749}
750
751impl VaultPath {
752    /// Creates a new vault path, for every invalid character
753    /// it gets replaced to an underscore `_`. If you want to validate
754    /// the path first, either use the `VaultPath::From` trait or use
755    /// `VaultPath::is_valid()`
756    pub fn new<S: AsRef<str>>(path: S) -> Self {
757        let mut slices = vec![];
758        let absolute = path.as_ref().starts_with(PATH_SEPARATOR);
759        path.as_ref()
760            .split(PATH_SEPARATOR)
761            .filter(|p| !p.is_empty()) // We remove the empty ones,
762            // so `//` are treated as `/`
763            .for_each(|slice| {
764                slices.push(VaultPathSlice::new(slice));
765            });
766        Self { absolute, slices }
767    }
768
769    fn from_string<S: AsRef<str>>(value: S) -> Result<Self, FSError> {
770        let path = value.as_ref();
771        if Self::is_valid(path) {
772            Ok(Self::new(path))
773        } else {
774            Err(FSError::InvalidPath {
775                path: path.to_string(),
776                message: "path contains invalid characters".to_string(),
777            })
778        }
779    }
780
781    /// Returns `true` if `path` is already a clean vault path needing no
782    /// sanitization: every component is valid on all three target filesystems
783    /// and there are no doubled separators. Use this to validate caller-supplied
784    /// strings up front; [`VaultPath::new`] will instead silently repair them.
785    ///
786    /// ```
787    /// use kimun_core::nfs::VaultPath;
788    /// assert!(VaultPath::is_valid("/projects/notes.md"));
789    /// assert!(!VaultPath::is_valid("bad?name"));
790    /// ```
791    pub fn is_valid<S: AsRef<str>>(path: S) -> bool {
792        // path can only start with one slash `/`
793        if path
794            .as_ref()
795            .starts_with(format!("{}{}", PATH_SEPARATOR, PATH_SEPARATOR).as_str())
796        {
797            return false;
798        }
799        !path
800            .as_ref()
801            .split(PATH_SEPARATOR)
802            .any(|s| !VaultPathSlice::is_valid(s))
803    }
804
805    /// Builds a sanitized note path from `path`, ensuring it ends with the note
806    /// extension. A trailing separator is dropped before the extension is added,
807    /// so `notes/` becomes `notes.md`. Unlike [`with_note_extension`], the rest
808    /// of the string is sanitized through [`VaultPath::new`], so this is the
809    /// correct constructor for real note paths (not search patterns).
810    ///
811    /// ```
812    /// use kimun_core::nfs::VaultPath;
813    /// assert_eq!(VaultPath::note_path_from("projects/todo").to_string(), "projects/todo.md");
814    /// assert_eq!(VaultPath::note_path_from("readme.md").to_string(), "readme.md");
815    /// ```
816    pub fn note_path_from<S: AsRef<str>>(path: S) -> Self {
817        let path = path.as_ref();
818        let path_clean = path.strip_suffix(PATH_SEPARATOR).unwrap_or(path);
819        let p = if !path_clean.ends_with(NOTE_EXTENSION) {
820            [path_clean, NOTE_EXTENSION].concat()
821        } else {
822            path_clean.to_owned()
823        };
824        VaultPath::new(p)
825    }
826
827    /// The vault root: an absolute path with no components, rendered as `/`.
828    ///
829    /// ```
830    /// use kimun_core::nfs::VaultPath;
831    /// assert_eq!(VaultPath::root().to_string(), "/");
832    /// ```
833    pub fn root() -> Self {
834        Self {
835            absolute: true,
836            slices: vec![],
837        }
838    }
839
840    /// The empty relative path: no components and not absolute, rendered as the
841    /// empty string. Distinct from [`root`](VaultPath::root), which is absolute.
842    ///
843    /// ```
844    /// use kimun_core::nfs::VaultPath;
845    /// assert_eq!(VaultPath::empty().to_string(), "");
846    /// ```
847    pub fn empty() -> Self {
848        Self {
849            absolute: false,
850            slices: vec![],
851        }
852    }
853
854    /// Returns `true` when the path has no components, i.e. it is either the
855    /// vault root or the empty path.
856    pub fn is_root_or_empty(&self) -> bool {
857        self.slices.is_empty()
858    }
859
860    /// Returns a variant of this path with its final component's name
861    /// incremented to avoid a collision. A numeric `_N` suffix is added or bumped
862    /// (e.g. `note.md` → `note_0.md`, `note_0.md` → `note_1.md`), preserving the
863    /// note extension. Used to pick a fresh name when the desired one is taken.
864    pub fn get_name_on_conflict(&self) -> VaultPath {
865        let mut slices = self.slices.clone();
866        match slices.pop() {
867            Some(slice) => {
868                if let VaultPathSlice::PathSlice(name) = slice {
869                    let new_name = if let Some(name) = name.strip_suffix(NOTE_EXTENSION) {
870                        format!("{}{}", Self::increment(name), NOTE_EXTENSION)
871                    } else {
872                        Self::increment(name)
873                    };
874                    slices.push(VaultPathSlice::new(new_name));
875                    VaultPath {
876                        absolute: self.absolute,
877                        slices,
878                    }
879                } else {
880                    VaultPath::new("0")
881                }
882            }
883            None => VaultPath::new("0"),
884        }
885    }
886
887    /// Returns the final component's name with the note extension stripped — the
888    /// note's display title as derived from its filename. For directories (no
889    /// extension) this is just the directory name. Compare [`get_name`], which
890    /// keeps the extension.
891    ///
892    /// [`get_name`]: VaultPath::get_name
893    ///
894    /// ```
895    /// use kimun_core::nfs::VaultPath;
896    /// assert_eq!(VaultPath::new("/projects/todo.md").get_clean_name(), "todo");
897    /// ```
898    pub fn get_clean_name(&self) -> String {
899        let name = self.get_name();
900        if let Some(name) = name.strip_suffix(NOTE_EXTENSION) {
901            name.to_string()
902        } else {
903            name
904        }
905    }
906
907    /// Returns the full vault path as a string with the note extension stripped.
908    /// E.g. `/projects/rust-notes.md` → `/projects/rust-notes`
909    /// If the path does not end with the note extension, returns it unchanged.
910    pub fn to_bare_string(&self) -> String {
911        let s = self.to_string();
912        s.strip_suffix(NOTE_EXTENSION)
913            .map(|bare| bare.to_owned())
914            .unwrap_or(s)
915    }
916
917    /// Returns the full vault path as a string, ensuring it ends with the note extension.
918    /// E.g. `/projects/rust-notes` → `/projects/rust-notes.md`
919    /// If the path already ends with the extension, returns it unchanged.
920    pub fn to_string_with_ext(&self) -> String {
921        with_note_extension(self.to_string())
922    }
923
924    fn increment<S: AsRef<str>>(name: S) -> String {
925        let name = name.as_ref();
926        let (n, suffix_num) = if let Some(caps) = RX_INCREMENT_SUFFIX.captures(name) {
927            let suffix = &caps["number"];
928            let n = name
929                .strip_suffix(&format!("_{}", suffix))
930                .map_or_else(|| name.to_string(), |s| s.to_string());
931            (n, suffix.parse::<u64>().map_or_else(|_e| 0, |n| n + 1))
932        } else {
933            (name.to_string(), 0)
934        };
935        format!("{}_{}", n, suffix_num)
936    }
937
938    /// Returns the path's components as plain strings, after [`flatten`]ing
939    /// (so no `.`/`..` entries remain). Useful for walking the path level by
940    /// level.
941    ///
942    /// [`flatten`]: VaultPath::flatten
943    ///
944    /// ```
945    /// use kimun_core::nfs::VaultPath;
946    /// assert_eq!(VaultPath::new("/a/b/c.md").get_slices(), vec!["a", "b", "c.md"]);
947    /// ```
948    pub fn get_slices(&self) -> Vec<String> {
949        self.flatten()
950            .slices
951            .iter()
952            .map(|slice| slice.to_string())
953            .collect()
954    }
955
956    /// Joins this path onto `workspace_path` to produce the canonical on-disk
957    /// `PathBuf`, mapping `/` to native separators and [`flatten`]ing first.
958    ///
959    /// This is the *canonical* (lowercase) location only; it does not perform
960    /// case-insensitive resolution, so an existing file stored under a different
961    /// case will not be found. Use the `nfs` resolver for that.
962    ///
963    /// [`flatten`]: VaultPath::flatten
964    pub fn to_pathbuf<P: AsRef<Path>>(&self, workspace_path: P) -> PathBuf {
965        let mut path = workspace_path.as_ref().to_path_buf();
966        for p in &self.flatten().slices {
967            let slice = p.to_string();
968            path = path.join(&slice);
969        }
970        path
971    }
972
973    /// Returns a full path without any relative slices
974    /// If it tries to go up beyond the current path, drops a warning
975    pub fn flatten(&self) -> VaultPath {
976        let mut slices = vec![];
977        for slice in &self.slices {
978            match slice {
979                VaultPathSlice::PathSlice(_name) => slices.push(slice.clone()),
980                VaultPathSlice::Up => {
981                    if slices.pop().is_none() {
982                        warn!("Trying to move a directory up from root")
983                    }
984                }
985                VaultPathSlice::Current => {}
986            }
987        }
988        VaultPath {
989            absolute: self.absolute,
990            slices,
991        }
992    }
993
994    /// Returns the last part of the path slices
995    /// if it is a note, will return the note filename, if it is a directory, will return the directory name
996    pub fn get_name(&self) -> String {
997        self.flatten().slices.last().map_or_else(String::new, |s| {
998            if let VaultPathSlice::PathSlice(name) = s {
999                name.to_owned()
1000            } else {
1001                String::new()
1002            }
1003        })
1004    }
1005
1006    /// Returns the path of `self` written relative to a note file's *directory*.
1007    ///
1008    /// Markdown engines resolve relative links against the containing folder,
1009    /// not the note file itself. Linking from `/notes/journal/today.md` to
1010    /// `/assets/img.png` therefore produces `../../assets/img.png` (two `..`s
1011    /// — for `journal/` and `notes/`), not three. This wraps
1012    /// [`Self::get_relative_to`] using the note's parent path so callers get the
1013    /// markdown-correct result.
1014    pub fn relative_link_from_note(&self, note_path: &VaultPath) -> VaultPath {
1015        let (parent, _) = note_path.flatten().get_parent_path();
1016        self.flatten().get_relative_to(&parent)
1017    }
1018
1019    /// Resolve `self` as a link target written inside `note_path`.
1020    ///
1021    /// Inverse of [`Self::relative_link_from_note`]: markdown links resolve against
1022    /// the *directory* containing the note, so a `../work/anton.md` target in
1023    /// `/journal/today.md` resolves to `/work/anton.md` (flattened, absolute).
1024    /// Absolute targets are returned flattened as-is. A bare filename with no
1025    /// directory part (e.g. `anton.md`) is returned unchanged so callers can
1026    /// fall back to a vault-wide name lookup (wiki-style links).
1027    pub fn resolve_link_in_note(&self, note_path: &VaultPath) -> VaultPath {
1028        if self.is_note_file() {
1029            return self.clone();
1030        }
1031        let (parent, _) = note_path.flatten().get_parent_path();
1032        parent.append(self).flatten().absolute()
1033    }
1034
1035    /// Expresses this path relative to `reference_path`, walking up with `..`
1036    /// for each component of the reference not shared with this path, then down
1037    /// into this path's remaining components. The result is always relative.
1038    ///
1039    /// Note `reference_path` is treated as a directory: every one of its trailing
1040    /// components becomes a `..`. To build a markdown link relative to a note
1041    /// *file*, use [`relative_link_from_note`], which accounts for the note's own
1042    /// filename.
1043    ///
1044    /// [`relative_link_from_note`]: VaultPath::relative_link_from_note
1045    ///
1046    /// ```
1047    /// use kimun_core::nfs::VaultPath;
1048    /// let from = VaultPath::new("/main/path/first");
1049    /// let target = VaultPath::new("/main/second");
1050    /// assert_eq!(target.get_relative_to(&from).to_string(), "../../second");
1051    /// ```
1052    pub fn get_relative_to(&self, reference_path: &VaultPath) -> VaultPath {
1053        let mut slices = vec![];
1054        let ref_slices = reference_path.slices.clone();
1055        let mut position = 0;
1056        for (pos, slice) in self.slices.iter().enumerate() {
1057            position = pos;
1058            if let Some(reference) = ref_slices.get(pos) {
1059                if !slice.eq(reference) {
1060                    break;
1061                }
1062            } else {
1063                break;
1064            }
1065        }
1066        ref_slices.iter().skip(position).for_each(|_| {
1067            slices.push(VaultPathSlice::Up);
1068        });
1069        self.slices.iter().skip(position).for_each(|slice| {
1070            slices.push(slice.to_owned());
1071        });
1072
1073        VaultPath {
1074            absolute: false,
1075            slices,
1076        }
1077    }
1078
1079    /// Converts a real on-disk path back into an absolute vault path by
1080    /// stripping the `workspace_path` prefix. Returns `FSError::InvalidPath` if
1081    /// `full_path` does not live inside the workspace. Each OS component is run
1082    /// through [`VaultPath::new`], so the result is sanitized and lowercased.
1083    pub fn from_path<P: AsRef<Path>, F: AsRef<Path>>(
1084        workspace_path: P,
1085        full_path: F,
1086    ) -> Result<Self, FSError> {
1087        let fp = full_path.as_ref();
1088        let relative = fp
1089            .strip_prefix(&workspace_path)
1090            .map_err(|_e| FSError::InvalidPath {
1091                path: path_to_string(&full_path),
1092                message: format!(
1093                    "The path provided is not a path belonging to the workspace: {}",
1094                    path_to_string(workspace_path)
1095                ),
1096            })?;
1097        let mut path_list = vec![PATH_SEPARATOR.to_string()];
1098        relative.components().for_each(|component| {
1099            let os_str = component.as_os_str();
1100            let slice = match os_str.to_str() {
1101                Some(comp) => comp.to_owned(),
1102                None => os_str.to_string_lossy().to_string(),
1103            };
1104            path_list.push(slice);
1105        });
1106        let pl = path_list.join(PATH_SEPARATOR.to_string().as_str());
1107
1108        Ok(VaultPath::new(pl).absolute())
1109    }
1110
1111    /// Returns `true` if this path is a *bare* note filename: a single,
1112    /// relative component ending in the note extension, with no directory part
1113    /// (e.g. `anton.md`). Such paths are the signal for a vault-wide, wiki-style
1114    /// name lookup rather than a directory-scoped path match.
1115    ///
1116    /// ```
1117    /// use kimun_core::nfs::VaultPath;
1118    /// assert!(VaultPath::new("anton.md").is_note_file());
1119    /// assert!(!VaultPath::new("/work/anton.md").is_note_file());
1120    /// ```
1121    pub fn is_note_file(&self) -> bool {
1122        match self.slices.last() {
1123            Some(path_slice) => path_slice.is_note() && self.slices.len() == 1 && !self.absolute,
1124            None => false,
1125        }
1126    }
1127
1128    /// Returns `true` if this path points at a note, i.e. its final component
1129    /// ends with the note extension. Unlike [`is_note_file`], the path may have
1130    /// any number of directory components.
1131    ///
1132    /// [`is_note_file`]: VaultPath::is_note_file
1133    pub fn is_note(&self) -> bool {
1134        match self.slices.last() {
1135            Some(path_slice) => path_slice.is_note(),
1136            None => false,
1137        }
1138    }
1139
1140    /// Returns Ok if the path looks like a note path; otherwise an `InvalidPath` error.
1141    pub fn ensure_note(&self) -> Result<(), FSError> {
1142        if self.is_note() {
1143            Ok(())
1144        } else {
1145            Err(FSError::InvalidPath {
1146                path: self.to_string(),
1147                message: "The path is not a note".to_string(),
1148            })
1149        }
1150    }
1151
1152    /// Returns Ok if the path does not have a note extension; otherwise an `InvalidPath` error.
1153    pub fn ensure_directory(&self) -> Result<(), FSError> {
1154        if self.is_note() {
1155            Err(FSError::InvalidPath {
1156                path: self.to_string(),
1157                message: "The path is not a directory".to_string(),
1158            })
1159        } else {
1160            Ok(())
1161        }
1162    }
1163
1164    /// Returns `true` if this path is relative (not rooted at the vault root).
1165    pub fn is_relative(&self) -> bool {
1166        !self.absolute
1167    }
1168
1169    /// Returns `true` if this path is absolute (rooted at the vault root).
1170    pub fn is_absolute(&self) -> bool {
1171        self.absolute
1172    }
1173
1174    /// Marks this path absolute in place.
1175    pub fn to_absolute(&mut self) {
1176        self.absolute = true;
1177    }
1178
1179    /// Consumes the path and returns it marked absolute (builder-style sibling
1180    /// of [`to_absolute`](VaultPath::to_absolute)).
1181    pub fn absolute(mut self) -> Self {
1182        self.absolute = true;
1183        self
1184    }
1185
1186    /// Marks this path relative in place.
1187    pub fn to_relative(&mut self) {
1188        self.absolute = false;
1189    }
1190
1191    /// Splits the path into its parent path and the final component's name.
1192    /// The parent keeps this path's absoluteness; the name is the empty string
1193    /// when the path has no components.
1194    ///
1195    /// ```
1196    /// use kimun_core::nfs::VaultPath;
1197    /// let (parent, name) = VaultPath::new("/a/b/c.md").get_parent_path();
1198    /// assert_eq!(parent.to_string(), "/a/b");
1199    /// assert_eq!(name, "c.md");
1200    /// ```
1201    pub fn get_parent_path(&self) -> (VaultPath, String) {
1202        let mut new_path = self.slices.clone();
1203        let current = new_path
1204            .pop()
1205            .map_or_else(|| "".to_string(), |s| s.to_string());
1206
1207        (
1208            Self {
1209                absolute: self.absolute,
1210                slices: new_path,
1211            },
1212            current,
1213        )
1214    }
1215
1216    /// Appends `path` to this one. If `path` is absolute it wins outright and is
1217    /// returned as-is; otherwise its components are concatenated onto this path,
1218    /// keeping this path's absoluteness. The result is not flattened, so any
1219    /// `..` in `path` survives until [`flatten`] is called.
1220    ///
1221    /// [`flatten`]: VaultPath::flatten
1222    ///
1223    /// ```
1224    /// use kimun_core::nfs::VaultPath;
1225    /// let base = VaultPath::new("/main/path");
1226    /// let rel = VaultPath::new("sub/note.md");
1227    /// assert_eq!(base.append(&rel).to_string(), "/main/path/sub/note.md");
1228    /// ```
1229    pub fn append(&self, path: &VaultPath) -> VaultPath {
1230        if !path.is_relative() {
1231            // Absolute paths are absolute
1232            path.to_owned()
1233        } else {
1234            let mut slices = self.slices.clone();
1235            let mut other_slices = path.slices.clone();
1236            slices.append(&mut other_slices);
1237            VaultPath {
1238                absolute: self.absolute,
1239                slices,
1240            }
1241        }
1242    }
1243
1244    /// Compares two paths by components only, ignoring whether each is absolute
1245    /// or relative. So `/a/b` is "like" `a/b`.
1246    ///
1247    /// ```
1248    /// use kimun_core::nfs::VaultPath;
1249    /// assert!(VaultPath::new("/a/b").is_like(&VaultPath::new("a/b")));
1250    /// ```
1251    pub fn is_like(&self, other: &VaultPath) -> bool {
1252        self.slices.eq(&other.slices)
1253    }
1254}
1255
1256impl Display for VaultPath {
1257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1258        if self.absolute {
1259            write!(f, "{}", PATH_SEPARATOR)?;
1260        }
1261        write!(
1262            f,
1263            "{}",
1264            self.slices
1265                .iter()
1266                .map(|s| s.to_string())
1267                .collect::<Vec<String>>()
1268                .join(&PATH_SEPARATOR.to_string())
1269        )
1270    }
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1274enum VaultPathSlice {
1275    PathSlice(String),
1276    Up,
1277    Current,
1278}
1279
1280impl VaultPathSlice {
1281    fn new<S: AsRef<str>>(slice: S) -> Self {
1282        // Replace runs of leading dots so "..foo" becomes "__foo".
1283        let slice = if filename::RX_PATH_NAME.is_match(slice.as_ref()) {
1284            slice.as_ref().replace(".", "_")
1285        } else {
1286            slice.as_ref().to_string()
1287        };
1288        if slice.eq("..") {
1289            VaultPathSlice::Up
1290        } else if slice.eq(".") {
1291            VaultPathSlice::Current
1292        } else {
1293            // Replace invalid chars, lowercase, strip leading/trailing spaces and
1294            // trailing dots (Windows silently strips them, causing silent collisions).
1295            let sanitized = filename::RX_PATH_CHARS
1296                .replace_all(&slice, "_")
1297                .to_lowercase();
1298            let sanitized = sanitized.trim().trim_end_matches('.').to_string();
1299            // Prefix Windows reserved device names so they don't map to device handles.
1300            let final_slice = if filename::RX_WIN_RESERVED.is_match(&sanitized) {
1301                format!("_{}", sanitized)
1302            } else {
1303                sanitized
1304            };
1305
1306            VaultPathSlice::PathSlice(final_slice)
1307        }
1308    }
1309
1310    fn is_valid<S: AsRef<str>>(slice: S) -> bool {
1311        let slice = slice.as_ref();
1312        if slice == "." || slice == ".." {
1313            return true;
1314        }
1315        !filename::RX_PATH_CHARS.is_match(slice)
1316            && !filename::RX_PATH_NAME.is_match(slice)
1317            && !filename::RX_WIN_RESERVED.is_match(slice)
1318            && !slice.ends_with('.')
1319            && !slice.starts_with(' ')
1320            && !slice.ends_with(' ')
1321    }
1322
1323    fn is_note(&self) -> bool {
1324        match self {
1325            VaultPathSlice::PathSlice(name) => name.ends_with(NOTE_EXTENSION),
1326            _ => false,
1327        }
1328    }
1329}
1330
1331impl Display for VaultPathSlice {
1332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1333        match self {
1334            VaultPathSlice::PathSlice(name) => write!(f, "{}", name),
1335            VaultPathSlice::Up => write!(f, ".."),
1336            VaultPathSlice::Current => write!(f, "."),
1337        }
1338    }
1339}
1340
1341fn filter_files(dir: &ignore::DirEntry) -> bool {
1342    // Prune dotfile / dot-directory entries (e.g. the hidden `.kimun` backups
1343    // dir) so they never enter the index. `path().starts_with(".")` does NOT
1344    // work here — the walker root is an absolute path, so an entry's path never
1345    // begins with "."; check the entry's own name instead. The `ignore` crate's
1346    // default hidden filter also covers these, but excluding them explicitly
1347    // keeps the walk correct even if that default is ever disabled.
1348    dir.file_name()
1349        .to_str()
1350        .map(|name| !name.starts_with('.'))
1351        .unwrap_or(true)
1352}
1353
1354pub(crate) fn list_directories<P: AsRef<Path>>(
1355    base_path: P,
1356    path: &VaultPath,
1357    recursive: bool,
1358) -> Result<Vec<super::DirectoryDetails>, FSError> {
1359    let base_path = base_path.as_ref();
1360    let os_path = resolve_path_on_disk_sync(base_path, path);
1361    let walker = WalkBuilder::new(&os_path)
1362        .max_depth(if recursive { None } else { Some(1) })
1363        .filter_entry(filter_files)
1364        .build();
1365
1366    let mut dirs = Vec::new();
1367    for entry in walker.flatten() {
1368        let entry_path = entry.path();
1369        if entry_path.is_dir() && entry_path != os_path {
1370            let vault_path = VaultPath::from_path(base_path, entry_path)?;
1371            dirs.push(super::DirectoryDetails { path: vault_path });
1372        }
1373    }
1374    Ok(dirs)
1375}
1376
1377pub(crate) fn get_file_walker<P: AsRef<Path>>(
1378    base_path: P,
1379    path: &VaultPath,
1380    recurse: bool,
1381) -> WalkParallel {
1382    let w = WalkBuilder::new(resolve_path_on_disk_sync(base_path, path))
1383        .max_depth(if recurse { None } else { Some(1) })
1384        .filter_entry(filter_files)
1385        // .threads(0)
1386        .build_parallel();
1387
1388    w
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393    use std::path::{Path, PathBuf};
1394
1395    use super::{save_attachment, with_note_extension};
1396
1397    #[test]
1398    fn with_note_extension_appends_when_missing() {
1399        assert_eq!(with_note_extension("projects"), "projects.md");
1400    }
1401
1402    #[test]
1403    fn with_note_extension_keeps_when_present() {
1404        assert_eq!(with_note_extension("projects.md"), "projects.md");
1405    }
1406
1407    #[test]
1408    fn with_note_extension_preserves_wildcards_and_path() {
1409        // Unlike VaultPath, this does not sanitize `*` so search wildcards survive.
1410        assert_eq!(with_note_extension("work/proj*"), "work/proj*.md");
1411    }
1412
1413    /// Returns true if the filesystem at `dir` is case-sensitive.
1414    /// Used to skip "no duplicate lowercase entry" assertions on macOS and other
1415    /// platforms that use a case-insensitive filesystem by default.
1416    fn is_case_sensitive_fs(dir: &Path) -> bool {
1417        // Write a probe file with a known uppercase name, then check whether the
1418        // lowercase variant resolves to the same entry or is absent.
1419        let upper = dir.join("__CaseSensitivityProbe__");
1420        std::fs::write(&upper, "").unwrap();
1421        let result = !dir.join("__casesensitivityprobe__").exists();
1422        std::fs::remove_file(&upper).unwrap();
1423        result
1424    }
1425
1426    use crate::{
1427        error::FSError,
1428        nfs::{
1429            create_directory, delete_directory, delete_note, rename_directory, rename_note,
1430            save_note, DirectoryEntryData, EntryData, VaultEntry, VaultEntryDetails,
1431        },
1432        utilities::path_to_string,
1433        DirectoryDetails, NoteDetails,
1434    };
1435
1436    use super::{load_note, VaultPath, VaultPathSlice};
1437
1438    // --- cross-platform character validation tests ---
1439
1440    #[test]
1441    fn control_chars_are_invalid() {
1442        // Control characters U+0001–U+001F must be rejected (Windows forbids them)
1443        assert!(!VaultPath::is_valid("note\x01name"));
1444        assert!(!VaultPath::is_valid("dir\x1fname"));
1445    }
1446
1447    #[test]
1448    fn control_chars_are_sanitized_in_new() {
1449        let path = VaultPath::new("note\x07name");
1450        assert_eq!("note_name", path.to_string());
1451    }
1452
1453    #[test]
1454    fn windows_reserved_names_are_invalid() {
1455        // Windows device names must be rejected regardless of extension or case
1456        for name in &["CON", "PRN", "AUX", "NUL", "COM1", "COM9", "LPT1", "LPT9"] {
1457            assert!(!VaultPath::is_valid(name), "{name} should be invalid");
1458            assert!(
1459                !VaultPath::is_valid(format!("{name}.md")),
1460                "{name}.md should be invalid"
1461            );
1462        }
1463        // Lower-case variants too
1464        assert!(!VaultPath::is_valid("con.md"));
1465        assert!(!VaultPath::is_valid("nul"));
1466    }
1467
1468    #[test]
1469    fn windows_reserved_names_are_sanitized_in_new() {
1470        // VaultPath::new should prefix reserved names with '_' so they don't map to
1471        // Windows device handles. The name is already lowercased by this point.
1472        let path = VaultPath::new("con.md");
1473        assert_eq!("_con.md", path.to_string());
1474
1475        let path = VaultPath::new("nul");
1476        assert_eq!("_nul", path.to_string());
1477
1478        let path = VaultPath::new("COM1.md");
1479        assert_eq!("_com1.md", path.to_string());
1480    }
1481
1482    #[test]
1483    fn trailing_dot_is_invalid() {
1484        // Windows silently strips trailing dots from filenames
1485        assert!(!VaultPath::is_valid("notes."));
1486        assert!(!VaultPath::is_valid("dir./sub"));
1487    }
1488
1489    #[test]
1490    fn trailing_dot_is_sanitized_in_new() {
1491        let path = VaultPath::new("notes./sub");
1492        // trailing dot stripped from directory component
1493        assert_eq!("notes/sub", path.to_string());
1494    }
1495
1496    #[test]
1497    fn leading_or_trailing_spaces_are_invalid() {
1498        assert!(!VaultPath::is_valid(" note"));
1499        assert!(!VaultPath::is_valid("note "));
1500        assert!(!VaultPath::is_valid(" dir /sub"));
1501    }
1502
1503    #[test]
1504    fn leading_and_trailing_spaces_are_sanitized_in_new() {
1505        let path = VaultPath::new(" note ");
1506        assert_eq!("note", path.to_string());
1507    }
1508
1509    #[test]
1510    fn should_print_correctly() {
1511        let path_with_root = "/some/path";
1512        let path_without_root = "another/one";
1513
1514        let path1 = VaultPath::new(path_with_root);
1515        let path2 = VaultPath::new(path_without_root);
1516
1517        assert_eq!("/some/path".to_string(), path1.to_string());
1518        assert_eq!("another/one".to_string(), path2.to_string());
1519    }
1520
1521    #[test]
1522    fn test_valid_path() {
1523        let path = "/some/path.md";
1524        assert!(VaultPath::is_valid(path));
1525    }
1526
1527    #[test]
1528    fn test_rel_path() {
1529        let path = VaultPath::new("../some/path.md");
1530        assert_eq!("../some/path.md", path.to_string());
1531        assert!(path.is_relative());
1532    }
1533
1534    #[test]
1535    fn join_two_paths() {
1536        let path1 = VaultPath::new("main/path");
1537        let path2 = VaultPath::new("sub/path");
1538        let joined = path1.append(&path2);
1539        assert_eq!("main/path/sub/path".to_string(), joined.to_string());
1540    }
1541
1542    #[test]
1543    fn join_two_paths_with_relative() {
1544        let path1 = VaultPath::new("/main/path");
1545        let path2 = VaultPath::new("../sub/path");
1546        let joined = path1.append(&path2).flatten();
1547        assert_eq!("/main/sub/path".to_string(), joined.to_string());
1548    }
1549
1550    #[test]
1551    fn path_with_up_dir_end() {
1552        let path = VaultPath::new("/main/path/..");
1553        assert_eq!("/main".to_string(), path.flatten().to_string());
1554    }
1555
1556    #[test]
1557    fn from_current_path() {
1558        let path = VaultPath::new("./path/subpath");
1559        assert!(!path.flatten().absolute);
1560        assert_eq!("path/subpath", path.flatten().to_string());
1561    }
1562
1563    #[test]
1564    fn only_dots_three_or_more_not_allowed_in_path() {
1565        let path = "/some/.../path";
1566        assert!(!VaultPath::is_valid(path));
1567
1568        let vault_path = VaultPath::new(path);
1569        assert_eq!("/some/___/path", vault_path.to_string());
1570    }
1571
1572    #[test]
1573    fn get_relative_to() {
1574        let path1 = VaultPath::new("/main/path/first");
1575        let path2 = VaultPath::new("/main/second");
1576        let rel = path2.get_relative_to(&path1);
1577
1578        assert_eq!("../../second".to_string(), rel.to_string());
1579    }
1580
1581    #[test]
1582    fn get_relative_to_less_deep() {
1583        let path1 = VaultPath::new("/main/second");
1584        let path2 = VaultPath::new("/main/path/first");
1585        let rel = path2.get_relative_to(&path1);
1586
1587        assert_eq!("../path/first".to_string(), rel.to_string());
1588    }
1589
1590    #[test]
1591    fn get_relative_to_same() {
1592        let path1 = VaultPath::new("/main/second");
1593        let path2 = VaultPath::new("/main/second/sub/deep");
1594        let rel = path2.get_relative_to(&path1);
1595
1596        assert_eq!("sub/deep".to_string(), rel.to_string());
1597    }
1598
1599    #[test]
1600    fn relative_link_from_note_uses_parent_dir() {
1601        let note = VaultPath::new("/notes/journal/today.md");
1602        let asset = VaultPath::new("/assets/img.png");
1603        assert_eq!(
1604            "../../assets/img.png",
1605            asset.relative_link_from_note(&note).to_string()
1606        );
1607    }
1608
1609    #[test]
1610    fn relative_link_from_root_note_to_assets() {
1611        let note = VaultPath::new("/note.md");
1612        let asset = VaultPath::new("/assets/img.png");
1613        assert_eq!(
1614            "assets/img.png",
1615            asset.relative_link_from_note(&note).to_string()
1616        );
1617    }
1618
1619    #[test]
1620    fn relative_link_to_sibling_dir() {
1621        let note = VaultPath::new("/notes/today.md");
1622        let asset = VaultPath::new("/notes/assets/img.png");
1623        assert_eq!(
1624            "assets/img.png",
1625            asset.relative_link_from_note(&note).to_string()
1626        );
1627    }
1628
1629    #[test]
1630    fn resolve_link_in_note_walks_up_and_lowercases() {
1631        let note = VaultPath::new("/journal/2026-03-01.md");
1632        let target = VaultPath::note_path_from("../Work/People/anton.md");
1633        assert_eq!(
1634            "/work/people/anton.md",
1635            target.resolve_link_in_note(&note).to_string()
1636        );
1637    }
1638
1639    #[test]
1640    fn resolve_link_in_note_keeps_bare_name_for_name_lookup() {
1641        let note = VaultPath::new("/journal/2026-03-01.md");
1642        let target = VaultPath::note_path_from("anton.md");
1643        // Bare name unchanged (relative, single slice) so open_or_search does a
1644        // vault-wide name lookup rather than a directory-scoped path match.
1645        let resolved = target.resolve_link_in_note(&note);
1646        assert_eq!("anton.md", resolved.to_string());
1647        assert!(resolved.is_note_file());
1648    }
1649
1650    #[test]
1651    fn resolve_link_in_note_absolute_target_unchanged() {
1652        let note = VaultPath::new("/journal/2026-03-01.md");
1653        let target = VaultPath::note_path_from("/work/people/anton.md");
1654        assert_eq!(
1655            "/work/people/anton.md",
1656            target.resolve_link_in_note(&note).to_string()
1657        );
1658    }
1659
1660    #[test]
1661    fn resolve_link_in_note_sibling_subdir() {
1662        let note = VaultPath::new("/journal/2026-03-01.md");
1663        let target = VaultPath::note_path_from("attachments/notes.md");
1664        assert_eq!(
1665            "/journal/attachments/notes.md",
1666            target.resolve_link_in_note(&note).to_string()
1667        );
1668    }
1669
1670    #[test]
1671    fn get_root() {
1672        let vault_path = VaultPath::root();
1673        assert_eq!("/".to_string(), vault_path.to_string());
1674
1675        let root_path = VaultPath::new("/");
1676        assert_eq!(root_path, vault_path);
1677    }
1678
1679    #[test]
1680    fn get_empty() {
1681        let vault_path = VaultPath::empty();
1682        assert_eq!("".to_string(), vault_path.to_string());
1683
1684        let root_path = VaultPath::new("");
1685        assert_eq!(root_path, vault_path);
1686    }
1687
1688    #[test]
1689    fn should_tell_if_its_note() {
1690        let path = "/some/../path.md";
1691        assert!(VaultPath::new(path).is_note());
1692    }
1693
1694    #[test]
1695    fn paths_should_flatten_correctly() {
1696        let path = "some/path/../hola";
1697        assert!(VaultPath::is_valid(path));
1698
1699        let vault_path = VaultPath::from_string(path).unwrap();
1700        let vault_path = vault_path.flatten();
1701
1702        assert_eq!("some/hola".to_string(), vault_path.to_string());
1703    }
1704
1705    #[test]
1706    fn test_file_should_not_look_like_url() {
1707        let valid = VaultPath::is_valid("http://example.com");
1708
1709        assert!(!valid);
1710    }
1711
1712    #[tokio::test]
1713    async fn test_file_not_exists() {
1714        let path = VaultPath::new("don't exist");
1715        let res = load_note(std::env::current_dir().unwrap(), &path).await;
1716
1717        let result = if let Err(e) = res {
1718            matches!(e, FSError::VaultPathNotFound { path: _ })
1719        } else {
1720            false
1721        };
1722
1723        assert!(result);
1724    }
1725
1726    #[test]
1727    fn test_slice_char_replace() {
1728        let slice_str = "Some?unvalid:Chars?";
1729        let slice = VaultPathSlice::new(slice_str);
1730
1731        assert_eq!("some_unvalid_chars_", slice.to_string());
1732        if let VaultPathSlice::PathSlice(name) = slice {
1733            assert_eq!("some_unvalid_chars_", name);
1734        }
1735    }
1736
1737    #[test]
1738    fn test_path_create_from_string() {
1739        let path = "this/is/five/level/path";
1740        let path = VaultPath::new(path);
1741
1742        assert_eq!(5, path.slices.len());
1743        assert_eq!("this", path.slices[0].to_string());
1744        assert_eq!("is", path.slices[1].to_string());
1745        assert_eq!("five", path.slices[2].to_string());
1746        assert_eq!("level", path.slices[3].to_string());
1747        assert_eq!("path", path.slices[4].to_string());
1748    }
1749
1750    #[test]
1751    fn test_path_with_unvalid_chars() {
1752        let path = "t*his/i+s/caca?/";
1753        let path = VaultPath::new(path);
1754
1755        assert_eq!(3, path.slices.len());
1756        assert_eq!("t_his", path.slices[0].to_string());
1757        assert_eq!("i+s", path.slices[1].to_string());
1758        assert_eq!("caca_", path.slices[2].to_string());
1759    }
1760
1761    #[test]
1762    fn test_to_path_buf() {
1763        let workspace_path = PathBuf::from("workspace");
1764        let sep = std::path::MAIN_SEPARATOR_STR;
1765
1766        let path = "/some/subpath";
1767        let path = VaultPath::new(path);
1768        let path_buf = path.to_pathbuf(&workspace_path);
1769
1770        let path_string = path_to_string(path_buf);
1771        let expected_path_str = format!("workspace{sep}some{sep}subpath");
1772        assert_eq!(expected_path_str, path_string);
1773    }
1774
1775    #[test]
1776    fn test_path_check_valid() {
1777        let path = PathBuf::from("/some/valid/path/workspace/note.md");
1778        let workspace = PathBuf::from("/some/valid/path");
1779
1780        let entry = VaultPath::from_path(&workspace, &path).unwrap();
1781
1782        assert_eq!("/workspace/note.md", entry.to_string());
1783    }
1784
1785    #[tokio::test]
1786    async fn create_a_note() {
1787        use tempfile::TempDir;
1788
1789        let temp_dir = TempDir::new().unwrap();
1790        let workspace_path = temp_dir.path();
1791        let note_path = VaultPath::new("note.md");
1792        let note_text = "this is an empty note".to_string();
1793
1794        let res = save_note(workspace_path, &note_path, &note_text).await;
1795        if let Err(e) = &res {
1796            panic!("Error saving note: {e}")
1797        }
1798
1799        let note = load_note(workspace_path, &note_path).await;
1800        if let Err(e) = &note {
1801            panic!("Error loading note: {e}")
1802        }
1803        assert_eq!(note.unwrap(), note_text);
1804
1805        let del_res = delete_note(workspace_path, &note_path).await;
1806        if let Err(e) = &del_res {
1807            panic!("Error deleting note: {e}")
1808        }
1809        assert!(load_note(workspace_path, &note_path).await.is_err());
1810    }
1811
1812    #[tokio::test]
1813    async fn move_a_note() {
1814        use tempfile::TempDir;
1815
1816        let temp_dir = TempDir::new().unwrap();
1817        let workspace_path = temp_dir.path();
1818        let note_path = VaultPath::new("note.md");
1819        let dest_note_path = VaultPath::new("directory/moved_note.md");
1820        let note_text = "this is an empty note".to_string();
1821
1822        let res = save_note(workspace_path, &note_path, &note_text).await;
1823        if let Err(e) = &res {
1824            panic!("Error saving note: {e}")
1825        }
1826        let note = load_note(workspace_path, &note_path).await;
1827        if let Err(e) = &note {
1828            panic!("Error loading note: {e}")
1829        }
1830        assert_eq!(note.as_ref().unwrap().to_owned(), note_text);
1831
1832        let ren_res = rename_note(workspace_path, &note_path, &dest_note_path).await;
1833        if let Err(e) = &ren_res {
1834            panic!("Error renaming note: {e}")
1835        }
1836        let moved_note = load_note(workspace_path, &dest_note_path).await;
1837        if let Err(e) = &moved_note {
1838            panic!("Error loading note: {e}")
1839        }
1840        assert_eq!(note.unwrap(), moved_note.unwrap());
1841        assert!(load_note(workspace_path, &note_path).await.is_err());
1842
1843        let del_res = delete_note(workspace_path, &dest_note_path).await;
1844        if let Err(e) = &del_res {
1845            panic!("Error deleting note: {e}")
1846        }
1847        assert!(load_note(workspace_path, &dest_note_path).await.is_err());
1848
1849        let del_res = delete_directory(workspace_path, &dest_note_path.get_parent_path().0).await;
1850        if let Err(e) = &del_res {
1851            panic!("Error deleting directory: {e}")
1852        }
1853    }
1854
1855    #[tokio::test]
1856    async fn move_a_directory() -> Result<(), FSError> {
1857        use tempfile::TempDir;
1858
1859        let temp_dir = TempDir::new().unwrap();
1860        let workspace_path = temp_dir.path();
1861        let from_note_dir = VaultPath::new("old_dir");
1862        let from_note_path = from_note_dir.append(&VaultPath::new("note.md"));
1863        let dest_note_dir = VaultPath::new("new_dir/two_levels");
1864        let dest_note_path = dest_note_dir.append(&VaultPath::new("note.md"));
1865        let note_text = "this is an empty note".to_string();
1866
1867        save_note(workspace_path, &from_note_path, &note_text).await?;
1868        let note = load_note(workspace_path, &from_note_path).await?;
1869        assert_eq!(note, note_text);
1870
1871        rename_directory(workspace_path, &from_note_dir, &dest_note_dir).await?;
1872        let moved_note = load_note(workspace_path, &dest_note_path).await?;
1873        assert_eq!(note, moved_note);
1874        assert!(load_note(workspace_path, &from_note_dir).await.is_err());
1875
1876        delete_note(workspace_path, &dest_note_path).await?;
1877        assert!(load_note(workspace_path, &dest_note_path).await.is_err());
1878
1879        let first_level = dest_note_path.get_parent_path().0;
1880        let second_level = first_level.get_parent_path().0;
1881        delete_directory(workspace_path, &first_level).await?;
1882        delete_directory(workspace_path, &second_level).await?;
1883
1884        Ok(())
1885    }
1886
1887    // Additional comprehensive tests for NFS module
1888
1889    #[tokio::test]
1890    async fn test_vault_entry_new_with_directory() {
1891        use tempfile::TempDir;
1892
1893        let temp_dir = TempDir::new().unwrap();
1894        let workspace_path = temp_dir.path();
1895        let dir_path = VaultPath::new("test_directory");
1896
1897        // Create directory first
1898        tokio::fs::create_dir_all(workspace_path.join("test_directory"))
1899            .await
1900            .ok();
1901
1902        let result = VaultEntry::new(workspace_path, dir_path.clone()).await;
1903        assert!(result.is_ok());
1904
1905        let entry = result.unwrap();
1906        assert_eq!(entry.path, dir_path);
1907        assert_eq!(entry.path_string, dir_path.to_string());
1908
1909        match entry.data {
1910            EntryData::Directory(dir_data) => {
1911                assert_eq!(dir_data.path, dir_path);
1912            }
1913            _ => panic!("Expected Directory entry data"),
1914        }
1915
1916        // Cleanup
1917        tokio::fs::remove_dir_all(workspace_path.join("test_directory"))
1918            .await
1919            .ok();
1920    }
1921
1922    #[tokio::test]
1923    async fn test_vault_entry_new_with_note() {
1924        let workspace_path = Path::new("testdata");
1925        let note_path = VaultPath::new("test_note.md");
1926        let note_content = "# Test Note\n\nThis is a test.";
1927
1928        // Create note first
1929        save_note(workspace_path, &note_path, note_content)
1930            .await
1931            .unwrap();
1932
1933        let result = VaultEntry::new(workspace_path, note_path.clone()).await;
1934        assert!(result.is_ok());
1935
1936        let entry = result.unwrap();
1937        assert_eq!(entry.path, note_path);
1938
1939        match entry.data {
1940            EntryData::Note(note_data) => {
1941                assert_eq!(note_data.path, note_path);
1942                assert!(note_data.size > 0);
1943                assert!(note_data.modified_secs > 0);
1944            }
1945            _ => panic!("Expected Note entry data"),
1946        }
1947
1948        // Cleanup
1949        delete_note(workspace_path, &note_path).await.ok();
1950    }
1951
1952    #[tokio::test]
1953    async fn test_vault_entry_new_with_attachment() {
1954        let workspace_path = Path::new("testdata");
1955        let attachment_path = VaultPath::new("test.txt");
1956
1957        // Create a text file (attachment)
1958        tokio::fs::create_dir_all(workspace_path).await.ok();
1959        tokio::fs::write(workspace_path.join("test.txt"), "test content")
1960            .await
1961            .unwrap();
1962
1963        let result = VaultEntry::new(workspace_path, attachment_path.clone()).await;
1964        assert!(result.is_ok());
1965
1966        let entry = result.unwrap();
1967        match entry.data {
1968            EntryData::Attachment => (),
1969            _ => panic!("Expected Attachment entry data"),
1970        }
1971
1972        // Cleanup
1973        tokio::fs::remove_file(workspace_path.join("test.txt"))
1974            .await
1975            .ok();
1976    }
1977
1978    #[tokio::test]
1979    async fn test_vault_entry_new_with_nonexistent_path() {
1980        let workspace_path = Path::new("testdata");
1981        let nonexistent_path = VaultPath::new("does_not_exist.md");
1982
1983        let result = VaultEntry::new(workspace_path, nonexistent_path).await;
1984        assert!(result.is_err());
1985
1986        match result.unwrap_err() {
1987            FSError::NoFileOrDirectoryFound { .. } => (),
1988            _ => panic!("Expected NoFileOrDirectoryFound error"),
1989        }
1990    }
1991
1992    #[tokio::test]
1993    async fn test_vault_entry_from_path() {
1994        let workspace_path = Path::new("testdata");
1995        let note_path = VaultPath::new("from_path_test.md");
1996        let note_content = "Test content";
1997
1998        // Create note
1999        save_note(workspace_path, &note_path, note_content)
2000            .await
2001            .unwrap();
2002
2003        let full_path = workspace_path.join("from_path_test.md");
2004        let result = VaultEntry::from_path(workspace_path, &full_path).await;
2005        assert!(result.is_ok());
2006
2007        let entry = result.unwrap();
2008        assert_eq!(entry.path, note_path.clone().absolute());
2009
2010        // Cleanup
2011        delete_note(workspace_path, &note_path).await.ok();
2012    }
2013
2014    #[tokio::test]
2015    async fn test_vault_entry_display() {
2016        let workspace_path = Path::new("testdata");
2017        let note_path = VaultPath::new("display_test.md");
2018        let dir_path = VaultPath::new("display_dir");
2019        let attachment_path = VaultPath::new("display.txt");
2020
2021        // Test note display
2022        save_note(workspace_path, &note_path, "content")
2023            .await
2024            .unwrap();
2025        let note_entry = VaultEntry::new(workspace_path, note_path.clone())
2026            .await
2027            .unwrap();
2028        let note_display = format!("{}", note_entry);
2029        assert!(note_display.contains("[NOT]"));
2030        assert!(note_display.contains(&note_path.to_string()));
2031
2032        // Test directory display
2033        tokio::fs::create_dir_all(workspace_path.join("display_dir"))
2034            .await
2035            .ok();
2036        let dir_entry = VaultEntry::new(workspace_path, dir_path.clone())
2037            .await
2038            .unwrap();
2039        let dir_display = format!("{}", dir_entry);
2040        assert!(dir_display.contains("[DIR]"));
2041        assert!(dir_display.contains(&dir_path.to_string()));
2042
2043        // Test attachment display
2044        tokio::fs::write(workspace_path.join("display.txt"), "content")
2045            .await
2046            .ok();
2047        let attachment_entry = VaultEntry::new(workspace_path, attachment_path.clone())
2048            .await
2049            .unwrap();
2050        let attachment_display = format!("{}", attachment_entry);
2051        assert!(attachment_display.contains("[ATT]"));
2052
2053        // Cleanup
2054        delete_note(workspace_path, &note_path).await.ok();
2055        tokio::fs::remove_dir_all(workspace_path.join("display_dir"))
2056            .await
2057            .ok();
2058        tokio::fs::remove_file(workspace_path.join("display.txt"))
2059            .await
2060            .ok();
2061    }
2062
2063    #[tokio::test]
2064    async fn test_note_entry_data_load_details() {
2065        let workspace_path = Path::new("testdata");
2066        let note_path = VaultPath::new("details_test.md");
2067        let note_content = "# Test\n\nContent here";
2068
2069        save_note(workspace_path, &note_path, note_content)
2070            .await
2071            .unwrap();
2072        let entry = VaultEntry::new(workspace_path, note_path.clone())
2073            .await
2074            .unwrap();
2075
2076        if let EntryData::Note(note_data) = entry.data {
2077            let details_result = note_data.load_details(workspace_path, &note_path).await;
2078            assert!(details_result.is_ok());
2079
2080            let details = details_result.unwrap();
2081            assert_eq!(details.path, note_path);
2082            assert_eq!(details.raw_text, note_content);
2083        } else {
2084            panic!("Expected Note entry data");
2085        }
2086
2087        // Cleanup
2088        delete_note(workspace_path, &note_path).await.ok();
2089    }
2090
2091    #[test]
2092    fn test_directory_entry_data_get_details() {
2093        let dir_path = VaultPath::new("test_dir");
2094        let dir_data = DirectoryEntryData {
2095            path: dir_path.clone(),
2096        };
2097
2098        let details = dir_data.get_details::<PathBuf>();
2099        assert_eq!(details.path, dir_path);
2100    }
2101
2102    #[test]
2103    fn test_vault_entry_details_get_title() {
2104        let note_path = VaultPath::new("test.md");
2105        let note_content = "# My Title\n\nContent";
2106        let note_details = NoteDetails::new(&note_path, note_content);
2107
2108        let mut note_entry_details = VaultEntryDetails::Note(note_details);
2109        let title = note_entry_details.get_title();
2110        assert_eq!(title, "My Title");
2111
2112        let dir_path = VaultPath::new("test_dir");
2113        let dir_details = DirectoryDetails { path: dir_path };
2114        let mut dir_entry_details = VaultEntryDetails::Directory(dir_details);
2115        let dir_title = dir_entry_details.get_title();
2116        assert_eq!(dir_title, "");
2117
2118        let mut none_details = VaultEntryDetails::None;
2119        let none_title = none_details.get_title();
2120        assert_eq!(none_title, "");
2121    }
2122
2123    #[test]
2124    fn test_hash_text() {
2125        use super::hash_text;
2126
2127        let text1 = "Hello, world!";
2128        let text2 = "Hello, world!";
2129        let text3 = "Different text";
2130
2131        let hash1 = hash_text(text1);
2132        let hash2 = hash_text(text2);
2133        let hash3 = hash_text(text3);
2134
2135        assert_eq!(hash1, hash2);
2136        assert_ne!(hash1, hash3);
2137        assert!(hash1 > 0);
2138    }
2139
2140    #[tokio::test]
2141    async fn test_create_directory_with_note_path() {
2142        let workspace_path = Path::new("testdata");
2143        let note_path = VaultPath::new("invalid.md");
2144
2145        let result = create_directory(workspace_path, &note_path).await;
2146        assert!(result.is_err());
2147
2148        match result.unwrap_err() {
2149            FSError::InvalidPath { message, .. } => {
2150                assert_eq!(message, "The path is not a directory");
2151            }
2152            _ => panic!("Expected InvalidPath error"),
2153        }
2154    }
2155
2156    #[tokio::test]
2157    async fn save_attachment_writes_bytes_and_creates_parent_dirs() {
2158        use tempfile::TempDir;
2159
2160        let temp_dir = TempDir::new().unwrap();
2161        let workspace = temp_dir.path();
2162        let path = VaultPath::new("/assets/img.png");
2163        let bytes = b"\x89PNG\r\n\x1a\n stub".to_vec();
2164
2165        save_attachment(workspace, &path, &bytes).await.unwrap();
2166
2167        let on_disk = workspace.join("assets").join("img.png");
2168        let read_back = tokio::fs::read(&on_disk).await.unwrap();
2169        assert_eq!(read_back, bytes);
2170    }
2171
2172    #[tokio::test]
2173    async fn test_save_note_with_directory_path() {
2174        let workspace_path = Path::new("testdata");
2175        let dir_path = VaultPath::new("directory");
2176        let content = "test content";
2177
2178        let result = save_note(workspace_path, &dir_path, content).await;
2179        assert!(result.is_err());
2180
2181        match result.unwrap_err() {
2182            FSError::InvalidPath { message, .. } => {
2183                assert_eq!(message, "The path is not a note");
2184            }
2185            _ => panic!("Expected InvalidPath error"),
2186        }
2187    }
2188
2189    #[tokio::test]
2190    async fn test_rename_note_with_invalid_paths() {
2191        let workspace_path = Path::new("testdata");
2192        let dir_path = VaultPath::new("directory");
2193        let note_path = VaultPath::new("note.md");
2194
2195        // Test renaming from directory (should fail)
2196        let result = rename_note(workspace_path, &dir_path, &note_path).await;
2197        assert!(result.is_err());
2198
2199        // Test renaming to directory (should fail)
2200        let result = rename_note(workspace_path, &note_path, &dir_path).await;
2201        assert!(result.is_err());
2202    }
2203
2204    #[tokio::test]
2205    async fn test_rename_directory_with_invalid_paths() {
2206        let workspace_path = Path::new("testdata");
2207        let dir_path = VaultPath::new("directory");
2208        let note_path = VaultPath::new("note.md");
2209
2210        // Test renaming from note (should fail)
2211        let result = rename_directory(workspace_path, &note_path, &dir_path).await;
2212        assert!(result.is_err());
2213
2214        // Test renaming to note (should fail)
2215        let result = rename_directory(workspace_path, &dir_path, &note_path).await;
2216        assert!(result.is_err());
2217    }
2218
2219    #[test]
2220    fn test_vault_path_serialization() {
2221        use serde_json;
2222
2223        let path = VaultPath::new("/test/path.md");
2224        let serialized = serde_json::to_string(&path).unwrap();
2225        assert_eq!(serialized, "\"/test/path.md\"");
2226
2227        let deserialized: VaultPath = serde_json::from_str(&serialized).unwrap();
2228        assert_eq!(deserialized, path);
2229    }
2230
2231    #[test]
2232    fn test_vault_path_try_from() {
2233        let path_str = "/valid/path.md";
2234        let path_result: Result<VaultPath, FSError> = path_str.try_into();
2235        assert!(path_result.is_ok());
2236
2237        let invalid_path_str = "/invalid:path.md";
2238        let invalid_result: Result<VaultPath, FSError> = invalid_path_str.try_into();
2239        assert!(invalid_result.is_err());
2240    }
2241
2242    #[test]
2243    fn test_vault_path_from_str() {
2244        use std::str::FromStr;
2245
2246        let path_str = "/test/path.md";
2247        let path = VaultPath::from_str(path_str).unwrap();
2248        assert_eq!(path.to_string(), path_str);
2249
2250        let invalid_str = "/invalid:path.md";
2251        let result = VaultPath::from_str(invalid_str);
2252        assert!(result.is_err());
2253    }
2254
2255    #[test]
2256    fn test_vault_path_note_path_from() {
2257        let path_without_extension = "test/note";
2258        let path_with_extension = "test/note.md";
2259        let path_with_trailing_slash = "test/note/";
2260
2261        let note_path1 = VaultPath::note_path_from(path_without_extension);
2262        let note_path2 = VaultPath::note_path_from(path_with_extension);
2263        let note_path3 = VaultPath::note_path_from(path_with_trailing_slash);
2264
2265        assert_eq!(note_path1.to_string(), "test/note.md");
2266        assert_eq!(note_path2.to_string(), "test/note.md");
2267        assert_eq!(note_path3.to_string(), "test/note.md");
2268
2269        assert!(note_path1.is_note());
2270        assert!(note_path2.is_note());
2271        assert!(note_path3.is_note());
2272    }
2273
2274    #[test]
2275    fn test_vault_path_get_name_on_conflict() {
2276        let note_path = VaultPath::new("test.md");
2277        let conflicted = note_path.get_name_on_conflict();
2278        assert_eq!(conflicted.to_string(), "test_0.md");
2279
2280        let numbered_path = VaultPath::new("test_5.md");
2281        let conflicted_numbered = numbered_path.get_name_on_conflict();
2282        assert_eq!(conflicted_numbered.to_string(), "test_6.md");
2283
2284        let dir_path = VaultPath::new("directory");
2285        let conflicted_dir = dir_path.get_name_on_conflict();
2286        assert_eq!(conflicted_dir.to_string(), "directory_0");
2287
2288        let empty_path = VaultPath::empty();
2289        let conflicted_empty = empty_path.get_name_on_conflict();
2290        assert_eq!(conflicted_empty.to_string(), "0");
2291    }
2292
2293    #[test]
2294    fn test_vault_path_get_clean_name() {
2295        let note_path = VaultPath::new("/path/to/note.md");
2296        assert_eq!(note_path.get_clean_name(), "note");
2297
2298        let dir_path = VaultPath::new("/path/to/directory");
2299        assert_eq!(dir_path.get_clean_name(), "directory");
2300
2301        let root_path = VaultPath::root();
2302        assert_eq!(root_path.get_clean_name(), "");
2303    }
2304
2305    #[test]
2306    fn test_vault_path_get_slices() {
2307        let path = VaultPath::new("/path/to/../file.md");
2308        let slices = path.get_slices();
2309        assert_eq!(slices, vec!["path", "file.md"]);
2310    }
2311
2312    #[test]
2313    fn test_vault_path_is_like() {
2314        let path1 = VaultPath::new("/test/path.md");
2315        let path2 = VaultPath::new("test/path.md"); // relative version
2316        let path3 = VaultPath::new("/different/path.md");
2317
2318        assert!(path1.is_like(&path2));
2319        assert!(!path1.is_like(&path3));
2320    }
2321
2322    #[test]
2323    fn test_vault_path_slice_edge_cases() {
2324        // Test slice with dots
2325        let path_with_dots = VaultPath::new("...invalid");
2326        assert_eq!(path_with_dots.to_string(), "___invalid");
2327
2328        // Test slice with invalid characters
2329        let path_with_invalid = VaultPath::new("test:file?.md");
2330        assert_eq!(path_with_invalid.to_string(), "test_file_.md");
2331
2332        // Test current directory slice
2333        let path_with_current = VaultPath::new("./test");
2334        assert_eq!(path_with_current.flatten().to_string(), "test");
2335
2336        // Test parent directory slice
2337        let path_with_parent = VaultPath::new("../test");
2338        assert_eq!(path_with_parent.to_string(), "../test");
2339    }
2340
2341    #[test]
2342    fn test_vault_path_increment_function() {
2343        use super::VaultPath;
2344
2345        // Test the increment functionality through get_name_on_conflict
2346        let base_name = VaultPath::new("test");
2347        let incremented = base_name.get_name_on_conflict();
2348        assert_eq!(incremented.to_string(), "test_0");
2349
2350        let numbered_name = VaultPath::new("test_3");
2351        let incremented_numbered = numbered_name.get_name_on_conflict();
2352        assert_eq!(incremented_numbered.to_string(), "test_4");
2353    }
2354
2355    #[test]
2356    fn vault_path_normalizes_to_lowercase() {
2357        // Paths are always stored lowercase regardless of input case
2358        let a = VaultPath::new("/Projects/Note.md");
2359        let b = VaultPath::new("/projects/note.md");
2360        assert_eq!(a, b);
2361        assert_eq!(a.to_string(), "/projects/note.md");
2362    }
2363
2364    // ── Case-insensitive disk resolution tests ────────────────────────────────
2365
2366    #[tokio::test]
2367    async fn resolve_finds_uppercase_directory() {
2368        let tmp = tempfile::TempDir::new().unwrap();
2369        tokio::fs::create_dir(tmp.path().join("Journal"))
2370            .await
2371            .unwrap();
2372
2373        let result = super::resolve_path_on_disk(tmp.path(), &VaultPath::new("/journal")).await;
2374        assert_eq!(result, tmp.path().join("Journal"));
2375    }
2376
2377    #[tokio::test]
2378    async fn resolve_finds_uppercase_file() {
2379        let tmp = tempfile::TempDir::new().unwrap();
2380        tokio::fs::create_dir(tmp.path().join("Projects"))
2381            .await
2382            .unwrap();
2383        tokio::fs::write(tmp.path().join("Projects").join("MyNote.md"), "hi")
2384            .await
2385            .unwrap();
2386
2387        let result =
2388            super::resolve_path_on_disk(tmp.path(), &VaultPath::new("/projects/mynote.md")).await;
2389        assert_eq!(result, tmp.path().join("Projects").join("MyNote.md"));
2390    }
2391
2392    #[tokio::test]
2393    async fn resolve_uses_lowercase_for_nonexistent_path() {
2394        let tmp = tempfile::TempDir::new().unwrap();
2395
2396        let result =
2397            super::resolve_path_on_disk(tmp.path(), &VaultPath::new("/newdir/note.md")).await;
2398        assert_eq!(result, tmp.path().join("newdir").join("note.md"));
2399    }
2400
2401    #[test]
2402    fn resolve_sync_finds_uppercase_directory() {
2403        let tmp = tempfile::TempDir::new().unwrap();
2404        std::fs::create_dir(tmp.path().join("Archive")).unwrap();
2405
2406        let result = super::resolve_path_on_disk_sync(tmp.path(), &VaultPath::new("/archive"));
2407        assert_eq!(result, tmp.path().join("Archive"));
2408    }
2409
2410    #[tokio::test]
2411    async fn load_note_finds_uppercase_file() {
2412        let tmp = tempfile::TempDir::new().unwrap();
2413        tokio::fs::create_dir(tmp.path().join("Journal"))
2414            .await
2415            .unwrap();
2416        tokio::fs::write(tmp.path().join("Journal").join("MyNote.md"), "# Hello")
2417            .await
2418            .unwrap();
2419
2420        let text = super::load_note(tmp.path(), &VaultPath::new("/journal/mynote.md"))
2421            .await
2422            .unwrap();
2423        assert_eq!(text, "# Hello");
2424    }
2425
2426    #[tokio::test]
2427    async fn save_note_writes_to_existing_uppercase_file() {
2428        let tmp = tempfile::TempDir::new().unwrap();
2429        tokio::fs::create_dir(tmp.path().join("Journal"))
2430            .await
2431            .unwrap();
2432        tokio::fs::write(tmp.path().join("Journal").join("MyNote.md"), "original")
2433            .await
2434            .unwrap();
2435
2436        save_note(tmp.path(), &VaultPath::new("/journal/mynote.md"), "updated")
2437            .await
2438            .unwrap();
2439
2440        // The uppercase file should be updated
2441        let content = tokio::fs::read_to_string(tmp.path().join("Journal").join("MyNote.md"))
2442            .await
2443            .unwrap();
2444        assert_eq!(content, "updated");
2445
2446        // On case-sensitive filesystems: no duplicate lowercase entries should exist.
2447        // On case-insensitive filesystems (e.g. macOS default APFS), 'Journal' and
2448        // 'journal' are the same path so these assertions are not meaningful.
2449        if is_case_sensitive_fs(tmp.path()) {
2450            assert!(!tmp.path().join("Journal").join("mynote.md").exists());
2451            assert!(!tmp.path().join("journal").exists());
2452        }
2453    }
2454
2455    #[tokio::test]
2456    async fn save_note_in_uppercase_parent_directory() {
2457        let tmp = tempfile::TempDir::new().unwrap();
2458        tokio::fs::create_dir(tmp.path().join("Projects"))
2459            .await
2460            .unwrap();
2461
2462        save_note(tmp.path(), &VaultPath::new("/projects/new.md"), "content")
2463            .await
2464            .unwrap();
2465
2466        // File should land inside the existing uppercase directory
2467        assert!(tmp.path().join("Projects").join("new.md").exists());
2468        // On case-sensitive filesystems: no duplicate lowercase directory should exist.
2469        if is_case_sensitive_fs(tmp.path()) {
2470            assert!(!tmp.path().join("projects").exists());
2471        }
2472    }
2473
2474    #[tokio::test]
2475    async fn delete_note_removes_uppercase_file() {
2476        let tmp = tempfile::TempDir::new().unwrap();
2477        tokio::fs::create_dir(tmp.path().join("Journal"))
2478            .await
2479            .unwrap();
2480        let file = tmp.path().join("Journal").join("MyNote.md");
2481        tokio::fs::write(&file, "bye").await.unwrap();
2482
2483        delete_note(tmp.path(), &VaultPath::new("/journal/mynote.md"))
2484            .await
2485            .unwrap();
2486
2487        assert!(!file.exists());
2488    }
2489
2490    #[tokio::test]
2491    async fn delete_directory_removes_uppercase_directory() {
2492        let tmp = tempfile::TempDir::new().unwrap();
2493        tokio::fs::create_dir(tmp.path().join("Archive"))
2494            .await
2495            .unwrap();
2496        tokio::fs::write(tmp.path().join("Archive").join("note.md"), "x")
2497            .await
2498            .unwrap();
2499
2500        delete_directory(tmp.path(), &VaultPath::new("/archive"))
2501            .await
2502            .unwrap();
2503
2504        assert!(!tmp.path().join("Archive").exists());
2505    }
2506
2507    #[tokio::test]
2508    async fn rename_note_finds_uppercase_source() {
2509        let tmp = tempfile::TempDir::new().unwrap();
2510        tokio::fs::create_dir(tmp.path().join("Projects"))
2511            .await
2512            .unwrap();
2513        tokio::fs::write(tmp.path().join("Projects").join("MyNote.md"), "data")
2514            .await
2515            .unwrap();
2516
2517        rename_note(
2518            tmp.path(),
2519            &VaultPath::new("/projects/mynote.md"),
2520            &VaultPath::new("/projects/renamed.md"),
2521        )
2522        .await
2523        .unwrap();
2524
2525        assert!(tmp.path().join("Projects").join("renamed.md").exists());
2526        assert!(!tmp.path().join("Projects").join("MyNote.md").exists());
2527    }
2528
2529    #[tokio::test]
2530    async fn rename_note_into_uppercase_parent() {
2531        let tmp = tempfile::TempDir::new().unwrap();
2532        tokio::fs::create_dir(tmp.path().join("Inbox"))
2533            .await
2534            .unwrap();
2535        tokio::fs::write(tmp.path().join("Inbox").join("note.md"), "data")
2536            .await
2537            .unwrap();
2538        tokio::fs::create_dir(tmp.path().join("Archive"))
2539            .await
2540            .unwrap();
2541
2542        rename_note(
2543            tmp.path(),
2544            &VaultPath::new("/inbox/note.md"),
2545            &VaultPath::new("/archive/note.md"),
2546        )
2547        .await
2548        .unwrap();
2549
2550        assert!(tmp.path().join("Archive").join("note.md").exists());
2551        // On case-sensitive filesystems: no duplicate lowercase directory should exist.
2552        if is_case_sensitive_fs(tmp.path()) {
2553            assert!(!tmp.path().join("archive").exists());
2554        }
2555    }
2556
2557    #[tokio::test]
2558    async fn rename_directory_finds_uppercase_source() {
2559        let tmp = tempfile::TempDir::new().unwrap();
2560        tokio::fs::create_dir(tmp.path().join("OldName"))
2561            .await
2562            .unwrap();
2563
2564        rename_directory(
2565            tmp.path(),
2566            &VaultPath::new("/oldname"),
2567            &VaultPath::new("/newname"),
2568        )
2569        .await
2570        .unwrap();
2571
2572        assert!(tmp.path().join("newname").exists());
2573        assert!(!tmp.path().join("OldName").exists());
2574    }
2575
2576    #[tokio::test]
2577    async fn vault_entry_from_path_uses_lowercase_vault_path() {
2578        let tmp = tempfile::TempDir::new().unwrap();
2579        tokio::fs::create_dir(tmp.path().join("Projects"))
2580            .await
2581            .unwrap();
2582        tokio::fs::write(tmp.path().join("Projects").join("MyNote.md"), "# Title")
2583            .await
2584            .unwrap();
2585
2586        let entry =
2587            VaultEntry::from_path(tmp.path(), tmp.path().join("Projects").join("MyNote.md"))
2588                .await
2589                .unwrap();
2590
2591        // VaultPath is always lowercase even though the disk file has uppercase
2592        assert_eq!(entry.path.to_string(), "/projects/mynote.md");
2593        assert!(matches!(entry.data, EntryData::Note(_)));
2594    }
2595
2596    #[tokio::test]
2597    async fn vault_entry_new_finds_uppercase_file() {
2598        let tmp = tempfile::TempDir::new().unwrap();
2599        tokio::fs::create_dir(tmp.path().join("Projects"))
2600            .await
2601            .unwrap();
2602        tokio::fs::write(tmp.path().join("Projects").join("MyNote.md"), "# Title")
2603            .await
2604            .unwrap();
2605
2606        let entry = VaultEntry::new(tmp.path(), VaultPath::new("/projects/mynote.md"))
2607            .await
2608            .unwrap();
2609
2610        assert_eq!(entry.path.to_string(), "/projects/mynote.md");
2611        assert!(matches!(entry.data, EntryData::Note(_)));
2612    }
2613}