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 ¬e.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 }