Skip to main content

obsidian_core/
vault.rs

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    /// The root directory of the vault.
19    path: PathBuf,
20    /// When `Some`, an authoritative, complete in-memory snapshot of every note in the vault,
21    /// keyed by absolute path. Searches and scans iterate this map and never touch the filesystem.
22    /// When `None`, the vault operates in disk-walk mode. In-memory overrides and unsaved notes are
23    /// simply entries in this map.
24    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    /// Opens a vault at the given path, returning an error if the path does not exist or is not a
38    /// directory.
39    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    /// Opens a vault and immediately caches all notes in memory.
51    ///
52    /// This is intended for long-lived processes, such as LSP or MCP servers, that repeatedly query
53    /// the same vault and receive explicit file-change notifications to refresh stale entries.
54    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    /// Opens the nearest vault by walking up from the current directory, looking for an
61    /// `.obsidian/` directory. Falls back to the current directory if none is found.
62    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    /// Resolve a note based on a path, filename, ID, title, or alias.
154    pub fn resolve_note(&self, note: &str) -> Result<Note, VaultError> {
155        // First try as a path.
156        if let Ok((path, _)) = self.resolve_note_path(note, true) {
157            return self.note_for_path(path);
158        }
159
160        // Then search by ID, aliases, and potentially filename.
161        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        // If we have more than one match, check for *exact* (case-sensitive) matches on ID and aliases before giving up.
180        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    /// Resolve a note path argument, which may be absolute or relative to either the current working
194    /// directory or the vault root.
195    /// Returns the resolved absolute path and the root it was resolved against, if any.
196    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        // If the cwd is inside of the vault root, prefer resolving against the cwd to avoid surprising
211        // behavior where a note exists in the vault but can't be found because the user is working in
212        // a subdirectory.
213        let cwd = current_dir()?;
214        let mut cwd_resolved = common::normalize_path(&path, Some(&cwd));
215        if cwd_resolved.starts_with(&self.path) {
216            // Return right away if it exists, otherwise check if the extension is missing.
217            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            // In strict mode, if we still haven't found an existing path, try the same thing against the vault root.
227            // Otherwise return the cwd-resolved path even if it doesn't exist, since
228            // that's more likely what the user intended than the vault root.
229            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    /// Loads all notes in the vault in parallel, each retaining its full contents in
262    /// [`Note::text`].
263    pub fn notes(&self) -> Vec<Result<Note, NoteError>> {
264        self.notes_filtered(|_| true)
265    }
266
267    /// Inserts or replaces an in-memory note in the cached snapshot. While present, this note
268    /// shadows its on-disk counterpart (matched by `note.path`) across all vault operations. Notes
269    /// with a path that does not exist on disk are included as additional candidates.
270    ///
271    /// Only meaningful for a cached vault (see [`open_cached`](Self::open_cached)); a no-op when the
272    /// vault is in disk-walk mode.
273    pub fn load_note(&mut self, mut note: Note) {
274        let resolved_path = self
275            .resolve_note_path(&note.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    /// Restores the on-disk version of a note in the cached snapshot, re-reading it from disk (or
285    /// removing it from the cache if it no longer exists). Does nothing in disk-walk mode.
286    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    /// Like [`notes`](Self::notes), but skips notes whose path does not satisfy `filter`.
299    /// In disk-walk mode, filtering happens at the filesystem traversal level, before any file is
300    /// read.
301    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(&note.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| &note.path);
310                let right_path = right.as_ref().ok().map(|note| &note.path);
311                left_path.cmp(&right_path)
312            });
313            results
314        } else {
315            search::find_notes_filtered(&self.path, filter)
316        }
317    }
318
319    /// Scans the vault for health issues: duplicate IDs, duplicate aliases, broken links, and stranded notes.
320    ///
321    /// Only notes whose path satisfies `filter` are included. Pass `|_| true` to scan
322    /// everything, or use a glob-based closure (see [`notes_filtered`](Self::notes_filtered))
323    /// to exclude specific paths.
324    ///
325    /// Note-load failures are silently skipped (consistent with other vault scan methods).
326    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, &notes)
329    }
330
331    /// Returns a [`SearchQuery`](search::SearchQuery) rooted at this vault's path.
332    /// For a cached vault, the query runs against the in-memory snapshot (including any notes
333    /// registered via [`load_note`](Self::load_note)); otherwise it walks the filesystem.
334    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    /// Returns all unique tags used in the vault, aggregated from frontmatter and inline tags.
344    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    /// Find all occurrences of specific tags, grouped by the note they appear in. Tags are matched
357    /// case-insensitively, and sub-tags are gathered as well.
358    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    /// Find and replaces all occurrences of `old_tag` with the new `new_tag` and return
363    /// the occurrences and location of the new tag.
364    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                    // Replace frontmatter tags immediately.
371                    Location::Frontmatter => {
372                        note.remove_tag(&lt.tag)?;
373                        note.add_tag(new_tag)?;
374                    }
375                    // And gather tags in the body by their line number (1-indexed).
376                    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                // Replace all occurrences of the old tag in the body with the new one.
385                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            // Re-collect the located tags.
403            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            // Persist to disk and keep any cached snapshot in sync.
410            note.write()?;
411            self.refresh_cached_note(&note.path)?;
412
413            results.push((note, tags));
414        }
415
416        Ok(results)
417    }
418
419    /// Returns all notes in the vault that link to `target`, paired with the specific
420    /// [`LocatedLink`]s within each note that point to it.
421    ///
422    /// Only wiki links (`[[target]]`) and markdown links (`[text](target.md)`) are
423    /// considered. Embed links are excluded. Notes that fail to load are silently skipped.
424    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(&notes, 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    /// Like [`backlinks`](Self::backlinks), but operates on an already-loaded slice of notes
451    /// instead of reading from disk. Returns references into `notes`.
452    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    /// Computes all replacement pairs for a rename without performing any I/O.
457    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 frontmatter_id_will_update =
482        //     id_needs_update && note.frontmatter.as_ref().is_some_and(|fm| fm.contains_key("id"));
483
484        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    /// Renames `note` to `new_path` (full destination path), updating all backlinks.
536    ///
537    /// Wiki links targeting the old ID are rewritten to the new stem. Markdown links pointing
538    /// to the old path are rewritten to the new path. Wiki links targeting an alias are left
539    /// unchanged. Returns the reloaded [`Note`] at the new path.
540    ///
541    /// Returns [`VaultError::DirectoryNotFound`] if the parent directory of `new_path` does not
542    /// exist, and [`VaultError::NoteAlreadyExists`] if `new_path` is already occupied.
543    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(&note.path)?;
555        self.remove_cached_note(&note.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    /// Returns a preview of what [`rename`](Self::rename) would change without touching the filesystem.
569    ///
570    /// Same validation and error variants as `rename`.
571    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    /// Returns the exact backlink edits that [`rename`](Self::rename) would make without touching the filesystem.
587    ///
588    /// Same validation and error variants as `rename`.
589    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(&note_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(&note_path).map_err(|e| e.error)?;
626        self.refresh_cached_note(&note_path)?;
627
628        Ok(updated)
629    }
630
631    /// Replaces the first (and only) occurrence of `old_string` in the body text of `note`
632    /// with `new_string`, preserving the rest of the raw note text unchanged.
633    ///
634    /// Returns [`VaultError::StringNotFound`] if `old_string` does not appear in the body, and
635    /// [`VaultError::StringFoundMultipleTimes`] if it appears more than once. Both checks operate
636    /// on the raw body text only (frontmatter excluded).
637    pub fn patch_note(&mut self, note: &Note, old_string: &str, new_string: &str) -> Result<Note, VaultError> {
638        let raw = self.text_for_path(&note.path)?;
639        let (raw_frontmatter, raw_body) = Self::raw_note_sections(&note.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(&note.path, patched_raw)
652    }
653
654    /// Appends `content` exactly as provided to the end of `note`'s body text, preserving the raw
655    /// frontmatter block unchanged.
656    pub fn append_to_note(&mut self, note: &Note, content: &str) -> Result<Note, VaultError> {
657        let raw = self.text_for_path(&note.path)?;
658        let (raw_frontmatter, raw_body) = Self::raw_note_sections(&note.path, &raw);
659        let appended_raw = format!("{raw_frontmatter}{raw_body}{content}");
660        self.apply_raw_note_update(&note.path, appended_raw)
661    }
662
663    /// Returns the exact source/new-note file contents that [`extract_to_note`](Self::extract_to_note)
664    /// would produce without touching the filesystem.
665    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(&note.path)?;
674        self.extract_to_note_edits_from_text(&note.path, &raw, selection, new_path, new_id, replace_with)
675    }
676
677    /// Like [`extract_to_note_edits`](Self::extract_to_note_edits), but uses the provided raw source
678    /// text instead of reading from the vault. This is useful for editor integrations that need
679    /// unsaved buffer contents to participate in the core extraction logic.
680    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    /// Extracts a section or span of body text from `note` into a new note.
708    ///
709    /// The source note is updated in-place, preserving its raw frontmatter formatting, while the
710    /// new note is created at `new_path`. If `replace_with` is `None`, the source note is updated
711    /// with a wiki link to the new note's ID.
712    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    /// Computes all changes required to merge `sources` into `dest_path` without performing I/O.
792    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        // Aggregate backlink replacements per linking note, skipping sources and dest.
818        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        // Load existing destination if present.
872        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        // Build merged body.
900        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        // Build merged frontmatter: dest wins on id/title, union on tags/aliases.
913        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(&lt.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        // Union remaining source frontmatter fields (dest wins on conflicts; id/tags/aliases are
959        // excluded because they're handled above or must not be inherited from sources).
960        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    /// Merges `sources` into `dest_path`: appends each source's body to the destination,
990    /// union-merges tags and aliases, rewrites all backlinks to sources in other notes to
991    /// point to the destination, and deletes the source files.
992    ///
993    /// The destination is created if it doesn't exist, or its content is appended to if it does.
994    /// Returns the resulting destination [`Note`].
995    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        // Build and write destination note.
1000        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        // Rewrite backlinks in external notes.
1015        for (note_path, replacements) in op.per_note_replacements {
1016            let raw_content = self.text_for_path(&note_path)?;
1017            let new_content = common::rewrite_links(&raw_content, replacements);
1018            std::fs::write(&note_path, new_content)?;
1019            self.refresh_cached_note(&note_path)?;
1020        }
1021
1022        // Delete source files.
1023        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    /// Returns a preview of what [`merge`](Self::merge) would change without touching the filesystem.
1032    ///
1033    /// Same validation and error variants as `merge`.
1034    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    /// Only notes with ≥1 replacement included.
1058    per_note_replacements: Vec<(Note, Vec<(LocatedLink, String)>)>,
1059}
1060
1061/// Public summary of what a rename would change, without touching the filesystem.
1062pub struct RenamePreview {
1063    pub new_path: PathBuf,
1064    pub id_will_update: bool,
1065    /// Notes with backlinks that would be rewritten, sorted by path. Each entry is (path, link_count).
1066    pub updated_notes: Vec<(PathBuf, usize)>,
1067}
1068
1069/// Exact text changes required for backlink rewrites during a note rename.
1070pub struct RenameEdits {
1071    pub new_path: PathBuf,
1072    pub new_stem: String,
1073    pub id_will_update: bool,
1074    /// Notes with backlinks that would be rewritten, sorted by path.
1075    pub backlink_edits: Vec<(PathBuf, Vec<(LocatedLink, String)>)>,
1076}
1077
1078/// Character-based span within a note's body text.
1079///
1080/// Lines are 1-indexed, columns are 0-indexed, and the end position is exclusive.
1081#[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/// Selection target for note extraction.
1090#[derive(Clone, Debug, PartialEq, Eq)]
1091pub enum ExtractSelection {
1092    Section(String),
1093    Span(TextSpan),
1094}
1095
1096/// Result of extracting content from one note into another.
1097#[derive(Clone)]
1098pub struct ExtractResult {
1099    pub source_note: Note,
1100    pub new_note: Note,
1101}
1102
1103/// Exact file contents that note extraction would write.
1104#[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
1114/// Public summary of what a merge would change, without touching the filesystem.
1115pub struct MergePreview {
1116    pub dest_path: PathBuf,
1117    pub dest_is_new: bool,
1118    /// Source paths that would be deleted.
1119    pub sources: Vec<PathBuf>,
1120    /// Notes with backlinks to any source that would be rewritten, sorted by path. Each entry is (path, link_count).
1121    pub updated_notes: Vec<(PathBuf, usize)>,
1122}
1123
1124struct MergeOp {
1125    dest_is_new: bool,
1126    /// Combined body content for the destination note (no leading whitespace).
1127    merged_content: String,
1128    /// Merged frontmatter for the destination note.
1129    merged_frontmatter: Option<IndexMap<String, Pod>>,
1130    /// Merged frontmatter tags
1131    merged_tags: Vec<LocatedTag>,
1132    /// Merged aliases
1133    merged_aliases: Vec<String>,
1134    /// External notes (not sources, not dest) with backlinks to rewrite.
1135    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(&current_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    // --- constructor tests ---
1650
1651    #[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            &note_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            &note_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(&note_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        // std::fs::create_dir(&dir).unwrap();
1799        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    // --- note resolution tests ---
1817
1818    #[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    // --- note loading tests ---
1860
1861    #[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    // --- backlinks tests ---
1887
1888    #[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    // --- rename tag tests ---
2090
2091    #[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    // --- patch_note tests ---
2111
2112    #[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(&note, "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(&note, "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(&note, "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(&note, "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(&note, "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(&note, "Before", "After").unwrap();
2184
2185        assert_eq!(patched.body(), "# After\nBody.");
2186    }
2187
2188    // --- append_to_note tests ---
2189
2190    #[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(&note, "\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(&note, "\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(&note, "\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    // --- extract_to_note tests ---
2244
2245    #[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                &note,
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                &note,
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                &note,
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                &note,
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                &note,
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                &note,
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                &note,
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    // --- rename tests ---
2445
2446    #[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(&note, &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(&note, &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(&note, &dir.path().join("renamed-note.md")).unwrap();
2487
2488        assert_eq!(renamed.id, "custom-id");
2489
2490        // Wiki link targeting the old stem should be unchanged
2491        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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &dir.path().join("new.md"));
2577
2578        assert!(matches!(result, Err(VaultError::NoteAlreadyExists(_))));
2579    }
2580
2581    // --- rename_preview tests ---
2582
2583    #[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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &dir.path().join("renamed.md")).unwrap();
2687
2688        // The alias link is a backlink but won't be rewritten, so updated_notes is empty
2689        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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &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(&note, &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    // --- merge tests ---
2820
2821    #[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        // id must NOT come from source
2901        assert_ne!(dest.id, "source-id");
2902        assert!(fm.contains_key("id"));
2903        // other fields ARE inherited when dest is new
2904        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}