1use std::collections::{BTreeSet, HashMap};
2use std::env::current_dir;
3use std::fs;
4use std::io::Write;
5use std::ops::Range;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use gray_matter::Pod;
10use indexmap::IndexMap;
11use rayon::prelude::*;
12
13use crate::health::VaultHealthReport;
14use crate::{InlineLocation, Link, LocatedLink, LocatedTag, Location, Note, NoteError, VaultError, common, search};
15
16#[derive(Clone)]
17pub struct Vault {
18 path: PathBuf,
20 cached_notes: Option<HashMap<PathBuf, Arc<Note>>>,
25}
26
27impl std::fmt::Debug for Vault {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.debug_struct("Vault")
30 .field("path", &self.path)
31 .field("cached_note_count", &self.cached_notes.as_ref().map(HashMap::len))
32 .finish()
33 }
34}
35
36impl Vault {
37 pub fn open(path: impl AsRef<Path>) -> Result<Self, VaultError> {
40 let path = common::normalize_path(path, None);
41 if !path.is_dir() {
42 return Err(VaultError::NotADirectory(path));
43 }
44 Ok(Vault {
45 path,
46 cached_notes: None,
47 })
48 }
49
50 pub fn open_cached(path: impl AsRef<Path>) -> Result<Self, VaultError> {
55 let mut vault = Self::open(path)?;
56 vault.cache_notes();
57 Ok(vault)
58 }
59
60 pub fn open_from_cwd() -> Result<Self, VaultError> {
63 let cwd = std::env::current_dir()?;
64 let mut current = cwd.as_path();
65 loop {
66 if current.join(".obsidian").is_dir() {
67 return Self::open(current);
68 }
69 match current.parent() {
70 Some(parent) => current = parent,
71 None => break,
72 }
73 }
74 Self::open(&cwd)
75 }
76
77 pub fn path(&self) -> &Path {
78 self.path.as_path()
79 }
80
81 pub fn is_note_path(path: impl AsRef<Path>) -> bool {
82 path.as_ref().extension().and_then(|ext| ext.to_str()) == Some("md")
83 }
84
85 pub fn normalize_path(&self, path: impl AsRef<Path>) -> PathBuf {
86 common::normalize_path(path, Some(&self.path))
87 }
88
89 pub fn cache_notes(&mut self) {
90 let notes = search::find_note_paths(&self.path)
91 .collect::<Vec<_>>()
92 .into_par_iter()
93 .filter_map(|path| Note::from_path(path).ok())
94 .map(|note| (note.path.clone(), Arc::new(note)))
95 .collect();
96 self.cached_notes = Some(notes);
97 }
98
99 pub fn has_cached_note(&self, path: impl AsRef<Path>) -> bool {
100 let path = self.normalize_path(path);
101 self.cached_notes
102 .as_ref()
103 .is_some_and(|notes| notes.contains_key(&path))
104 }
105
106 fn has_known_note_path(&self, path: &Path) -> bool {
107 let path = common::normalize_path(path, None);
108 if let Some(cached_notes) = self.cached_notes.as_ref() {
109 cached_notes.contains_key(&path)
110 } else {
111 path.is_file()
112 }
113 }
114
115 pub fn note_for_path(&self, path: impl AsRef<Path>) -> Result<Note, VaultError> {
116 let path = self.normalize_path(path);
117 if let Some(note) = self.cached_notes.as_ref().and_then(|notes| notes.get(&path)) {
118 return Ok(note.as_ref().clone());
119 }
120 Ok(Note::from_path(path)?)
121 }
122
123 pub fn text_for_path(&self, path: impl AsRef<Path>) -> Result<String, VaultError> {
124 let path = self.normalize_path(path);
125 if let Some(note) = self.cached_notes.as_ref().and_then(|notes| notes.get(&path)) {
126 return Ok(note.text().to_string());
127 }
128 Ok(fs::read_to_string(path)?)
129 }
130
131 pub fn refresh_cached_note(&mut self, path: impl AsRef<Path>) -> Result<bool, VaultError> {
132 let path = self.normalize_path(path);
133 let Some(cached_notes) = self.cached_notes.as_mut() else {
134 return Ok(false);
135 };
136 if !Self::is_note_path(&path) || !path.is_file() {
137 cached_notes.remove(&path);
138 return Ok(false);
139 }
140
141 let note = Note::from_path(&path)?;
142 cached_notes.insert(note.path.clone(), Arc::new(note));
143 Ok(true)
144 }
145
146 pub fn remove_cached_note(&mut self, path: impl AsRef<Path>) -> bool {
147 let path = self.normalize_path(path);
148 self.cached_notes
149 .as_mut()
150 .is_some_and(|cached_notes| cached_notes.remove(&path).is_some())
151 }
152
153 pub fn resolve_note(&self, note: &str) -> Result<Note, VaultError> {
155 if let Ok((path, _)) = self.resolve_note_path(note, true) {
157 return self.note_for_path(path);
158 }
159
160 let mut search = self.search().or_has_id(note).or_has_alias(note).ignore_case();
162 if note.ends_with(".md") && !note.contains('/') {
163 let glob = format!("**/{}", note);
164 let stem = note.trim_end_matches(".md");
165 search = search.or_glob(glob).or_has_id(stem).or_has_alias(stem);
166 }
167
168 let results = search.execute().map_err(VaultError::Search)?;
169 let mut notes: Vec<Note> = results.into_iter().filter_map(|r| r.ok()).collect();
170
171 if notes.is_empty() {
172 return Err(VaultError::NoteNotFound(note.to_string()));
173 }
174
175 if notes.len() == 1 {
176 return Ok(notes.remove(0));
177 }
178
179 let paths = notes.iter().map(|n| n.path.clone()).collect();
181 let mut notes: Vec<_> = notes
182 .into_iter()
183 .filter(|n| n.id == note || n.aliases.iter().any(|a| a == note))
184 .collect();
185
186 if notes.len() == 1 {
187 return Ok(notes.remove(0));
188 }
189
190 Err(VaultError::AmbiguousNoteIdentifier(note.to_string(), paths))
191 }
192
193 pub fn resolve_note_path(
197 &self,
198 path: impl AsRef<Path>,
199 strict: bool,
200 ) -> Result<(std::path::PathBuf, Option<std::path::PathBuf>), VaultError> {
201 let path = path.as_ref().to_path_buf();
202 if path.is_absolute() {
203 if self.has_known_note_path(&path) || !strict {
204 return Ok((common::normalize_path(&path, None), None));
205 } else {
206 return Err(VaultError::NoteNotFound(path.to_string_lossy().to_string()));
207 }
208 }
209
210 let cwd = current_dir()?;
214 let mut cwd_resolved = common::normalize_path(&path, Some(&cwd));
215 if cwd_resolved.starts_with(&self.path) {
216 if self.has_known_note_path(&cwd_resolved) {
218 return Ok((cwd_resolved, Some(cwd)));
219 } else if cwd_resolved.extension().is_none() {
220 cwd_resolved.set_extension("md");
221 if self.has_known_note_path(&cwd_resolved) {
222 return Ok((cwd_resolved, Some(cwd)));
223 }
224 }
225
226 let mut vault_resolved = common::normalize_path(&path, Some(&self.path));
230 if strict {
231 if self.has_known_note_path(&vault_resolved) {
232 return Ok((vault_resolved, Some(self.path.clone())));
233 } else if vault_resolved.extension().is_none() {
234 vault_resolved.set_extension("md");
235 if self.has_known_note_path(&vault_resolved) {
236 return Ok((vault_resolved, Some(self.path.clone())));
237 }
238 }
239 } else {
240 return Ok((cwd_resolved, Some(cwd)));
241 }
242 } else {
243 let mut vault_resolved = common::normalize_path(&path, Some(&self.path));
244 if self.has_known_note_path(&vault_resolved) {
245 return Ok((vault_resolved, Some(self.path.clone())));
246 } else if vault_resolved.extension().is_none() {
247 vault_resolved.set_extension("md");
248 if self.has_known_note_path(&vault_resolved) {
249 return Ok((vault_resolved, Some(self.path.clone())));
250 }
251 }
252
253 if !strict {
254 return Ok((vault_resolved, Some(self.path.clone())));
255 }
256 }
257
258 Err(VaultError::NoteNotFound(path.to_string_lossy().to_string()))
259 }
260
261 pub fn notes(&self) -> Vec<Result<Note, NoteError>> {
264 self.notes_filtered(|_| true)
265 }
266
267 pub fn load_note(&mut self, mut note: Note) {
274 let resolved_path = self
275 .resolve_note_path(¬e.path, false)
276 .map(|(n, _)| n)
277 .unwrap_or_else(|_| note.path.clone());
278 note.path = resolved_path;
279 if let Some(cached_notes) = self.cached_notes.as_mut() {
280 cached_notes.insert(note.path.clone(), Arc::new(note));
281 }
282 }
283
284 pub fn unload_note(&mut self, path: &Path) {
287 let resolved_path = self
288 .resolve_note_path(path, false)
289 .map(|(n, _)| n)
290 .unwrap_or_else(|_| path.into());
291 let _ = self.refresh_cached_note(&resolved_path);
292 }
293
294 pub fn note_is_loaded(&self, path: impl AsRef<Path>) -> bool {
295 self.has_cached_note(path)
296 }
297
298 pub fn notes_filtered(&self, filter: impl Fn(&Path) -> bool) -> Vec<Result<Note, NoteError>> {
302 if let Some(cached_notes) = &self.cached_notes {
303 let mut results: Vec<Result<Note, NoteError>> = cached_notes
304 .values()
305 .filter(|note| filter(¬e.path))
306 .map(|note| Ok(note.as_ref().clone()))
307 .collect();
308 results.sort_by(|left, right| {
309 let left_path = left.as_ref().ok().map(|note| ¬e.path);
310 let right_path = right.as_ref().ok().map(|note| ¬e.path);
311 left_path.cmp(&right_path)
312 });
313 results
314 } else {
315 search::find_notes_filtered(&self.path, filter)
316 }
317 }
318
319 pub fn check(&self, filter: impl Fn(&Path) -> bool) -> VaultHealthReport {
327 let notes: Vec<Note> = self.notes_filtered(filter).into_iter().filter_map(|r| r.ok()).collect();
328 crate::health::check_notes(&self.path, ¬es)
329 }
330
331 pub fn search(&self) -> search::SearchQuery<'_> {
335 let query = search::SearchQuery::new(&self.path);
336 if let Some(cached_notes) = &self.cached_notes {
337 query.with_cached_notes(cached_notes)
338 } else {
339 query
340 }
341 }
342
343 pub fn list_tags(&self) -> Result<Vec<String>, VaultError> {
345 if self.cached_notes.is_some() {
346 let mut tags = BTreeSet::new();
347 for note in self.notes_filtered(|_| true).into_iter().filter_map(Result::ok) {
348 tags.extend(note.tags.into_iter().map(|tag| tag.tag.to_lowercase()));
349 }
350 Ok(tags.into_iter().collect())
351 } else {
352 search::find_all_tags(&self.path).map_err(VaultError::Note)
353 }
354 }
355
356 pub fn find_tags(&self, tags: &[String]) -> Result<Vec<(Note, Vec<LocatedTag>)>, VaultError> {
359 search::find_tags_with_query(self.search(), tags).map_err(VaultError::Search)
360 }
361
362 pub fn rename_tag(&mut self, old_tag: &str, new_tag: &str) -> Result<Vec<(Note, Vec<LocatedTag>)>, VaultError> {
365 let mut results: Vec<(Note, Vec<LocatedTag>)> = Vec::new();
366 for (mut note, tags) in self.find_tags(&[old_tag.into()])? {
367 let mut tags_by_line: HashMap<usize, Vec<InlineLocation>> = HashMap::new();
368 for lt in tags {
369 match lt.location {
370 Location::Frontmatter => {
372 note.remove_tag(<.tag)?;
373 note.add_tag(new_tag)?;
374 }
375 Location::Inline(loc) => {
377 tags_by_line.entry(loc.line).or_default();
378 tags_by_line.get_mut(&loc.line).unwrap().push(loc);
379 }
380 };
381 }
382
383 if !tags_by_line.is_empty() {
384 let mut lines: Vec<String> = note.body().lines().map(|s| s.to_string()).collect();
386 for (lnum, locs) in tags_by_line.drain() {
387 let line = lines.get_mut(lnum - 1 - note.frontmatter_line_count).unwrap();
388 let mut offset = 0;
389 for loc in locs {
390 line.replace_range(
391 (offset + loc.col_start)..(offset + loc.col_end),
392 &format!("#{}", new_tag),
393 );
394 offset += new_tag.len() - old_tag.len();
395 }
396 }
397
398 let body = lines.join("\n");
399 note.update_content(Some(&body), None)?;
400 }
401
402 let tags = note
404 .tags
405 .iter()
406 .filter_map(|lt| if lt.tag == new_tag { Some(lt.clone()) } else { None })
407 .collect();
408
409 note.write()?;
411 self.refresh_cached_note(¬e.path)?;
412
413 results.push((note, tags));
414 }
415
416 Ok(results)
417 }
418
419 pub fn backlinks(&self, target: &Note) -> Result<Vec<(Note, Vec<LocatedLink>)>, VaultError> {
425 if self.cached_notes.is_some() {
426 let notes: Vec<Note> = self.notes().into_iter().filter_map(Result::ok).collect();
427 return Ok(self
428 .backlinks_from(¬es, target)
429 .into_iter()
430 .map(|(note, links)| (note.clone(), links))
431 .collect());
432 }
433
434 let results = self
435 .search()
436 .and_links_to(target.clone())
437 .execute()
438 .map_err(VaultError::Search)?;
439 let notes: Vec<Note> = results.into_iter().filter_map(|r| r.ok()).collect();
440 let results = notes
441 .into_iter()
442 .map(|source| {
443 let matching = search::find_matching_links(&source, target, &self.path);
444 (source, matching)
445 })
446 .collect();
447 Ok(results)
448 }
449
450 pub fn backlinks_from<'a>(&self, notes: &'a [Note], target: &Note) -> Vec<(&'a Note, Vec<LocatedLink>)> {
453 crate::health::backlinks_from(notes, target, &self.path)
454 }
455
456 fn compute_rename_op(&self, note: &Note, new_path: &Path) -> Result<RenameOp, VaultError> {
458 let new_dir = new_path.parent().unwrap_or_else(|| Path::new("."));
459 if !new_dir.is_dir() {
460 return Err(VaultError::DirectoryNotFound(new_dir.to_path_buf()));
461 }
462
463 if new_path.exists() {
464 return Err(VaultError::NoteAlreadyExists(new_path.to_path_buf()));
465 }
466
467 let new_stem = new_path
468 .file_stem()
469 .and_then(|s| s.to_str())
470 .unwrap_or_default()
471 .to_string();
472
473 let old_stem = note
474 .path
475 .file_stem()
476 .and_then(|s| s.to_str())
477 .unwrap_or_default()
478 .to_string();
479
480 let id_needs_update = note.id == old_stem;
481 let backlinks = self.backlinks(note)?;
485 let mut per_note_replacements: Vec<(Note, Vec<(LocatedLink, String)>)> = Vec::new();
486
487 for (source_note, links) in backlinks {
488 let mut replacements: Vec<(LocatedLink, String)> = Vec::new();
489
490 for ll in links {
491 let new_text = match &ll.link {
492 Link::Wiki { target, heading, alias } if id_needs_update && target == &old_stem => {
493 let mut wiki = format!("[[{}", new_stem);
494 if let Some(h) = heading {
495 wiki.push('#');
496 wiki.push_str(h);
497 }
498 if let Some(a) = alias {
499 wiki.push('|');
500 wiki.push_str(a);
501 }
502 wiki.push_str("]]");
503 Some(wiki)
504 }
505 Link::Wiki { .. } => None,
506 Link::Markdown { text, url } => {
507 let fragment = url.find('#').map(|i| url[i..].to_string());
508 let new_url = common::relative_path(&self.path, new_path);
509 let new_url_str = new_url.to_string_lossy().replace('\\', "/");
510 let full_url = match fragment {
511 Some(f) => format!("{}{}", new_url_str, f),
512 None => new_url_str,
513 };
514 Some(format!("[{}]({})", text, full_url))
515 }
516 _ => None,
517 };
518 if let Some(text) = new_text {
519 replacements.push((ll, text));
520 }
521 }
522
523 if !replacements.is_empty() {
524 per_note_replacements.push((source_note, replacements));
525 }
526 }
527
528 Ok(RenameOp {
529 new_stem,
530 frontmatter_id_will_update: id_needs_update,
531 per_note_replacements,
532 })
533 }
534
535 pub fn rename(&mut self, note: &Note, new_path: &Path) -> Result<Note, VaultError> {
544 let new_path = common::normalize_path(new_path, Some(&self.path));
545 let op = self.compute_rename_op(note, &new_path)?;
546
547 let mut renamed = note.clone();
548 renamed.path = new_path.clone();
549 if op.frontmatter_id_will_update {
550 renamed.id = op.new_stem;
551 }
552
553 renamed.write()?;
554 std::fs::remove_file(¬e.path)?;
555 self.remove_cached_note(¬e.path);
556 self.refresh_cached_note(&new_path)?;
557
558 for (source_note, replacements) in op.per_note_replacements {
559 let raw_content = self.text_for_path(&source_note.path)?;
560 let new_content = common::rewrite_links(&raw_content, replacements);
561 std::fs::write(&source_note.path, new_content)?;
562 self.refresh_cached_note(&source_note.path)?;
563 }
564
565 self.note_for_path(&new_path)
566 }
567
568 pub fn rename_preview(&self, note: &Note, new_path: &Path) -> Result<RenamePreview, VaultError> {
572 let edits = self.rename_edits(note, new_path)?;
573 let updated_notes = edits
574 .backlink_edits
575 .iter()
576 .map(|(path, replacements)| (path.clone(), replacements.len()))
577 .collect();
578
579 Ok(RenamePreview {
580 new_path: edits.new_path,
581 id_will_update: edits.id_will_update,
582 updated_notes,
583 })
584 }
585
586 pub fn rename_edits(&self, note: &Note, new_path: &Path) -> Result<RenameEdits, VaultError> {
590 let new_path = common::normalize_path(new_path, Some(&self.path));
591 let op = self.compute_rename_op(note, &new_path)?;
592
593 let mut backlink_edits: Vec<(PathBuf, Vec<(LocatedLink, String)>)> = op
594 .per_note_replacements
595 .into_iter()
596 .map(|(source_note, replacements)| (source_note.path, replacements))
597 .collect();
598 backlink_edits.sort_by(|(a, _), (b, _)| a.cmp(b));
599
600 Ok(RenameEdits {
601 new_path: new_path.to_path_buf(),
602 new_stem: op.new_stem,
603 id_will_update: op.frontmatter_id_will_update,
604 backlink_edits,
605 })
606 }
607
608 fn raw_note_sections<'a>(note_path: &Path, raw: &'a str) -> (&'a str, &'a str) {
609 let parsed = Note::parse(note_path, raw);
610 let body_start = raw
611 .split_inclusive('\n')
612 .take(parsed.frontmatter_line_count)
613 .map(str::len)
614 .sum();
615 raw.split_at(body_start)
616 }
617
618 fn apply_raw_note_update(&mut self, note_path: &Path, raw: String) -> Result<Note, VaultError> {
619 let note_path = self.normalize_path(note_path);
620 let updated = Note::parse(¬e_path, &raw);
621
622 let parent = note_path.parent().unwrap_or_else(|| Path::new("."));
623 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
624 tmp.write_all(raw.as_bytes())?;
625 tmp.persist(¬e_path).map_err(|e| e.error)?;
626 self.refresh_cached_note(¬e_path)?;
627
628 Ok(updated)
629 }
630
631 pub fn patch_note(&mut self, note: &Note, old_string: &str, new_string: &str) -> Result<Note, VaultError> {
638 let raw = self.text_for_path(¬e.path)?;
639 let (raw_frontmatter, raw_body) = Self::raw_note_sections(¬e.path, &raw);
640
641 let count = raw_body.matches(old_string).count();
642 if count == 0 {
643 return Err(VaultError::StringNotFound(note.path.clone()));
644 }
645 if count > 1 {
646 return Err(VaultError::StringFoundMultipleTimes(note.path.clone()));
647 }
648
649 let patched_body = raw_body.replacen(old_string, new_string, 1);
650 let patched_raw = format!("{raw_frontmatter}{patched_body}");
651 self.apply_raw_note_update(¬e.path, patched_raw)
652 }
653
654 pub fn append_to_note(&mut self, note: &Note, content: &str) -> Result<Note, VaultError> {
657 let raw = self.text_for_path(¬e.path)?;
658 let (raw_frontmatter, raw_body) = Self::raw_note_sections(¬e.path, &raw);
659 let appended_raw = format!("{raw_frontmatter}{raw_body}{content}");
660 self.apply_raw_note_update(¬e.path, appended_raw)
661 }
662
663 pub fn extract_to_note_edits(
666 &self,
667 note: &Note,
668 selection: &ExtractSelection,
669 new_path: impl AsRef<Path>,
670 new_id: Option<&str>,
671 replace_with: Option<&str>,
672 ) -> Result<ExtractEdits, VaultError> {
673 let raw = self.text_for_path(¬e.path)?;
674 self.extract_to_note_edits_from_text(¬e.path, &raw, selection, new_path, new_id, replace_with)
675 }
676
677 pub fn extract_to_note_edits_from_text(
681 &self,
682 source_path: impl AsRef<Path>,
683 raw_source: &str,
684 selection: &ExtractSelection,
685 new_path: impl AsRef<Path>,
686 new_id: Option<&str>,
687 replace_with: Option<&str>,
688 ) -> Result<ExtractEdits, VaultError> {
689 let source_path = self.normalize_path(source_path);
690 let new_path = self.prepare_new_note_path(new_path.as_ref());
691 if self.has_known_note_path(&new_path) {
692 return Err(VaultError::NoteAlreadyExists(new_path));
693 }
694
695 let op =
696 self.compute_extract_op_from_text(&source_path, raw_source, selection, &new_path, new_id, replace_with)?;
697 Ok(ExtractEdits {
698 source_path: source_path.clone(),
699 source_content: op.source_raw,
700 source_note: op.source_note,
701 new_path: op.new_note.path.clone(),
702 new_content: op.new_raw,
703 new_note: op.new_note,
704 })
705 }
706
707 pub fn extract_to_note(
713 &mut self,
714 note: &Note,
715 selection: &ExtractSelection,
716 new_path: impl AsRef<Path>,
717 new_id: Option<&str>,
718 replace_with: Option<&str>,
719 ) -> Result<ExtractResult, VaultError> {
720 let edits = self.extract_to_note_edits(note, selection, new_path, new_id, replace_with)?;
721
722 if let Some(parent) = edits.new_path.parent() {
723 fs::create_dir_all(parent)?;
724 }
725
726 edits.new_note.write()?;
727
728 let source_note = match self.apply_raw_note_update(&edits.source_path, edits.source_content.clone()) {
729 Ok(note) => note,
730 Err(error) => {
731 let _ = fs::remove_file(&edits.new_path);
732 return Err(error);
733 }
734 };
735
736 Ok(ExtractResult {
737 source_note,
738 new_note: edits.new_note,
739 })
740 }
741
742 fn prepare_new_note_path(&self, new_path: &Path) -> PathBuf {
743 let mut path = if new_path.is_absolute() {
744 new_path.to_path_buf()
745 } else {
746 self.path.join(new_path)
747 };
748 if path.extension().is_none() {
749 path.set_extension("md");
750 }
751 common::normalize_path(path, Some(&self.path))
752 }
753
754 fn compute_extract_op_from_text(
755 &self,
756 source_path: &Path,
757 raw_source: &str,
758 selection: &ExtractSelection,
759 new_path: &Path,
760 new_id: Option<&str>,
761 replace_with: Option<&str>,
762 ) -> Result<ExtractOp, VaultError> {
763 let resolved = resolve_extract_selection(source_path, raw_source, selection)?;
764
765 let mut extracted = raw_source[resolved.extracted_range.clone()].to_string();
766 if let Some(root_level) = resolved.section_root_level {
767 extracted = normalize_section_heading_levels(&extracted, root_level);
768 }
769 extracted = rewrite_relative_markdown_links(&extracted, source_path, new_path);
770
771 let new_note = build_extracted_note(new_path, &extracted, new_id)?;
772 let new_raw = new_note.read(true)?;
773
774 let default_link = format!("[[{}]]", new_note.id);
775 let replacement = replace_with.unwrap_or(default_link.as_str());
776 let source_raw = if resolved.section_root_level.is_some() {
777 replace_section_body(raw_source, resolved.source_replace_range, replacement)
778 } else {
779 replace_text_range(raw_source, resolved.source_replace_range, replacement)
780 };
781 let source_note = Note::parse(source_path, &source_raw);
782
783 Ok(ExtractOp {
784 source_raw,
785 source_note,
786 new_raw,
787 new_note,
788 })
789 }
790
791 fn compute_merge_op(&self, sources: &[Note], dest_path: impl AsRef<Path>) -> Result<MergeOp, VaultError> {
793 use std::collections::HashMap;
794
795 let dest_path = dest_path.as_ref();
796 let dest_dir = &dest_path.parent().unwrap_or_else(|| Path::new("."));
797 if !dest_dir.is_dir() {
798 return Err(VaultError::DirectoryNotFound(dest_dir.to_path_buf()));
799 }
800
801 for source in sources {
802 if source.path == dest_path {
803 return Err(VaultError::MergeSourceIsDestination(source.path.clone()));
804 }
805 }
806
807 let dest_is_new = !dest_path.exists();
808
809 let dest_stem = dest_path
810 .file_stem()
811 .and_then(|s| s.to_str())
812 .unwrap_or_default()
813 .to_string();
814
815 let source_paths: Vec<&Path> = sources.iter().map(|s| s.path.as_path()).collect();
816
817 let mut replacements_by_path: HashMap<PathBuf, Vec<(LocatedLink, String)>> = HashMap::new();
819
820 for source in sources {
821 let backlinks = self.backlinks(source)?;
822 for (linking_note, links) in backlinks {
823 if source_paths.iter().any(|p| *p == linking_note.path) {
824 continue;
825 }
826 if linking_note.path == dest_path {
827 continue;
828 }
829
830 let entry = replacements_by_path.entry(linking_note.path.clone()).or_default();
831
832 for ll in links {
833 let new_text = match &ll.link {
834 Link::Wiki { heading, alias, .. } => {
835 let mut wiki = format!("[[{}", dest_stem);
836 if let Some(h) = heading {
837 wiki.push('#');
838 wiki.push_str(h);
839 }
840 if let Some(a) = alias {
841 wiki.push('|');
842 wiki.push_str(a);
843 }
844 wiki.push_str("]]");
845 Some(wiki)
846 }
847 Link::Markdown { text, url } => {
848 let fragment = url.find('#').map(|i| url[i..].to_string());
849 let new_url = common::relative_path(&self.path, dest_path);
850 let new_url_str = new_url.to_string_lossy().replace('\\', "/");
851 let full_url = match fragment {
852 Some(f) => format!("{}{}", new_url_str, f),
853 None => new_url_str.to_string(),
854 };
855 Some(format!("[{}]({})", text, full_url))
856 }
857 _ => None,
858 };
859 if let Some(text) = new_text {
860 entry.push((ll, text));
861 }
862 }
863 }
864 }
865
866 let per_note_replacements: Vec<(PathBuf, Vec<(LocatedLink, String)>)> = replacements_by_path
867 .into_iter()
868 .filter(|(_, r)| !r.is_empty())
869 .collect();
870
871 let (dest_body, dest_fm_tags, dest_fm_aliases, dest_frontmatter) = if dest_is_new {
873 (String::new(), Vec::<String>::new(), Vec::<String>::new(), None)
874 } else {
875 let d = Note::from_path(dest_path)?;
876 let tags = d
877 .frontmatter
878 .as_ref()
879 .and_then(|fm| fm.get("tags"))
880 .and_then(|p| p.as_vec().ok())
881 .unwrap_or_default()
882 .into_iter()
883 .filter_map(|p| p.as_string().ok())
884 .collect::<Vec<_>>();
885 let aliases = d
886 .frontmatter
887 .as_ref()
888 .and_then(|fm| fm.get("aliases"))
889 .and_then(|p| p.as_vec().ok())
890 .unwrap_or_default()
891 .into_iter()
892 .filter_map(|p| p.as_string().ok())
893 .collect::<Vec<_>>();
894 let body = d.body().trim_start().to_string();
895 let fm = d.frontmatter;
896 (body, tags, aliases, fm)
897 };
898
899 let mut body_parts: Vec<String> = Vec::new();
901 if !dest_body.is_empty() {
902 body_parts.push(dest_body);
903 }
904 for source in sources {
905 let body = source.body().trim_start().to_string();
906 if !body.is_empty() {
907 body_parts.push(body);
908 }
909 }
910 let merged_content = body_parts.join("\n\n---\n\n");
911
912 let mut fm: IndexMap<String, Pod> = dest_frontmatter.unwrap_or_default();
914
915 let mut tag_strings: Vec<String> = dest_fm_tags;
916 for source in sources {
917 for lt in source
918 .tags
919 .iter()
920 .filter(|t| matches!(t.location, Location::Frontmatter))
921 {
922 if !tag_strings.contains(<.tag) {
923 tag_strings.push(lt.tag.clone());
924 }
925 }
926 }
927 if !tag_strings.is_empty() {
928 fm.insert(
929 "tags".to_string(),
930 Pod::Array(tag_strings.clone().into_iter().map(Pod::String).collect()),
931 );
932 }
933
934 let mut alias_strings: Vec<String> = dest_fm_aliases;
935 for source in sources {
936 let src_aliases: Vec<String> = source
937 .frontmatter
938 .as_ref()
939 .and_then(|sfm| sfm.get("aliases"))
940 .and_then(|p| p.as_vec().ok())
941 .unwrap_or_default()
942 .into_iter()
943 .filter_map(|p| p.as_string().ok())
944 .collect();
945 for alias in src_aliases {
946 if !alias_strings.contains(&alias) {
947 alias_strings.push(alias);
948 }
949 }
950 }
951 if !alias_strings.is_empty() {
952 fm.insert(
953 "aliases".to_string(),
954 Pod::Array(alias_strings.clone().into_iter().map(Pod::String).collect()),
955 );
956 }
957
958 const SKIP_KEYS: &[&str] = &["id", "tags", "aliases"];
961 for source in sources {
962 if let Some(sfm) = &source.frontmatter {
963 for (k, v) in sfm {
964 if !SKIP_KEYS.contains(&k.as_str()) {
965 fm.entry(k.clone()).or_insert_with(|| v.clone());
966 }
967 }
968 }
969 }
970
971 let merged_frontmatter = if fm.is_empty() { None } else { Some(fm) };
972
973 Ok(MergeOp {
974 dest_is_new,
975 merged_content,
976 merged_frontmatter,
977 merged_tags: tag_strings
978 .into_iter()
979 .map(|tag| LocatedTag {
980 tag,
981 location: Location::Frontmatter,
982 })
983 .collect(),
984 merged_aliases: alias_strings,
985 per_note_replacements,
986 })
987 }
988
989 pub fn merge(&mut self, sources: &[Note], dest_path: &impl AsRef<Path>) -> Result<Note, VaultError> {
996 let dest_path = common::normalize_path(dest_path, Some(&self.path));
997 let op = self.compute_merge_op(sources, &dest_path)?;
998
999 if op.dest_is_new {
1001 let mut dest = Note::builder(dest_path.clone())?
1002 .aliases(&op.merged_aliases)
1003 .located_tags(&op.merged_tags)
1004 .build()?;
1005 dest.update_content(Some(&op.merged_content), op.merged_frontmatter)?;
1006 dest.write()?;
1007 } else {
1008 let mut dest = Note::from_path(&dest_path)?;
1009 dest.update_content(Some(&op.merged_content), op.merged_frontmatter)?;
1010 dest.write()?;
1011 };
1012 self.refresh_cached_note(&dest_path)?;
1013
1014 for (note_path, replacements) in op.per_note_replacements {
1016 let raw_content = self.text_for_path(¬e_path)?;
1017 let new_content = common::rewrite_links(&raw_content, replacements);
1018 std::fs::write(¬e_path, new_content)?;
1019 self.refresh_cached_note(¬e_path)?;
1020 }
1021
1022 for source in sources {
1024 std::fs::remove_file(&source.path)?;
1025 self.remove_cached_note(&source.path);
1026 }
1027
1028 self.note_for_path(&dest_path)
1029 }
1030
1031 pub fn merge_preview(&self, sources: &[Note], dest_path: impl AsRef<Path>) -> Result<MergePreview, VaultError> {
1035 let dest_path = common::normalize_path(dest_path, Some(&self.path));
1036 let op = self.compute_merge_op(sources, &dest_path)?;
1037
1038 let mut updated_notes: Vec<(PathBuf, usize)> = op
1039 .per_note_replacements
1040 .iter()
1041 .map(|(path, reps)| (path.clone(), reps.len()))
1042 .collect();
1043 updated_notes.sort_by(|(a, _), (b, _)| a.cmp(b));
1044
1045 Ok(MergePreview {
1046 dest_path: dest_path.to_path_buf(),
1047 dest_is_new: op.dest_is_new,
1048 sources: sources.iter().map(|s| s.path.clone()).collect(),
1049 updated_notes,
1050 })
1051 }
1052}
1053
1054struct RenameOp {
1055 new_stem: String,
1056 frontmatter_id_will_update: bool,
1057 per_note_replacements: Vec<(Note, Vec<(LocatedLink, String)>)>,
1059}
1060
1061pub struct RenamePreview {
1063 pub new_path: PathBuf,
1064 pub id_will_update: bool,
1065 pub updated_notes: Vec<(PathBuf, usize)>,
1067}
1068
1069pub struct RenameEdits {
1071 pub new_path: PathBuf,
1072 pub new_stem: String,
1073 pub id_will_update: bool,
1074 pub backlink_edits: Vec<(PathBuf, Vec<(LocatedLink, String)>)>,
1076}
1077
1078#[derive(Clone, Debug, PartialEq, Eq)]
1082pub struct TextSpan {
1083 pub start_line: usize,
1084 pub start_col: usize,
1085 pub end_line: usize,
1086 pub end_col: usize,
1087}
1088
1089#[derive(Clone, Debug, PartialEq, Eq)]
1091pub enum ExtractSelection {
1092 Section(String),
1093 Span(TextSpan),
1094}
1095
1096#[derive(Clone)]
1098pub struct ExtractResult {
1099 pub source_note: Note,
1100 pub new_note: Note,
1101}
1102
1103#[derive(Clone)]
1105pub struct ExtractEdits {
1106 pub source_path: PathBuf,
1107 pub source_content: String,
1108 pub source_note: Note,
1109 pub new_path: PathBuf,
1110 pub new_content: String,
1111 pub new_note: Note,
1112}
1113
1114pub struct MergePreview {
1116 pub dest_path: PathBuf,
1117 pub dest_is_new: bool,
1118 pub sources: Vec<PathBuf>,
1120 pub updated_notes: Vec<(PathBuf, usize)>,
1122}
1123
1124struct MergeOp {
1125 dest_is_new: bool,
1126 merged_content: String,
1128 merged_frontmatter: Option<IndexMap<String, Pod>>,
1130 merged_tags: Vec<LocatedTag>,
1132 merged_aliases: Vec<String>,
1134 per_note_replacements: Vec<(PathBuf, Vec<(LocatedLink, String)>)>,
1136}
1137
1138struct ExtractOp {
1139 source_raw: String,
1140 source_note: Note,
1141 new_raw: String,
1142 new_note: Note,
1143}
1144
1145struct ResolvedExtract {
1146 extracted_range: Range<usize>,
1147 source_replace_range: Range<usize>,
1148 section_root_level: Option<usize>,
1149}
1150
1151struct ResolvedSection {
1152 start_byte: usize,
1153 body_start_byte: usize,
1154 end_byte: usize,
1155 level: usize,
1156}
1157
1158struct HeadingFragmentSegment {
1159 raw: String,
1160 normalized: String,
1161}
1162
1163struct HeadingPathSegment {
1164 text: String,
1165 normalized_anchor: String,
1166 resolved_anchor: String,
1167}
1168
1169fn resolve_extract_selection(
1170 note_path: &Path,
1171 raw_source: &str,
1172 selection: &ExtractSelection,
1173) -> Result<ResolvedExtract, VaultError> {
1174 let (raw_frontmatter, raw_body) = Vault::raw_note_sections(note_path, raw_source);
1175 let body_start = raw_frontmatter.len();
1176
1177 match selection {
1178 ExtractSelection::Section(section) => {
1179 let resolved = resolve_section_bounds(note_path, raw_body, section)?;
1180 Ok(ResolvedExtract {
1181 extracted_range: (body_start + resolved.start_byte)..(body_start + resolved.end_byte),
1182 source_replace_range: (body_start + resolved.body_start_byte)..(body_start + resolved.end_byte),
1183 section_root_level: Some(resolved.level),
1184 })
1185 }
1186 ExtractSelection::Span(span) => {
1187 let start = line_col_to_byte_index(raw_source, span.start_line, span.start_col).ok_or_else(|| {
1188 invalid_extract_span(
1189 note_path,
1190 format!(
1191 "start position {}:{} is outside the note",
1192 span.start_line, span.start_col
1193 ),
1194 )
1195 })?;
1196 let end = line_col_to_byte_index(raw_source, span.end_line, span.end_col).ok_or_else(|| {
1197 invalid_extract_span(
1198 note_path,
1199 format!("end position {}:{} is outside the note", span.end_line, span.end_col),
1200 )
1201 })?;
1202 if start >= end {
1203 return Err(invalid_extract_span(
1204 note_path,
1205 "start position must come before end position".to_string(),
1206 ));
1207 }
1208 if start < body_start || end < body_start {
1209 return Err(invalid_extract_span(
1210 note_path,
1211 "span overlaps frontmatter; only note body text can be extracted".to_string(),
1212 ));
1213 }
1214 Ok(ResolvedExtract {
1215 extracted_range: start..end,
1216 source_replace_range: start..end,
1217 section_root_level: None,
1218 })
1219 }
1220 }
1221}
1222
1223fn resolve_section_bounds(note_path: &Path, raw_body: &str, section: &str) -> Result<ResolvedSection, VaultError> {
1224 let expected = parse_heading_fragment_segments(section);
1225 if expected.is_empty() {
1226 return Err(VaultError::SectionNotFound {
1227 path: note_path.to_path_buf(),
1228 section: section.to_string(),
1229 });
1230 }
1231
1232 let lines: Vec<&str> = raw_body.split_inclusive('\n').collect();
1233 let mut line_starts = Vec::with_capacity(lines.len());
1234 let mut offset = 0;
1235 for line in &lines {
1236 line_starts.push(offset);
1237 offset += line.len();
1238 }
1239
1240 let mut seen_anchors = HashMap::new();
1241 let mut current_path = Vec::new();
1242 let mut matches = Vec::new();
1243 let mut in_fenced_code = false;
1244
1245 for (index, line) in lines.iter().enumerate() {
1246 let (line_content, _) = split_line_ending(line);
1247 if is_fence_line(line_content) {
1248 in_fenced_code = !in_fenced_code;
1249 continue;
1250 }
1251 if in_fenced_code {
1252 continue;
1253 }
1254
1255 let Some((level, heading_text)) = heading_line_parts(line_content) else {
1256 continue;
1257 };
1258 let Some(resolved_anchor) = resolve_heading_anchor(&heading_text, &mut seen_anchors) else {
1259 continue;
1260 };
1261 let normalized_anchor = normalize_heading_anchor(&heading_text);
1262
1263 current_path.truncate(level.saturating_sub(1));
1264 current_path.push(HeadingPathSegment {
1265 text: heading_text,
1266 normalized_anchor,
1267 resolved_anchor,
1268 });
1269
1270 if heading_path_matches(¤t_path, &expected) {
1271 matches.push(ResolvedSection {
1272 start_byte: line_starts[index],
1273 body_start_byte: line_starts[index] + line.len(),
1274 end_byte: find_section_end(&lines, index + 1, level, raw_body.len()),
1275 level,
1276 });
1277 }
1278 }
1279
1280 match matches.len() {
1281 0 => Err(VaultError::SectionNotFound {
1282 path: note_path.to_path_buf(),
1283 section: section.to_string(),
1284 }),
1285 1 => Ok(matches.remove(0)),
1286 _ => Err(VaultError::AmbiguousSection {
1287 path: note_path.to_path_buf(),
1288 section: section.to_string(),
1289 }),
1290 }
1291}
1292
1293fn find_section_end(lines: &[&str], start_index: usize, level: usize, raw_len: usize) -> usize {
1294 let mut offset = lines.iter().take(start_index).map(|line| line.len()).sum();
1295 let mut in_fenced_code = false;
1296
1297 for line in lines.iter().skip(start_index) {
1298 let (line_content, _) = split_line_ending(line);
1299 if is_fence_line(line_content) {
1300 in_fenced_code = !in_fenced_code;
1301 offset += line.len();
1302 continue;
1303 }
1304 if !in_fenced_code
1305 && let Some((next_level, _)) = heading_line_parts(line_content)
1306 && next_level <= level
1307 {
1308 return offset;
1309 }
1310 offset += line.len();
1311 }
1312
1313 raw_len
1314}
1315
1316fn parse_heading_fragment_segments(heading: &str) -> Vec<HeadingFragmentSegment> {
1317 heading
1318 .split('#')
1319 .filter(|segment| !segment.is_empty())
1320 .map(|segment| HeadingFragmentSegment {
1321 raw: segment.to_string(),
1322 normalized: normalize_heading_anchor(segment),
1323 })
1324 .collect()
1325}
1326
1327fn heading_path_matches(path: &[HeadingPathSegment], expected: &[HeadingFragmentSegment]) -> bool {
1328 if expected.len() > path.len() {
1329 return false;
1330 }
1331
1332 path[path.len() - expected.len()..]
1333 .iter()
1334 .zip(expected.iter())
1335 .all(|(candidate, expected_segment)| heading_segment_matches(candidate, expected_segment))
1336}
1337
1338fn heading_segment_matches(candidate: &HeadingPathSegment, expected: &HeadingFragmentSegment) -> bool {
1339 candidate.text == expected.raw
1340 || candidate.normalized_anchor == expected.normalized
1341 || candidate.resolved_anchor == expected.normalized
1342}
1343
1344fn heading_line_parts(line: &str) -> Option<(usize, String)> {
1345 let trimmed = line.trim_start();
1346 if !trimmed.starts_with('#') {
1347 return None;
1348 }
1349
1350 let marker_bytes = trimmed
1351 .char_indices()
1352 .take_while(|(_, ch)| *ch == '#')
1353 .last()
1354 .map_or(0, |(index, ch)| index + ch.len_utf8());
1355 let level = trimmed[..marker_bytes].chars().count();
1356 let after_markers = &trimmed[marker_bytes..];
1357 let content = after_markers.trim_start();
1358 if content.is_empty() {
1359 return None;
1360 }
1361
1362 let heading_text = strip_optional_heading_closing_hashes(content);
1363 if heading_text.is_empty() {
1364 return None;
1365 }
1366
1367 Some((level, heading_text.to_string()))
1368}
1369
1370fn heading_marker_span(line: &str) -> Option<(Range<usize>, usize)> {
1371 let trimmed = line.trim_start();
1372 if !trimmed.starts_with('#') {
1373 return None;
1374 }
1375
1376 let marker_bytes = trimmed
1377 .char_indices()
1378 .take_while(|(_, ch)| *ch == '#')
1379 .last()
1380 .map_or(0, |(index, ch)| index + ch.len_utf8());
1381 let level = trimmed[..marker_bytes].chars().count();
1382 let after_markers = &trimmed[marker_bytes..];
1383 let content = after_markers.trim_start();
1384 if content.is_empty() {
1385 return None;
1386 }
1387
1388 let heading_text = strip_optional_heading_closing_hashes(content);
1389 if heading_text.is_empty() {
1390 return None;
1391 }
1392
1393 let leading_bytes = line.len() - trimmed.len();
1394 Some((leading_bytes..leading_bytes + marker_bytes, level))
1395}
1396
1397fn strip_optional_heading_closing_hashes(text: &str) -> &str {
1398 let trimmed = text.trim_end();
1399 let without_hashes = trimmed.trim_end_matches('#');
1400 if without_hashes.len() == trimmed.len() || !without_hashes.chars().last().is_some_and(char::is_whitespace) {
1401 return trimmed;
1402 }
1403
1404 without_hashes.trim_end()
1405}
1406
1407fn normalize_heading_anchor(text: &str) -> String {
1408 let mut anchor = String::new();
1409 let mut last_was_separator = true;
1410
1411 for ch in text.trim().chars().flat_map(char::to_lowercase) {
1412 if ch.is_alphanumeric() || ch == '_' {
1413 anchor.push(ch);
1414 last_was_separator = false;
1415 } else if (ch.is_whitespace() || ch == '-') && !last_was_separator && !anchor.is_empty() {
1416 anchor.push('-');
1417 last_was_separator = true;
1418 }
1419 }
1420
1421 while anchor.ends_with('-') {
1422 anchor.pop();
1423 }
1424
1425 anchor
1426}
1427
1428fn resolve_heading_anchor(heading_text: &str, seen_anchors: &mut HashMap<String, usize>) -> Option<String> {
1429 let base_anchor = normalize_heading_anchor(heading_text);
1430 if base_anchor.is_empty() {
1431 return None;
1432 }
1433
1434 let seen_count = seen_anchors.entry(base_anchor.clone()).or_default();
1435 let anchor = if *seen_count == 0 {
1436 base_anchor
1437 } else {
1438 format!("{base_anchor}-{seen_count}")
1439 };
1440 *seen_count += 1;
1441
1442 Some(anchor)
1443}
1444
1445fn normalize_section_heading_levels(content: &str, root_level: usize) -> String {
1446 if root_level <= 1 {
1447 return content.to_string();
1448 }
1449
1450 let shift = root_level.saturating_sub(1);
1451 let mut normalized = String::with_capacity(content.len());
1452 let mut in_fenced_code = false;
1453
1454 for line in content.split_inclusive('\n') {
1455 let (line_content, line_ending) = split_line_ending(line);
1456 if is_fence_line(line_content) {
1457 in_fenced_code = !in_fenced_code;
1458 normalized.push_str(line);
1459 continue;
1460 }
1461 if in_fenced_code {
1462 normalized.push_str(line);
1463 continue;
1464 }
1465
1466 let Some((marker_range, level)) = heading_marker_span(line_content) else {
1467 normalized.push_str(line);
1468 continue;
1469 };
1470 let new_level = level.saturating_sub(shift).max(1);
1471 normalized.push_str(&line_content[..marker_range.start]);
1472 normalized.push_str(&"#".repeat(new_level));
1473 normalized.push_str(&line_content[marker_range.end..]);
1474 normalized.push_str(line_ending);
1475 }
1476
1477 normalized
1478}
1479
1480fn rewrite_relative_markdown_links(content: &str, source_path: &Path, new_path: &Path) -> String {
1481 let replacements = crate::link::parse_links(content)
1482 .into_iter()
1483 .filter_map(|located_link| {
1484 let Link::Markdown { text, url } = located_link.link.clone() else {
1485 return None;
1486 };
1487 let new_url = rewrite_relative_markdown_url(source_path, new_path, &url)?;
1488 Some((located_link, format!("[{text}]({new_url})")))
1489 })
1490 .collect::<Vec<_>>();
1491
1492 if replacements.is_empty() {
1493 content.to_string()
1494 } else {
1495 common::rewrite_links(content, replacements)
1496 }
1497}
1498
1499fn rewrite_relative_markdown_url(source_path: &Path, new_path: &Path, url: &str) -> Option<String> {
1500 if !is_relative_markdown_url(url) {
1501 return None;
1502 }
1503
1504 let (path_part, fragment) = match url.split_once('#') {
1505 Some((path, fragment)) => (path, Some(fragment)),
1506 None => (url, None),
1507 };
1508 let source_dir = source_path.parent().unwrap_or(source_path);
1509 let target_path = if path_part.is_empty() {
1510 source_path.to_path_buf()
1511 } else {
1512 common::normalize_path(source_dir.join(common::percent_decode(path_part)), None)
1513 };
1514 let target_dir = new_path.parent().unwrap_or_else(|| Path::new("."));
1515 let mut new_url = common::relative_path(target_dir, &target_path)
1516 .to_string_lossy()
1517 .replace('\\', "/");
1518 if let Some(fragment) = fragment
1519 && !fragment.is_empty()
1520 {
1521 new_url.push('#');
1522 new_url.push_str(fragment);
1523 }
1524 Some(new_url)
1525}
1526
1527fn is_relative_markdown_url(url: &str) -> bool {
1528 if url.starts_with('/') {
1529 return false;
1530 }
1531
1532 let path_part = url.split('#').next().unwrap_or(url);
1533 if let Some((scheme, _)) = path_part.split_once(':')
1534 && !scheme.is_empty()
1535 && scheme.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic())
1536 && scheme
1537 .chars()
1538 .all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.')
1539 {
1540 return false;
1541 }
1542
1543 true
1544}
1545
1546fn replace_text_range(raw: &str, range: Range<usize>, replacement: &str) -> String {
1547 let mut updated = String::with_capacity(raw.len() - (range.end - range.start) + replacement.len());
1548 updated.push_str(&raw[..range.start]);
1549 updated.push_str(replacement);
1550 updated.push_str(&raw[range.end..]);
1551 updated
1552}
1553
1554fn replace_section_body(raw: &str, range: Range<usize>, replacement: &str) -> String {
1555 let prefix = &raw[..range.start];
1556 let suffix = &raw[range.end..];
1557 let mut updated = String::with_capacity(raw.len() - (range.end - range.start) + replacement.len() + 2);
1558 updated.push_str(prefix);
1559 if !replacement.is_empty() {
1560 if !prefix.ends_with('\n') && !replacement.starts_with('\n') {
1561 updated.push('\n');
1562 }
1563 updated.push_str(replacement);
1564 if !suffix.is_empty() && !replacement.ends_with('\n') {
1565 updated.push('\n');
1566 }
1567 }
1568 updated.push_str(suffix);
1569 updated
1570}
1571
1572fn build_extracted_note(new_path: &Path, body: &str, new_id: Option<&str>) -> Result<Note, VaultError> {
1573 let mut builder = Note::builder(new_path)?;
1574 if let Some(id) = new_id.map(str::trim).filter(|id| !id.is_empty()) {
1575 builder = builder.id(id);
1576 }
1577 builder.body(body).build().map_err(VaultError::Note)
1578}
1579
1580fn line_col_to_byte_index(text: &str, line: usize, col: usize) -> Option<usize> {
1581 if line == 0 {
1582 return None;
1583 }
1584
1585 let mut line_starts = vec![0];
1586 for (index, ch) in text.char_indices() {
1587 if ch == '\n' {
1588 line_starts.push(index + 1);
1589 }
1590 }
1591
1592 let start = *line_starts.get(line - 1)?;
1593 let line_end = line_starts
1594 .get(line)
1595 .copied()
1596 .map(|next| next - 1)
1597 .unwrap_or(text.len());
1598 let mut line_text = &text[start..line_end];
1599 if let Some(stripped) = line_text.strip_suffix('\r') {
1600 line_text = stripped;
1601 }
1602
1603 let char_count = line_text.chars().count();
1604 if col > char_count {
1605 return None;
1606 }
1607 if col == char_count {
1608 return Some(start + line_text.len());
1609 }
1610
1611 for (seen, (offset, _)) in line_text.char_indices().enumerate() {
1612 if seen == col {
1613 return Some(start + offset);
1614 }
1615 }
1616
1617 Some(start + line_text.len())
1618}
1619
1620fn split_line_ending(line: &str) -> (&str, &str) {
1621 if let Some(without_newline) = line.strip_suffix('\n') {
1622 if let Some(without_crlf) = without_newline.strip_suffix('\r') {
1623 (without_crlf, "\r\n")
1624 } else {
1625 (without_newline, "\n")
1626 }
1627 } else {
1628 (line, "")
1629 }
1630}
1631
1632fn is_fence_line(line: &str) -> bool {
1633 let trimmed = line.trim_start();
1634 trimmed.starts_with("```") || trimmed.starts_with("~~~")
1635}
1636
1637fn invalid_extract_span(path: &Path, message: String) -> VaultError {
1638 VaultError::InvalidExtractSpan {
1639 path: path.to_path_buf(),
1640 message,
1641 }
1642}
1643
1644#[cfg(test)]
1645mod tests {
1646 use super::*;
1647 use std::fs;
1648
1649 #[test]
1652 fn open_from_cwd_finds_obsidian_dir() {
1653 let dir = tempfile::tempdir().unwrap();
1654 let subdir = dir.path().join("notes/daily");
1655 fs::create_dir_all(&subdir).unwrap();
1656 fs::create_dir(dir.path().join(".obsidian")).unwrap();
1657
1658 let original_cwd = std::env::current_dir().unwrap();
1659 std::env::set_current_dir(&subdir).unwrap();
1660 let vault = Vault::open_from_cwd().unwrap();
1661 std::env::set_current_dir(original_cwd).unwrap();
1662
1663 assert_eq!(vault.path.canonicalize().unwrap(), dir.path().canonicalize().unwrap());
1664 }
1665
1666 #[test]
1667 fn cached_vault_refresh_path_adds_created_note_and_updates_health() {
1668 let vault_dir = tempfile::tempdir().unwrap();
1669 fs::create_dir(vault_dir.path().join(".obsidian")).unwrap();
1670 let source_path = vault_dir.path().join("source.md");
1671 fs::write(&source_path, "See [[target]].").unwrap();
1672
1673 let mut vault = Vault::open_cached(vault_dir.path()).unwrap();
1674 assert_eq!(vault.notes().len(), 1);
1675 assert_eq!(vault.check(|_| true).broken_links.len(), 1);
1676
1677 let target_path = vault_dir.path().join("target.md");
1678 fs::write(&target_path, "---\nid: target\n---\n").unwrap();
1679 assert!(vault.refresh_cached_note(&target_path).unwrap());
1680
1681 assert_eq!(vault.notes().len(), 2);
1682 assert!(vault.check(|_| true).broken_links.is_empty());
1683 }
1684
1685 #[test]
1686 fn cached_vault_remove_path_removes_note_and_updates_health() {
1687 let vault_dir = tempfile::tempdir().unwrap();
1688 fs::create_dir(vault_dir.path().join(".obsidian")).unwrap();
1689 let source_path = vault_dir.path().join("source.md");
1690 fs::write(&source_path, "See [[target]].").unwrap();
1691 let target_path = vault_dir.path().join("target.md");
1692 fs::write(&target_path, "---\nid: target\n---\n").unwrap();
1693
1694 let mut vault = Vault::open_cached(vault_dir.path()).unwrap();
1695 assert!(vault.check(|_| true).broken_links.is_empty());
1696
1697 let target_path = target_path.canonicalize().unwrap();
1698 fs::remove_file(&target_path).unwrap();
1699 assert!(vault.remove_cached_note(&target_path));
1700
1701 let report = vault.check(|_| true);
1702 assert_eq!(report.note_count, 1);
1703 assert_eq!(report.broken_links.len(), 1);
1704 }
1705
1706 #[test]
1707 fn cached_vault_health_checks_markdown_links_against_cached_disk_notes() {
1708 let vault_dir = tempfile::tempdir().unwrap();
1709 fs::create_dir(vault_dir.path().join(".obsidian")).unwrap();
1710 let source_path = vault_dir.path().join("source.md");
1711 fs::write(&source_path, "See [target](target.md).").unwrap();
1712 let target_path = vault_dir.path().join("target.md");
1713 fs::write(&target_path, "---\nid: target\n---\n").unwrap();
1714
1715 let mut vault = Vault::open_cached(vault_dir.path()).unwrap();
1716 let target_path = target_path.canonicalize().unwrap();
1717 fs::remove_file(&target_path).unwrap();
1718
1719 assert!(
1720 vault.check(|_| true).broken_links.is_empty(),
1721 "cached target should keep markdown link valid until cache is updated"
1722 );
1723
1724 assert!(vault.remove_cached_note(&target_path));
1725 assert_eq!(vault.check(|_| true).broken_links.len(), 1);
1726 }
1727
1728 #[test]
1729 fn cached_vault_search_uses_cached_disk_notes_until_refresh() {
1730 let vault_dir = tempfile::tempdir().unwrap();
1731 fs::create_dir(vault_dir.path().join(".obsidian")).unwrap();
1732 let note_path = vault_dir.path().join("note.md");
1733 fs::write(
1734 ¬e_path,
1735 "---\nid: cached-id\ntags: [cached]\n---\n\ncached body #cached-inline\n",
1736 )
1737 .unwrap();
1738
1739 let mut vault = Vault::open_cached(vault_dir.path()).unwrap();
1740 fs::write(
1741 ¬e_path,
1742 "---\nid: fresh-id\ntags: [fresh]\n---\n\nfresh body #fresh-inline\n",
1743 )
1744 .unwrap();
1745
1746 let cached_id_results = vault.search().or_has_id("cached-id").execute().unwrap();
1747 assert_eq!(cached_id_results.into_iter().filter_map(Result::ok).count(), 1);
1748 assert!(
1749 vault
1750 .search()
1751 .or_has_id("fresh-id")
1752 .execute()
1753 .unwrap()
1754 .into_iter()
1755 .filter_map(Result::ok)
1756 .next()
1757 .is_none()
1758 );
1759
1760 let cached_content_results = vault.search().and_content_contains("cached body").execute().unwrap();
1761 assert_eq!(cached_content_results.into_iter().filter_map(Result::ok).count(), 1);
1762 assert!(
1763 vault
1764 .search()
1765 .and_content_contains("fresh body")
1766 .execute()
1767 .unwrap()
1768 .into_iter()
1769 .filter_map(Result::ok)
1770 .next()
1771 .is_none()
1772 );
1773
1774 assert_eq!(vault.find_tags(&["cached".to_string()]).unwrap().len(), 1);
1775 assert!(vault.find_tags(&["fresh".to_string()]).unwrap().is_empty());
1776
1777 assert!(vault.refresh_cached_note(¬e_path).unwrap());
1778 let fresh_id_results = vault.search().or_has_id("fresh-id").execute().unwrap();
1779 assert_eq!(fresh_id_results.into_iter().filter_map(Result::ok).count(), 1);
1780 assert_eq!(vault.find_tags(&["fresh".to_string()]).unwrap().len(), 1);
1781 }
1782
1783 #[test]
1784 fn open_from_cwd_falls_back_to_cwd_when_no_obsidian_dir() {
1785 let dir = tempfile::tempdir().unwrap();
1786
1787 let original_cwd = std::env::current_dir().unwrap();
1788 std::env::set_current_dir(&dir).unwrap();
1789 let vault = Vault::open_from_cwd().unwrap();
1790 std::env::set_current_dir(original_cwd).unwrap();
1791
1792 assert_eq!(vault.path.canonicalize().unwrap(), dir.path().canonicalize().unwrap());
1793 }
1794
1795 #[test]
1796 fn open_valid_directory() {
1797 let dir = tempfile::tempdir().unwrap();
1798 let vault = Vault::open(dir.path()).expect("should open valid directory");
1800 assert_eq!(vault.path, common::normalize_path(dir.path(), None));
1801 }
1802
1803 #[test]
1804 fn open_nonexistent_path_errors() {
1805 let result = Vault::open("/nonexistent/path/to/vault");
1806 assert!(result.is_err());
1807 }
1808
1809 #[test]
1810 fn open_file_path_errors() {
1811 let file = tempfile::NamedTempFile::new().unwrap();
1812 let result = Vault::open(file.path());
1813 assert!(result.is_err());
1814 }
1815
1816 #[test]
1819 fn resolve_note_by_filename() {
1820 let dir = tempfile::tempdir().unwrap();
1821 let subdir = dir.path().join("subdir");
1822 fs::create_dir(&subdir).unwrap();
1823 fs::write(dir.path().join("root.md"), "---\nid: root\n---\n\nRoot note.").unwrap();
1824 fs::write(subdir.join("nested.md"), "---\nid: nested\n---\n\nNested note.").unwrap();
1825
1826 let vault = Vault::open(dir.path()).unwrap();
1827 let note = vault.resolve_note("root.md").expect("should resolve root.md");
1828 assert_eq!(note.id, "root");
1829
1830 let note = vault
1831 .resolve_note("nested.md")
1832 .expect("should resolve subdir/nested.md");
1833 assert_eq!(note.id, "nested");
1834 }
1835
1836 #[test]
1837 fn resolve_note_by_alias_exact_match() {
1838 let dir = tempfile::tempdir().unwrap();
1839 fs::write(
1840 dir.path().join("note_a.md"),
1841 "---\nid: note_a\naliases: [Foo, A]\n---\n\nNote A.",
1842 )
1843 .unwrap();
1844 fs::write(
1845 dir.path().join("note_b.md"),
1846 "---\nid: note_b\naliases: [foo, B]\n---\n\nNote B.",
1847 )
1848 .unwrap();
1849
1850 let vault = Vault::open(dir.path()).unwrap();
1851
1852 let note = vault.resolve_note("Foo").expect("should resolve note");
1853 assert_eq!(note.id, "note_a");
1854
1855 let note = vault.resolve_note("foo").expect("should resolve note");
1856 assert_eq!(note.id, "note_b");
1857 }
1858
1859 #[test]
1862 fn notes_loads_md_files() {
1863 let dir = tempfile::tempdir().unwrap();
1864 fs::write(dir.path().join("a.md"), "# Note A\n\nContent A.").unwrap();
1865 fs::write(dir.path().join("b.md"), "# Note B\n\nContent B.").unwrap();
1866 fs::write(dir.path().join("not-a-note.txt"), "ignored").unwrap();
1867
1868 let vault = Vault::open(dir.path()).unwrap();
1869 let notes: Vec<Note> = vault.notes().into_iter().map(|r| r.unwrap()).collect();
1870 assert_eq!(notes.len(), 2);
1871 }
1872
1873 #[test]
1874 fn notes_finds_nested_md_files() {
1875 let dir = tempfile::tempdir().unwrap();
1876 let subdir = dir.path().join("subdir");
1877 fs::create_dir(&subdir).unwrap();
1878 fs::write(dir.path().join("root.md"), "Root note.").unwrap();
1879 fs::write(subdir.join("nested.md"), "Nested note.").unwrap();
1880
1881 let vault = Vault::open(dir.path()).unwrap();
1882 let notes: Vec<Note> = vault.notes().into_iter().map(|r| r.unwrap()).collect();
1883 assert_eq!(notes.len(), 2);
1884 }
1885
1886 #[test]
1889 fn backlinks_wiki_by_id() {
1890 let dir = tempfile::tempdir().unwrap();
1891 fs::write(dir.path().join("target.md"), "---\nid: my-id\n---\nTarget.").unwrap();
1892 fs::write(dir.path().join("source.md"), "See [[my-id]].").unwrap();
1893
1894 let vault = Vault::open(dir.path()).unwrap();
1895 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1896 let backlinks = vault.backlinks(&target).unwrap();
1897
1898 assert_eq!(backlinks.len(), 1);
1899 assert!(backlinks[0].0.path.ends_with("source.md"));
1900 assert_eq!(backlinks[0].1.len(), 1);
1901 }
1902
1903 #[test]
1904 fn backlinks_wiki_by_stem_when_id_differs() {
1905 let dir = tempfile::tempdir().unwrap();
1906 fs::write(dir.path().join("my-note.md"), "---\nid: custom-id\n---\nTarget.").unwrap();
1907 fs::write(dir.path().join("source.md"), "See [[my-note]].").unwrap();
1908
1909 let vault = Vault::open(dir.path()).unwrap();
1910 let target = Note::from_path(dir.path().join("my-note.md")).unwrap();
1911 let backlinks = vault.backlinks(&target).unwrap();
1912
1913 assert_eq!(backlinks.len(), 1);
1914 assert!(backlinks[0].0.path.ends_with("source.md"));
1915 }
1916
1917 #[test]
1918 fn backlinks_wiki_by_alias() {
1919 let dir = tempfile::tempdir().unwrap();
1920 fs::write(dir.path().join("target.md"), "---\naliases: [t-alias]\n---\nTarget.").unwrap();
1921 fs::write(dir.path().join("source.md"), "See [[t-alias]].").unwrap();
1922
1923 let vault = Vault::open(dir.path()).unwrap();
1924 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1925 let backlinks = vault.backlinks(&target).unwrap();
1926
1927 assert_eq!(backlinks.len(), 1);
1928 }
1929
1930 #[test]
1931 fn backlinks_wiki_by_title() {
1932 let dir = tempfile::tempdir().unwrap();
1933 fs::write(dir.path().join("target.md"), "# My Title\n\nContent.").unwrap();
1934 fs::write(dir.path().join("source.md"), "See [[My Title]].").unwrap();
1935
1936 let vault = Vault::open(dir.path()).unwrap();
1937 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1938 let backlinks = vault.backlinks(&target).unwrap();
1939
1940 assert_eq!(backlinks.len(), 1);
1941 }
1942
1943 #[test]
1944 fn backlinks_wiki_with_heading_suffix() {
1945 let dir = tempfile::tempdir().unwrap();
1946 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1947 fs::write(dir.path().join("source.md"), "See [[target#section]].").unwrap();
1948
1949 let vault = Vault::open(dir.path()).unwrap();
1950 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1951 let backlinks = vault.backlinks(&target).unwrap();
1952
1953 assert_eq!(backlinks.len(), 1);
1954 }
1955
1956 #[test]
1957 fn backlinks_excludes_self() {
1958 let dir = tempfile::tempdir().unwrap();
1959 fs::write(dir.path().join("target.md"), "Self link: [[target]].").unwrap();
1960
1961 let vault = Vault::open(dir.path()).unwrap();
1962 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1963 let backlinks = vault.backlinks(&target).unwrap();
1964
1965 assert!(backlinks.is_empty());
1966 }
1967
1968 #[test]
1969 fn backlinks_excludes_notes_with_no_match() {
1970 let dir = tempfile::tempdir().unwrap();
1971 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1972 fs::write(dir.path().join("other.md"), "No links here.").unwrap();
1973
1974 let vault = Vault::open(dir.path()).unwrap();
1975 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1976 let backlinks = vault.backlinks(&target).unwrap();
1977
1978 assert!(backlinks.is_empty());
1979 }
1980
1981 #[test]
1982 fn backlinks_returns_all_matching_links_from_one_note() {
1983 let dir = tempfile::tempdir().unwrap();
1984 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1985 fs::write(dir.path().join("source.md"), "See [[target]] and also [[target]].").unwrap();
1986
1987 let vault = Vault::open(dir.path()).unwrap();
1988 let target = Note::from_path(dir.path().join("target.md")).unwrap();
1989 let backlinks = vault.backlinks(&target).unwrap();
1990
1991 assert_eq!(backlinks.len(), 1);
1992 assert_eq!(backlinks[0].1.len(), 2);
1993 }
1994
1995 #[test]
1996 fn backlinks_no_match_on_unrelated_wiki_link() {
1997 let dir = tempfile::tempdir().unwrap();
1998 fs::write(dir.path().join("target.md"), "Target.").unwrap();
1999 fs::write(dir.path().join("source.md"), "See [[other-note]].").unwrap();
2000
2001 let vault = Vault::open(dir.path()).unwrap();
2002 let target = Note::from_path(dir.path().join("target.md")).unwrap();
2003 let backlinks = vault.backlinks(&target).unwrap();
2004
2005 assert!(backlinks.is_empty());
2006 }
2007
2008 #[test]
2009 fn backlinks_markdown_relative_path() {
2010 let dir = tempfile::tempdir().unwrap();
2011 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2012 fs::write(dir.path().join("source.md"), "[link](target.md)").unwrap();
2013
2014 let vault = Vault::open(dir.path()).unwrap();
2015 let target = Note::from_path(dir.path().join("target.md")).unwrap();
2016 let backlinks = vault.backlinks(&target).unwrap();
2017
2018 assert_eq!(backlinks.len(), 1);
2019 assert!(backlinks[0].0.path.ends_with("source.md"));
2020 }
2021
2022 #[test]
2023 fn backlinks_markdown_fragment_stripped() {
2024 let dir = tempfile::tempdir().unwrap();
2025 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2026 fs::write(dir.path().join("source.md"), "[link](target.md#section)").unwrap();
2027
2028 let vault = Vault::open(dir.path()).unwrap();
2029 let target = Note::from_path(dir.path().join("target.md")).unwrap();
2030 let backlinks = vault.backlinks(&target).unwrap();
2031
2032 assert_eq!(backlinks.len(), 1);
2033 }
2034
2035 #[test]
2036 fn backlinks_markdown_parent_traversal() {
2037 let dir = tempfile::tempdir().unwrap();
2038 let subdir = dir.path().join("sub");
2039 fs::create_dir(&subdir).unwrap();
2040 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2041 fs::write(subdir.join("source.md"), "[link](../target.md)").unwrap();
2042
2043 let vault = Vault::open(dir.path()).unwrap();
2044 let target = Note::from_path(dir.path().join("target.md")).unwrap();
2045 let backlinks = vault.backlinks(&target).unwrap();
2046
2047 assert_eq!(backlinks.len(), 1);
2048 }
2049
2050 #[test]
2051 fn backlinks_markdown_external_url_excluded() {
2052 let dir = tempfile::tempdir().unwrap();
2053 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2054 fs::write(dir.path().join("source.md"), "[link](https://example.com/target.md)").unwrap();
2055
2056 let vault = Vault::open(dir.path()).unwrap();
2057 let target = Note::from_path(dir.path().join("target.md")).unwrap();
2058 let backlinks = vault.backlinks(&target).unwrap();
2059
2060 assert!(backlinks.is_empty());
2061 }
2062
2063 #[test]
2064 fn backlinks_markdown_absolute_path_excluded() {
2065 let dir = tempfile::tempdir().unwrap();
2066 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2067 fs::write(dir.path().join("source.md"), "[link](/absolute/target.md)").unwrap();
2068
2069 let vault = Vault::open(dir.path()).unwrap();
2070 let target = Note::from_path(dir.path().join("target.md")).unwrap();
2071 let backlinks = vault.backlinks(&target).unwrap();
2072
2073 assert!(backlinks.is_empty());
2074 }
2075
2076 #[test]
2077 fn backlinks_markdown_extension_less_excluded() {
2078 let dir = tempfile::tempdir().unwrap();
2079 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2080 fs::write(dir.path().join("source.md"), "[link](target)").unwrap();
2081
2082 let vault = Vault::open(dir.path()).unwrap();
2083 let target = Note::from_path(dir.path().join("target.md")).unwrap();
2084 let backlinks = vault.backlinks(&target).unwrap();
2085
2086 assert!(backlinks.is_empty());
2087 }
2088
2089 #[test]
2092 fn rename_tag_basic() {
2093 let dir = tempfile::tempdir().unwrap();
2094 fs::write(
2095 dir.path().join("note.md"),
2096 "---\nid: note\ntags:\n- foo\n- old-tag\n---\n\nHello world #old-tag here and #old-tag there.",
2097 )
2098 .unwrap();
2099
2100 let mut vault = Vault::open(dir.path()).unwrap();
2101 vault.rename_tag("old-tag", "new-tag").unwrap();
2102
2103 let content = fs::read_to_string(dir.path().join("note.md")).unwrap();
2104 assert_eq!(
2105 content,
2106 "---\nid: note\ntags:\n- foo\n- new-tag\n---\n\nHello world #new-tag here and #new-tag there."
2107 );
2108 }
2109
2110 #[test]
2113 fn patch_note_replaces_string() {
2114 let dir = tempfile::tempdir().unwrap();
2115 fs::write(dir.path().join("note.md"), "Hello world.").unwrap();
2116
2117 let mut vault = Vault::open(dir.path()).unwrap();
2118 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2119 vault.patch_note(¬e, "world", "Rust").unwrap();
2120
2121 let content = fs::read_to_string(dir.path().join("note.md")).unwrap();
2122 assert_eq!(content, "Hello Rust.");
2123 }
2124
2125 #[test]
2126 fn patch_note_string_not_found_errors() {
2127 let dir = tempfile::tempdir().unwrap();
2128 fs::write(dir.path().join("note.md"), "Hello world.").unwrap();
2129
2130 let mut vault = Vault::open(dir.path()).unwrap();
2131 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2132 let result = vault.patch_note(¬e, "missing", "replacement");
2133
2134 assert!(matches!(result, Err(VaultError::StringNotFound(_))));
2135 }
2136
2137 #[test]
2138 fn patch_note_multiple_matches_errors() {
2139 let dir = tempfile::tempdir().unwrap();
2140 fs::write(dir.path().join("note.md"), "foo and foo").unwrap();
2141
2142 let mut vault = Vault::open(dir.path()).unwrap();
2143 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2144 let result = vault.patch_note(¬e, "foo", "bar");
2145
2146 assert!(matches!(result, Err(VaultError::StringFoundMultipleTimes(_))));
2147 }
2148
2149 #[test]
2150 fn patch_note_preserves_explicit_empty_frontmatter_arrays() {
2151 let dir = tempfile::tempdir().unwrap();
2152 fs::write(dir.path().join("note.md"), "---\ntags: []\n---\n\nHello world.").unwrap();
2153
2154 let mut vault = Vault::open(dir.path()).unwrap();
2155 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2156 let patched = vault.patch_note(¬e, "world", "Rust").unwrap();
2157
2158 let content = fs::read_to_string(dir.path().join("note.md")).unwrap();
2159 assert_eq!(content, "---\ntags: []\n---\n\nHello Rust.");
2160 assert_eq!(
2161 patched.frontmatter_json().unwrap().get("tags"),
2162 Some(&serde_json::json!([]))
2163 );
2164 }
2165
2166 #[test]
2167 fn patch_note_does_not_work_in_frontmatter() {
2168 let dir = tempfile::tempdir().unwrap();
2169 fs::write(dir.path().join("note.md"), "---\ntitle: Old Title\n---\nBody.").unwrap();
2170
2171 let mut vault = Vault::open(dir.path()).unwrap();
2172 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2173 assert!(vault.patch_note(¬e, "Old Title", "New Title").is_err());
2174 }
2175
2176 #[test]
2177 fn patch_note_returns_reloaded_note() {
2178 let dir = tempfile::tempdir().unwrap();
2179 fs::write(dir.path().join("note.md"), "---\ntitle: Before\n---\n# Before\nBody.").unwrap();
2180
2181 let mut vault = Vault::open(dir.path()).unwrap();
2182 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2183 let patched = vault.patch_note(¬e, "Before", "After").unwrap();
2184
2185 assert_eq!(patched.body(), "# After\nBody.");
2186 }
2187
2188 #[test]
2191 fn append_to_note_appends_content_and_returns_reloaded_note() {
2192 let dir = tempfile::tempdir().unwrap();
2193 fs::write(dir.path().join("note.md"), "Hello.").unwrap();
2194
2195 let mut vault = Vault::open(dir.path()).unwrap();
2196 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2197 let appended = vault.append_to_note(¬e, "\nWorld.").unwrap();
2198
2199 let content = fs::read_to_string(dir.path().join("note.md")).unwrap();
2200 assert_eq!(content, "Hello.\nWorld.");
2201 assert_eq!(appended.body(), "Hello.\nWorld.");
2202 }
2203
2204 #[test]
2205 fn append_to_note_preserves_explicit_empty_frontmatter_arrays() {
2206 let dir = tempfile::tempdir().unwrap();
2207 fs::write(dir.path().join("note.md"), "---\ntags: []\naliases: []\n---\n\nHello.").unwrap();
2208
2209 let mut vault = Vault::open(dir.path()).unwrap();
2210 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2211 let appended = vault.append_to_note(¬e, "\nWorld.").unwrap();
2212
2213 let content = fs::read_to_string(dir.path().join("note.md")).unwrap();
2214 assert_eq!(content, "---\ntags: []\naliases: []\n---\n\nHello.\nWorld.");
2215 assert_eq!(
2216 appended.frontmatter_json().unwrap().get("tags"),
2217 Some(&serde_json::json!([]))
2218 );
2219 assert_eq!(
2220 appended.frontmatter_json().unwrap().get("aliases"),
2221 Some(&serde_json::json!([]))
2222 );
2223 }
2224
2225 #[test]
2226 fn append_to_note_reparses_inline_links_and_tags() {
2227 let dir = tempfile::tempdir().unwrap();
2228 fs::write(dir.path().join("note.md"), "Start.").unwrap();
2229
2230 let mut vault = Vault::open(dir.path()).unwrap();
2231 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2232 let appended = vault.append_to_note(¬e, "\nSee [[target]]. #new-tag").unwrap();
2233
2234 assert_eq!(appended.links.len(), 1);
2235 assert!(
2236 appended
2237 .tags
2238 .iter()
2239 .any(|tag| { tag.tag == "new-tag" && matches!(tag.location, Location::Inline(_)) })
2240 );
2241 }
2242
2243 #[test]
2246 fn extract_span_creates_new_note_and_replaces_source_text() {
2247 let dir = tempfile::tempdir().unwrap();
2248 let source_path = dir.path().join("source.md");
2249 fs::write(&source_path, "---\ntags: []\n---\n\nHello world.").unwrap();
2250
2251 let mut vault = Vault::open(dir.path()).unwrap();
2252 let note = Note::from_path(&source_path).unwrap();
2253 let result = vault
2254 .extract_to_note(
2255 ¬e,
2256 &ExtractSelection::Span(TextSpan {
2257 start_line: 5,
2258 start_col: 6,
2259 end_line: 5,
2260 end_col: 11,
2261 }),
2262 dir.path().join("new.md"),
2263 None,
2264 None,
2265 )
2266 .unwrap();
2267
2268 assert_eq!(
2269 fs::read_to_string(&source_path).unwrap(),
2270 "---\ntags: []\n---\n\nHello [[new]]."
2271 );
2272 assert_eq!(
2273 fs::read_to_string(dir.path().join("new.md")).unwrap(),
2274 "---\nid: new\n---\n\nworld"
2275 );
2276 assert_eq!(result.source_note.body(), "Hello [[new]].");
2277 assert_eq!(result.new_note.id, "new");
2278 }
2279
2280 #[test]
2281 fn extract_span_rewrites_relative_markdown_links() {
2282 let dir = tempfile::tempdir().unwrap();
2283 let source_path = dir.path().join("journal/daily/source.md");
2284 fs::create_dir_all(source_path.parent().unwrap()).unwrap();
2285 fs::create_dir_all(dir.path().join("notes")).unwrap();
2286 fs::write(dir.path().join("notes/topic.md"), "# Topic\n").unwrap();
2287 let line = "See [Target](../../notes/topic.md#section).";
2288 fs::write(&source_path, line).unwrap();
2289
2290 let mut vault = Vault::open(dir.path()).unwrap();
2291 let note = Note::from_path(&source_path).unwrap();
2292 vault
2293 .extract_to_note(
2294 ¬e,
2295 &ExtractSelection::Span(TextSpan {
2296 start_line: 1,
2297 start_col: 0,
2298 end_line: 1,
2299 end_col: line.chars().count(),
2300 }),
2301 dir.path().join("projects/extract.md"),
2302 None,
2303 Some("See [[extract]]."),
2304 )
2305 .unwrap();
2306
2307 let extracted = fs::read_to_string(dir.path().join("projects/extract.md")).unwrap();
2308 assert!(extracted.contains("[Target](../notes/topic.md#section)"));
2309 }
2310
2311 #[test]
2312 fn extract_section_keeps_source_heading_and_normalizes_extracted_headings() {
2313 let dir = tempfile::tempdir().unwrap();
2314 let source_path = dir.path().join("source.md");
2315 fs::write(
2316 &source_path,
2317 "# Root\n\n## Section\nIntro\n### Child\nBody\n\n## Next\nStay.\n",
2318 )
2319 .unwrap();
2320
2321 let mut vault = Vault::open(dir.path()).unwrap();
2322 let note = Note::from_path(&source_path).unwrap();
2323 vault
2324 .extract_to_note(
2325 ¬e,
2326 &ExtractSelection::Section("Section".to_string()),
2327 dir.path().join("section.md"),
2328 None,
2329 None,
2330 )
2331 .unwrap();
2332
2333 let source = fs::read_to_string(&source_path).unwrap();
2334 assert!(source.contains("## Section\n[[section]]\n## Next"));
2335
2336 let extracted = fs::read_to_string(dir.path().join("section.md")).unwrap();
2337 assert!(extracted.contains("id: section"));
2338 assert!(extracted.contains("# Section"));
2339 assert!(extracted.contains("## Child"));
2340 assert!(!extracted.contains("### Child"));
2341 }
2342
2343 #[test]
2344 fn extract_section_default_link_uses_new_id() {
2345 let dir = tempfile::tempdir().unwrap();
2346 let source_path = dir.path().join("source.md");
2347 fs::write(&source_path, "## Section\nBody.\n").unwrap();
2348
2349 let mut vault = Vault::open(dir.path()).unwrap();
2350 let note = Note::from_path(&source_path).unwrap();
2351 vault
2352 .extract_to_note(
2353 ¬e,
2354 &ExtractSelection::Section("Section".to_string()),
2355 dir.path().join("section.md"),
2356 Some("section-id"),
2357 None,
2358 )
2359 .unwrap();
2360
2361 let source = fs::read_to_string(&source_path).unwrap();
2362 let extracted = fs::read_to_string(dir.path().join("section.md")).unwrap();
2363 assert!(source.contains("[[section-id]]"));
2364 assert!(extracted.contains("id: section-id"));
2365 }
2366
2367 #[test]
2368 fn extract_section_default_id_uses_normalized_filename_id() {
2369 let dir = tempfile::tempdir().unwrap();
2370 let source_path = dir.path().join("source.md");
2371 fs::write(&source_path, "## Section\nBody.\n").unwrap();
2372
2373 let mut vault = Vault::open(dir.path()).unwrap();
2374 let note = Note::from_path(&source_path).unwrap();
2375 vault
2376 .extract_to_note(
2377 ¬e,
2378 &ExtractSelection::Section("Section".to_string()),
2379 dir.path().join("Café Note.md"),
2380 None,
2381 None,
2382 )
2383 .unwrap();
2384
2385 let source = fs::read_to_string(&source_path).unwrap();
2386 let extracted = fs::read_to_string(dir.path().join("Café Note.md")).unwrap();
2387 assert!(source.contains("[[cafe-note]]"));
2388 assert!(extracted.contains("id: cafe-note"));
2389 }
2390
2391 #[test]
2392 fn extract_section_duplicate_heading_errors() {
2393 let dir = tempfile::tempdir().unwrap();
2394 let source_path = dir.path().join("source.md");
2395 fs::write(
2396 &source_path,
2397 "# Root\n\n## Section\nOne.\n\n## Other\nBody.\n\n## Section\nTwo.\n",
2398 )
2399 .unwrap();
2400
2401 let vault = Vault::open(dir.path()).unwrap();
2402 let note = Note::from_path(&source_path).unwrap();
2403 let error = vault
2404 .extract_to_note_edits(
2405 ¬e,
2406 &ExtractSelection::Section("Section".to_string()),
2407 dir.path().join("section.md"),
2408 None,
2409 None,
2410 )
2411 .err()
2412 .expect("duplicate sections should error");
2413
2414 assert!(matches!(error, VaultError::AmbiguousSection { .. }));
2415 }
2416
2417 #[test]
2418 fn extract_span_overlapping_frontmatter_errors() {
2419 let dir = tempfile::tempdir().unwrap();
2420 let source_path = dir.path().join("source.md");
2421 fs::write(&source_path, "---\nid: source\n---\n\nBody.\n").unwrap();
2422
2423 let vault = Vault::open(dir.path()).unwrap();
2424 let note = Note::from_path(&source_path).unwrap();
2425 let error = vault
2426 .extract_to_note_edits(
2427 ¬e,
2428 &ExtractSelection::Span(TextSpan {
2429 start_line: 2,
2430 start_col: 0,
2431 end_line: 2,
2432 end_col: 2,
2433 }),
2434 dir.path().join("frontmatter.md"),
2435 None,
2436 None,
2437 )
2438 .err()
2439 .expect("frontmatter spans should error");
2440
2441 assert!(matches!(error, VaultError::InvalidExtractSpan { .. }));
2442 }
2443
2444 #[test]
2447 fn rename_basic() {
2448 let dir = tempfile::tempdir().unwrap();
2449 fs::write(dir.path().join("old.md"), "Content.").unwrap();
2450
2451 let mut vault = Vault::open(dir.path()).unwrap();
2452 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2453 let renamed = vault.rename(¬e, &dir.path().join("new.md")).unwrap();
2454
2455 assert!(!dir.path().join("old.md").exists());
2456 assert!(dir.path().join("new.md").exists());
2457 assert_eq!(renamed.id, "new");
2458 }
2459
2460 #[test]
2461 fn rename_explicit_id_equals_stem_updated() {
2462 let dir = tempfile::tempdir().unwrap();
2463 fs::write(dir.path().join("old-note.md"), "---\nid: old-note\n---\nContent.").unwrap();
2464 fs::write(dir.path().join("source.md"), "See [[old-note]].").unwrap();
2465
2466 let mut vault = Vault::open(dir.path()).unwrap();
2467 let note = Note::from_path(dir.path().join("old-note.md")).unwrap();
2468 let renamed = vault.rename(¬e, &dir.path().join("new-note.md")).unwrap();
2469
2470 assert!(!dir.path().join("old-note.md").exists());
2471 assert!(dir.path().join("new-note.md").exists());
2472 assert_eq!(renamed.id, "new-note");
2473
2474 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2475 assert_eq!(source_content, "See [[new-note]].");
2476 }
2477
2478 #[test]
2479 fn rename_explicit_id_differs_from_stem_unchanged() {
2480 let dir = tempfile::tempdir().unwrap();
2481 fs::write(dir.path().join("my-note.md"), "---\nid: custom-id\n---\nContent.").unwrap();
2482 fs::write(dir.path().join("source.md"), "See [[my-note]].").unwrap();
2483
2484 let mut vault = Vault::open(dir.path()).unwrap();
2485 let note = Note::from_path(dir.path().join("my-note.md")).unwrap();
2486 let renamed = vault.rename(¬e, &dir.path().join("renamed-note.md")).unwrap();
2487
2488 assert_eq!(renamed.id, "custom-id");
2489
2490 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2492 assert_eq!(source_content, "See [[my-note]].");
2493 }
2494
2495 #[test]
2496 fn rename_updates_markdown_backlinks() {
2497 let dir = tempfile::tempdir().unwrap();
2498 fs::write(dir.path().join("old.md"), "Target.").unwrap();
2499 fs::write(dir.path().join("source.md"), "[link](old.md)").unwrap();
2500
2501 let mut vault = Vault::open(dir.path()).unwrap();
2502 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2503 vault.rename(¬e, &dir.path().join("new.md")).unwrap();
2504
2505 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2506 assert_eq!(source_content, "[link](new.md)");
2507 }
2508
2509 #[test]
2510 fn rename_updates_wiki_backlinks_by_stem() {
2511 let dir = tempfile::tempdir().unwrap();
2512 fs::write(dir.path().join("old-stem.md"), "Content.").unwrap();
2513 fs::write(dir.path().join("source.md"), "See [[old-stem]].").unwrap();
2514
2515 let mut vault = Vault::open(dir.path()).unwrap();
2516 let note = Note::from_path(dir.path().join("old-stem.md")).unwrap();
2517 vault.rename(¬e, &dir.path().join("new-stem.md")).unwrap();
2518
2519 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2520 assert_eq!(source_content, "See [[new-stem]].");
2521 }
2522
2523 #[test]
2524 fn rename_leaves_wiki_alias_links_unchanged() {
2525 let dir = tempfile::tempdir().unwrap();
2526 fs::write(dir.path().join("target.md"), "---\naliases: [my-alias]\n---\nContent.").unwrap();
2527 fs::write(dir.path().join("source.md"), "See [[my-alias]].").unwrap();
2528
2529 let mut vault = Vault::open(dir.path()).unwrap();
2530 let note = Note::from_path(dir.path().join("target.md")).unwrap();
2531 vault.rename(¬e, &dir.path().join("renamed-target.md")).unwrap();
2532
2533 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2534 assert_eq!(source_content, "See [[my-alias]].");
2535 }
2536
2537 #[test]
2538 fn rename_moves_to_different_directory() {
2539 let dir = tempfile::tempdir().unwrap();
2540 let subdir = dir.path().join("sub");
2541 fs::create_dir(&subdir).unwrap();
2542 fs::write(dir.path().join("root.md"), "Root.").unwrap();
2543 fs::write(dir.path().join("source.md"), "[link](root.md)").unwrap();
2544
2545 let mut vault = Vault::open(dir.path()).unwrap();
2546 let note = Note::from_path(dir.path().join("root.md")).unwrap();
2547 vault.rename(¬e, &subdir.join("root.md")).unwrap();
2548
2549 assert!(!dir.path().join("root.md").exists());
2550 assert!(subdir.join("root.md").exists());
2551
2552 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2553 assert_eq!(source_content, "[link](sub/root.md)");
2554 }
2555
2556 #[test]
2557 fn rename_directory_not_found_errors() {
2558 let dir = tempfile::tempdir().unwrap();
2559 fs::write(dir.path().join("old.md"), "Content.").unwrap();
2560
2561 let mut vault = Vault::open(dir.path()).unwrap();
2562 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2563 let result = vault.rename(¬e, &dir.path().join("nonexistent/new.md"));
2564
2565 assert!(matches!(result, Err(VaultError::DirectoryNotFound(_))));
2566 }
2567
2568 #[test]
2569 fn rename_target_already_exists_errors() {
2570 let dir = tempfile::tempdir().unwrap();
2571 fs::write(dir.path().join("old.md"), "Old.").unwrap();
2572 fs::write(dir.path().join("new.md"), "Already exists.").unwrap();
2573
2574 let mut vault = Vault::open(dir.path()).unwrap();
2575 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2576 let result = vault.rename(¬e, &dir.path().join("new.md"));
2577
2578 assert!(matches!(result, Err(VaultError::NoteAlreadyExists(_))));
2579 }
2580
2581 #[test]
2584 fn rename_preview_basic() {
2585 let dir = tempfile::tempdir().unwrap();
2586 fs::write(dir.path().join("old.md"), "Content.").unwrap();
2587
2588 let vault = Vault::open(dir.path()).unwrap();
2589 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2590 let preview = vault.rename_preview(¬e, &dir.path().join("new.md")).unwrap();
2591
2592 assert_eq!(
2593 preview.new_path,
2594 common::normalize_path(dir.path().join("new.md"), None)
2595 );
2596 assert!(preview.updated_notes.is_empty());
2597 assert!(preview.id_will_update);
2598 }
2599
2600 #[test]
2601 fn rename_preview_with_wiki_backlink() {
2602 let dir = tempfile::tempdir().unwrap();
2603 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2604 fs::write(dir.path().join("source.md"), "See [[target]].").unwrap();
2605
2606 let vault = Vault::open(dir.path()).unwrap();
2607 let note = Note::from_path(dir.path().join("target.md")).unwrap();
2608 let preview = vault.rename_preview(¬e, &dir.path().join("renamed.md")).unwrap();
2609
2610 assert_eq!(preview.updated_notes.len(), 1);
2611 assert!(preview.updated_notes[0].0.ends_with("source.md"));
2612 assert_eq!(preview.updated_notes[0].1, 1);
2613 }
2614
2615 #[test]
2616 fn rename_preview_with_markdown_backlink() {
2617 let dir = tempfile::tempdir().unwrap();
2618 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2619 fs::write(dir.path().join("source.md"), "[link](target.md)").unwrap();
2620
2621 let vault = Vault::open(dir.path()).unwrap();
2622 let note = Note::from_path(dir.path().join("target.md")).unwrap();
2623 let preview = vault.rename_preview(¬e, &dir.path().join("renamed.md")).unwrap();
2624
2625 assert_eq!(preview.updated_notes.len(), 1);
2626 assert!(preview.updated_notes[0].0.ends_with("source.md"));
2627 assert_eq!(preview.updated_notes[0].1, 1);
2628 }
2629
2630 #[test]
2631 fn rename_edits_include_exact_backlink_replacements() {
2632 let dir = tempfile::tempdir().unwrap();
2633 fs::write(dir.path().join("target.md"), "---\nid: target\n---\nTarget.").unwrap();
2634 fs::write(dir.path().join("source.md"), "See [[target]] and [link](target.md).").unwrap();
2635
2636 let vault = Vault::open(dir.path()).unwrap();
2637 let note = Note::from_path(dir.path().join("target.md")).unwrap();
2638 let edits = vault.rename_edits(¬e, &dir.path().join("renamed.md")).unwrap();
2639
2640 assert_eq!(edits.new_stem, "renamed");
2641 assert!(edits.id_will_update);
2642 assert_eq!(edits.backlink_edits.len(), 1);
2643 assert!(edits.backlink_edits[0].0.ends_with("source.md"));
2644 let replacements = edits.backlink_edits[0]
2645 .1
2646 .iter()
2647 .map(|(_, new_text)| new_text.as_str())
2648 .collect::<Vec<_>>();
2649 assert_eq!(replacements, vec!["[[renamed]]", "[link](renamed.md)"]);
2650 }
2651
2652 #[test]
2653 fn rename_preview_id_will_update() {
2654 let dir = tempfile::tempdir().unwrap();
2655 fs::write(dir.path().join("old-note.md"), "---\nid: old-note\n---\nContent.").unwrap();
2656
2657 let vault = Vault::open(dir.path()).unwrap();
2658 let note = Note::from_path(dir.path().join("old-note.md")).unwrap();
2659 let preview = vault.rename_preview(¬e, &dir.path().join("new-note.md")).unwrap();
2660
2661 assert!(preview.id_will_update);
2662 }
2663
2664 #[test]
2665 fn rename_preview_id_will_not_update() {
2666 let dir = tempfile::tempdir().unwrap();
2667 fs::write(dir.path().join("my-note.md"), "---\nid: custom-id\n---\nContent.").unwrap();
2668
2669 let vault = Vault::open(dir.path()).unwrap();
2670 let note = Note::from_path(dir.path().join("my-note.md")).unwrap();
2671 let preview = vault
2672 .rename_preview(¬e, &dir.path().join("renamed-note.md"))
2673 .unwrap();
2674
2675 assert!(!preview.id_will_update);
2676 }
2677
2678 #[test]
2679 fn rename_preview_excludes_alias_only_links() {
2680 let dir = tempfile::tempdir().unwrap();
2681 fs::write(dir.path().join("target.md"), "---\naliases: [my-alias]\n---\nContent.").unwrap();
2682 fs::write(dir.path().join("source.md"), "See [[my-alias]].").unwrap();
2683
2684 let vault = Vault::open(dir.path()).unwrap();
2685 let note = Note::from_path(dir.path().join("target.md")).unwrap();
2686 let preview = vault.rename_preview(¬e, &dir.path().join("renamed.md")).unwrap();
2687
2688 assert!(preview.updated_notes.is_empty());
2690 }
2691
2692 #[test]
2693 fn rename_preview_does_not_modify_filesystem() {
2694 let dir = tempfile::tempdir().unwrap();
2695 fs::write(dir.path().join("old.md"), "Content.").unwrap();
2696 fs::write(dir.path().join("source.md"), "See [[old]].").unwrap();
2697
2698 let vault = Vault::open(dir.path()).unwrap();
2699 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2700 vault.rename_preview(¬e, &dir.path().join("new.md")).unwrap();
2701
2702 assert!(dir.path().join("old.md").exists());
2703 assert!(!dir.path().join("new.md").exists());
2704
2705 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2706 assert_eq!(source_content, "See [[old]].");
2707 }
2708
2709 #[test]
2710 fn rename_preview_directory_not_found() {
2711 let dir = tempfile::tempdir().unwrap();
2712 fs::write(dir.path().join("old.md"), "Content.").unwrap();
2713
2714 let vault = Vault::open(dir.path()).unwrap();
2715 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2716 let result = vault.rename_preview(¬e, &dir.path().join("nonexistent/new.md"));
2717
2718 assert!(matches!(result, Err(VaultError::DirectoryNotFound(_))));
2719 }
2720
2721 #[test]
2722 fn rename_preview_target_already_exists() {
2723 let dir = tempfile::tempdir().unwrap();
2724 fs::write(dir.path().join("old.md"), "Old.").unwrap();
2725 fs::write(dir.path().join("new.md"), "Already exists.").unwrap();
2726
2727 let vault = Vault::open(dir.path()).unwrap();
2728 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2729 let result = vault.rename_preview(¬e, &dir.path().join("new.md"));
2730
2731 assert!(matches!(result, Err(VaultError::NoteAlreadyExists(_))));
2732 }
2733
2734 #[test]
2735 fn rename_preview_updated_notes_sorted_by_path() {
2736 let dir = tempfile::tempdir().unwrap();
2737 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2738 fs::write(dir.path().join("z-source.md"), "See [[target]].").unwrap();
2739 fs::write(dir.path().join("a-source.md"), "See [[target]].").unwrap();
2740
2741 let vault = Vault::open(dir.path()).unwrap();
2742 let note = Note::from_path(dir.path().join("target.md")).unwrap();
2743 let preview = vault.rename_preview(¬e, &dir.path().join("renamed.md")).unwrap();
2744
2745 assert_eq!(preview.updated_notes.len(), 2);
2746 assert!(preview.updated_notes[0].0 < preview.updated_notes[1].0);
2747 }
2748
2749 #[test]
2750 fn rename_markdown_link_with_subdir() {
2751 let dir = tempfile::tempdir().unwrap();
2752 let subdir = dir.path().join("sub");
2753 fs::create_dir(&subdir).unwrap();
2754 fs::write(dir.path().join("root.md"), "Root.").unwrap();
2755 fs::write(subdir.join("source.md"), "[link](root.md)\n[link](sub/target.md)").unwrap();
2756 fs::write(subdir.join("target.md"), "Target.").unwrap();
2757
2758 let mut vault = Vault::open(dir.path()).unwrap();
2759
2760 {
2761 let note = Note::from_path(dir.path().join("root.md")).unwrap();
2762 vault.rename(¬e, &dir.path().join("new-root.md")).unwrap();
2763
2764 let source_content = fs::read_to_string(subdir.join("source.md")).unwrap();
2765 assert_eq!(source_content, "[link](new-root.md)\n[link](sub/target.md)");
2766 }
2767
2768 {
2769 let note = Note::from_path(subdir.join("target.md")).unwrap();
2770 vault.rename(¬e, &subdir.join("new-target.md")).unwrap();
2771
2772 let source_content = fs::read_to_string(subdir.join("source.md")).unwrap();
2773 assert_eq!(source_content, "[link](new-root.md)\n[link](sub/new-target.md)");
2774 }
2775 }
2776
2777 #[test]
2778 fn rename_multiple_links_same_source() {
2779 let dir = tempfile::tempdir().unwrap();
2780 fs::write(dir.path().join("target.md"), "Target.").unwrap();
2781 fs::write(dir.path().join("source.md"), "[first](target.md)\n[second](target.md)").unwrap();
2782
2783 let mut vault = Vault::open(dir.path()).unwrap();
2784 let note = Note::from_path(dir.path().join("target.md")).unwrap();
2785 vault.rename(¬e, &dir.path().join("renamed.md")).unwrap();
2786
2787 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2788 assert_eq!(source_content, "[first](renamed.md)\n[second](renamed.md)");
2789 }
2790
2791 #[test]
2792 fn rename_preserves_fragment() {
2793 let dir = tempfile::tempdir().unwrap();
2794 fs::write(dir.path().join("old.md"), "Old.").unwrap();
2795 fs::write(dir.path().join("source.md"), "[link](old.md#section)").unwrap();
2796
2797 let mut vault = Vault::open(dir.path()).unwrap();
2798 let note = Note::from_path(dir.path().join("old.md")).unwrap();
2799 vault.rename(¬e, &dir.path().join("new.md")).unwrap();
2800
2801 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2802 assert_eq!(source_content, "[link](new.md#section)");
2803 }
2804
2805 #[test]
2806 fn rename_wiki_preserves_heading_and_alias() {
2807 let dir = tempfile::tempdir().unwrap();
2808 fs::write(dir.path().join("old-stem.md"), "Content.").unwrap();
2809 fs::write(dir.path().join("source.md"), "See [[old-stem#h1|display]].").unwrap();
2810
2811 let mut vault = Vault::open(dir.path()).unwrap();
2812 let note = Note::from_path(dir.path().join("old-stem.md")).unwrap();
2813 vault.rename(¬e, &dir.path().join("new-stem.md")).unwrap();
2814
2815 let source_content = fs::read_to_string(dir.path().join("source.md")).unwrap();
2816 assert_eq!(source_content, "See [[new-stem#h1|display]].");
2817 }
2818
2819 #[test]
2822 fn merge_basic_creates_dest_and_deletes_sources() {
2823 let dir = tempfile::tempdir().unwrap();
2824 fs::write(dir.path().join("a.md"), "Body A.").unwrap();
2825 fs::write(dir.path().join("b.md"), "Body B.").unwrap();
2826
2827 let mut vault = Vault::open(dir.path()).unwrap();
2828 let a = Note::from_path(dir.path().join("a.md")).unwrap();
2829 let b = Note::from_path(dir.path().join("b.md")).unwrap();
2830 let dest_path = dir.path().join("combined.md");
2831 vault.merge(&[a, b], &dest_path).unwrap();
2832
2833 assert!(!dir.path().join("a.md").exists());
2834 assert!(!dir.path().join("b.md").exists());
2835 assert!(dest_path.exists());
2836 let content = fs::read_to_string(&dest_path).unwrap();
2837 assert!(content.contains("Body A."));
2838 assert!(content.contains("Body B."));
2839 }
2840
2841 #[test]
2842 fn merge_into_existing_appends_content() {
2843 let dir = tempfile::tempdir().unwrap();
2844 fs::write(dir.path().join("src.md"), "Source body.").unwrap();
2845 fs::write(dir.path().join("dest.md"), "Existing body.").unwrap();
2846
2847 let mut vault = Vault::open(dir.path()).unwrap();
2848 let src = Note::from_path(dir.path().join("src.md")).unwrap();
2849 vault.merge(&[src], &dir.path().join("dest.md")).unwrap();
2850
2851 assert!(!dir.path().join("src.md").exists());
2852 let content = fs::read_to_string(dir.path().join("dest.md")).unwrap();
2853 assert!(content.contains("Existing body."));
2854 assert!(content.contains("Source body."));
2855 }
2856
2857 #[test]
2858 fn merge_unions_tags() {
2859 let dir = tempfile::tempdir().unwrap();
2860 fs::write(dir.path().join("a.md"), "---\ntags: [rust]\n---\nBody A.").unwrap();
2861 fs::write(dir.path().join("b.md"), "---\ntags: [obsidian]\n---\nBody B.").unwrap();
2862
2863 let mut vault = Vault::open(dir.path()).unwrap();
2864 let a = Note::from_path(dir.path().join("a.md")).unwrap();
2865 let b = Note::from_path(dir.path().join("b.md")).unwrap();
2866 let dest_path = dir.path().join("combined.md");
2867 vault.merge(&[a, b], &dest_path).unwrap();
2868
2869 let combined = Note::from_path(&dest_path).unwrap();
2870 assert!(
2871 combined
2872 .tags
2873 .iter()
2874 .any(|t| t.tag == "rust" && matches!(t.location, Location::Frontmatter))
2875 );
2876 assert!(
2877 combined
2878 .tags
2879 .iter()
2880 .any(|t| t.tag == "obsidian" && matches!(t.location, Location::Frontmatter))
2881 );
2882 }
2883
2884 #[test]
2885 fn merges_not_inherit_source_id() {
2886 let dir = tempfile::tempdir().unwrap();
2887 fs::write(
2888 dir.path().join("src.md"),
2889 "---\nid: source-id\nauthor: alice\n---\nBody.",
2890 )
2891 .unwrap();
2892
2893 let mut vault = Vault::open(dir.path()).unwrap();
2894 let src = Note::from_path(dir.path().join("src.md")).unwrap();
2895 let dest_path = dir.path().join("dest.md");
2896 vault.merge(&[src], &dest_path).unwrap();
2897
2898 let dest = Note::from_path(&dest_path).unwrap();
2899 let fm = dest.frontmatter.unwrap();
2900 assert_ne!(dest.id, "source-id");
2902 assert!(fm.contains_key("id"));
2903 assert!(fm.contains_key("author"));
2905 }
2906
2907 #[test]
2908 fn merge_other_frontmatter_fields_inherited_from_source_when_dest_is_new() {
2909 let dir = tempfile::tempdir().unwrap();
2910 fs::write(
2911 dir.path().join("src.md"),
2912 "---\nauthor: alice\ncreated: 2024-01-01\n---\nBody.",
2913 )
2914 .unwrap();
2915
2916 let mut vault = Vault::open(dir.path()).unwrap();
2917 let src = Note::from_path(dir.path().join("src.md")).unwrap();
2918 let dest_path = dir.path().join("dest.md");
2919 vault.merge(&[src], &dest_path).unwrap();
2920
2921 let dest = Note::from_path(&dest_path).unwrap();
2922 let fm = dest.frontmatter.unwrap();
2923 assert!(fm.contains_key("author"));
2924 assert!(fm.contains_key("created"));
2925 }
2926
2927 #[test]
2928 fn merge_dest_wins_on_conflicting_fields() {
2929 let dir = tempfile::tempdir().unwrap();
2930 fs::write(dir.path().join("src.md"), "---\nauthor: alice\n---\nSource.").unwrap();
2931 fs::write(dir.path().join("dest.md"), "---\nauthor: bob\n---\nDest.").unwrap();
2932
2933 let mut vault = Vault::open(dir.path()).unwrap();
2934 let src = Note::from_path(dir.path().join("src.md")).unwrap();
2935 vault.merge(&[src], &dir.path().join("dest.md")).unwrap();
2936
2937 let dest = Note::from_path(dir.path().join("dest.md")).unwrap();
2938 let fm = dest.frontmatter.unwrap();
2939 assert_eq!(fm["author"].as_string().unwrap(), "bob");
2940 }
2941
2942 #[test]
2943 fn merge_updates_wiki_backlinks() {
2944 let dir = tempfile::tempdir().unwrap();
2945 fs::write(dir.path().join("src.md"), "Source.").unwrap();
2946 fs::write(dir.path().join("linker.md"), "See [[src]].").unwrap();
2947
2948 let mut vault = Vault::open(dir.path()).unwrap();
2949 let src = Note::from_path(dir.path().join("src.md")).unwrap();
2950 vault.merge(&[src], &dir.path().join("dest.md")).unwrap();
2951
2952 let linker = fs::read_to_string(dir.path().join("linker.md")).unwrap();
2953 assert_eq!(linker, "See [[dest]].");
2954 }
2955
2956 #[test]
2957 fn merge_source_is_dest_errors() {
2958 let dir = tempfile::tempdir().unwrap();
2959 fs::write(dir.path().join("note.md"), "Content.").unwrap();
2960
2961 let mut vault = Vault::open(dir.path()).unwrap();
2962 let note = Note::from_path(dir.path().join("note.md")).unwrap();
2963 let result = vault.merge(&[note], &dir.path().join("note.md"));
2964
2965 assert!(matches!(result, Err(VaultError::MergeSourceIsDestination(_))));
2966 }
2967
2968 #[test]
2969 fn merge_preview_does_not_modify_filesystem() {
2970 let dir = tempfile::tempdir().unwrap();
2971 fs::write(dir.path().join("src.md"), "Source.").unwrap();
2972 fs::write(dir.path().join("linker.md"), "See [[src]].").unwrap();
2973
2974 let vault = Vault::open(dir.path()).unwrap();
2975 let src = Note::from_path(dir.path().join("src.md")).unwrap();
2976 vault.merge_preview(&[src], dir.path().join("dest.md")).unwrap();
2977
2978 assert!(dir.path().join("src.md").exists());
2979 assert!(!dir.path().join("dest.md").exists());
2980 let linker = fs::read_to_string(dir.path().join("linker.md")).unwrap();
2981 assert_eq!(linker, "See [[src]].");
2982 }
2983}