Skip to main content

obsidian_core/
vault.rs

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