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
22pub const PATH_SEPARATOR: char = '/';
26const NOTE_EXTENSION: &str = ".md";
27
28pub 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#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
69pub struct NoteEntryData {
70 pub path: VaultPath,
72 pub size: u64,
74 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
119pub struct DirectoryEntryData {
120 pub path: VaultPath,
122}
123impl DirectoryEntryData {
124 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 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
228pub(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(¤t).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
263pub(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(¤t)
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
289pub(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 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 if let Ok(ft) = entry.file_type() {
336 if ft.is_dir() {
337 subdirs.push(entry.path());
338 }
339 }
340 }
341
342 for subdir in subdirs {
345 conflicts.extend(check_conflicts_in_dir(workspace_root, &subdir));
346 }
347
348 conflicts
349}
350
351pub(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
372pub(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
395pub(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 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
429pub(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
483async 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}
511const BACKUP_RETENTION_DAYS: i64 = 30;
514
515static 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
523async fn purge_old_backups(backups_root: &Path) {
529 let today = chrono::Utc::now().date_naive();
530 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
557async 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
584pub(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 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 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
637pub(crate) fn ensure_dir(dir: &Path) -> Result<(), FSError> {
639 std::fs::create_dir_all(dir).map_err(FSError::ReadFileError)
640}
641
642pub(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#[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 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()) .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 pub fn is_valid<S: AsRef<str>>(path: S) -> bool {
792 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 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 pub fn root() -> Self {
834 Self {
835 absolute: true,
836 slices: vec![],
837 }
838 }
839
840 pub fn empty() -> Self {
848 Self {
849 absolute: false,
850 slices: vec![],
851 }
852 }
853
854 pub fn is_root_or_empty(&self) -> bool {
857 self.slices.is_empty()
858 }
859
860 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn is_relative(&self) -> bool {
1166 !self.absolute
1167 }
1168
1169 pub fn is_absolute(&self) -> bool {
1171 self.absolute
1172 }
1173
1174 pub fn to_absolute(&mut self) {
1176 self.absolute = true;
1177 }
1178
1179 pub fn absolute(mut self) -> Self {
1182 self.absolute = true;
1183 self
1184 }
1185
1186 pub fn to_relative(&mut self) {
1188 self.absolute = false;
1189 }
1190
1191 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 pub fn append(&self, path: &VaultPath) -> VaultPath {
1230 if !path.is_relative() {
1231 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 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 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 let sanitized = filename::RX_PATH_CHARS
1296 .replace_all(&slice, "_")
1297 .to_lowercase();
1298 let sanitized = sanitized.trim().trim_end_matches('.').to_string();
1299 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 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 .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 assert_eq!(with_note_extension("work/proj*"), "work/proj*.md");
1411 }
1412
1413 fn is_case_sensitive_fs(dir: &Path) -> bool {
1417 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 #[test]
1441 fn control_chars_are_invalid() {
1442 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 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 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 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 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 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(¬e).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(¬e).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(¬e).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(¬e).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 let resolved = target.resolve_link_in_note(¬e);
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(¬e).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(¬e).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, ¬e_path, ¬e_text).await;
1795 if let Err(e) = &res {
1796 panic!("Error saving note: {e}")
1797 }
1798
1799 let note = load_note(workspace_path, ¬e_path).await;
1800 if let Err(e) = ¬e {
1801 panic!("Error loading note: {e}")
1802 }
1803 assert_eq!(note.unwrap(), note_text);
1804
1805 let del_res = delete_note(workspace_path, ¬e_path).await;
1806 if let Err(e) = &del_res {
1807 panic!("Error deleting note: {e}")
1808 }
1809 assert!(load_note(workspace_path, ¬e_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, ¬e_path, ¬e_text).await;
1823 if let Err(e) = &res {
1824 panic!("Error saving note: {e}")
1825 }
1826 let note = load_note(workspace_path, ¬e_path).await;
1827 if let Err(e) = ¬e {
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, ¬e_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, ¬e_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, ¬e_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 #[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 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 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 save_note(workspace_path, ¬e_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 delete_note(workspace_path, ¬e_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 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 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 save_note(workspace_path, ¬e_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 delete_note(workspace_path, ¬e_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 save_note(workspace_path, ¬e_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(¬e_path.to_string()));
2031
2032 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 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 delete_note(workspace_path, ¬e_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, ¬e_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, ¬e_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 delete_note(workspace_path, ¬e_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(¬e_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, ¬e_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 let result = rename_note(workspace_path, &dir_path, ¬e_path).await;
2197 assert!(result.is_err());
2198
2199 let result = rename_note(workspace_path, ¬e_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 let result = rename_directory(workspace_path, ¬e_path, &dir_path).await;
2212 assert!(result.is_err());
2213
2214 let result = rename_directory(workspace_path, &dir_path, ¬e_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"); 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 let path_with_dots = VaultPath::new("...invalid");
2326 assert_eq!(path_with_dots.to_string(), "___invalid");
2327
2328 let path_with_invalid = VaultPath::new("test:file?.md");
2330 assert_eq!(path_with_invalid.to_string(), "test_file_.md");
2331
2332 let path_with_current = VaultPath::new("./test");
2334 assert_eq!(path_with_current.flatten().to_string(), "test");
2335
2336 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 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 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 #[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 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 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 assert!(tmp.path().join("Projects").join("new.md").exists());
2468 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 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 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}