git.lucas.co / cce-vault
notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git

src/index.rs (29.5K)

  1 //! The vault index: every file, every parsed note, and where each link
  2 //! points.
  3 //!
  4 //! The files are the source of truth. The index is rebuilt from them on
  5 //! [`Index::open`], reusing a parse from the on-disk cache only where a
  6 //! file's size and mtime still match, and patched with
  7 //! [`Index::apply_changes`] as the watcher reports paths. There is no daemon;
  8 //! every app that needs the index holds its own.
  9 //!
 10 //! Resolution is re-run for the whole vault after any change batch. That
 11 //! keeps the one subtle case correct for free: creating `Beta.md` must turn
 12 //! every `[[Beta]]` that was unresolved anywhere into a backlink, and
 13 //! deleting it must turn them back.
 14 //!
 15 //! Measured on a generated vault of 5,000 notes (40 MB, 100,000 links,
 16 //! 20-core laptop, warm page cache, 2026-09-30): parse 36 ms and relink
 17 //! 21 ms, both spread over up to eight threads; loading the JSON cache
 18 //! instead takes 47 ms for an 18 MB file. So the cache is opt-in. It can
 19 //! only pay off where reading the files is the slow part (a cold page cache
 20 //! at login, a network filesystem), which has not been measured yet.
 21 
 22 use std::collections::{BTreeMap, HashMap};
 23 use std::io;
 24 use std::path::{Component, Path, PathBuf};
 25 use std::time::{Instant, UNIX_EPOCH};
 26 
 27 use serde::{Deserialize, Serialize};
 28 
 29 use crate::canvas;
 30 use crate::parse::{self, Link, LinkKind, Note, Task};
 31 
 32 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 33 #[serde(rename_all = "snake_case")]
 34 pub enum FileKind {
 35     Note,
 36     Canvas,
 37     Attachment,
 38 }
 39 
 40 impl FileKind {
 41     pub fn of(path: &str) -> FileKind {
 42         match extension(path).map(str::to_ascii_lowercase).as_deref() {
 43             Some("md") => FileKind::Note,
 44             Some("canvas") => FileKind::Canvas,
 45             _ => FileKind::Attachment,
 46         }
 47     }
 48 }
 49 
 50 #[derive(Debug, Clone, Serialize, Deserialize)]
 51 pub struct Entry {
 52     pub kind: FileKind,
 53     /// Nanoseconds since the epoch; with `size`, the cache's validity key.
 54     pub mtime: u64,
 55     pub size: u64,
 56     /// Parsed content for notes and canvases; `None` for attachments.
 57     pub note: Option<Note>,
 58 }
 59 
 60 /// How a build went, for `cce-vault stats` and for noticing a cold cache.
 61 #[derive(Debug, Clone, Default, Serialize)]
 62 pub struct BuildStats {
 63     pub parsed: usize,
 64     pub reused: usize,
 65     pub millis: u128,
 66 }
 67 
 68 /// What [`Index::apply_changes`] did.
 69 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
 70 pub struct Changes {
 71     pub updated: Vec<String>,
 72     pub removed: Vec<String>,
 73 }
 74 
 75 impl Changes {
 76     pub fn is_empty(&self) -> bool {
 77         self.updated.is_empty() && self.removed.is_empty()
 78     }
 79 }
 80 
 81 /// A link seen from its target: which file it is in, and the link itself.
 82 #[derive(Debug, Clone, Copy)]
 83 pub struct Backlink<'a> {
 84     pub source: &'a str,
 85     pub link: &'a Link,
 86 }
 87 
 88 pub struct Index {
 89     root: PathBuf,
 90     files: BTreeMap<String, Entry>,
 91     /// Lowercased basename → paths. Notes are keyed without `.md`, since
 92     /// `[[Beta]]` and `[[Beta.md]]` both mean `Beta.md`; every other file
 93     /// keeps its extension (`[[pic.png]]`, `[[Board.canvas]]`).
 94     by_name: HashMap<String, Vec<String>>,
 95     /// Lowercased path → path, for case-insensitive exact matches.
 96     by_lower: HashMap<String, String>,
 97     /// Per source file, where each of its links resolved, in link order.
 98     resolved: HashMap<String, Vec<Option<String>>>,
 99     /// Target path → (source path, link index).
100     backlinks: HashMap<String, Vec<(String, usize)>>,
101     cache_dirty: bool,
102     pub stats: BuildStats,
103 }
104 
105 impl Index {
106     /// Index the vault at `root`, reusing the on-disk cache where it is
107     /// still valid when `use_cache` is set.
108     pub fn open(root: &Path, use_cache: bool) -> io::Result<Index> {
109         let started = Instant::now();
110         let root = root.canonicalize()?;
111         let mut cached = if use_cache { load_cache(&root) } else { BTreeMap::new() };
112         let t_cache = started.elapsed();
113         let mut index = Index::empty(root.clone());
114         let found = walk(&root, &root)?;
115         let found_count = found.len();
116         let mut to_parse: Vec<(String, PathBuf, u64, u64)> = Vec::new();
117         for (rel, abs) in found {
118             let Ok(meta) = std::fs::metadata(&abs) else { continue };
119             let (mtime, size) = stamp(&meta);
120             match cached.remove(&rel) {
121                 Some(entry) if entry.mtime == mtime && entry.size == size => {
122                     index.stats.reused += 1;
123                     index.files.insert(rel, entry);
124                 }
125                 _ => to_parse.push((rel, abs, mtime, size)),
126             }
127         }
128         index.stats.parsed = to_parse.len();
129         let parsed = par_map(&to_parse, |(rel, abs, mtime, size)| {
130             Some((rel.clone(), read_entry(abs, rel, *mtime, *size)))
131         });
132         index.files.extend(parsed);
133         let t_files = started.elapsed();        // Anything the cache knew that the walk did not find is gone.
134         index.cache_dirty = use_cache && (index.stats.parsed > 0 || !cached.is_empty());
135         index.relink();
136         index.stats.millis = started.elapsed().as_millis();
137         log::debug!(
138             "indexed {} files under {} ({} parsed, {} from cache) in {} ms \
139              (cache load {} ms, walk+parse {} ms, relink {} ms)",
140             found_count,
141             root.display(),
142             index.stats.parsed,
143             index.stats.reused,
144             index.stats.millis,
145             t_cache.as_millis(),
146             (t_files - t_cache).as_millis(),
147             (started.elapsed() - t_files).as_millis(),
148         );
149         Ok(index)
150     }
151 
152     fn empty(root: PathBuf) -> Index {
153         Index {
154             root,
155             files: BTreeMap::new(),
156             by_name: HashMap::new(),
157             by_lower: HashMap::new(),
158             resolved: HashMap::new(),
159             backlinks: HashMap::new(),
160             cache_dirty: false,
161             stats: BuildStats::default(),
162         }
163     }
164 
165     pub fn root(&self) -> &Path {
166         &self.root
167     }
168 
169     pub fn abs(&self, rel: &str) -> PathBuf {
170         self.root.join(rel)
171     }
172 
173     /// The vault-relative path of an absolute one, or `None` when it is
174     /// outside the vault or inside a hidden folder (`.obsidian`, `.trash`).
175     pub fn rel(&self, abs: &Path) -> Option<String> {
176         let rel = abs.strip_prefix(&self.root).ok()?;
177         let mut parts = Vec::new();
178         for c in rel.components() {
179             let Component::Normal(s) = c else { return None };
180             let s = s.to_str()?;
181             if s.starts_with('.') {
182                 return None;
183             }
184             parts.push(s);
185         }
186         (!parts.is_empty()).then(|| parts.join("/"))
187     }
188 
189     pub fn files(&self) -> &BTreeMap<String, Entry> {
190         &self.files
191     }
192 
193     pub fn entry(&self, path: &str) -> Option<&Entry> {
194         self.files.get(path)
195     }
196 
197     pub fn note(&self, path: &str) -> Option<&Note> {
198         self.files.get(path)?.note.as_ref()
199     }
200 
201     /// Markdown notes, in path order.
202     pub fn notes(&self) -> impl Iterator<Item = (&str, &Note)> {
203         self.documents().filter(|(p, _)| FileKind::of(p) == FileKind::Note)
204     }
205 
206     /// Notes and canvases: every file that can hold links.
207     pub fn documents(&self) -> impl Iterator<Item = (&str, &Note)> {
208         self.files.iter().filter_map(|(p, e)| Some((p.as_str(), e.note.as_ref()?)))
209     }
210 
211     /// Re-read the files at these absolute paths (as a watcher reports
212     /// them): changed ones are re-parsed, vanished ones dropped, a directory
213     /// that appeared is walked. Paths outside the vault or in hidden folders
214     /// are ignored.
215     pub fn apply_changes(&mut self, paths: &[PathBuf]) -> Changes {
216         let mut changes = Changes::default();
217         for abs in paths {
218             let Some(rel) = self.rel(abs) else { continue };
219             match std::fs::metadata(abs) {
220                 Ok(meta) if meta.is_dir() => {
221                     if let Ok(found) = walk(&self.root, abs) {
222                         for (rel, abs) in found {
223                             self.upsert(&rel, &abs, &mut changes);
224                         }
225                     }
226                 }
227                 Ok(_) => self.upsert(&rel, abs, &mut changes),
228                 Err(_) => {
229                     // A file, or a directory that took files with it.
230                     let prefix = format!("{rel}/");
231                     let gone: Vec<String> = self
232                         .files
233                         .keys()
234                         .filter(|k| **k == rel || k.starts_with(&prefix))
235                         .cloned()
236                         .collect();
237                     for k in gone {
238                         self.files.remove(&k);
239                         changes.removed.push(k);
240                     }
241                 }
242             }
243         }
244         if !changes.is_empty() {
245             changes.updated.sort();
246             changes.updated.dedup();
247             changes.removed.sort();
248             changes.removed.dedup();
249             self.cache_dirty = true;
250             self.relink();
251         }
252         changes
253     }
254 
255     fn upsert(&mut self, rel: &str, abs: &Path, changes: &mut Changes) {
256         let Ok(meta) = std::fs::metadata(abs) else { return };
257         let (mtime, size) = stamp(&meta);
258         if let Some(e) = self.files.get(rel) {
259             if e.mtime == mtime && e.size == size {
260                 return;
261             }
262         }
263         self.files.insert(rel.to_string(), read_entry(abs, rel, mtime, size));
264         changes.updated.push(rel.to_string());
265     }
266 
267     /// Rebuild the name maps, every link's resolution and the backlinks.
268     fn relink(&mut self) {
269         self.by_name.clear();
270         self.by_lower.clear();
271         for path in self.files.keys() {
272             self.by_name.entry(name_key(path)).or_default().push(path.clone());
273             self.by_lower.insert(path.to_lowercase(), path.clone());
274         }
275         // Resolution only reads the maps, so it spreads across threads.
276         let docs: Vec<(&str, &Note)> = self.documents().collect();
277         let per_doc = par_map(&docs, |(source, note)| {
278             let targets: Vec<Option<String>> =
279                 note.links.iter().map(|l| self.resolve(Some(source), l)).collect();
280             Some((source.to_string(), targets))
281         });
282         let mut resolved = HashMap::new();
283         let mut backlinks: HashMap<String, Vec<(String, usize)>> = HashMap::new();
284         for (source, targets) in per_doc {
285             for (i, t) in targets.iter().enumerate() {
286                 if let Some(t) = t {
287                     backlinks.entry(t.clone()).or_default().push((source.clone(), i));
288                 }
289             }
290             resolved.insert(source, targets);
291         }
292         self.resolved = resolved;
293         self.backlinks = backlinks;
294     }
295 
296     /// Where a link in `from` points, following Obsidian's rules: an exact
297     /// vault path first (relative to the note for a markdown link), then
298     /// the file whose name matches, preferring one in the linking note's
299     /// own folder and then the shortest path. Case-insensitive throughout,
300     /// as Obsidian is.
301     pub fn resolve(&self, from: Option<&str>, link: &Link) -> Option<String> {
302         let relative_first = link.kind == LinkKind::Markdown;
303         self.resolve_path(from, &link.target, relative_first)
304     }
305 
306     /// Resolve link text as a user would type it (`Note`, `folder/Note`,
307     /// `Note#Heading`); the subpath is ignored.
308     pub fn resolve_text(&self, from: Option<&str>, text: &str) -> Option<String> {
309         let path = text.split('#').next().unwrap_or(text).trim();
310         self.resolve_path(from, path, false)
311     }
312 
313     fn resolve_path(&self, from: Option<&str>, target: &str, relative_first: bool) -> Option<String> {
314         let target = target.trim();
315         if target.is_empty() {
316             return None;
317         }
318         let from_dir = from.map(parent).unwrap_or("");
319         let explicit_rel = target.starts_with("./") || target.starts_with("../");
320         // A leading `/` means the vault root, never the note's folder.
321         let rooted_only = target.starts_with('/');
322         let mut candidates = vec![target.to_string()];
323         if FileKind::of(target) != FileKind::Note {
324             candidates.push(format!("{target}.md"));
325         }
326         for c in &candidates {
327             let rooted = normalize("", c.trim_start_matches('/'));
328             let relative = normalize(from_dir, c);
329             let relative = if rooted_only { None } else { relative };
330             let order = if relative_first || explicit_rel {
331                 [relative, rooted]
332             } else {
333                 [rooted, relative]
334             };
335             for p in order.into_iter().flatten() {
336                 if let Some(hit) = self.by_lower.get(&p.to_lowercase()) {
337                     return Some(hit.clone());
338                 }
339             }
340         }
341         if explicit_rel {
342             return None;
343         }
344         // By name, with any folder part of the link as a path suffix.
345         let clean = target.trim_start_matches('/');
346         let lower = clean.to_lowercase();
347         let lower_md = format!("{lower}.md");
348         let mut hits: Vec<&String> = self
349             .by_name
350             .get(&name_key(clean))?
351             .iter()
352             .filter(|p| {
353                 let p = p.to_lowercase();
354                 [&lower, &lower_md].iter().any(|t| {
355                     p == **t || (p.ends_with(t.as_str()) && p[..p.len() - t.len()].ends_with('/'))
356                 })
357             })
358             .collect();
359         hits.sort_by(|a, b| {
360             let same_a = parent(a) == from_dir;
361             let same_b = parent(b) == from_dir;
362             same_b.cmp(&same_a).then(a.len().cmp(&b.len())).then(a.cmp(b))
363         });
364         hits.first().map(|p| (*p).clone())
365     }
366 
367     /// Each outgoing link of `path` with the file it resolves to.
368     pub fn outgoing(&self, path: &str) -> Vec<(&Link, Option<&str>)> {
369         let Some(note) = self.note(path) else { return Vec::new() };
370         let resolved = self.resolved.get(path);
371         note.links
372             .iter()
373             .enumerate()
374             .map(|(i, l)| (l, resolved.and_then(|r| r.get(i)?.as_deref())))
375             .collect()
376     }
377 
378     /// Every link that points at `path`, in source-path then line order.
379     pub fn backlinks(&self, path: &str) -> Vec<Backlink<'_>> {
380         let mut out: Vec<Backlink> = self
381             .backlinks
382             .get(path)
383             .into_iter()
384             .flatten()
385             .filter_map(|(source, i)| {
386                 let (source, entry) = self.files.get_key_value(source)?;
387                 Some(Backlink { source, link: entry.note.as_ref()?.links.get(*i)? })
388             })
389             .collect();
390         out.sort_by(|a, b| a.source.cmp(b.source).then(a.link.span.start.cmp(&b.link.span.start)));
391         out
392     }
393 
394     /// Links that resolve to nothing, grouped by what they ask for
395     /// (lowercased, the way Obsidian merges `[[idea]]` and `[[Idea]]`).
396     pub fn unresolved(&self) -> BTreeMap<String, Vec<Backlink<'_>>> {
397         let mut out: BTreeMap<String, Vec<Backlink>> = BTreeMap::new();
398         for (source, note) in self.documents() {
399             let Some(resolved) = self.resolved.get(source) else { continue };
400             for (link, target) in note.links.iter().zip(resolved) {
401                 if target.is_none() {
402                     out.entry(link.target.to_lowercase())
403                         .or_default()
404                         .push(Backlink { source, link });
405                 }
406             }
407         }
408         out
409     }
410 
411     /// Tag → number of notes carrying it. Tags are case-insensitive; each
412     /// is shown in the casing first met. Nested tags count toward their
413     /// parents too (`#a/b` is also `#a`), as Obsidian's tag pane shows them.
414     pub fn tags(&self) -> Vec<(String, usize)> {
415         let mut counts: BTreeMap<String, (String, usize)> = BTreeMap::new();
416         for (_, note) in self.documents() {
417             let mut seen = std::collections::HashSet::new();
418             for tag in &note.tags {
419                 let parts: Vec<&str> = tag.name.split('/').collect();
420                 for n in 1..=parts.len() {
421                     let name = parts[..n].join("/");
422                     if seen.insert(name.to_lowercase()) {
423                         let e = counts.entry(name.to_lowercase()).or_insert((name, 0));
424                         e.1 += 1;
425                     }
426                 }
427             }
428         }
429         counts.into_values().collect()
430     }
431 
432     /// Documents carrying `tag` or one nested under it.
433     pub fn tagged(&self, tag: &str) -> Vec<&str> {
434         let want = tag.trim_start_matches('#').to_lowercase();
435         let nested = format!("{want}/");
436         self.documents()
437             .filter(|(_, n)| {
438                 n.tags.iter().any(|t| {
439                     let t = t.name.to_lowercase();
440                     t == want || t.starts_with(&nested)
441                 })
442             })
443             .map(|(p, _)| p)
444             .collect()
445     }
446 
447     /// Every task in the vault, in path then line order.
448     pub fn tasks(&self) -> impl Iterator<Item = (&str, &Task)> {
449         self.documents().flat_map(|(p, n)| n.tasks.iter().map(move |t| (p, t)))
450     }
451 
452     /// A note named on a command line or in a request: an exact path, a
453     /// path missing its `.md`, or link text resolved from the vault root.
454     pub fn lookup(&self, query: &str) -> Option<String> {
455         let q = query.trim().trim_start_matches('/');
456         if self.files.contains_key(q) {
457             return Some(q.to_string());
458         }
459         self.resolve_text(None, q)
460     }
461 
462     /// Write the cache if anything changed since it was read.
463     pub fn save_cache(&mut self) -> io::Result<()> {
464         if !self.cache_dirty {
465             return Ok(());
466         }
467         let path = cache_path(&self.root);
468         let file = CacheFile {
469             version: CACHE_VERSION,
470             root: self.root.to_string_lossy().into_owned(),
471             files: self.files.clone(),
472         };
473         let bytes = serde_json::to_vec(&file).map_err(io::Error::other)?;
474         crate::write::atomic_write(&path, &bytes)?;
475         self.cache_dirty = false;
476         Ok(())
477     }
478 
479     /// Record a file this process just wrote, so the watcher's echo of the
480     /// write is recognised as already applied.
481     pub(crate) fn refresh(&mut self, rels: &[String]) -> Changes {
482         let paths: Vec<PathBuf> = rels.iter().map(|r| self.abs(r)).collect();
483         self.apply_changes(&paths)
484     }
485 }
486 
487 /// Bumped whenever `Entry` or `Note` change shape; an old cache is then
488 /// ignored rather than misread.
489 const CACHE_VERSION: u32 = 1;
490 
491 #[derive(Serialize, Deserialize)]
492 struct CacheFile {
493     version: u32,
494     root: String,
495     files: BTreeMap<String, Entry>,
496 }
497 
498 fn cache_path(root: &Path) -> PathBuf {
499     let base = std::env::var_os("XDG_CACHE_HOME")
500         .map(PathBuf::from)
501         .filter(|p| p.is_absolute())
502         .unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache"));
503     // FNV-1a: std's hasher is not stable across Rust releases, and the
504     // cache file name must be.
505     let mut h: u64 = 0xcbf29ce484222325;
506     for b in root.to_string_lossy().bytes() {
507         h ^= b as u64;
508         h = h.wrapping_mul(0x100000001b3);
509     }
510     base.join("cce").join("vault").join(format!("{h:016x}.json"))
511 }
512 
513 fn load_cache(root: &Path) -> BTreeMap<String, Entry> {
514     let Ok(bytes) = std::fs::read(cache_path(root)) else { return BTreeMap::new() };
515     match serde_json::from_slice::<CacheFile>(&bytes) {
516         Ok(c) if c.version == CACHE_VERSION && Path::new(&c.root) == root => c.files,
517         Ok(_) => BTreeMap::new(),
518         Err(e) => {
519             log::warn!("ignoring unreadable vault cache: {e}");
520             BTreeMap::new()
521         }
522     }
523 }
524 
525 /// Run `f` over `items` on up to eight threads, keeping the `Some`
526 /// results. Small inputs stay on the calling thread.
527 pub(crate) fn par_map<I, T, F>(items: &[I], f: F) -> Vec<T>
528 where
529     I: Sync,
530     T: Send,
531     F: Fn(&I) -> Option<T> + Sync,
532 {
533     let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
534     if items.len() < 64 || threads < 2 {
535         return items.iter().filter_map(&f).collect();
536     }
537     let chunk = items.len().div_ceil(threads);
538     std::thread::scope(|s| {
539         let handles: Vec<_> = items
540             .chunks(chunk)
541             .map(|part| {
542                 let f = &f;
543                 s.spawn(move || part.iter().filter_map(f).collect::<Vec<T>>())
544             })
545             .collect();
546         handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect()
547     })
548 }
549 
550 fn stamp(meta: &std::fs::Metadata) -> (u64, u64) {
551     let mtime = meta
552         .modified()
553         .ok()
554         .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
555         .map(|d| d.as_nanos() as u64)
556         .unwrap_or(0);
557     (mtime, meta.len())
558 }
559 
560 fn read_entry(abs: &Path, rel: &str, mtime: u64, size: u64) -> Entry {
561     let kind = FileKind::of(rel);
562     let note = match kind {
563         FileKind::Attachment => None,
564         _ => {
565             let text = match std::fs::read(abs) {
566                 Ok(bytes) => String::from_utf8(bytes)
567                     .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()),
568                 Err(e) => {
569                     log::warn!("cannot read {}: {e}", abs.display());
570                     String::new()
571                 }
572             };
573             Some(match kind {
574                 FileKind::Canvas => match canvas::from_str(&text) {
575                     Ok(c) => canvas::index(&c),
576                     Err(e) => {
577                         log::warn!("{rel}: {e}");
578                         Note::default()
579                     }
580                 },
581                 _ => parse::parse(&text),
582             })
583         }
584     };
585     Entry { kind, mtime, size, note }
586 }
587 
588 /// Every visible file under `dir`, as (vault-relative path, absolute path).
589 /// Hidden files and folders (`.obsidian`, `.trash`, `.git`) are skipped the
590 /// way Obsidian skips them. Symlinks are followed; walkdir breaks loops.
591 fn walk(root: &Path, dir: &Path) -> io::Result<Vec<(String, PathBuf)>> {
592     let mut out = Vec::new();
593     let walker = walkdir::WalkDir::new(dir)
594         .follow_links(true)
595         .into_iter()
596         .filter_entry(|e| e.depth() == 0 || !e.file_name().to_string_lossy().starts_with('.'));
597     for entry in walker {
598         let entry = match entry {
599             Ok(e) => e,
600             Err(e) => {
601                 log::warn!("walking the vault: {e}");
602                 continue;
603             }
604         };
605         if !entry.file_type().is_file() {
606             continue;
607         }
608         let Ok(rel) = entry.path().strip_prefix(root) else { continue };
609         let Some(rel) = rel.to_str() else { continue };
610         out.push((rel.replace('\\', "/"), entry.path().to_path_buf()));
611     }
612     Ok(out)
613 }
614 
615 fn extension(path: &str) -> Option<&str> {
616     let name = path.rsplit('/').next()?;
617     let dot = name.rfind('.')?;
618     (dot > 0).then(|| &name[dot + 1..])
619 }
620 
621 /// The folder part of a vault path (`""` at the root).
622 pub fn parent(path: &str) -> &str {
623     path.rfind('/').map(|i| &path[..i]).unwrap_or("")
624 }
625 
626 /// A note's display name: its file name without `.md`.
627 pub fn stem(path: &str) -> &str {
628     let name = path.rsplit('/').next().unwrap_or(path);
629     if FileKind::of(name) == FileKind::Note {
630         &name[..name.len() - 3]
631     } else {
632         name
633     }
634 }
635 
636 fn name_key(path: &str) -> String {
637     stem(path.trim_end_matches('/')).to_lowercase()
638 }
639 
640 /// Join `rel` onto `dir` and fold `.` and `..`; `None` if it climbs out of
641 /// the vault.
642 fn normalize(dir: &str, rel: &str) -> Option<String> {
643     let mut parts: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
644     for seg in rel.split('/') {
645         match seg {
646             "" | "." => {}
647             ".." => {
648                 parts.pop()?;
649             }
650             s => parts.push(s),
651         }
652     }
653     (!parts.is_empty()).then(|| parts.join("/"))
654 }
655 
656 #[cfg(test)]
657 mod tests {
658     use super::*;
659 
660     fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
661         let dir = tempfile::tempdir().unwrap();
662         for (path, text) in files {
663             let p = dir.path().join(path);
664             std::fs::create_dir_all(p.parent().unwrap()).unwrap();
665             std::fs::write(p, text).unwrap();
666         }
667         let index = Index::open(dir.path(), false).unwrap();
668         (dir, index)
669     }
670 
671     fn resolve(ix: &Index, from: &str, text: &str) -> Option<String> {
672         ix.resolve_text(Some(from), text)
673     }
674 
675     #[test]
676     fn resolution_rules() {
677         let (_d, ix) = vault(&[
678             ("Alpha.md", ""),
679             ("a/Dup.md", ""),
680             ("b/Dup.md", ""),
681             ("b/deep/Dup.md", ""),
682             ("a/Only.md", ""),
683             ("img/pic.png", ""),
684             ("Board.canvas", "{\"nodes\":[],\"edges\":[]}"),
685             (".obsidian/app.json", "{}"),
686             ("b/Linker.md", ""),
687         ]);
688         assert_eq!(resolve(&ix, "b/Linker.md", "alpha").as_deref(), Some("Alpha.md"));
689         assert_eq!(resolve(&ix, "b/Linker.md", "Alpha.md").as_deref(), Some("Alpha.md"));
690         assert_eq!(resolve(&ix, "b/Linker.md", "Only#Part").as_deref(), Some("a/Only.md"));
691         // Same folder wins, then the shortest path.
692         assert_eq!(resolve(&ix, "b/Linker.md", "Dup").as_deref(), Some("b/Dup.md"));
693         assert_eq!(resolve(&ix, "Alpha.md", "Dup").as_deref(), Some("a/Dup.md"));
694         // A folder part narrows by path suffix.
695         assert_eq!(resolve(&ix, "Alpha.md", "deep/Dup").as_deref(), Some("b/deep/Dup.md"));
696         assert_eq!(resolve(&ix, "Alpha.md", "b/Dup").as_deref(), Some("b/Dup.md"));
697         assert_eq!(resolve(&ix, "Alpha.md", "pic.png").as_deref(), Some("img/pic.png"));
698         assert_eq!(resolve(&ix, "Alpha.md", "Board.canvas").as_deref(), Some("Board.canvas"));
699         assert_eq!(resolve(&ix, "Alpha.md", "Board"), None);
700         assert_eq!(resolve(&ix, "Alpha.md", "Missing"), None);
701         assert!(ix.files().keys().all(|k| !k.starts_with('.')));
702     }
703 
704     #[test]
705     fn markdown_links_resolve_relative_first() {
706         let (_d, ix) = vault(&[
707             ("x.md", "root"),
708             ("sub/x.md", "sub"),
709             ("sub/n.md", "[r](x.md) [up](../x.md) [abs](/x.md) [sp](My%20File.md)"),
710             ("sub/My File.md", ""),
711         ]);
712         let out: Vec<_> = ix.outgoing("sub/n.md").into_iter().map(|(_, t)| t).collect();
713         assert_eq!(out, [Some("sub/x.md"), Some("x.md"), Some("x.md"), Some("sub/My File.md")]);
714     }
715 
716     #[test]
717     fn backlinks_unresolved_tags_tasks() {
718         let (_d, ix) = vault(&[
719             ("A.md", "[[B]] [[B#Sec|b]] [[Nope]] #proj/x\n- [ ] one\n- [x] two\n"),
720             ("B.md", "---\ntags: [proj]\n---\n[[A]] [[nope]]\n"),
721             ("C.canvas", "{\"nodes\":[{\"id\":\"n\",\"type\":\"file\",\"file\":\"B.md\",\"x\":0,\"y\":0,\"width\":1,\"height\":1}],\"edges\":[]}"),
722         ]);
723         let bl: Vec<_> = ix.backlinks("B.md").iter().map(|b| (b.source, b.link.line)).collect();
724         assert_eq!(bl, [("A.md", 0), ("A.md", 0), ("C.canvas", 0)]);
725         let un = ix.unresolved();
726         assert_eq!(un.keys().collect::<Vec<_>>(), ["nope"]);
727         assert_eq!(un["nope"].len(), 2);
728         assert_eq!(ix.tags(), [("proj".to_string(), 2), ("proj/x".to_string(), 1)]);
729         assert_eq!(ix.tagged("#proj"), ["A.md", "B.md"]);
730         let open: Vec<_> = ix.tasks().filter(|(_, t)| t.is_open()).map(|(p, t)| (p, t.text.as_str())).collect();
731         assert_eq!(open, [("A.md", "one")]);
732     }
733 
734     #[test]
735     fn changes_relink_the_vault() {
736         let (dir, mut ix) = vault(&[("A.md", "[[B]]"), ("old/C.md", "[[A]]")]);
737         assert!(ix.backlinks("B.md").is_empty());
738         std::fs::write(dir.path().join("B.md"), "[[A]]").unwrap();
739         let ch = ix.apply_changes(&[dir.path().join("B.md")]);
740         assert_eq!(ch.updated, ["B.md"]);
741         assert_eq!(ix.backlinks("B.md").len(), 1);
742         assert_eq!(ix.backlinks("A.md").len(), 2);
743 
744         std::fs::remove_dir_all(dir.path().join("old")).unwrap();
745         let ch = ix.apply_changes(&[dir.path().join("old")]);
746         assert_eq!(ch.removed, ["old/C.md"]);
747         assert_eq!(ix.backlinks("A.md").len(), 1);
748 
749         std::fs::create_dir_all(dir.path().join("new/deeper")).unwrap();
750         std::fs::write(dir.path().join("new/deeper/D.md"), "[[B]]").unwrap();
751         let ch = ix.apply_changes(&[dir.path().join("new")]);
752         assert_eq!(ch.updated, ["new/deeper/D.md"]);
753         assert_eq!(ix.backlinks("B.md").len(), 2);
754 
755         // Unchanged files and hidden paths are no-ops.
756         let ch = ix.apply_changes(&[dir.path().join("A.md"), dir.path().join(".obsidian/x.json")]);
757         assert!(ch.is_empty());
758     }
759 
760     #[test]
761     fn cache_is_reused_until_a_file_changes() {
762         let cache = tempfile::tempdir().unwrap();
763         // The only test that reads XDG_CACHE_HOME; every other index test
764         // opens with the cache off.
765         std::env::set_var("XDG_CACHE_HOME", cache.path());
766         let (dir, _) = vault(&[("A.md", "[[B]]"), ("B.md", "x")]);
767         let mut first = Index::open(dir.path(), true).unwrap();
768         assert_eq!((first.stats.parsed, first.stats.reused), (2, 0));
769         first.save_cache().unwrap();
770         let second = Index::open(dir.path(), true).unwrap();
771         assert_eq!((second.stats.parsed, second.stats.reused), (0, 2));
772         assert_eq!(second.backlinks("B.md").len(), 1);
773         std::fs::write(dir.path().join("B.md"), "changed, and longer").unwrap();
774         let third = Index::open(dir.path(), true).unwrap();
775         assert_eq!((third.stats.parsed, third.stats.reused), (1, 1));
776     }
777 
778     #[test]
779     fn helpers() {
780         assert_eq!(normalize("a/b", "../c.md").as_deref(), Some("a/c.md"));
781         assert_eq!(normalize("", "../c.md"), None);
782         assert_eq!(stem("x/Note.md"), "Note");
783         assert_eq!(stem("x/pic.png"), "pic.png");
784         assert_eq!(FileKind::of("a/B.MD"), FileKind::Note);
785         assert_eq!(FileKind::of(".md"), FileKind::Attachment);
786     }
787 }