1use std::collections::HashMap;
2use std::env::current_dir;
3use std::path::{Path, PathBuf};
4
5use gray_matter::Pod;
6use indexmap::IndexMap;
7
8use crate::{InlineLocation, Link, LocatedLink, LocatedTag, Location, Note, NoteError, VaultError, common, search};
9
10pub struct Vault {
11 path: PathBuf,
12 loaded_notes: HashMap<PathBuf, Note>,
13}
14
15impl Vault {
16 pub fn open(path: impl AsRef<Path>) -> Result<Self, VaultError> {
19 let path = common::normalize_path(path, None);
20 if !path.is_dir() {
21 return Err(VaultError::NotADirectory(path));
22 }
23 Ok(Vault {
24 path,
25 loaded_notes: HashMap::new(),
26 })
27 }
28
29 pub fn open_from_cwd() -> Result<Self, VaultError> {
32 let cwd = std::env::current_dir()?;
33 let mut current = cwd.as_path();
34 loop {
35 if current.join(".obsidian").is_dir() {
36 return Self::open(current);
37 }
38 match current.parent() {
39 Some(parent) => current = parent,
40 None => break,
41 }
42 }
43 Self::open(&cwd)
44 }
45
46 pub fn path(&self) -> &Path {
47 self.path.as_path()
48 }
49
50 pub fn resolve_note(&self, note: &str) -> Result<Note, VaultError> {
52 if let Ok((path, _)) = self.resolve_note_path(note, true) {
54 return Note::from_path(path).map_err(VaultError::Note);
55 }
56
57 let mut search = self.search().or_has_id(note).or_has_alias(note).ignore_case();
59 if note.ends_with(".md") && !note.contains('/') {
60 let glob = format!("**/{}", note);
61 let stem = note.trim_end_matches(".md");
62 search = search.or_glob(glob).or_has_id(stem).or_has_alias(stem);
63 }
64
65 let results = search.execute().map_err(VaultError::Search)?;
66 let mut notes: Vec<Note> = results.into_iter().filter_map(|r| r.ok()).collect();
67
68 if notes.is_empty() {
69 return Err(VaultError::NoteNotFound(note.to_string()));
70 }
71
72 if notes.len() == 1 {
73 return Ok(notes.remove(0));
74 }
75
76 let paths = notes.iter().map(|n| n.path.clone()).collect();
78 let mut notes: Vec<_> = notes
79 .into_iter()
80 .filter(|n| n.id == note || n.aliases.iter().any(|a| a == note))
81 .collect();
82
83 if notes.len() == 1 {
84 return Ok(notes.remove(0));
85 }
86
87 Err(VaultError::AmbiguousNoteIdentifier(note.to_string(), paths))
88 }
89
90 pub fn resolve_note_path(
94 &self,
95 path: impl AsRef<Path>,
96 strict: bool,
97 ) -> Result<(std::path::PathBuf, Option<std::path::PathBuf>), VaultError> {
98 let path = path.as_ref().to_path_buf();
99 if path.is_absolute() {
100 if path.exists() || self.loaded_notes.contains_key(&path) || !strict {
101 return Ok((common::normalize_path(&path, None), None));
102 } else {
103 return Err(VaultError::NoteNotFound(path.to_string_lossy().to_string()));
104 }
105 }
106
107 let cwd = current_dir()?;
111 let mut cwd_resolved = common::normalize_path(&path, Some(&cwd));
112 if cwd_resolved.starts_with(&self.path) {
113 if cwd_resolved.exists() || self.loaded_notes.contains_key(&cwd_resolved) {
115 return Ok((cwd_resolved, Some(cwd)));
116 } else if cwd_resolved.extension().is_none() {
117 cwd_resolved.set_extension("md");
118 if cwd_resolved.exists() || self.loaded_notes.contains_key(&cwd_resolved) {
119 return Ok((cwd_resolved, Some(cwd)));
120 }
121 }
122
123 let mut vault_resolved = common::normalize_path(&path, Some(&self.path));
127 if strict {
128 if vault_resolved.exists() || self.loaded_notes.contains_key(&vault_resolved) {
129 return Ok((vault_resolved, Some(self.path.clone())));
130 } else if vault_resolved.extension().is_none() {
131 vault_resolved.set_extension("md");
132 if vault_resolved.exists() || self.loaded_notes.contains_key(&vault_resolved) {
133 return Ok((vault_resolved, Some(self.path.clone())));
134 }
135 }
136 } else {
137 return Ok((cwd_resolved, Some(cwd)));
138 }
139 } else {
140 let mut vault_resolved = common::normalize_path(&path, Some(&self.path));
141 if vault_resolved.exists() {
142 return Ok((vault_resolved, Some(self.path.clone())));
143 } else if vault_resolved.extension().is_none() {
144 vault_resolved.set_extension("md");
145 if vault_resolved.exists() || self.loaded_notes.contains_key(&vault_resolved) {
146 return Ok((vault_resolved, Some(self.path.clone())));
147 }
148 }
149
150 if !strict {
151 return Ok((vault_resolved, Some(self.path.clone())));
152 }
153 }
154
155 Err(VaultError::NoteNotFound(path.to_string_lossy().to_string()))
156 }
157
158 pub fn notes(&self) -> Vec<Result<Note, NoteError>> {
163 search::find_notes(&self.path)
164 }
165
166 pub fn notes_with_content(&self) -> Vec<Result<Note, NoteError>> {
168 search::find_notes_with_content(&self.path)
169 }
170
171 pub fn load_note(&mut self, mut note: Note) {
175 let resolved_path = self
176 .resolve_note_path(¬e.path, false)
177 .map(|(n, _)| n)
178 .unwrap_or_else(|_| note.path.clone());
179 note.path = resolved_path;
180 self.loaded_notes.insert(note.path.clone(), note);
181 }
182
183 pub fn unload_note(&mut self, path: &Path) {
186 let resolved_path = self
187 .resolve_note_path(path, false)
188 .map(|(n, _)| n)
189 .unwrap_or_else(|_| path.into());
190 self.loaded_notes.remove(&resolved_path);
191 }
192
193 pub fn note_is_loaded(&self, path: impl AsRef<Path>) -> bool {
194 self.loaded_notes.contains_key(&path.as_ref().to_path_buf())
195 }
196
197 pub fn notes_filtered(&self, filter: impl Fn(&Path) -> bool) -> Vec<Result<Note, NoteError>> {
200 search::find_notes_filtered(&self.path, filter, Some(&self.loaded_notes))
201 }
202
203 pub fn notes_filtered_with_content(&self, filter: impl Fn(&Path) -> bool) -> Vec<Result<Note, NoteError>> {
205 search::find_notes_filtered_with_content(&self.path, filter, Some(&self.loaded_notes))
206 }
207
208 pub fn search(&self) -> search::SearchQuery<'_> {
212 search::SearchQuery::new(&self.path).with_loaded_notes(&self.loaded_notes)
213 }
214
215 pub fn list_tags(&self) -> Result<Vec<String>, VaultError> {
217 search::find_all_tags(&self.path, Some(&self.loaded_notes)).map_err(VaultError::Note)
218 }
219
220 pub fn find_tags(&self, tags: &[String]) -> Result<Vec<(Note, Vec<LocatedTag>)>, VaultError> {
223 search::find_tags(&self.path, tags, Some(&self.loaded_notes)).map_err(VaultError::Search)
224 }
225
226 pub fn rename_tag(&mut self, old_tag: &str, new_tag: &str) -> Result<Vec<(Note, Vec<LocatedTag>)>, VaultError> {
229 let mut results: Vec<(Note, Vec<LocatedTag>)> = Vec::new();
230 for (mut note, tags) in self.find_tags(&[old_tag.into()])? {
231 let mut tags_by_line: HashMap<usize, Vec<InlineLocation>> = HashMap::new();
232 for lt in tags {
233 match lt.location {
234 Location::Frontmatter => {
236 note.remove_tag(<.tag);
237 note.add_tag(new_tag);
238 }
239 Location::Inline(loc) => {
241 tags_by_line.entry(loc.line).or_default();
242 tags_by_line.get_mut(&loc.line).unwrap().push(loc);
243 }
244 };
245 }
246
247 if !tags_by_line.is_empty() {
248 note.load_body()?;
250 let mut lines: Vec<String> = note.body.as_ref().unwrap().lines().map(|s| s.to_string()).collect();
251 for (lnum, locs) in tags_by_line.drain() {
252 let line = lines.get_mut(lnum - 1 - note.frontmatter_line_count).unwrap();
253 let mut offset = 0;
254 for loc in locs {
255 line.replace_range(
256 (offset + loc.col_start)..(offset + loc.col_end),
257 &format!("#{}", new_tag),
258 );
259 offset += new_tag.len() - old_tag.len();
260 }
261 }
262
263 let body = lines.join("\n");
264 note.update_content(Some(&body), None)?;
265 }
266
267 let tags = note
269 .tags
270 .iter()
271 .filter_map(|lt| if lt.tag == new_tag { Some(lt.clone()) } else { None })
272 .collect();
273
274 if self.note_is_loaded(¬e.path) {
276 self.load_note(note.clone());
277 } else {
278 note.write()?;
279 }
280
281 results.push((note, tags));
282 }
283
284 Ok(results)
285 }
286
287 pub fn backlinks(&self, target: &Note) -> Result<Vec<(Note, Vec<LocatedLink>)>, VaultError> {
293 let results = self
294 .search()
295 .and_links_to(target.clone())
296 .execute()
297 .map_err(VaultError::Search)?;
298 let notes: Vec<Note> = results.into_iter().filter_map(|r| r.ok()).collect();
299 let results = notes
300 .into_iter()
301 .map(|source| {
302 let matching = search::find_matching_links(&source, target, &self.path);
303 (source, matching)
304 })
305 .collect();
306 Ok(results)
307 }
308
309 pub fn backlinks_from<'a>(&self, notes: &'a [Note], target: &Note) -> Vec<(&'a Note, Vec<LocatedLink>)> {
312 notes
313 .iter()
314 .filter_map(|source| {
315 let matching = search::find_matching_links(source, target, &self.path);
316 if matching.is_empty() {
317 None
318 } else {
319 Some((source, matching))
320 }
321 })
322 .collect()
323 }
324
325 fn compute_rename_op(&self, note: &Note, new_path: &Path) -> Result<RenameOp, VaultError> {
327 let new_dir = new_path.parent().unwrap_or_else(|| Path::new("."));
328 if !new_dir.is_dir() {
329 return Err(VaultError::DirectoryNotFound(new_dir.to_path_buf()));
330 }
331
332 if new_path.exists() {
333 return Err(VaultError::NoteAlreadyExists(new_path.to_path_buf()));
334 }
335
336 let new_stem = new_path
337 .file_stem()
338 .and_then(|s| s.to_str())
339 .unwrap_or_default()
340 .to_string();
341
342 let old_stem = note
343 .path
344 .file_stem()
345 .and_then(|s| s.to_str())
346 .unwrap_or_default()
347 .to_string();
348
349 let id_needs_update = note.id == old_stem;
350 let backlinks = self.backlinks(note)?;
354 let mut per_note_replacements: Vec<(Note, Vec<(LocatedLink, String)>)> = Vec::new();
355
356 for (source_note, links) in backlinks {
357 let mut replacements: Vec<(LocatedLink, String)> = Vec::new();
358
359 for ll in links {
360 let new_text = match &ll.link {
361 Link::Wiki { target, heading, alias } if id_needs_update && target == &old_stem => {
362 let mut wiki = format!("[[{}", new_stem);
363 if let Some(h) = heading {
364 wiki.push('#');
365 wiki.push_str(h);
366 }
367 if let Some(a) = alias {
368 wiki.push('|');
369 wiki.push_str(a);
370 }
371 wiki.push_str("]]");
372 Some(wiki)
373 }
374 Link::Wiki { .. } => None,
375 Link::Markdown { text, url } => {
376 let fragment = url.find('#').map(|i| url[i..].to_string());
377 let new_url = common::relative_path(&self.path, new_path);
378 let new_url_str = new_url.to_string_lossy().replace('\\', "/");
379 let full_url = match fragment {
380 Some(f) => format!("{}{}", new_url_str, f),
381 None => new_url_str,
382 };
383 Some(format!("[{}]({})", text, full_url))
384 }
385 _ => None,
386 };
387 if let Some(text) = new_text {
388 replacements.push((ll, text));
389 }
390 }
391
392 if !replacements.is_empty() {
393 per_note_replacements.push((source_note, replacements));
394 }
395 }
396
397 Ok(RenameOp {
398 new_stem,
399 frontmatter_id_will_update: id_needs_update,
400 per_note_replacements,
401 })
402 }
403
404 pub fn rename(&mut self, note: &Note, new_path: &Path) -> Result<Note, VaultError> {
413 let new_path = common::normalize_path(new_path, Some(&self.path));
414 let op = self.compute_rename_op(note, &new_path)?;
415
416 let mut renamed = note.clone();
417 renamed.load_body()?;
418 renamed.path = new_path;
419 if op.frontmatter_id_will_update {
420 renamed.id = op.new_stem;
421 }
422
423 if self.note_is_loaded(¬e.path) {
424 self.load_note(renamed.clone());
425 _ = std::fs::remove_file(¬e.path);
426 } else {
427 renamed.write()?;
428 std::fs::remove_file(¬e.path)?;
429 }
430
431 for (mut source_note, replacements) in op.per_note_replacements {
432 if self.note_is_loaded(&source_note.path) {
433 let new_content = common::rewrite_links(
434 &source_note
435 .body
436 .clone()
437 .ok_or(VaultError::Note(NoteError::BodyNotLoaded))?,
438 replacements,
439 );
440 source_note.update_content(Some(&new_content), None)?;
441 self.load_note(source_note);
442 } else {
443 let raw_content = std::fs::read_to_string(&source_note.path)?;
444 let new_content = common::rewrite_links(&raw_content, replacements);
445 std::fs::write(&source_note.path, new_content)?;
446 }
447 }
448
449 Ok(renamed)
450 }
451
452 pub fn rename_preview(&self, note: &Note, new_path: &Path) -> Result<RenamePreview, VaultError> {
456 let new_path = common::normalize_path(new_path, Some(&self.path));
457 let op = self.compute_rename_op(note, &new_path)?;
458
459 let mut updated_notes: Vec<(PathBuf, usize)> = op
460 .per_note_replacements
461 .iter()
462 .map(|(source_note, replacements)| (source_note.path.clone(), replacements.len()))
463 .collect();
464 updated_notes.sort_by(|(a, _), (b, _)| a.cmp(b));
465
466 Ok(RenamePreview {
467 new_path: new_path.to_path_buf(),
468 id_will_update: op.frontmatter_id_will_update,
469 updated_notes,
470 })
471 }
472
473 pub fn patch_note(&mut self, note: &Note, old_string: &str, new_string: &str) -> Result<Note, VaultError> {
480 let raw = if let Some(loaded) = self.loaded_notes.get(¬e.path) {
481 loaded.read(false)?
482 } else {
483 note.read(false)?
484 };
485
486 let count = raw.matches(old_string).count();
487 if count == 0 {
488 return Err(VaultError::StringNotFound(note.path.clone()));
489 }
490 if count > 1 {
491 return Err(VaultError::StringFoundMultipleTimes(note.path.clone()));
492 }
493
494 let patched_content = raw.replacen(old_string, new_string, 1);
495 let mut patched_note = note.clone();
496 patched_note.update_content(Some(&patched_content), None)?;
497
498 if self.note_is_loaded(¬e.path) {
499 self.load_note(patched_note.clone());
500 Ok(patched_note)
501 } else {
502 patched_note.write()?;
503 Ok(patched_note)
504 }
505 }
506
507 fn compute_merge_op(&self, sources: &[Note], dest_path: impl AsRef<Path>) -> Result<MergeOp, VaultError> {
509 use std::collections::HashMap;
510
511 let dest_path = dest_path.as_ref();
512 let dest_dir = &dest_path.parent().unwrap_or_else(|| Path::new("."));
513 if !dest_dir.is_dir() {
514 return Err(VaultError::DirectoryNotFound(dest_dir.to_path_buf()));
515 }
516
517 for source in sources {
518 if source.path == dest_path {
519 return Err(VaultError::MergeSourceIsDestination(source.path.clone()));
520 }
521 }
522
523 let dest_is_loaded = self.note_is_loaded(dest_path);
524 let dest_is_new = !dest_is_loaded && !dest_path.exists();
525
526 let dest_stem = dest_path
527 .file_stem()
528 .and_then(|s| s.to_str())
529 .unwrap_or_default()
530 .to_string();
531
532 let source_paths: Vec<&Path> = sources.iter().map(|s| s.path.as_path()).collect();
533
534 let mut replacements_by_path: HashMap<PathBuf, Vec<(LocatedLink, String)>> = HashMap::new();
536
537 for source in sources {
538 let backlinks = self.backlinks(source)?;
539 for (linking_note, links) in backlinks {
540 if source_paths.iter().any(|p| *p == linking_note.path) {
541 continue;
542 }
543 if linking_note.path == dest_path {
544 continue;
545 }
546
547 let entry = replacements_by_path.entry(linking_note.path.clone()).or_default();
548
549 for ll in links {
550 let new_text = match &ll.link {
551 Link::Wiki { heading, alias, .. } => {
552 let mut wiki = format!("[[{}", dest_stem);
553 if let Some(h) = heading {
554 wiki.push('#');
555 wiki.push_str(h);
556 }
557 if let Some(a) = alias {
558 wiki.push('|');
559 wiki.push_str(a);
560 }
561 wiki.push_str("]]");
562 Some(wiki)
563 }
564 Link::Markdown { text, url } => {
565 let fragment = url.find('#').map(|i| url[i..].to_string());
566 let new_url = common::relative_path(&self.path, dest_path);
567 let new_url_str = new_url.to_string_lossy().replace('\\', "/");
568 let full_url = match fragment {
569 Some(f) => format!("{}{}", new_url_str, f),
570 None => new_url_str.to_string(),
571 };
572 Some(format!("[{}]({})", text, full_url))
573 }
574 _ => None,
575 };
576 if let Some(text) = new_text {
577 entry.push((ll, text));
578 }
579 }
580 }
581 }
582
583 let per_note_replacements: Vec<(PathBuf, Vec<(LocatedLink, String)>)> = replacements_by_path
584 .into_iter()
585 .filter(|(_, r)| !r.is_empty())
586 .collect();
587
588 let (dest_body, dest_fm_tags, dest_fm_aliases, dest_frontmatter) = if dest_is_new {
590 (String::new(), Vec::<String>::new(), Vec::<String>::new(), None)
591 } else {
592 let d = Note::from_path_with_body(dest_path)?;
593 let tags = d
594 .frontmatter
595 .as_ref()
596 .and_then(|fm| fm.get("tags"))
597 .and_then(|p| p.as_vec().ok())
598 .unwrap_or_default()
599 .into_iter()
600 .filter_map(|p| p.as_string().ok())
601 .collect::<Vec<_>>();
602 let aliases = d
603 .frontmatter
604 .as_ref()
605 .and_then(|fm| fm.get("aliases"))
606 .and_then(|p| p.as_vec().ok())
607 .unwrap_or_default()
608 .into_iter()
609 .filter_map(|p| p.as_string().ok())
610 .collect::<Vec<_>>();
611 let body = d.body.as_deref().unwrap_or("").trim_start().to_string();
612 let fm = d.frontmatter;
613 (body, tags, aliases, fm)
614 };
615
616 let mut body_parts: Vec<String> = Vec::new();
618 if !dest_body.is_empty() {
619 body_parts.push(dest_body);
620 }
621 for source in sources {
622 let body = source
623 .body
624 .as_deref()
625 .ok_or(crate::NoteError::BodyNotLoaded)?
626 .trim_start()
627 .to_string();
628 if !body.is_empty() {
629 body_parts.push(body);
630 }
631 }
632 let merged_content = body_parts.join("\n\n---\n\n");
633
634 let mut fm: IndexMap<String, Pod> = dest_frontmatter.unwrap_or_default();
636
637 let mut tag_strings: Vec<String> = dest_fm_tags;
638 for source in sources {
639 for lt in source
640 .tags
641 .iter()
642 .filter(|t| matches!(t.location, Location::Frontmatter))
643 {
644 if !tag_strings.contains(<.tag) {
645 tag_strings.push(lt.tag.clone());
646 }
647 }
648 }
649 if !tag_strings.is_empty() {
650 fm.insert(
651 "tags".to_string(),
652 Pod::Array(tag_strings.clone().into_iter().map(Pod::String).collect()),
653 );
654 }
655
656 let mut alias_strings: Vec<String> = dest_fm_aliases;
657 for source in sources {
658 let src_aliases: Vec<String> = source
659 .frontmatter
660 .as_ref()
661 .and_then(|sfm| sfm.get("aliases"))
662 .and_then(|p| p.as_vec().ok())
663 .unwrap_or_default()
664 .into_iter()
665 .filter_map(|p| p.as_string().ok())
666 .collect();
667 for alias in src_aliases {
668 if !alias_strings.contains(&alias) {
669 alias_strings.push(alias);
670 }
671 }
672 }
673 if !alias_strings.is_empty() {
674 fm.insert(
675 "aliases".to_string(),
676 Pod::Array(alias_strings.clone().into_iter().map(Pod::String).collect()),
677 );
678 }
679
680 const SKIP_KEYS: &[&str] = &["id", "tags", "aliases"];
683 for source in sources {
684 if let Some(sfm) = &source.frontmatter {
685 for (k, v) in sfm {
686 if !SKIP_KEYS.contains(&k.as_str()) {
687 fm.entry(k.clone()).or_insert_with(|| v.clone());
688 }
689 }
690 }
691 }
692
693 let merged_frontmatter = if fm.is_empty() { None } else { Some(fm) };
694
695 Ok(MergeOp {
696 dest_is_new,
697 dest_is_loaded,
698 merged_content,
699 merged_frontmatter,
700 merged_tags: tag_strings
701 .into_iter()
702 .map(|tag| LocatedTag {
703 tag,
704 location: Location::Frontmatter,
705 })
706 .collect(),
707 merged_aliases: alias_strings,
708 per_note_replacements,
709 })
710 }
711
712 pub fn merge(&mut self, sources: &[Note], dest_path: &impl AsRef<Path>) -> Result<Note, VaultError> {
719 let dest_path = common::normalize_path(dest_path, Some(&self.path));
720 let op = self.compute_merge_op(sources, &dest_path)?;
721
722 let dest_note = if op.dest_is_new {
724 let mut dest = Note::builder(dest_path)?
725 .aliases(&op.merged_aliases)
726 .located_tags(&op.merged_tags)
727 .build()?;
728 dest.update_content(Some(&op.merged_content), op.merged_frontmatter)?;
729 dest.write()?;
730 dest
731 } else if op.dest_is_loaded {
732 let dest = self.loaded_notes.get_mut(&dest_path).unwrap();
733 dest.update_content(Some(&op.merged_content), op.merged_frontmatter)?;
734 dest.clone()
735 } else {
736 let mut dest = Note::from_path_with_body(&dest_path)?;
737 dest.update_content(Some(&op.merged_content), op.merged_frontmatter)?;
738 dest.write()?;
739 Note::from_path(&dest_path)?
740 };
741
742 for (note_path, replacements) in op.per_note_replacements {
744 let raw_content = std::fs::read_to_string(¬e_path)?;
745 let new_content = common::rewrite_links(&raw_content, replacements);
746 std::fs::write(¬e_path, new_content)?;
747 }
748
749 for source in sources {
751 std::fs::remove_file(&source.path)?;
752 }
753
754 Ok(dest_note)
755 }
756
757 pub fn merge_preview(&self, sources: &[Note], dest_path: impl AsRef<Path>) -> Result<MergePreview, VaultError> {
761 let dest_path = common::normalize_path(dest_path, Some(&self.path));
762 let op = self.compute_merge_op(sources, &dest_path)?;
763
764 let mut updated_notes: Vec<(PathBuf, usize)> = op
765 .per_note_replacements
766 .iter()
767 .map(|(path, reps)| (path.clone(), reps.len()))
768 .collect();
769 updated_notes.sort_by(|(a, _), (b, _)| a.cmp(b));
770
771 Ok(MergePreview {
772 dest_path: dest_path.to_path_buf(),
773 dest_is_new: op.dest_is_new,
774 dest_is_loaded: op.dest_is_loaded,
775 sources: sources.iter().map(|s| s.path.clone()).collect(),
776 updated_notes,
777 })
778 }
779}
780
781struct RenameOp {
782 new_stem: String,
783 frontmatter_id_will_update: bool,
784 per_note_replacements: Vec<(Note, Vec<(LocatedLink, String)>)>,
786}
787
788pub struct RenamePreview {
790 pub new_path: PathBuf,
791 pub id_will_update: bool,
792 pub updated_notes: Vec<(PathBuf, usize)>,
794}
795
796pub struct MergePreview {
798 pub dest_path: PathBuf,
799 pub dest_is_new: bool,
800 pub dest_is_loaded: bool,
801 pub sources: Vec<PathBuf>,
803 pub updated_notes: Vec<(PathBuf, usize)>,
805}
806
807struct MergeOp {
808 dest_is_new: bool,
809 dest_is_loaded: bool,
810 merged_content: String,
812 merged_frontmatter: Option<IndexMap<String, Pod>>,
814 merged_tags: Vec<LocatedTag>,
816 merged_aliases: Vec<String>,
818 per_note_replacements: Vec<(PathBuf, Vec<(LocatedLink, String)>)>,
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825 use std::fs;
826
827 #[test]
830 fn open_from_cwd_finds_obsidian_dir() {
831 let dir = tempfile::tempdir().unwrap();
832 let subdir = dir.path().join("notes/daily");
833 fs::create_dir_all(&subdir).unwrap();
834 fs::create_dir(dir.path().join(".obsidian")).unwrap();
835
836 let original_cwd = std::env::current_dir().unwrap();
837 std::env::set_current_dir(&subdir).unwrap();
838 let vault = Vault::open_from_cwd().unwrap();
839 std::env::set_current_dir(original_cwd).unwrap();
840
841 assert_eq!(vault.path.canonicalize().unwrap(), dir.path().canonicalize().unwrap());
842 }
843
844 #[test]
845 fn open_from_cwd_falls_back_to_cwd_when_no_obsidian_dir() {
846 let dir = tempfile::tempdir().unwrap();
847
848 let original_cwd = std::env::current_dir().unwrap();
849 std::env::set_current_dir(&dir).unwrap();
850 let vault = Vault::open_from_cwd().unwrap();
851 std::env::set_current_dir(original_cwd).unwrap();
852
853 assert_eq!(vault.path.canonicalize().unwrap(), dir.path().canonicalize().unwrap());
854 }
855
856 #[test]
857 fn open_valid_directory() {
858 let dir = tempfile::tempdir().unwrap();
859 let vault = Vault::open(&dir.path()).expect("should open valid directory");
861 assert_eq!(vault.path, common::normalize_path(dir.path(), None));
862 }
863
864 #[test]
865 fn open_nonexistent_path_errors() {
866 let result = Vault::open("/nonexistent/path/to/vault");
867 assert!(result.is_err());
868 }
869
870 #[test]
871 fn open_file_path_errors() {
872 let file = tempfile::NamedTempFile::new().unwrap();
873 let result = Vault::open(file.path());
874 assert!(result.is_err());
875 }
876
877 #[test]
880 fn resolve_note_by_filename() {
881 let dir = tempfile::tempdir().unwrap();
882 let subdir = dir.path().join("subdir");
883 fs::create_dir(&subdir).unwrap();
884 fs::write(dir.path().join("root.md"), "---\nid: root\n---\n\nRoot note.").unwrap();
885 fs::write(subdir.join("nested.md"), "---\nid: nested\n---\n\nNested note.").unwrap();
886
887 let vault = Vault::open(dir.path()).unwrap();
888 let note = vault.resolve_note("root.md").expect("should resolve root.md");
889 assert_eq!(note.id, "root");
890
891 let note = vault
892 .resolve_note("nested.md")
893 .expect("should resolve subdir/nested.md");
894 assert_eq!(note.id, "nested");
895 }
896
897 #[test]
898 fn resolve_note_by_alias_exact_match() {
899 let dir = tempfile::tempdir().unwrap();
900 fs::write(
901 dir.path().join("note_a.md"),
902 "---\nid: note_a\naliases: [Foo, A]\n---\n\nNote A.",
903 )
904 .unwrap();
905 fs::write(
906 dir.path().join("note_b.md"),
907 "---\nid: note_b\naliases: [foo, B]\n---\n\nNote B.",
908 )
909 .unwrap();
910
911 let vault = Vault::open(dir.path()).unwrap();
912
913 let note = vault.resolve_note("Foo").expect("should resolve note");
914 assert_eq!(note.id, "note_a");
915
916 let note = vault.resolve_note("foo").expect("should resolve note");
917 assert_eq!(note.id, "note_b");
918 }
919
920 #[test]
923 fn notes_loads_md_files() {
924 let dir = tempfile::tempdir().unwrap();
925 fs::write(dir.path().join("a.md"), "# Note A\n\nContent A.").unwrap();
926 fs::write(dir.path().join("b.md"), "# Note B\n\nContent B.").unwrap();
927 fs::write(dir.path().join("not-a-note.txt"), "ignored").unwrap();
928
929 let vault = Vault::open(dir.path()).unwrap();
930 let notes: Vec<Note> = vault.notes().into_iter().map(|r| r.unwrap()).collect();
931 assert_eq!(notes.len(), 2);
932 }
933
934 #[test]
935 fn notes_finds_nested_md_files() {
936 let dir = tempfile::tempdir().unwrap();
937 let subdir = dir.path().join("subdir");
938 fs::create_dir(&subdir).unwrap();
939 fs::write(dir.path().join("root.md"), "Root note.").unwrap();
940 fs::write(subdir.join("nested.md"), "Nested note.").unwrap();
941
942 let vault = Vault::open(dir.path()).unwrap();
943 let notes: Vec<Note> = vault.notes().into_iter().map(|r| r.unwrap()).collect();
944 assert_eq!(notes.len(), 2);
945 }
946
947 #[test]
950 fn backlinks_wiki_by_id() {
951 let dir = tempfile::tempdir().unwrap();
952 fs::write(dir.path().join("target.md"), "---\nid: my-id\n---\nTarget.").unwrap();
953 fs::write(dir.path().join("source.md"), "See [[my-id]].").unwrap();
954
955 let vault = Vault::open(dir.path()).unwrap();
956 let target = Note::from_path(dir.path().join("target.md")).unwrap();
957 let backlinks = vault.backlinks(&target).unwrap();
958
959 assert_eq!(backlinks.len(), 1);
960 assert!(backlinks[0].0.path.ends_with("source.md"));
961 assert_eq!(backlinks[0].1.len(), 1);
962 }
963
964 #[test]
965 fn backlinks_wiki_by_stem_when_id_differs() {
966 let dir = tempfile::tempdir().unwrap();
967 fs::write(dir.path().join("my-note.md"), "---\nid: custom-id\n---\nTarget.").unwrap();
968 fs::write(dir.path().join("source.md"), "See [[my-note]].").unwrap();
969
970 let vault = Vault::open(dir.path()).unwrap();
971 let target = Note::from_path(dir.path().join("my-note.md")).unwrap();
972 let backlinks = vault.backlinks(&target).unwrap();
973
974 assert_eq!(backlinks.len(), 1);
975 assert!(backlinks[0].0.path.ends_with("source.md"));
976 }
977
978 #[test]
979 fn backlinks_wiki_by_alias() {
980 let dir = tempfile::tempdir().unwrap();
981 fs::write(dir.path().join("target.md"), "---\naliases: [t-alias]\n---\nTarget.").unwrap();
982 fs::write(dir.path().join("source.md"), "See [[t-alias]].").unwrap();
983
984 let vault = Vault::open(dir.path()).unwrap();
985 let target = Note::from_path(dir.path().join("target.md")).unwrap();
986 let backlinks = vault.backlinks(&target).unwrap();
987
988 assert_eq!(backlinks.len(), 1);
989 }
990
991 #[test]
992 fn backlinks_wiki_by_title() {
993 let dir = tempfile::tempdir().unwrap();
994 fs::write(dir.path().join("target.md"), "# My Title\n\nContent.").unwrap();
995 fs::write(dir.path().join("source.md"), "See [[My Title]].").unwrap();
996
997 let vault = Vault::open(dir.path()).unwrap();
998 let target = Note::from_path(dir.path().join("target.md")).unwrap();
999 let backlinks = vault.backlinks(&target).unwrap();
1000
1001 assert_eq!(backlinks.len(), 1);
1002 }
1003
1004 #[test]
1005 fn backlinks_wiki_with_heading_suffix() {
1006 let dir = tempfile::tempdir().unwrap();
1007 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1008 fs::write(dir.path().join("source.md"), "See [[target#section]].").unwrap();
1009
1010 let vault = Vault::open(dir.path()).unwrap();
1011 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1012 let backlinks = vault.backlinks(&target).unwrap();
1013
1014 assert_eq!(backlinks.len(), 1);
1015 }
1016
1017 #[test]
1018 fn backlinks_excludes_self() {
1019 let dir = tempfile::tempdir().unwrap();
1020 fs::write(dir.path().join("target.md"), "Self link: [[target]].").unwrap();
1021
1022 let vault = Vault::open(dir.path()).unwrap();
1023 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1024 let backlinks = vault.backlinks(&target).unwrap();
1025
1026 assert!(backlinks.is_empty());
1027 }
1028
1029 #[test]
1030 fn backlinks_excludes_notes_with_no_match() {
1031 let dir = tempfile::tempdir().unwrap();
1032 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1033 fs::write(dir.path().join("other.md"), "No links here.").unwrap();
1034
1035 let vault = Vault::open(dir.path()).unwrap();
1036 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1037 let backlinks = vault.backlinks(&target).unwrap();
1038
1039 assert!(backlinks.is_empty());
1040 }
1041
1042 #[test]
1043 fn backlinks_returns_all_matching_links_from_one_note() {
1044 let dir = tempfile::tempdir().unwrap();
1045 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1046 fs::write(dir.path().join("source.md"), "See [[target]] and also [[target]].").unwrap();
1047
1048 let vault = Vault::open(dir.path()).unwrap();
1049 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1050 let backlinks = vault.backlinks(&target).unwrap();
1051
1052 assert_eq!(backlinks.len(), 1);
1053 assert_eq!(backlinks[0].1.len(), 2);
1054 }
1055
1056 #[test]
1057 fn backlinks_no_match_on_unrelated_wiki_link() {
1058 let dir = tempfile::tempdir().unwrap();
1059 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1060 fs::write(dir.path().join("source.md"), "See [[other-note]].").unwrap();
1061
1062 let vault = Vault::open(dir.path()).unwrap();
1063 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1064 let backlinks = vault.backlinks(&target).unwrap();
1065
1066 assert!(backlinks.is_empty());
1067 }
1068
1069 #[test]
1070 fn backlinks_markdown_relative_path() {
1071 let dir = tempfile::tempdir().unwrap();
1072 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1073 fs::write(dir.path().join("source.md"), "[link](target.md)").unwrap();
1074
1075 let vault = Vault::open(dir.path()).unwrap();
1076 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1077 let backlinks = vault.backlinks(&target).unwrap();
1078
1079 assert_eq!(backlinks.len(), 1);
1080 assert!(backlinks[0].0.path.ends_with("source.md"));
1081 }
1082
1083 #[test]
1084 fn backlinks_markdown_fragment_stripped() {
1085 let dir = tempfile::tempdir().unwrap();
1086 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1087 fs::write(dir.path().join("source.md"), "[link](target.md#section)").unwrap();
1088
1089 let vault = Vault::open(dir.path()).unwrap();
1090 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1091 let backlinks = vault.backlinks(&target).unwrap();
1092
1093 assert_eq!(backlinks.len(), 1);
1094 }
1095
1096 #[test]
1097 fn backlinks_markdown_parent_traversal() {
1098 let dir = tempfile::tempdir().unwrap();
1099 let subdir = dir.path().join("sub");
1100 fs::create_dir(&subdir).unwrap();
1101 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1102 fs::write(subdir.join("source.md"), "[link](../target.md)").unwrap();
1103
1104 let vault = Vault::open(dir.path()).unwrap();
1105 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1106 let backlinks = vault.backlinks(&target).unwrap();
1107
1108 assert_eq!(backlinks.len(), 1);
1109 }
1110
1111 #[test]
1112 fn backlinks_markdown_external_url_excluded() {
1113 let dir = tempfile::tempdir().unwrap();
1114 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1115 fs::write(dir.path().join("source.md"), "[link](https://example.com/target.md)").unwrap();
1116
1117 let vault = Vault::open(dir.path()).unwrap();
1118 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1119 let backlinks = vault.backlinks(&target).unwrap();
1120
1121 assert!(backlinks.is_empty());
1122 }
1123
1124 #[test]
1125 fn backlinks_markdown_absolute_path_excluded() {
1126 let dir = tempfile::tempdir().unwrap();
1127 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1128 fs::write(dir.path().join("source.md"), "[link](/absolute/target.md)").unwrap();
1129
1130 let vault = Vault::open(dir.path()).unwrap();
1131 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1132 let backlinks = vault.backlinks(&target).unwrap();
1133
1134 assert!(backlinks.is_empty());
1135 }
1136
1137 #[test]
1138 fn backlinks_markdown_extension_less_excluded() {
1139 let dir = tempfile::tempdir().unwrap();
1140 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1141 fs::write(dir.path().join("source.md"), "[link](target)").unwrap();
1142
1143 let vault = Vault::open(dir.path()).unwrap();
1144 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1145 let backlinks = vault.backlinks(&target).unwrap();
1146
1147 assert!(backlinks.is_empty());
1148 }
1149
1150 #[test]
1153 fn rename_tag_basic() {
1154 let dir = tempfile::tempdir().unwrap();
1155 fs::write(
1156 dir.path().join("note.md"),
1157 "---\nid: note\ntags:\n- foo\n- old-tag\n---\n\nHello world #old-tag here and #old-tag there.",
1158 )
1159 .unwrap();
1160
1161 let mut vault = Vault::open(dir.path()).unwrap();
1162 vault.rename_tag("old-tag", "new-tag").unwrap();
1163
1164 let content = fs::read_to_string(dir.path().join("note.md")).unwrap();
1165 assert_eq!(
1166 content,
1167 "---\nid: note\ntags:\n- foo\n- new-tag\n---\n\nHello world #new-tag here and #new-tag there."
1168 );
1169 }
1170
1171 #[test]
1174 fn patch_note_replaces_string() {
1175 let dir = tempfile::tempdir().unwrap();
1176 fs::write(dir.path().join("note.md"), "Hello world.").unwrap();
1177
1178 let mut vault = Vault::open(dir.path()).unwrap();
1179 let note = Note::from_path_with_body(dir.path().join("note.md")).unwrap();
1180 vault.patch_note(¬e, "world", "Rust").unwrap();
1181
1182 let content = fs::read_to_string(dir.path().join("note.md")).unwrap();
1183 assert_eq!(content, "---\nid: note\n---\n\nHello Rust.");
1184 }
1185
1186 #[test]
1187 fn patch_note_string_not_found_errors() {
1188 let dir = tempfile::tempdir().unwrap();
1189 fs::write(dir.path().join("note.md"), "Hello world.").unwrap();
1190
1191 let mut vault = Vault::open(dir.path()).unwrap();
1192 let note = Note::from_path_with_body(dir.path().join("note.md")).unwrap();
1193 let result = vault.patch_note(¬e, "missing", "replacement");
1194
1195 assert!(matches!(result, Err(VaultError::StringNotFound(_))));
1196 }
1197
1198 #[test]
1199 fn patch_note_multiple_matches_errors() {
1200 let dir = tempfile::tempdir().unwrap();
1201 fs::write(dir.path().join("note.md"), "foo and foo").unwrap();
1202
1203 let mut vault = Vault::open(dir.path()).unwrap();
1204 let note = Note::from_path_with_body(dir.path().join("note.md")).unwrap();
1205 let result = vault.patch_note(¬e, "foo", "bar");
1206
1207 assert!(matches!(result, Err(VaultError::StringFoundMultipleTimes(_))));
1208 }
1209
1210 #[test]
1211 fn patch_note_does_not_work_in_frontmatter() {
1212 let dir = tempfile::tempdir().unwrap();
1213 fs::write(dir.path().join("note.md"), "---\ntitle: Old Title\n---\nBody.").unwrap();
1214
1215 let mut vault = Vault::open(dir.path()).unwrap();
1216 let note = Note::from_path_with_body(dir.path().join("note.md")).unwrap();
1217 assert!(vault.patch_note(¬e, "Old Title", "New Title").is_err());
1218 }
1219
1220 #[test]
1221 fn patch_note_returns_reloaded_note() {
1222 let dir = tempfile::tempdir().unwrap();
1223 fs::write(dir.path().join("note.md"), "---\ntitle: Before\n---\n# Before\nBody.").unwrap();
1224
1225 let mut vault = Vault::open(dir.path()).unwrap();
1226 let note = Note::from_path_with_body(dir.path().join("note.md")).unwrap();
1227 let patched = vault.patch_note(¬e, "Before", "After").unwrap();
1228
1229 assert_eq!(patched.body, Some("# After\nBody.".to_string()));
1230 }
1231
1232 #[test]
1235 fn rename_basic() {
1236 let dir = tempfile::tempdir().unwrap();
1237 fs::write(dir.path().join("old.md"), "Content.").unwrap();
1238
1239 let mut vault = Vault::open(dir.path()).unwrap();
1240 let note = Note::from_path_with_body(dir.path().join("old.md")).unwrap();
1241 let renamed = vault.rename(¬e, &dir.path().join("new.md")).unwrap();
1242
1243 assert!(!dir.path().join("old.md").exists());
1244 assert!(dir.path().join("new.md").exists());
1245 assert_eq!(renamed.id, "new");
1246 }
1247
1248 #[test]
1249 fn rename_explicit_id_equals_stem_updated() {
1250 let dir = tempfile::tempdir().unwrap();
1251 fs::write(dir.path().join("old-note.md"), "---\nid: old-note\n---\nContent.").unwrap();
1252 fs::write(dir.path().join("source.md"), "See [[old-note]].").unwrap();
1253
1254 let mut vault = Vault::open(dir.path()).unwrap();
1255 let note = Note::from_path(dir.path().join("old-note.md")).unwrap();
1256 let renamed = vault.rename(¬e, &dir.path().join("new-note.md")).unwrap();
1257
1258 assert!(!dir.path().join("old-note.md").exists());
1259 assert!(dir.path().join("new-note.md").exists());
1260 assert_eq!(renamed.id, "new-note");
1261
1262 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1263 assert_eq!(source_content, "See [[new-note]].");
1264 }
1265
1266 #[test]
1267 fn rename_explicit_id_differs_from_stem_unchanged() {
1268 let dir = tempfile::tempdir().unwrap();
1269 fs::write(dir.path().join("my-note.md"), "---\nid: custom-id\n---\nContent.").unwrap();
1270 fs::write(dir.path().join("source.md"), "See [[my-note]].").unwrap();
1271
1272 let mut vault = Vault::open(dir.path()).unwrap();
1273 let note = Note::from_path(dir.path().join("my-note.md")).unwrap();
1274 let renamed = vault.rename(¬e, &dir.path().join("renamed-note.md")).unwrap();
1275
1276 assert_eq!(renamed.id, "custom-id");
1277
1278 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1280 assert_eq!(source_content, "See [[my-note]].");
1281 }
1282
1283 #[test]
1284 fn rename_updates_markdown_backlinks() {
1285 let dir = tempfile::tempdir().unwrap();
1286 fs::write(dir.path().join("old.md"), "Target.").unwrap();
1287 fs::write(dir.path().join("source.md"), "[link](old.md)").unwrap();
1288
1289 let mut vault = Vault::open(dir.path()).unwrap();
1290 let note = Note::from_path(dir.path().join("old.md")).unwrap();
1291 vault.rename(¬e, &dir.path().join("new.md")).unwrap();
1292
1293 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1294 assert_eq!(source_content, "[link](new.md)");
1295 }
1296
1297 #[test]
1298 fn rename_updates_wiki_backlinks_by_stem() {
1299 let dir = tempfile::tempdir().unwrap();
1300 fs::write(dir.path().join("old-stem.md"), "Content.").unwrap();
1301 fs::write(dir.path().join("source.md"), "See [[old-stem]].").unwrap();
1302
1303 let mut vault = Vault::open(dir.path()).unwrap();
1304 let note = Note::from_path(dir.path().join("old-stem.md")).unwrap();
1305 vault.rename(¬e, &dir.path().join("new-stem.md")).unwrap();
1306
1307 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1308 assert_eq!(source_content, "See [[new-stem]].");
1309 }
1310
1311 #[test]
1312 fn rename_leaves_wiki_alias_links_unchanged() {
1313 let dir = tempfile::tempdir().unwrap();
1314 fs::write(dir.path().join("target.md"), "---\naliases: [my-alias]\n---\nContent.").unwrap();
1315 fs::write(dir.path().join("source.md"), "See [[my-alias]].").unwrap();
1316
1317 let mut vault = Vault::open(dir.path()).unwrap();
1318 let note = Note::from_path(dir.path().join("target.md")).unwrap();
1319 vault.rename(¬e, &dir.path().join("renamed-target.md")).unwrap();
1320
1321 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1322 assert_eq!(source_content, "See [[my-alias]].");
1323 }
1324
1325 #[test]
1326 fn rename_moves_to_different_directory() {
1327 let dir = tempfile::tempdir().unwrap();
1328 let subdir = dir.path().join("sub");
1329 fs::create_dir(&subdir).unwrap();
1330 fs::write(dir.path().join("root.md"), "Root.").unwrap();
1331 fs::write(dir.path().join("source.md"), "[link](root.md)").unwrap();
1332
1333 let mut vault = Vault::open(dir.path()).unwrap();
1334 let note = Note::from_path(dir.path().join("root.md")).unwrap();
1335 vault.rename(¬e, &subdir.join("root.md")).unwrap();
1336
1337 assert!(!dir.path().join("root.md").exists());
1338 assert!(subdir.join("root.md").exists());
1339
1340 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1341 assert_eq!(source_content, "[link](sub/root.md)");
1342 }
1343
1344 #[test]
1345 fn rename_directory_not_found_errors() {
1346 let dir = tempfile::tempdir().unwrap();
1347 fs::write(dir.path().join("old.md"), "Content.").unwrap();
1348
1349 let mut vault = Vault::open(dir.path()).unwrap();
1350 let note = Note::from_path(dir.path().join("old.md")).unwrap();
1351 let result = vault.rename(¬e, &dir.path().join("nonexistent/new.md"));
1352
1353 assert!(matches!(result, Err(VaultError::DirectoryNotFound(_))));
1354 }
1355
1356 #[test]
1357 fn rename_target_already_exists_errors() {
1358 let dir = tempfile::tempdir().unwrap();
1359 fs::write(dir.path().join("old.md"), "Old.").unwrap();
1360 fs::write(dir.path().join("new.md"), "Already exists.").unwrap();
1361
1362 let mut vault = Vault::open(dir.path()).unwrap();
1363 let note = Note::from_path(dir.path().join("old.md")).unwrap();
1364 let result = vault.rename(¬e, &dir.path().join("new.md"));
1365
1366 assert!(matches!(result, Err(VaultError::NoteAlreadyExists(_))));
1367 }
1368
1369 #[test]
1372 fn rename_preview_basic() {
1373 let dir = tempfile::tempdir().unwrap();
1374 fs::write(dir.path().join("old.md"), "Content.").unwrap();
1375
1376 let vault = Vault::open(dir.path()).unwrap();
1377 let note = Note::from_path_with_body(dir.path().join("old.md")).unwrap();
1378 let preview = vault.rename_preview(¬e, &dir.path().join("new.md")).unwrap();
1379
1380 assert_eq!(
1381 preview.new_path,
1382 common::normalize_path(&dir.path().join("new.md"), None)
1383 );
1384 assert!(preview.updated_notes.is_empty());
1385 assert!(preview.id_will_update);
1386 }
1387
1388 #[test]
1389 fn rename_preview_with_wiki_backlink() {
1390 let dir = tempfile::tempdir().unwrap();
1391 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1392 fs::write(dir.path().join("source.md"), "See [[target]].").unwrap();
1393
1394 let vault = Vault::open(dir.path()).unwrap();
1395 let note = Note::from_path(dir.path().join("target.md")).unwrap();
1396 let preview = vault.rename_preview(¬e, &dir.path().join("renamed.md")).unwrap();
1397
1398 assert_eq!(preview.updated_notes.len(), 1);
1399 assert!(preview.updated_notes[0].0.ends_with("source.md"));
1400 assert_eq!(preview.updated_notes[0].1, 1);
1401 }
1402
1403 #[test]
1404 fn rename_preview_with_markdown_backlink() {
1405 let dir = tempfile::tempdir().unwrap();
1406 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1407 fs::write(dir.path().join("source.md"), "[link](target.md)").unwrap();
1408
1409 let vault = Vault::open(dir.path()).unwrap();
1410 let note = Note::from_path(dir.path().join("target.md")).unwrap();
1411 let preview = vault.rename_preview(¬e, &dir.path().join("renamed.md")).unwrap();
1412
1413 assert_eq!(preview.updated_notes.len(), 1);
1414 assert!(preview.updated_notes[0].0.ends_with("source.md"));
1415 assert_eq!(preview.updated_notes[0].1, 1);
1416 }
1417
1418 #[test]
1419 fn rename_preview_id_will_update() {
1420 let dir = tempfile::tempdir().unwrap();
1421 fs::write(dir.path().join("old-note.md"), "---\nid: old-note\n---\nContent.").unwrap();
1422
1423 let vault = Vault::open(dir.path()).unwrap();
1424 let note = Note::from_path(dir.path().join("old-note.md")).unwrap();
1425 let preview = vault.rename_preview(¬e, &dir.path().join("new-note.md")).unwrap();
1426
1427 assert!(preview.id_will_update);
1428 }
1429
1430 #[test]
1431 fn rename_preview_id_will_not_update() {
1432 let dir = tempfile::tempdir().unwrap();
1433 fs::write(dir.path().join("my-note.md"), "---\nid: custom-id\n---\nContent.").unwrap();
1434
1435 let vault = Vault::open(dir.path()).unwrap();
1436 let note = Note::from_path(dir.path().join("my-note.md")).unwrap();
1437 let preview = vault
1438 .rename_preview(¬e, &dir.path().join("renamed-note.md"))
1439 .unwrap();
1440
1441 assert!(!preview.id_will_update);
1442 }
1443
1444 #[test]
1445 fn rename_preview_excludes_alias_only_links() {
1446 let dir = tempfile::tempdir().unwrap();
1447 fs::write(dir.path().join("target.md"), "---\naliases: [my-alias]\n---\nContent.").unwrap();
1448 fs::write(dir.path().join("source.md"), "See [[my-alias]].").unwrap();
1449
1450 let vault = Vault::open(dir.path()).unwrap();
1451 let note = Note::from_path(dir.path().join("target.md")).unwrap();
1452 let preview = vault.rename_preview(¬e, &dir.path().join("renamed.md")).unwrap();
1453
1454 assert!(preview.updated_notes.is_empty());
1456 }
1457
1458 #[test]
1459 fn rename_preview_does_not_modify_filesystem() {
1460 let dir = tempfile::tempdir().unwrap();
1461 fs::write(dir.path().join("old.md"), "Content.").unwrap();
1462 fs::write(dir.path().join("source.md"), "See [[old]].").unwrap();
1463
1464 let vault = Vault::open(dir.path()).unwrap();
1465 let note = Note::from_path(dir.path().join("old.md")).unwrap();
1466 vault.rename_preview(¬e, &dir.path().join("new.md")).unwrap();
1467
1468 assert!(dir.path().join("old.md").exists());
1469 assert!(!dir.path().join("new.md").exists());
1470
1471 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1472 assert_eq!(source_content, "See [[old]].");
1473 }
1474
1475 #[test]
1476 fn rename_preview_directory_not_found() {
1477 let dir = tempfile::tempdir().unwrap();
1478 fs::write(dir.path().join("old.md"), "Content.").unwrap();
1479
1480 let vault = Vault::open(dir.path()).unwrap();
1481 let note = Note::from_path(dir.path().join("old.md")).unwrap();
1482 let result = vault.rename_preview(¬e, &dir.path().join("nonexistent/new.md"));
1483
1484 assert!(matches!(result, Err(VaultError::DirectoryNotFound(_))));
1485 }
1486
1487 #[test]
1488 fn rename_preview_target_already_exists() {
1489 let dir = tempfile::tempdir().unwrap();
1490 fs::write(dir.path().join("old.md"), "Old.").unwrap();
1491 fs::write(dir.path().join("new.md"), "Already exists.").unwrap();
1492
1493 let vault = Vault::open(dir.path()).unwrap();
1494 let note = Note::from_path(dir.path().join("old.md")).unwrap();
1495 let result = vault.rename_preview(¬e, &dir.path().join("new.md"));
1496
1497 assert!(matches!(result, Err(VaultError::NoteAlreadyExists(_))));
1498 }
1499
1500 #[test]
1501 fn rename_preview_updated_notes_sorted_by_path() {
1502 let dir = tempfile::tempdir().unwrap();
1503 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1504 fs::write(dir.path().join("z-source.md"), "See [[target]].").unwrap();
1505 fs::write(dir.path().join("a-source.md"), "See [[target]].").unwrap();
1506
1507 let vault = Vault::open(dir.path()).unwrap();
1508 let note = Note::from_path(dir.path().join("target.md")).unwrap();
1509 let preview = vault.rename_preview(¬e, &dir.path().join("renamed.md")).unwrap();
1510
1511 assert_eq!(preview.updated_notes.len(), 2);
1512 assert!(preview.updated_notes[0].0 < preview.updated_notes[1].0);
1513 }
1514
1515 #[test]
1516 fn rename_markdown_link_with_subdir() {
1517 let dir = tempfile::tempdir().unwrap();
1518 let subdir = dir.path().join("sub");
1519 fs::create_dir(&subdir).unwrap();
1520 fs::write(dir.path().join("root.md"), "Root.").unwrap();
1521 fs::write(subdir.join("source.md"), "[link](root.md)\n[link](sub/target.md)").unwrap();
1522 fs::write(subdir.join("target.md"), "Target.").unwrap();
1523
1524 let mut vault = Vault::open(dir.path()).unwrap();
1525
1526 {
1527 let note = Note::from_path(dir.path().join("root.md")).unwrap();
1528 vault.rename(¬e, &dir.path().join("new-root.md")).unwrap();
1529
1530 let source_content = fs::read_to_string(subdir.join("source.md")).unwrap();
1531 assert_eq!(source_content, "[link](new-root.md)\n[link](sub/target.md)");
1532 }
1533
1534 {
1535 let note = Note::from_path(subdir.join("target.md")).unwrap();
1536 vault.rename(¬e, &subdir.join("new-target.md")).unwrap();
1537
1538 let source_content = fs::read_to_string(subdir.join("source.md")).unwrap();
1539 assert_eq!(source_content, "[link](new-root.md)\n[link](sub/new-target.md)");
1540 }
1541 }
1542
1543 #[test]
1544 fn rename_multiple_links_same_source() {
1545 let dir = tempfile::tempdir().unwrap();
1546 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1547 fs::write(dir.path().join("source.md"), "[first](target.md)\n[second](target.md)").unwrap();
1548
1549 let mut vault = Vault::open(dir.path()).unwrap();
1550 let note = Note::from_path(dir.path().join("target.md")).unwrap();
1551 vault.rename(¬e, &dir.path().join("renamed.md")).unwrap();
1552
1553 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1554 assert_eq!(source_content, "[first](renamed.md)\n[second](renamed.md)");
1555 }
1556
1557 #[test]
1558 fn rename_preserves_fragment() {
1559 let dir = tempfile::tempdir().unwrap();
1560 fs::write(dir.path().join("old.md"), "Old.").unwrap();
1561 fs::write(dir.path().join("source.md"), "[link](old.md#section)").unwrap();
1562
1563 let mut vault = Vault::open(dir.path()).unwrap();
1564 let note = Note::from_path(dir.path().join("old.md")).unwrap();
1565 vault.rename(¬e, &dir.path().join("new.md")).unwrap();
1566
1567 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1568 assert_eq!(source_content, "[link](new.md#section)");
1569 }
1570
1571 #[test]
1572 fn rename_wiki_preserves_heading_and_alias() {
1573 let dir = tempfile::tempdir().unwrap();
1574 fs::write(dir.path().join("old-stem.md"), "Content.").unwrap();
1575 fs::write(dir.path().join("source.md"), "See [[old-stem#h1|display]].").unwrap();
1576
1577 let mut vault = Vault::open(dir.path()).unwrap();
1578 let note = Note::from_path(dir.path().join("old-stem.md")).unwrap();
1579 vault.rename(¬e, &dir.path().join("new-stem.md")).unwrap();
1580
1581 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
1582 assert_eq!(source_content, "See [[new-stem#h1|display]].");
1583 }
1584
1585 #[test]
1588 fn merge_basic_creates_dest_and_deletes_sources() {
1589 let dir = tempfile::tempdir().unwrap();
1590 fs::write(dir.path().join("a.md"), "Body A.").unwrap();
1591 fs::write(dir.path().join("b.md"), "Body B.").unwrap();
1592
1593 let mut vault = Vault::open(dir.path()).unwrap();
1594 let a = Note::from_path_with_body(dir.path().join("a.md")).unwrap();
1595 let b = Note::from_path_with_body(dir.path().join("b.md")).unwrap();
1596 let dest_path = dir.path().join("combined.md");
1597 vault.merge(&[a, b], &dest_path).unwrap();
1598
1599 assert!(!dir.path().join("a.md").exists());
1600 assert!(!dir.path().join("b.md").exists());
1601 assert!(dest_path.exists());
1602 let content = fs::read_to_string(&dest_path).unwrap();
1603 assert!(content.contains("Body A."));
1604 assert!(content.contains("Body B."));
1605 }
1606
1607 #[test]
1608 fn merge_into_existing_appends_content() {
1609 let dir = tempfile::tempdir().unwrap();
1610 fs::write(dir.path().join("src.md"), "Source body.").unwrap();
1611 fs::write(dir.path().join("dest.md"), "Existing body.").unwrap();
1612
1613 let mut vault = Vault::open(dir.path()).unwrap();
1614 let src = Note::from_path_with_body(dir.path().join("src.md")).unwrap();
1615 vault.merge(&[src], &dir.path().join("dest.md")).unwrap();
1616
1617 assert!(!dir.path().join("src.md").exists());
1618 let content = fs::read_to_string(dir.path().join("dest.md")).unwrap();
1619 assert!(content.contains("Existing body."));
1620 assert!(content.contains("Source body."));
1621 }
1622
1623 #[test]
1624 fn merge_unions_tags() {
1625 let dir = tempfile::tempdir().unwrap();
1626 fs::write(dir.path().join("a.md"), "---\ntags: [rust]\n---\nBody A.").unwrap();
1627 fs::write(dir.path().join("b.md"), "---\ntags: [obsidian]\n---\nBody B.").unwrap();
1628
1629 let mut vault = Vault::open(dir.path()).unwrap();
1630 let a = Note::from_path_with_body(dir.path().join("a.md")).unwrap();
1631 let b = Note::from_path_with_body(dir.path().join("b.md")).unwrap();
1632 let dest_path = dir.path().join("combined.md");
1633 vault.merge(&[a, b], &dest_path).unwrap();
1634
1635 let combined = Note::from_path(&dest_path).unwrap();
1636 assert!(
1637 combined
1638 .tags
1639 .iter()
1640 .any(|t| t.tag == "rust" && matches!(t.location, Location::Frontmatter))
1641 );
1642 assert!(
1643 combined
1644 .tags
1645 .iter()
1646 .any(|t| t.tag == "obsidian" && matches!(t.location, Location::Frontmatter))
1647 );
1648 }
1649
1650 #[test]
1651 fn merges_not_inherit_source_id() {
1652 let dir = tempfile::tempdir().unwrap();
1653 fs::write(
1654 dir.path().join("src.md"),
1655 "---\nid: source-id\nauthor: alice\n---\nBody.",
1656 )
1657 .unwrap();
1658
1659 let mut vault = Vault::open(dir.path()).unwrap();
1660 let src = Note::from_path_with_body(dir.path().join("src.md")).unwrap();
1661 let dest_path = dir.path().join("dest.md");
1662 vault.merge(&[src], &dest_path).unwrap();
1663
1664 let dest = Note::from_path(&dest_path).unwrap();
1665 let fm = dest.frontmatter.unwrap();
1666 assert_ne!(dest.id, "source-id");
1668 assert!(fm.contains_key("id"));
1669 assert!(fm.contains_key("author"));
1671 }
1672
1673 #[test]
1674 fn merge_other_frontmatter_fields_inherited_from_source_when_dest_is_new() {
1675 let dir = tempfile::tempdir().unwrap();
1676 fs::write(
1677 dir.path().join("src.md"),
1678 "---\nauthor: alice\ncreated: 2024-01-01\n---\nBody.",
1679 )
1680 .unwrap();
1681
1682 let mut vault = Vault::open(dir.path()).unwrap();
1683 let src = Note::from_path_with_body(dir.path().join("src.md")).unwrap();
1684 let dest_path = dir.path().join("dest.md");
1685 vault.merge(&[src], &dest_path).unwrap();
1686
1687 let dest = Note::from_path(&dest_path).unwrap();
1688 let fm = dest.frontmatter.unwrap();
1689 assert!(fm.contains_key("author"));
1690 assert!(fm.contains_key("created"));
1691 }
1692
1693 #[test]
1694 fn merge_dest_wins_on_conflicting_fields() {
1695 let dir = tempfile::tempdir().unwrap();
1696 fs::write(dir.path().join("src.md"), "---\nauthor: alice\n---\nSource.").unwrap();
1697 fs::write(dir.path().join("dest.md"), "---\nauthor: bob\n---\nDest.").unwrap();
1698
1699 let mut vault = Vault::open(dir.path()).unwrap();
1700 let src = Note::from_path_with_body(dir.path().join("src.md")).unwrap();
1701 vault.merge(&[src], &dir.path().join("dest.md")).unwrap();
1702
1703 let dest = Note::from_path(dir.path().join("dest.md")).unwrap();
1704 let fm = dest.frontmatter.unwrap();
1705 assert_eq!(fm["author"].as_string().unwrap(), "bob");
1706 }
1707
1708 #[test]
1709 fn merge_updates_wiki_backlinks() {
1710 let dir = tempfile::tempdir().unwrap();
1711 fs::write(dir.path().join("src.md"), "Source.").unwrap();
1712 fs::write(dir.path().join("linker.md"), "See [[src]].").unwrap();
1713
1714 let mut vault = Vault::open(dir.path()).unwrap();
1715 let src = Note::from_path_with_body(dir.path().join("src.md")).unwrap();
1716 vault.merge(&[src], &dir.path().join("dest.md")).unwrap();
1717
1718 let linker = fs::read_to_string(dir.path().join("linker.md")).unwrap();
1719 assert_eq!(linker, "See [[dest]].");
1720 }
1721
1722 #[test]
1723 fn merge_source_is_dest_errors() {
1724 let dir = tempfile::tempdir().unwrap();
1725 fs::write(dir.path().join("note.md"), "Content.").unwrap();
1726
1727 let mut vault = Vault::open(dir.path()).unwrap();
1728 let note = Note::from_path(dir.path().join("note.md")).unwrap();
1729 let result = vault.merge(&[note], &dir.path().join("note.md"));
1730
1731 assert!(matches!(result, Err(VaultError::MergeSourceIsDestination(_))));
1732 }
1733
1734 #[test]
1735 fn merge_preview_does_not_modify_filesystem() {
1736 let dir = tempfile::tempdir().unwrap();
1737 fs::write(dir.path().join("src.md"), "Source.").unwrap();
1738 fs::write(dir.path().join("linker.md"), "See [[src]].").unwrap();
1739
1740 let vault = Vault::open(dir.path()).unwrap();
1741 let src = Note::from_path_with_body(dir.path().join("src.md")).unwrap();
1742 vault.merge_preview(&[src], &dir.path().join("dest.md")).unwrap();
1743
1744 assert!(dir.path().join("src.md").exists());
1745 assert!(!dir.path().join("dest.md").exists());
1746 let linker = fs::read_to_string(dir.path().join("linker.md")).unwrap();
1747 assert_eq!(linker, "See [[src]].");
1748 }
1749}