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

commit8e4747fe87cd4661c16a784c0d38389d2c8cb4ea
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 15:10
cce-vault: the shared notes vault crate (Obsidian-on-cce milestone 1)

A folder of Markdown notes, canvases and attachments, kept byte-compatible
with Obsidian, and everything about it that is not UI:

- parse: frontmatter properties, wikilinks/embeds/markdown links with exact
  byte spans, tags, headings, block ids, tasks; pulldown-cmark marks code,
  Obsidian syntax is scanned from the raw bytes around it
- index: every file, Obsidian's link resolution, backlinks, unresolved
  links, tags, tasks; whole-vault relink per change batch; opt-in parse cache
- watch: recursive notify watcher, 150 ms debounce with a 1 s cap
- search: fuzzy names (best alignment, not leftmost), full text, unlinked
  mentions
- write: atomic writes, create/append, task toggling, rename with link
  rewrite across notes and canvases
- canvas: JSON Canvas kept as ordered raw fields, written byte-exact
- daily: Obsidian's daily-note settings, moment.js formats, templates
- config: --vault, $CCE_VAULT, or vault { path } in config.kdl
- the cce-vault CLI over all of it

No cce-ui dependency and no daemon: each app embeds an Index and a watcher.
serde_json's preserve_order is deliberately NOT used -- it would unify
across the workspace and reorder every other crate's JSON maps.

5,000-note benchmark vault: 36 ms parse + 21 ms relink; CLAUDE.md has the
numbers and the generator (bench/gen-vault.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 .gitignore         |   1 +
 CLAUDE.md          | 105 +++++++
 Cargo.lock         | 847 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 Cargo.toml         |  30 ++
 Makefile           |  17 ++
 bench/gen-vault.py |  26 ++
 src/canvas.rs      | 279 ++++++++++++++++++
 src/config.rs      | 113 +++++++
 src/daily.rs       | 275 +++++++++++++++++
 src/index.rs       | 787 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/lib.rs         |  45 +++
 src/main.rs        | 438 +++++++++++++++++++++++++++
 src/parse.rs       | 799 ++++++++++++++++++++++++++++++++++++++++++++++++++
 src/search.rs      | 378 ++++++++++++++++++++++++
 src/watch.rs       | 124 ++++++++
 src/write.rs       | 597 +++++++++++++++++++++++++++++++++++++
 16 files changed, 4861 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ee3cabd
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,105 @@
+# cce-vault
+
+The notes vault shared by cce apps: a folder of Markdown notes, canvases
+and attachments, kept **byte-compatible with Obsidian** so the real app can
+keep working on the same files. This crate is everything about that folder
+that is not UI. It is milestone 1 of the Obsidian-on-cce plan; the apps
+that sit on it (`cce-notes`, a vault mode in `cce-graph`, note cards on
+`cce-grid`, vault tasks in `cce-list`) come later.
+
+It has **no cce-ui dependency** on purpose. The `cce-vault` CLI, tests and
+any non-GUI tool use it without a Wayland stack. There is **no daemon**:
+each app embeds an `Index` and a `VaultWatcher` and applies the watcher's
+batches on its own event loop. The files are the source of truth.
+
+## Layout
+
+| Module | What it owns |
+| --- | --- |
+| `parse` | One note → properties, links, tags, headings, block ids, tasks, all with byte spans |
+| `index` | Every file, link resolution, backlinks, unresolved links, tags, tasks; the optional parse cache |
+| `watch` | Recursive `notify` watcher, debounced (150 ms quiet, 1 s cap), hidden paths dropped |
+| `search` | Fuzzy names (quick switcher), full-text scan, unlinked mentions |
+| `write` | `atomic_write`, create/append, `set_task`, `rename` with link rewrite |
+| `canvas` | JSON Canvas as `serde_json::Value`, written byte-exact with Obsidian |
+| `daily` | Daily notes from `.obsidian/daily-notes.json`; moment.js formats; template variables |
+| `config` | The vault root: `--vault`, `$CCE_VAULT`, then `vault { path "…" }` in `~/.config/cce/config.kdl` |
+| `main.rs` | The `cce-vault` CLI (`cce-vault --help`) |
+
+## Invariants — each was a design decision, keep them
+
+- **Obsidian syntax is scanned from raw bytes; pulldown-cmark only marks
+  code.** pulldown decides what is code/math and finds headings and inline
+  `[text](dest)` links. Wikilinks, embeds, `#tags`, `^block` ids,
+  `%%comments%%` and task statuses are scanned by hand outside those
+  ranges. That gives every link an exact `target_span`, which is what a
+  rename rewrites. pulldown's own `ENABLE_WIKILINKS` stays **off**: it knows
+  neither `![[embed]]` nor the `\|` escape inside tables.
+- **Every write re-reads and re-parses the file it edits.** The index may
+  be a watcher batch behind (Obsidian or a sync client wrote a second ago),
+  and a span from a stale parse cuts the wrong bytes. `set_task` and
+  `rename` both do this. Do not "optimise" it into using the index's copy.
+- **Resolution follows Obsidian**, case-insensitive throughout: an exact
+  vault path first (relative to the note first for a *markdown* link; a
+  leading `/` is always the vault root), then by file name with the link's
+  folder part as a path suffix, preferring the linking note's own folder,
+  then the shortest path, then alphabetical. Aliases do **not** resolve
+  links, as in Obsidian. `[[Beta]]` and `[[Beta.md]]` both mean `Beta.md`;
+  every other file keeps its extension (`[[pic.png]]`, `[[Board.canvas]]`).
+- **Relink is whole-vault after any change batch.** Adding `Beta.md` must
+  turn every unresolved `[[Beta]]` anywhere into a backlink, and it costs
+  ~21 ms for 100,000 links. Don't make it incremental without a benchmark.
+- **Hidden means ignored**: any path component starting with `.`
+  (`.obsidian`, `.trash`, `.git`, and this crate's own `.name.PID.cce-tmp`
+  write temps). The walk, `Index::rel` and the watcher all apply it.
+  `.obsidian/` is read (daily-note settings) and **never written**.
+- **Canvases are `Value`s with `preserve_order`, never typed structs**, and
+  `canvas::to_string` reproduces Obsidian's layout (tab indent, one compact
+  node per line, no trailing newline). A rename that touches a canvas must
+  diff as the one field it changed. The ignored test
+  `real_canvases_round_trip` checks real files:
+  `CCE_CANVAS_DIR=<vault> cargo test -- --ignored`.
+- **Rename writes links in the shortest form that still reaches the file**,
+  and in full when the link was written in full or the short name would be
+  captured by another file. `.md` suffixes, subpaths, display text and
+  embed `!` are kept; markdown links stay relative or rooted as they were,
+  and stay angle-bracketed or %-encoded as they were. A note moved to
+  another folder gets its own relative links re-rooted, and any short
+  wikilink whose target would change under the same-folder rule is pinned
+  to its old target (`pin_moved_links`).
+
+## Performance (measured 2026-09-30)
+
+A generated vault of 5,000 notes (40 MB, 100,000 links, 7,500 tasks) on the
+20-core laptop, warm page cache, release build:
+
+| Step | Time |
+| --- | --- |
+| Parse (8 threads) | 36 ms |
+| Relink (8 threads) | 21 ms |
+| Load the JSON parse cache instead of parsing | 47 ms (18 MB file) |
+| A whole CLI call (`backlinks`, `find`, `rename --dry-run`) | ~90 ms |
+| `search`, `mentions` on top of the open | +15 ms, +35 ms |
+
+So the parse cache (`Index::open(root, true)`, CLI `--cache`) is **opt-in**.
+It can only pay off where reading the files is the slow part (a cold page
+cache at login, a network filesystem), and that has not been measured.
+Regenerate the test vault with `bench/gen-vault.py <dir>`, then time
+`cce-vault --vault <dir> stats` (`RUST_LOG=debug` prints the phase split).
+It lives in `bench/`, not `scripts/`: ccebuild installs every crate's
+`scripts/` into `~/.local/bin`.
+
+## Build and test
+
+```sh
+cargo test -p cce-vault                  # unit tests, incl. a real notify watcher
+cargo build --release -p cce-vault
+cce-vault --vault <dir> stats            # or set CCE_VAULT
+```
+
+Test writes against a **copy** of a vault (`cp -a`), never the live one:
+the live vault syncs to other devices.
+
+This crate is a workspace member and must still build standalone (its own
+`Cargo.lock` is committed). Install the CLI with
+`ccebuild install --no-build cce-vault` after a release build.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..f1380b3
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,847 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "arraydeque"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cc"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cce-vault"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "indexmap",
+ "kdl",
+ "log",
+ "notify",
+ "percent-encoding",
+ "pulldown-cmark",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "walkdir",
+ "yaml-rust2",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "inotify"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kdl"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117"
+dependencies = [
+ "miette",
+ "nom",
+ "thiserror",
+]
+
+[[package]]
+name = "kqueue"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miette"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
+dependencies = [
+ "miette-derive",
+ "once_cell",
+ "thiserror",
+ "unicode-width",
+]
+
+[[package]]
+name = "miette-derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
+dependencies = [
+ "bitflags",
+ "memchr",
+ "unicase",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "yaml-rust2"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
+dependencies = [
+ "arraydeque",
+ "encoding_rs",
+ "hashlink",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..4902741
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "cce-vault"
+version = "0.1.0"
+edition = "2021"
+description = "The notes vault shared by cce apps: Obsidian-compatible Markdown parsing, link index, watcher, search and writes"
+
+[lib]
+name = "cce_vault"
+path = "src/lib.rs"
+
+[[bin]]
+name = "cce-vault"
+path = "src/main.rs"
+
+
+[dependencies]
+chrono = "0.4.45"
+indexmap = { version = "2", features = ["serde"] }
+kdl = "4.6"
+log = "0.4"
+notify = "8.2"
+percent-encoding = "2"
+pulldown-cmark = { version = "0.13.4", default-features = false }
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = { version = "1.0.151", features = ["raw_value"] }
+walkdir = "2.5"
+yaml-rust2 = "0.13.0"
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..7ec67d8
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,17 @@
+.PHONY: build install run clean
+
+build:
+	cargo build --release
+
+# Binaries, helper scripts and user units are enumerated by ccebuild from
+# cargo metadata, so extra [[bin]] targets are picked up without being named
+# here — hand-listing them is what left crates shipping incomplete for weeks.
+install: build
+	@command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+	ccebuild install --no-build cce-vault
+
+run:
+	cargo run
+
+clean:
+	cargo clean
diff --git a/bench/gen-vault.py b/bench/gen-vault.py
new file mode 100755
index 0000000..418f7c7
--- /dev/null
+++ b/bench/gen-vault.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+"""Generate a synthetic vault for timing cce-vault (see CLAUDE.md, Performance).
+
+usage: bench/gen-vault.py DIR [NOTES]    (default 5000 notes, ~40 MB)
+
+Each note has frontmatter tags and an alias, 20 random [[Note N]] links, an
+inline tag, twelve paragraphs of filler, three tasks and a fenced code block
+holding a link that must NOT be indexed. Seeded, so runs are comparable.
+"""
+import os, random, sys
+
+root = sys.argv[1]
+n = int(sys.argv[2]) if len(sys.argv) > 2 else 5000
+random.seed(1)
+words = ("alpha beta gamma delta rust wayland vulkan note idea project meeting "
+         "design canvas graph index link task daily review plan").split()
+for i in range(n):
+    folder = f"f{i % 40}"
+    os.makedirs(f"{root}/{folder}", exist_ok=True)
+    links = " ".join(f"[[Note {random.randrange(n)}]]" for _ in range(20))
+    body = "\n\n".join(" ".join(random.choice(words) for _ in range(60)) for _ in range(12))
+    tasks = "\n".join(f"- [{random.choice(' x')}] {random.choice(words)} {random.choice(words)}"
+                      for _ in range(3))
+    with open(f"{root}/{folder}/Note {i}.md", "w") as f:
+        f.write(f"---\ntags: [{random.choice(words)}]\naliases: [N{i}]\n---\n# Note {i}\n{links}\n"
+                f"#{random.choice(words)}\n{body}\n{tasks}\n```\n[[NotALink]]\n```\n")
diff --git a/src/canvas.rs b/src/canvas.rs
new file mode 100644
index 0000000..ff6fd81
--- /dev/null
+++ b/src/canvas.rs
@@ -0,0 +1,279 @@
+//! JSON Canvas (<https://jsoncanvas.org>), the format behind Obsidian's
+//! `.canvas` files and — from milestone 5 — the cce desktop's own boards.
+//!
+//! A canvas is held as ordered fields whose values stay as the raw JSON
+//! text they were read from (`serde_json::value::RawValue`), never as typed
+//! structs or a parsed `Value`. A write must give back every key it did not
+//! touch, in its original order and with its original number formatting,
+//! including keys a newer Obsidian adds. [`to_string`] reproduces
+//! Obsidian's layout byte for byte (tabs, one compact node per line, no
+//! trailing newline), so a rename touching a canvas shows up in a diff as
+//! the one field it changed.
+//!
+//! Not `serde_json`'s `preserve_order` feature: features unify across the
+//! workspace, and turning it on here would switch every other crate's JSON
+//! maps from sorted to insertion order in a `--workspace` build — the
+//! compositor's `state.json` included.
+
+use serde::de::{Deserialize, Deserializer, MapAccess, Visitor};
+use serde_json::value::RawValue;
+
+use crate::parse::{self, Link, LinkKind, Note};
+
+#[derive(Debug)]
+pub struct CanvasError(pub String);
+
+impl std::fmt::Display for CanvasError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "invalid canvas: {}", self.0)
+    }
+}
+
+impl std::error::Error for CanvasError {}
+
+/// One JSON object as ordered (key, raw value) pairs.
+#[derive(Debug, Clone)]
+pub struct Object(Vec<(String, Box<RawValue>)>);
+
+impl<'de> Deserialize<'de> for Object {
+    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
+        struct V;
+        impl<'de> Visitor<'de> for V {
+            type Value = Object;
+            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+                f.write_str("a JSON object")
+            }
+            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Object, A::Error> {
+                let mut out = Vec::new();
+                while let Some((k, v)) = map.next_entry::<String, Box<RawValue>>()? {
+                    out.push((k, v));
+                }
+                Ok(Object(out))
+            }
+        }
+        d.deserialize_map(V)
+    }
+}
+
+impl Object {
+    fn raw(&self, key: &str) -> Option<&RawValue> {
+        self.0.iter().find(|(k, _)| k == key).map(|(_, v)| &**v)
+    }
+
+    /// A string field's value.
+    pub fn str(&self, key: &str) -> Option<String> {
+        serde_json::from_str::<String>(self.raw(key)?.get()).ok()
+    }
+
+    /// Set a string field in place, or append it when missing.
+    pub fn set_str(&mut self, key: &str, value: &str) {
+        let raw = serde_json::value::to_raw_value(value).expect("a string always serialises");
+        match self.0.iter_mut().find(|(k, _)| k == key) {
+            Some((_, v)) => *v = raw,
+            None => self.0.push((key.to_string(), raw)),
+        }
+    }
+
+    fn write_compact(&self, out: &mut String) {
+        out.push('{');
+        for (i, (k, v)) in self.0.iter().enumerate() {
+            if i > 0 {
+                out.push(',');
+            }
+            out.push_str(&serde_json::to_string(k).unwrap_or_default());
+            out.push(':');
+            out.push_str(v.get());
+        }
+        out.push('}');
+    }
+}
+
+#[derive(Debug, Clone)]
+enum Field {
+    /// An array of objects (`nodes`, `edges`): written one per line.
+    Objects(Vec<Object>),
+    /// Anything else, kept exactly as read.
+    Raw(Box<RawValue>),
+}
+
+#[derive(Debug, Clone)]
+pub struct Canvas {
+    fields: Vec<(String, Field)>,
+}
+
+pub fn from_str(src: &str) -> Result<Canvas, CanvasError> {
+    let top: Object = serde_json::from_str(src).map_err(|e| CanvasError(e.to_string()))?;
+    let fields = top
+        .0
+        .into_iter()
+        .map(|(k, v)| {
+            let field = match serde_json::from_str::<Vec<Object>>(v.get()) {
+                Ok(list) if v.get().trim_start().starts_with('[') => Field::Objects(list),
+                _ => Field::Raw(v),
+            };
+            (k, field)
+        })
+        .collect();
+    Ok(Canvas { fields })
+}
+
+/// Obsidian's layout: `JSON.stringify` with a tab indent at the top level
+/// and each array element compact on its own line.
+pub fn to_string(canvas: &Canvas) -> String {
+    let mut out = String::from("{\n");
+    let n = canvas.fields.len();
+    for (i, (key, field)) in canvas.fields.iter().enumerate() {
+        out.push('\t');
+        out.push_str(&serde_json::to_string(key).unwrap_or_default());
+        out.push(':');
+        match field {
+            Field::Objects(items) if !items.is_empty() => {
+                out.push_str("[\n");
+                for (j, item) in items.iter().enumerate() {
+                    out.push_str("\t\t");
+                    item.write_compact(&mut out);
+                    if j + 1 < items.len() {
+                        out.push(',');
+                    }
+                    out.push('\n');
+                }
+                out.push_str("\t]");
+            }
+            Field::Objects(_) => out.push_str("[]"),
+            Field::Raw(v) => out.push_str(v.get()),
+        }
+        if i + 1 < n {
+            out.push(',');
+        }
+        out.push('\n');
+    }
+    out.push('}');
+    out
+}
+
+impl Canvas {
+    pub fn nodes(&self) -> impl Iterator<Item = &Object> {
+        self.fields.iter().filter(|(k, _)| k == "nodes").flat_map(|(_, f)| match f {
+            Field::Objects(list) => list.iter(),
+            Field::Raw(_) => Default::default(),
+        })
+    }
+
+    pub fn nodes_mut(&mut self) -> impl Iterator<Item = &mut Object> {
+        self.fields.iter_mut().filter(|(k, _)| k == "nodes").flat_map(|(_, f)| match f {
+            Field::Objects(list) => list.iter_mut(),
+            Field::Raw(_) => Default::default(),
+        })
+    }
+}
+
+/// A canvas as the index sees it: `file` nodes are embeds of that file,
+/// and `text` nodes are small notes whose links, tags and tasks count.
+/// Link spans on text-node links are offsets into that node's `text`.
+pub fn index(canvas: &Canvas) -> Note {
+    let mut note = Note::default();
+    for node in canvas.nodes() {
+        let id = node.str("id").unwrap_or_default();
+        match node.str("type").as_deref() {
+            Some("file") => {
+                let Some(file) = node.str("file") else { continue };
+                note.links.push(Link {
+                    kind: LinkKind::CanvasFile,
+                    embed: true,
+                    target: file,
+                    subpath: node
+                        .str("subpath")
+                        .map(|s| s.trim_start_matches('#').to_string())
+                        .filter(|s| !s.is_empty()),
+                    display: None,
+                    span: 0..0,
+                    target_span: 0..0,
+                    line: 0,
+                    node: Some(id),
+                });
+            }
+            Some("text") => {
+                let text = node.str("text").unwrap_or_default();
+                let inner = parse::parse(&text);
+                note.links.extend(inner.links.into_iter().map(|mut l| {
+                    l.node = Some(id.clone());
+                    l
+                }));
+                note.tags.extend(inner.tags);
+                note.tasks.extend(inner.tasks);
+            }
+            _ => {}
+        }
+    }
+    note
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const SAMPLE: &str = "{\n\t\"nodes\":[\n\
+        \t\t{\"id\":\"a1\",\"x\":18,\"y\":-193,\"width\":250,\"height\":60,\"type\":\"text\",\"text\":\"see [[Alpha]] #idea\\n- [ ] do it\"},\n\
+        \t\t{\"id\":\"b2\",\"type\":\"file\",\"file\":\"notes/Beta.md\",\"subpath\":\"#Part\",\"x\":0.50,\"y\":1e2,\"width\":400,\"height\":400,\"color\":\"4\"},\n\
+        \t\t{\"id\":\"c3\",\"type\":\"link\",\"url\":\"https://example.com\",\"x\":0,\"y\":0,\"width\":1,\"height\":1,\"future\":{\"b\":1,\"a\":[2]}}\n\
+        \t],\n\t\"edges\":[\n\
+        \t\t{\"id\":\"e1\",\"fromNode\":\"a1\",\"fromSide\":\"right\",\"toNode\":\"b2\",\"toSide\":\"left\"}\n\
+        \t],\n\t\"zeta\":{\"keep\": \"as written\"}\n}";
+
+    #[test]
+    fn round_trip_is_byte_exact() {
+        let canvas = from_str(SAMPLE).unwrap();
+        assert_eq!(to_string(&canvas), SAMPLE);
+        let empty = "{\n\t\"nodes\":[],\n\t\"edges\":[]\n}";
+        assert_eq!(to_string(&from_str(empty).unwrap()), empty);
+        assert!(from_str("[1]").is_err());
+    }
+
+    /// Every `.canvas` under `$CCE_CANVAS_DIR` must survive a read and a
+    /// write unchanged: `CCE_CANVAS_DIR=~/vault cargo test -- --ignored`.
+    #[test]
+    #[ignore]
+    fn real_canvases_round_trip() {
+        let dir = std::env::var("CCE_CANVAS_DIR").expect("set CCE_CANVAS_DIR");
+        let mut checked = 0;
+        for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
+            if entry.path().extension().is_some_and(|e| e == "canvas") {
+                let text = std::fs::read_to_string(entry.path()).unwrap();
+                assert_eq!(to_string(&from_str(&text).unwrap()), text, "{}", entry.path().display());
+                checked += 1;
+            }
+        }
+        assert!(checked > 0, "no canvases found");
+    }
+
+    #[test]
+    fn index_file_and_text_nodes() {
+        let note = index(&from_str(SAMPLE).unwrap());
+        let got: Vec<_> = note
+            .links
+            .iter()
+            .map(|l| (l.target.as_str(), l.kind, l.node.as_deref(), l.subpath.as_deref()))
+            .collect();
+        assert_eq!(
+            got,
+            [
+                ("Alpha", LinkKind::Wiki, Some("a1"), None),
+                ("notes/Beta.md", LinkKind::CanvasFile, Some("b2"), Some("Part")),
+            ]
+        );
+        assert_eq!(note.tags[0].name, "idea");
+        assert_eq!(note.tasks[0].text, "do it");
+    }
+
+    #[test]
+    fn edit_changes_only_that_field() {
+        let mut canvas = from_str(SAMPLE).unwrap();
+        for node in canvas.nodes_mut() {
+            if node.str("file").as_deref() == Some("notes/Beta.md") {
+                node.set_str("file", "notes/Gamma \"quoted\".md");
+            }
+        }
+        let expected = SAMPLE.replace("notes/Beta.md", "notes/Gamma \\\"quoted\\\".md");
+        assert_eq!(to_string(&canvas), expected);
+    }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..5172ace
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,113 @@
+//! Where the vault is.
+//!
+//! In order: an explicit path (a `--vault` flag), `$CCE_VAULT`, then the
+//! shared cce config, `~/.config/cce/config.kdl`:
+//!
+//! ```kdl
+//! vault {
+//!     path "~/Dropbox/Apps/remotely-save/Vault 1"
+//! }
+//! ```
+//!
+//! The config is read with the `kdl` crate directly rather than through
+//! cce-ui's loader, so that this crate stays free of the toolkit and a
+//! shell tool or test can use it without a Wayland stack.
+
+use std::path::{Path, PathBuf};
+
+#[derive(Debug)]
+pub enum ConfigError {
+    /// No vault is configured anywhere.
+    Unset(PathBuf),
+    /// A vault is configured but is not a directory.
+    Missing(PathBuf),
+    Parse(PathBuf, String),
+}
+
+impl std::fmt::Display for ConfigError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ConfigError::Unset(cfg) => write!(
+                f,
+                "no vault configured: set CCE_VAULT, pass --vault, or add `vault {{ path \"…\" }}` to {}",
+                cfg.display()
+            ),
+            ConfigError::Missing(p) => write!(f, "vault is not a directory: {}", p.display()),
+            ConfigError::Parse(cfg, e) => write!(f, "{}: {e}", cfg.display()),
+        }
+    }
+}
+
+impl std::error::Error for ConfigError {}
+
+pub fn config_path() -> PathBuf {
+    std::env::var_os("XDG_CONFIG_HOME")
+        .map(PathBuf::from)
+        .filter(|p| p.is_absolute())
+        .unwrap_or_else(|| home().join(".config"))
+        .join("cce")
+        .join("config.kdl")
+}
+
+fn home() -> PathBuf {
+    PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
+}
+
+fn expand(path: &str) -> PathBuf {
+    match path.strip_prefix("~/") {
+        Some(rest) => home().join(rest),
+        None if path == "~" => home(),
+        None => PathBuf::from(path),
+    }
+}
+
+/// The `vault` block's path from a config document, if it names one.
+/// Accepts `vault { path "…" }` and the one-line `vault path="…"`.
+pub fn path_from_kdl(text: &str) -> Result<Option<PathBuf>, String> {
+    let doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?;
+    let Some(node) = doc.get("vault") else { return Ok(None) };
+    let from_child = node.children().and_then(|c| c.get_arg("path")).and_then(|v| v.as_string());
+    let from_prop = node.get("path").and_then(|e| e.value().as_string());
+    Ok(from_child.or(from_prop).map(expand))
+}
+
+pub fn vault_root(explicit: Option<&Path>) -> Result<PathBuf, ConfigError> {
+    let cfg = config_path();
+    let chosen = if let Some(p) = explicit {
+        p.to_path_buf()
+    } else if let Some(p) = std::env::var_os("CCE_VAULT").filter(|v| !v.is_empty()) {
+        expand(&p.to_string_lossy())
+    } else {
+        let text = match std::fs::read_to_string(&cfg) {
+            Ok(t) => t,
+            Err(_) => return Err(ConfigError::Unset(cfg)),
+        };
+        match path_from_kdl(&text) {
+            Ok(Some(p)) => p,
+            Ok(None) => return Err(ConfigError::Unset(cfg)),
+            Err(e) => return Err(ConfigError::Parse(cfg, e)),
+        }
+    };
+    if chosen.is_dir() {
+        Ok(chosen)
+    } else {
+        Err(ConfigError::Missing(chosen))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn kdl_forms() {
+        let home = home();
+        assert_eq!(
+            path_from_kdl("layout { gap 4; }\nvault {\n    path \"~/Notes\"\n}\n").unwrap(),
+            Some(home.join("Notes"))
+        );
+        assert_eq!(path_from_kdl("vault path=\"/v\"").unwrap(), Some(PathBuf::from("/v")));
+        assert_eq!(path_from_kdl("other 1").unwrap(), None);
+        assert!(path_from_kdl("vault {").is_err());
+    }
+}
diff --git a/src/daily.rs b/src/daily.rs
new file mode 100644
index 0000000..f23b825
--- /dev/null
+++ b/src/daily.rs
@@ -0,0 +1,275 @@
+//! Daily notes, compatible with Obsidian's core plugin: the folder, file
+//! name format and template come from `.obsidian/daily-notes.json`, and the
+//! format is a moment.js format string (`YYYY-MM-DD`, `YYYY/MM/DD dddd`),
+//! so a vault shared with Obsidian finds the same file for the same day.
+//!
+//! Templates expand the core Templates plugin's variables: `{{title}}`,
+//! `{{date}}`, `{{time}}`, and `{{date:FORMAT}}` / `{{time:FORMAT}}`.
+
+use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, Timelike};
+use serde::Deserialize;
+
+use crate::index::{FileKind, Index};
+use crate::write::{atomic_write, WriteError};
+
+pub const DEFAULT_FORMAT: &str = "YYYY-MM-DD";
+
+#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
+pub struct DailyConfig {
+    #[serde(default)]
+    pub folder: String,
+    #[serde(default)]
+    pub format: String,
+    #[serde(default)]
+    pub template: String,
+}
+
+impl DailyConfig {
+    /// Obsidian's settings for this vault, or its defaults (vault root,
+    /// `YYYY-MM-DD`, no template) when there are none.
+    pub fn load(index: &Index) -> DailyConfig {
+        let path = index.root().join(".obsidian/daily-notes.json");
+        let mut cfg: DailyConfig = std::fs::read_to_string(path)
+            .ok()
+            .and_then(|t| serde_json::from_str(&t).ok())
+            .unwrap_or_default();
+        cfg.folder = cfg.folder.trim().trim_matches('/').to_string();
+        if cfg.format.trim().is_empty() {
+            cfg.format = DEFAULT_FORMAT.to_string();
+        }
+        cfg
+    }
+
+    /// The vault path of `date`'s note.
+    pub fn path_for(&self, date: NaiveDate) -> String {
+        let name = format_moment(&date.and_hms_opt(0, 0, 0).unwrap(), &self.format);
+        if self.folder.is_empty() {
+            format!("{name}.md")
+        } else {
+            format!("{}/{name}.md", self.folder)
+        }
+    }
+}
+
+impl Index {
+    /// `date`'s daily note: its path, and whether this call created it.
+    /// With `create` unset, only the path is computed.
+    pub fn daily(&mut self, date: NaiveDate, create: bool) -> Result<(String, bool), WriteError> {
+        let cfg = DailyConfig::load(self);
+        let path = cfg.path_for(date);
+        if !create || self.entry(&path).is_some() || self.abs(&path).exists() {
+            return Ok((path, false));
+        }
+        let body = self.template_text(&cfg.template).unwrap_or_default();
+        let now = Local::now().naive_local();
+        let at = date.and_hms_opt(now.hour(), now.minute(), now.second()).unwrap();
+        let title = crate::index::stem(&path).to_string();
+        let text = expand_template(&body, &title, &at, &cfg.format);
+        atomic_write(&self.abs(&path), text.as_bytes())?;
+        self.refresh(std::slice::from_ref(&path));
+        Ok((path, true))
+    }
+
+    fn template_text(&self, template: &str) -> Option<String> {
+        let t = template.trim().trim_start_matches('/');
+        if t.is_empty() {
+            return None;
+        }
+        let rel = if FileKind::of(t) == FileKind::Note { t.to_string() } else { format!("{t}.md") };
+        std::fs::read_to_string(self.abs(&rel)).ok()
+    }
+}
+
+/// Expand `{{title}}`, `{{date}}`, `{{time}}`, `{{date:FMT}}` and
+/// `{{time:FMT}}`. Unknown `{{…}}` are left as written.
+pub fn expand_template(body: &str, title: &str, at: &NaiveDateTime, date_format: &str) -> String {
+    let mut out = String::with_capacity(body.len());
+    let mut rest = body;
+    while let Some(open) = rest.find("{{") {
+        out.push_str(&rest[..open]);
+        let after = &rest[open + 2..];
+        let Some(close) = after.find("}}") else {
+            out.push_str(&rest[open..]);
+            return out;
+        };
+        let inner = after[..close].trim();
+        let (name, fmt) = match inner.split_once(':') {
+            Some((n, f)) => (n.trim(), Some(f.trim())),
+            None => (inner, None),
+        };
+        match name.to_ascii_lowercase().as_str() {
+            "title" => out.push_str(title),
+            "date" => out.push_str(&format_moment(at, fmt.unwrap_or(date_format))),
+            "time" => out.push_str(&format_moment(at, fmt.unwrap_or("HH:mm"))),
+            _ => out.push_str(&rest[open..open + 2 + close + 2]),
+        }
+        rest = &after[close + 2..];
+    }
+    out.push_str(rest);
+    out
+}
+
+const MONTHS: [&str; 12] = [
+    "January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
+    "November", "December",
+];
+const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+/// The moment.js tokens a daily-note format uses, longest first so `MMMM`
+/// wins over `MM`. `[text]` is literal, as in moment.
+const TOKENS: [&str; 38] = [
+    "YYYY", "GGGG", "gggg", "MMMM", "DDDD", "dddd", "MMM", "DDD", "ddd", "YY", "MM", "Mo", "DD", "Do",
+    "dd", "do", "WW", "Wo", "ww", "wo", "HH", "hh", "mm", "ss", "Q", "M", "D", "d", "E", "e", "W", "w",
+    "H", "h", "m", "s", "A", "a",
+];
+
+pub fn format_moment(at: &NaiveDateTime, fmt: &str) -> String {
+    let d = at.date();
+    let mut out = String::new();
+    let mut rest = fmt;
+    'outer: while !rest.is_empty() {
+        if let Some(r) = rest.strip_prefix('[') {
+            let end = r.find(']').unwrap_or(r.len());
+            out.push_str(&r[..end]);
+            rest = r.get(end + 1..).unwrap_or("");
+            continue;
+        }
+        for tok in TOKENS {
+            if let Some(r) = rest.strip_prefix(tok) {
+                out.push_str(&token(at, d, tok));
+                rest = r;
+                continue 'outer;
+            }
+        }
+        let ch = rest.chars().next().unwrap();
+        out.push(ch);
+        rest = &rest[ch.len_utf8()..];
+    }
+    out
+}
+
+fn ordinal(n: u32) -> String {
+    let suffix = match (n % 10, n % 100) {
+        (_, 11..=13) => "th",
+        (1, _) => "st",
+        (2, _) => "nd",
+        (3, _) => "rd",
+        _ => "th",
+    };
+    format!("{n}{suffix}")
+}
+
+fn token(at: &NaiveDateTime, d: NaiveDate, tok: &str) -> String {
+    let dow = d.weekday().num_days_from_sunday();
+    let week = d.iso_week();
+    let h12 = match at.hour() % 12 {
+        0 => 12,
+        h => h,
+    };
+    match tok {
+        "YYYY" => format!("{:04}", d.year()),
+        "YY" => format!("{:02}", d.year().rem_euclid(100)),
+        "GGGG" | "gggg" => format!("{:04}", week.year()),
+        "Q" => ((d.month() - 1) / 3 + 1).to_string(),
+        "MMMM" => MONTHS[d.month0() as usize].to_string(),
+        "MMM" => MONTHS[d.month0() as usize][..3].to_string(),
+        "MM" => format!("{:02}", d.month()),
+        "Mo" => ordinal(d.month()),
+        "M" => d.month().to_string(),
+        "DDDD" => format!("{:03}", d.ordinal()),
+        "DDD" => d.ordinal().to_string(),
+        "DD" => format!("{:02}", d.day()),
+        "Do" => ordinal(d.day()),
+        "D" => d.day().to_string(),
+        "dddd" => DAYS[dow as usize].to_string(),
+        "ddd" => DAYS[dow as usize][..3].to_string(),
+        "dd" => DAYS[dow as usize][..2].to_string(),
+        "do" => ordinal(dow),
+        "d" | "e" => dow.to_string(),
+        "E" => d.weekday().number_from_monday().to_string(),
+        // Locale weeks (`w`) are taken as ISO weeks, which is what an
+        // en-GB or ISO-configured Obsidian shows.
+        "WW" | "ww" => format!("{:02}", week.week()),
+        "Wo" | "wo" => ordinal(week.week()),
+        "W" | "w" => week.week().to_string(),
+        "HH" => format!("{:02}", at.hour()),
+        "H" => at.hour().to_string(),
+        "hh" => format!("{h12:02}"),
+        "h" => h12.to_string(),
+        "mm" => format!("{:02}", at.minute()),
+        "m" => at.minute().to_string(),
+        "ss" => format!("{:02}", at.second()),
+        "s" => at.second().to_string(),
+        "A" => if at.hour() < 12 { "AM" } else { "PM" }.to_string(),
+        "a" => if at.hour() < 12 { "am" } else { "pm" }.to_string(),
+        _ => tok.to_string(),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn at(y: i32, m: u32, d: u32, h: u32, min: u32) -> NaiveDateTime {
+        NaiveDate::from_ymd_opt(y, m, d).unwrap().and_hms_opt(h, min, 5).unwrap()
+    }
+
+    #[test]
+    fn moment_formats() {
+        let t = at(2026, 9, 30, 14, 7);
+        assert_eq!(format_moment(&t, "YYYY-MM-DD"), "2026-09-30");
+        assert_eq!(format_moment(&t, "dddd, MMMM Do YYYY"), "Wednesday, September 30th 2026");
+        assert_eq!(format_moment(&t, "YYYY/MM/YYYY-MM-DD ddd"), "2026/09/2026-09-30 Wed");
+        assert_eq!(format_moment(&t, "[Week] W, gggg"), "Week 40, 2026");
+        assert_eq!(format_moment(&t, "h:mm A, HH:mm:ss"), "2:07 PM, 14:07:05");
+        assert_eq!(format_moment(&t, "DDDD Q E d"), "273 3 3 3");
+        assert_eq!(format_moment(&at(2026, 1, 1, 0, 0), "Do MMM hh a"), "1st Jan 12 am");
+        assert_eq!(format_moment(&at(2026, 1, 12, 0, 0), "Do"), "12th");
+    }
+
+    #[test]
+    fn templates() {
+        let t = at(2026, 9, 30, 9, 5);
+        let body = "# {{title}}\nCreated {{date}} {{time}}\n{{date:dddd}} {{ time : h A }} {{other}} {{";
+        assert_eq!(
+            expand_template(body, "2026-09-30", &t, "YYYY-MM-DD"),
+            "# 2026-09-30\nCreated 2026-09-30 09:05\nWednesday 9 AM {{other}} {{"
+        );
+    }
+
+    #[test]
+    fn daily_note_from_obsidian_settings() {
+        let dir = tempfile::tempdir().unwrap();
+        let root = dir.path();
+        std::fs::create_dir_all(root.join(".obsidian")).unwrap();
+        std::fs::write(
+            root.join(".obsidian/daily-notes.json"),
+            r#"{"folder":"Journal/","format":"YYYY/MM-DD ddd","template":"Templates/Day"}"#,
+        )
+        .unwrap();
+        std::fs::create_dir_all(root.join("Templates")).unwrap();
+        std::fs::write(root.join("Templates/Day.md"), "# {{date:dddd}}\n[[{{date:YYYY-[W]WW}}]]\n").unwrap();
+        let mut ix = Index::open(root, false).unwrap();
+        let day = NaiveDate::from_ymd_opt(2026, 9, 30).unwrap();
+        assert_eq!(ix.daily(day, false).unwrap(), ("Journal/2026/09-30 Wed.md".to_string(), false));
+        assert_eq!(ix.daily(day, true).unwrap(), ("Journal/2026/09-30 Wed.md".to_string(), true));
+        assert_eq!(
+            std::fs::read_to_string(root.join("Journal/2026/09-30 Wed.md")).unwrap(),
+            "# Wednesday\n[[2026-W40]]\n"
+        );
+        assert!(!ix.daily(day, true).unwrap().1);
+        // The new note's link is indexed. (The template's own
+        // `[[{{date:…}}]]` is a note too, and unresolved, as in Obsidian.)
+        assert!(ix.unresolved().contains_key("2026-w40"));
+    }
+
+    #[test]
+    fn defaults_without_settings() {
+        let dir = tempfile::tempdir().unwrap();
+        let mut ix = Index::open(dir.path(), false).unwrap();
+        let day = NaiveDate::from_ymd_opt(2026, 1, 2).unwrap();
+        let (path, created) = ix.daily(day, true).unwrap();
+        assert_eq!((path.as_str(), created), ("2026-01-02.md", true));
+        assert_eq!(std::fs::read_to_string(dir.path().join(path)).unwrap(), "");
+    }
+}
diff --git a/src/index.rs b/src/index.rs
new file mode 100644
index 0000000..ea94292
--- /dev/null
+++ b/src/index.rs
@@ -0,0 +1,787 @@
+//! The vault index: every file, every parsed note, and where each link
+//! points.
+//!
+//! The files are the source of truth. The index is rebuilt from them on
+//! [`Index::open`], reusing a parse from the on-disk cache only where a
+//! file's size and mtime still match, and patched with
+//! [`Index::apply_changes`] as the watcher reports paths. There is no daemon;
+//! every app that needs the index holds its own.
+//!
+//! Resolution is re-run for the whole vault after any change batch. That
+//! keeps the one subtle case correct for free: creating `Beta.md` must turn
+//! every `[[Beta]]` that was unresolved anywhere into a backlink, and
+//! deleting it must turn them back.
+//!
+//! Measured on a generated vault of 5,000 notes (40 MB, 100,000 links,
+//! 20-core laptop, warm page cache, 2026-09-30): parse 36 ms and relink
+//! 21 ms, both spread over up to eight threads; loading the JSON cache
+//! instead takes 47 ms for an 18 MB file. So the cache is opt-in. It can
+//! only pay off where reading the files is the slow part (a cold page cache
+//! at login, a network filesystem), which has not been measured yet.
+
+use std::collections::{BTreeMap, HashMap};
+use std::io;
+use std::path::{Component, Path, PathBuf};
+use std::time::{Instant, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+use crate::canvas;
+use crate::parse::{self, Link, LinkKind, Note, Task};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FileKind {
+    Note,
+    Canvas,
+    Attachment,
+}
+
+impl FileKind {
+    pub fn of(path: &str) -> FileKind {
+        match extension(path).map(str::to_ascii_lowercase).as_deref() {
+            Some("md") => FileKind::Note,
+            Some("canvas") => FileKind::Canvas,
+            _ => FileKind::Attachment,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Entry {
+    pub kind: FileKind,
+    /// Nanoseconds since the epoch; with `size`, the cache's validity key.
+    pub mtime: u64,
+    pub size: u64,
+    /// Parsed content for notes and canvases; `None` for attachments.
+    pub note: Option<Note>,
+}
+
+/// How a build went, for `cce-vault stats` and for noticing a cold cache.
+#[derive(Debug, Clone, Default, Serialize)]
+pub struct BuildStats {
+    pub parsed: usize,
+    pub reused: usize,
+    pub millis: u128,
+}
+
+/// What [`Index::apply_changes`] did.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
+pub struct Changes {
+    pub updated: Vec<String>,
+    pub removed: Vec<String>,
+}
+
+impl Changes {
+    pub fn is_empty(&self) -> bool {
+        self.updated.is_empty() && self.removed.is_empty()
+    }
+}
+
+/// A link seen from its target: which file it is in, and the link itself.
+#[derive(Debug, Clone, Copy)]
+pub struct Backlink<'a> {
+    pub source: &'a str,
+    pub link: &'a Link,
+}
+
+pub struct Index {
+    root: PathBuf,
+    files: BTreeMap<String, Entry>,
+    /// Lowercased basename → paths. Notes are keyed without `.md`, since
+    /// `[[Beta]]` and `[[Beta.md]]` both mean `Beta.md`; every other file
+    /// keeps its extension (`[[pic.png]]`, `[[Board.canvas]]`).
+    by_name: HashMap<String, Vec<String>>,
+    /// Lowercased path → path, for case-insensitive exact matches.
+    by_lower: HashMap<String, String>,
+    /// Per source file, where each of its links resolved, in link order.
+    resolved: HashMap<String, Vec<Option<String>>>,
+    /// Target path → (source path, link index).
+    backlinks: HashMap<String, Vec<(String, usize)>>,
+    cache_dirty: bool,
+    pub stats: BuildStats,
+}
+
+impl Index {
+    /// Index the vault at `root`, reusing the on-disk cache where it is
+    /// still valid when `use_cache` is set.
+    pub fn open(root: &Path, use_cache: bool) -> io::Result<Index> {
+        let started = Instant::now();
+        let root = root.canonicalize()?;
+        let mut cached = if use_cache { load_cache(&root) } else { BTreeMap::new() };
+        let t_cache = started.elapsed();
+        let mut index = Index::empty(root.clone());
+        let found = walk(&root, &root)?;
+        let found_count = found.len();
+        let mut to_parse: Vec<(String, PathBuf, u64, u64)> = Vec::new();
+        for (rel, abs) in found {
+            let Ok(meta) = std::fs::metadata(&abs) else { continue };
+            let (mtime, size) = stamp(&meta);
+            match cached.remove(&rel) {
+                Some(entry) if entry.mtime == mtime && entry.size == size => {
+                    index.stats.reused += 1;
+                    index.files.insert(rel, entry);
+                }
+                _ => to_parse.push((rel, abs, mtime, size)),
+            }
+        }
+        index.stats.parsed = to_parse.len();
+        let parsed = par_map(&to_parse, |(rel, abs, mtime, size)| {
+            Some((rel.clone(), read_entry(abs, rel, *mtime, *size)))
+        });
+        index.files.extend(parsed);
+        let t_files = started.elapsed();        // Anything the cache knew that the walk did not find is gone.
+        index.cache_dirty = use_cache && (index.stats.parsed > 0 || !cached.is_empty());
+        index.relink();
+        index.stats.millis = started.elapsed().as_millis();
+        log::debug!(
+            "indexed {} files under {} ({} parsed, {} from cache) in {} ms \
+             (cache load {} ms, walk+parse {} ms, relink {} ms)",
+            found_count,
+            root.display(),
+            index.stats.parsed,
+            index.stats.reused,
+            index.stats.millis,
+            t_cache.as_millis(),
+            (t_files - t_cache).as_millis(),
+            (started.elapsed() - t_files).as_millis(),
+        );
+        Ok(index)
+    }
+
+    fn empty(root: PathBuf) -> Index {
+        Index {
+            root,
+            files: BTreeMap::new(),
+            by_name: HashMap::new(),
+            by_lower: HashMap::new(),
+            resolved: HashMap::new(),
+            backlinks: HashMap::new(),
+            cache_dirty: false,
+            stats: BuildStats::default(),
+        }
+    }
+
+    pub fn root(&self) -> &Path {
+        &self.root
+    }
+
+    pub fn abs(&self, rel: &str) -> PathBuf {
+        self.root.join(rel)
+    }
+
+    /// The vault-relative path of an absolute one, or `None` when it is
+    /// outside the vault or inside a hidden folder (`.obsidian`, `.trash`).
+    pub fn rel(&self, abs: &Path) -> Option<String> {
+        let rel = abs.strip_prefix(&self.root).ok()?;
+        let mut parts = Vec::new();
+        for c in rel.components() {
+            let Component::Normal(s) = c else { return None };
+            let s = s.to_str()?;
+            if s.starts_with('.') {
+                return None;
+            }
+            parts.push(s);
+        }
+        (!parts.is_empty()).then(|| parts.join("/"))
+    }
+
+    pub fn files(&self) -> &BTreeMap<String, Entry> {
+        &self.files
+    }
+
+    pub fn entry(&self, path: &str) -> Option<&Entry> {
+        self.files.get(path)
+    }
+
+    pub fn note(&self, path: &str) -> Option<&Note> {
+        self.files.get(path)?.note.as_ref()
+    }
+
+    /// Markdown notes, in path order.
+    pub fn notes(&self) -> impl Iterator<Item = (&str, &Note)> {
+        self.documents().filter(|(p, _)| FileKind::of(p) == FileKind::Note)
+    }
+
+    /// Notes and canvases: every file that can hold links.
+    pub fn documents(&self) -> impl Iterator<Item = (&str, &Note)> {
+        self.files.iter().filter_map(|(p, e)| Some((p.as_str(), e.note.as_ref()?)))
+    }
+
+    /// Re-read the files at these absolute paths (as a watcher reports
+    /// them): changed ones are re-parsed, vanished ones dropped, a directory
+    /// that appeared is walked. Paths outside the vault or in hidden folders
+    /// are ignored.
+    pub fn apply_changes(&mut self, paths: &[PathBuf]) -> Changes {
+        let mut changes = Changes::default();
+        for abs in paths {
+            let Some(rel) = self.rel(abs) else { continue };
+            match std::fs::metadata(abs) {
+                Ok(meta) if meta.is_dir() => {
+                    if let Ok(found) = walk(&self.root, abs) {
+                        for (rel, abs) in found {
+                            self.upsert(&rel, &abs, &mut changes);
+                        }
+                    }
+                }
+                Ok(_) => self.upsert(&rel, abs, &mut changes),
+                Err(_) => {
+                    // A file, or a directory that took files with it.
+                    let prefix = format!("{rel}/");
+                    let gone: Vec<String> = self
+                        .files
+                        .keys()
+                        .filter(|k| **k == rel || k.starts_with(&prefix))
+                        .cloned()
+                        .collect();
+                    for k in gone {
+                        self.files.remove(&k);
+                        changes.removed.push(k);
+                    }
+                }
+            }
+        }
+        if !changes.is_empty() {
+            changes.updated.sort();
+            changes.updated.dedup();
+            changes.removed.sort();
+            changes.removed.dedup();
+            self.cache_dirty = true;
+            self.relink();
+        }
+        changes
+    }
+
+    fn upsert(&mut self, rel: &str, abs: &Path, changes: &mut Changes) {
+        let Ok(meta) = std::fs::metadata(abs) else { return };
+        let (mtime, size) = stamp(&meta);
+        if let Some(e) = self.files.get(rel) {
+            if e.mtime == mtime && e.size == size {
+                return;
+            }
+        }
+        self.files.insert(rel.to_string(), read_entry(abs, rel, mtime, size));
+        changes.updated.push(rel.to_string());
+    }
+
+    /// Rebuild the name maps, every link's resolution and the backlinks.
+    fn relink(&mut self) {
+        self.by_name.clear();
+        self.by_lower.clear();
+        for path in self.files.keys() {
+            self.by_name.entry(name_key(path)).or_default().push(path.clone());
+            self.by_lower.insert(path.to_lowercase(), path.clone());
+        }
+        // Resolution only reads the maps, so it spreads across threads.
+        let docs: Vec<(&str, &Note)> = self.documents().collect();
+        let per_doc = par_map(&docs, |(source, note)| {
+            let targets: Vec<Option<String>> =
+                note.links.iter().map(|l| self.resolve(Some(source), l)).collect();
+            Some((source.to_string(), targets))
+        });
+        let mut resolved = HashMap::new();
+        let mut backlinks: HashMap<String, Vec<(String, usize)>> = HashMap::new();
+        for (source, targets) in per_doc {
+            for (i, t) in targets.iter().enumerate() {
+                if let Some(t) = t {
+                    backlinks.entry(t.clone()).or_default().push((source.clone(), i));
+                }
+            }
+            resolved.insert(source, targets);
+        }
+        self.resolved = resolved;
+        self.backlinks = backlinks;
+    }
+
+    /// Where a link in `from` points, following Obsidian's rules: an exact
+    /// vault path first (relative to the note for a markdown link), then
+    /// the file whose name matches, preferring one in the linking note's
+    /// own folder and then the shortest path. Case-insensitive throughout,
+    /// as Obsidian is.
+    pub fn resolve(&self, from: Option<&str>, link: &Link) -> Option<String> {
+        let relative_first = link.kind == LinkKind::Markdown;
+        self.resolve_path(from, &link.target, relative_first)
+    }
+
+    /// Resolve link text as a user would type it (`Note`, `folder/Note`,
+    /// `Note#Heading`); the subpath is ignored.
+    pub fn resolve_text(&self, from: Option<&str>, text: &str) -> Option<String> {
+        let path = text.split('#').next().unwrap_or(text).trim();
+        self.resolve_path(from, path, false)
+    }
+
+    fn resolve_path(&self, from: Option<&str>, target: &str, relative_first: bool) -> Option<String> {
+        let target = target.trim();
+        if target.is_empty() {
+            return None;
+        }
+        let from_dir = from.map(parent).unwrap_or("");
+        let explicit_rel = target.starts_with("./") || target.starts_with("../");
+        // A leading `/` means the vault root, never the note's folder.
+        let rooted_only = target.starts_with('/');
+        let mut candidates = vec![target.to_string()];
+        if FileKind::of(target) != FileKind::Note {
+            candidates.push(format!("{target}.md"));
+        }
+        for c in &candidates {
+            let rooted = normalize("", c.trim_start_matches('/'));
+            let relative = normalize(from_dir, c);
+            let relative = if rooted_only { None } else { relative };
+            let order = if relative_first || explicit_rel {
+                [relative, rooted]
+            } else {
+                [rooted, relative]
+            };
+            for p in order.into_iter().flatten() {
+                if let Some(hit) = self.by_lower.get(&p.to_lowercase()) {
+                    return Some(hit.clone());
+                }
+            }
+        }
+        if explicit_rel {
+            return None;
+        }
+        // By name, with any folder part of the link as a path suffix.
+        let clean = target.trim_start_matches('/');
+        let lower = clean.to_lowercase();
+        let lower_md = format!("{lower}.md");
+        let mut hits: Vec<&String> = self
+            .by_name
+            .get(&name_key(clean))?
+            .iter()
+            .filter(|p| {
+                let p = p.to_lowercase();
+                [&lower, &lower_md].iter().any(|t| {
+                    p == **t || (p.ends_with(t.as_str()) && p[..p.len() - t.len()].ends_with('/'))
+                })
+            })
+            .collect();
+        hits.sort_by(|a, b| {
+            let same_a = parent(a) == from_dir;
+            let same_b = parent(b) == from_dir;
+            same_b.cmp(&same_a).then(a.len().cmp(&b.len())).then(a.cmp(b))
+        });
+        hits.first().map(|p| (*p).clone())
+    }
+
+    /// Each outgoing link of `path` with the file it resolves to.
+    pub fn outgoing(&self, path: &str) -> Vec<(&Link, Option<&str>)> {
+        let Some(note) = self.note(path) else { return Vec::new() };
+        let resolved = self.resolved.get(path);
+        note.links
+            .iter()
+            .enumerate()
+            .map(|(i, l)| (l, resolved.and_then(|r| r.get(i)?.as_deref())))
+            .collect()
+    }
+
+    /// Every link that points at `path`, in source-path then line order.
+    pub fn backlinks(&self, path: &str) -> Vec<Backlink<'_>> {
+        let mut out: Vec<Backlink> = self
+            .backlinks
+            .get(path)
+            .into_iter()
+            .flatten()
+            .filter_map(|(source, i)| {
+                let (source, entry) = self.files.get_key_value(source)?;
+                Some(Backlink { source, link: entry.note.as_ref()?.links.get(*i)? })
+            })
+            .collect();
+        out.sort_by(|a, b| a.source.cmp(b.source).then(a.link.span.start.cmp(&b.link.span.start)));
+        out
+    }
+
+    /// Links that resolve to nothing, grouped by what they ask for
+    /// (lowercased, the way Obsidian merges `[[idea]]` and `[[Idea]]`).
+    pub fn unresolved(&self) -> BTreeMap<String, Vec<Backlink<'_>>> {
+        let mut out: BTreeMap<String, Vec<Backlink>> = BTreeMap::new();
+        for (source, note) in self.documents() {
+            let Some(resolved) = self.resolved.get(source) else { continue };
+            for (link, target) in note.links.iter().zip(resolved) {
+                if target.is_none() {
+                    out.entry(link.target.to_lowercase())
+                        .or_default()
+                        .push(Backlink { source, link });
+                }
+            }
+        }
+        out
+    }
+
+    /// Tag → number of notes carrying it. Tags are case-insensitive; each
+    /// is shown in the casing first met. Nested tags count toward their
+    /// parents too (`#a/b` is also `#a`), as Obsidian's tag pane shows them.
+    pub fn tags(&self) -> Vec<(String, usize)> {
+        let mut counts: BTreeMap<String, (String, usize)> = BTreeMap::new();
+        for (_, note) in self.documents() {
+            let mut seen = std::collections::HashSet::new();
+            for tag in &note.tags {
+                let parts: Vec<&str> = tag.name.split('/').collect();
+                for n in 1..=parts.len() {
+                    let name = parts[..n].join("/");
+                    if seen.insert(name.to_lowercase()) {
+                        let e = counts.entry(name.to_lowercase()).or_insert((name, 0));
+                        e.1 += 1;
+                    }
+                }
+            }
+        }
+        counts.into_values().collect()
+    }
+
+    /// Documents carrying `tag` or one nested under it.
+    pub fn tagged(&self, tag: &str) -> Vec<&str> {
+        let want = tag.trim_start_matches('#').to_lowercase();
+        let nested = format!("{want}/");
+        self.documents()
+            .filter(|(_, n)| {
+                n.tags.iter().any(|t| {
+                    let t = t.name.to_lowercase();
+                    t == want || t.starts_with(&nested)
+                })
+            })
+            .map(|(p, _)| p)
+            .collect()
+    }
+
+    /// Every task in the vault, in path then line order.
+    pub fn tasks(&self) -> impl Iterator<Item = (&str, &Task)> {
+        self.documents().flat_map(|(p, n)| n.tasks.iter().map(move |t| (p, t)))
+    }
+
+    /// A note named on a command line or in a request: an exact path, a
+    /// path missing its `.md`, or link text resolved from the vault root.
+    pub fn lookup(&self, query: &str) -> Option<String> {
+        let q = query.trim().trim_start_matches('/');
+        if self.files.contains_key(q) {
+            return Some(q.to_string());
+        }
+        self.resolve_text(None, q)
+    }
+
+    /// Write the cache if anything changed since it was read.
+    pub fn save_cache(&mut self) -> io::Result<()> {
+        if !self.cache_dirty {
+            return Ok(());
+        }
+        let path = cache_path(&self.root);
+        let file = CacheFile {
+            version: CACHE_VERSION,
+            root: self.root.to_string_lossy().into_owned(),
+            files: self.files.clone(),
+        };
+        let bytes = serde_json::to_vec(&file).map_err(io::Error::other)?;
+        crate::write::atomic_write(&path, &bytes)?;
+        self.cache_dirty = false;
+        Ok(())
+    }
+
+    /// Record a file this process just wrote, so the watcher's echo of the
+    /// write is recognised as already applied.
+    pub(crate) fn refresh(&mut self, rels: &[String]) -> Changes {
+        let paths: Vec<PathBuf> = rels.iter().map(|r| self.abs(r)).collect();
+        self.apply_changes(&paths)
+    }
+}
+
+/// Bumped whenever `Entry` or `Note` change shape; an old cache is then
+/// ignored rather than misread.
+const CACHE_VERSION: u32 = 1;
+
+#[derive(Serialize, Deserialize)]
+struct CacheFile {
+    version: u32,
+    root: String,
+    files: BTreeMap<String, Entry>,
+}
+
+fn cache_path(root: &Path) -> PathBuf {
+    let base = std::env::var_os("XDG_CACHE_HOME")
+        .map(PathBuf::from)
+        .filter(|p| p.is_absolute())
+        .unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache"));
+    // FNV-1a: std's hasher is not stable across Rust releases, and the
+    // cache file name must be.
+    let mut h: u64 = 0xcbf29ce484222325;
+    for b in root.to_string_lossy().bytes() {
+        h ^= b as u64;
+        h = h.wrapping_mul(0x100000001b3);
+    }
+    base.join("cce").join("vault").join(format!("{h:016x}.json"))
+}
+
+fn load_cache(root: &Path) -> BTreeMap<String, Entry> {
+    let Ok(bytes) = std::fs::read(cache_path(root)) else { return BTreeMap::new() };
+    match serde_json::from_slice::<CacheFile>(&bytes) {
+        Ok(c) if c.version == CACHE_VERSION && Path::new(&c.root) == root => c.files,
+        Ok(_) => BTreeMap::new(),
+        Err(e) => {
+            log::warn!("ignoring unreadable vault cache: {e}");
+            BTreeMap::new()
+        }
+    }
+}
+
+/// Run `f` over `items` on up to eight threads, keeping the `Some`
+/// results. Small inputs stay on the calling thread.
+pub(crate) fn par_map<I, T, F>(items: &[I], f: F) -> Vec<T>
+where
+    I: Sync,
+    T: Send,
+    F: Fn(&I) -> Option<T> + Sync,
+{
+    let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);
+    if items.len() < 64 || threads < 2 {
+        return items.iter().filter_map(&f).collect();
+    }
+    let chunk = items.len().div_ceil(threads);
+    std::thread::scope(|s| {
+        let handles: Vec<_> = items
+            .chunks(chunk)
+            .map(|part| {
+                let f = &f;
+                s.spawn(move || part.iter().filter_map(f).collect::<Vec<T>>())
+            })
+            .collect();
+        handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect()
+    })
+}
+
+fn stamp(meta: &std::fs::Metadata) -> (u64, u64) {
+    let mtime = meta
+        .modified()
+        .ok()
+        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
+        .map(|d| d.as_nanos() as u64)
+        .unwrap_or(0);
+    (mtime, meta.len())
+}
+
+fn read_entry(abs: &Path, rel: &str, mtime: u64, size: u64) -> Entry {
+    let kind = FileKind::of(rel);
+    let note = match kind {
+        FileKind::Attachment => None,
+        _ => {
+            let text = match std::fs::read(abs) {
+                Ok(bytes) => String::from_utf8(bytes)
+                    .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()),
+                Err(e) => {
+                    log::warn!("cannot read {}: {e}", abs.display());
+                    String::new()
+                }
+            };
+            Some(match kind {
+                FileKind::Canvas => match canvas::from_str(&text) {
+                    Ok(c) => canvas::index(&c),
+                    Err(e) => {
+                        log::warn!("{rel}: {e}");
+                        Note::default()
+                    }
+                },
+                _ => parse::parse(&text),
+            })
+        }
+    };
+    Entry { kind, mtime, size, note }
+}
+
+/// Every visible file under `dir`, as (vault-relative path, absolute path).
+/// Hidden files and folders (`.obsidian`, `.trash`, `.git`) are skipped the
+/// way Obsidian skips them. Symlinks are followed; walkdir breaks loops.
+fn walk(root: &Path, dir: &Path) -> io::Result<Vec<(String, PathBuf)>> {
+    let mut out = Vec::new();
+    let walker = walkdir::WalkDir::new(dir)
+        .follow_links(true)
+        .into_iter()
+        .filter_entry(|e| e.depth() == 0 || !e.file_name().to_string_lossy().starts_with('.'));
+    for entry in walker {
+        let entry = match entry {
+            Ok(e) => e,
+            Err(e) => {
+                log::warn!("walking the vault: {e}");
+                continue;
+            }
+        };
+        if !entry.file_type().is_file() {
+            continue;
+        }
+        let Ok(rel) = entry.path().strip_prefix(root) else { continue };
+        let Some(rel) = rel.to_str() else { continue };
+        out.push((rel.replace('\\', "/"), entry.path().to_path_buf()));
+    }
+    Ok(out)
+}
+
+fn extension(path: &str) -> Option<&str> {
+    let name = path.rsplit('/').next()?;
+    let dot = name.rfind('.')?;
+    (dot > 0).then(|| &name[dot + 1..])
+}
+
+/// The folder part of a vault path (`""` at the root).
+pub fn parent(path: &str) -> &str {
+    path.rfind('/').map(|i| &path[..i]).unwrap_or("")
+}
+
+/// A note's display name: its file name without `.md`.
+pub fn stem(path: &str) -> &str {
+    let name = path.rsplit('/').next().unwrap_or(path);
+    if FileKind::of(name) == FileKind::Note {
+        &name[..name.len() - 3]
+    } else {
+        name
+    }
+}
+
+fn name_key(path: &str) -> String {
+    stem(path.trim_end_matches('/')).to_lowercase()
+}
+
+/// Join `rel` onto `dir` and fold `.` and `..`; `None` if it climbs out of
+/// the vault.
+fn normalize(dir: &str, rel: &str) -> Option<String> {
+    let mut parts: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
+    for seg in rel.split('/') {
+        match seg {
+            "" | "." => {}
+            ".." => {
+                parts.pop()?;
+            }
+            s => parts.push(s),
+        }
+    }
+    (!parts.is_empty()).then(|| parts.join("/"))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
+        let dir = tempfile::tempdir().unwrap();
+        for (path, text) in files {
+            let p = dir.path().join(path);
+            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+            std::fs::write(p, text).unwrap();
+        }
+        let index = Index::open(dir.path(), false).unwrap();
+        (dir, index)
+    }
+
+    fn resolve(ix: &Index, from: &str, text: &str) -> Option<String> {
+        ix.resolve_text(Some(from), text)
+    }
+
+    #[test]
+    fn resolution_rules() {
+        let (_d, ix) = vault(&[
+            ("Alpha.md", ""),
+            ("a/Dup.md", ""),
+            ("b/Dup.md", ""),
+            ("b/deep/Dup.md", ""),
+            ("a/Only.md", ""),
+            ("img/pic.png", ""),
+            ("Board.canvas", "{\"nodes\":[],\"edges\":[]}"),
+            (".obsidian/app.json", "{}"),
+            ("b/Linker.md", ""),
+        ]);
+        assert_eq!(resolve(&ix, "b/Linker.md", "alpha").as_deref(), Some("Alpha.md"));
+        assert_eq!(resolve(&ix, "b/Linker.md", "Alpha.md").as_deref(), Some("Alpha.md"));
+        assert_eq!(resolve(&ix, "b/Linker.md", "Only#Part").as_deref(), Some("a/Only.md"));
+        // Same folder wins, then the shortest path.
+        assert_eq!(resolve(&ix, "b/Linker.md", "Dup").as_deref(), Some("b/Dup.md"));
+        assert_eq!(resolve(&ix, "Alpha.md", "Dup").as_deref(), Some("a/Dup.md"));
+        // A folder part narrows by path suffix.
+        assert_eq!(resolve(&ix, "Alpha.md", "deep/Dup").as_deref(), Some("b/deep/Dup.md"));
+        assert_eq!(resolve(&ix, "Alpha.md", "b/Dup").as_deref(), Some("b/Dup.md"));
+        assert_eq!(resolve(&ix, "Alpha.md", "pic.png").as_deref(), Some("img/pic.png"));
+        assert_eq!(resolve(&ix, "Alpha.md", "Board.canvas").as_deref(), Some("Board.canvas"));
+        assert_eq!(resolve(&ix, "Alpha.md", "Board"), None);
+        assert_eq!(resolve(&ix, "Alpha.md", "Missing"), None);
+        assert!(ix.files().keys().all(|k| !k.starts_with('.')));
+    }
+
+    #[test]
+    fn markdown_links_resolve_relative_first() {
+        let (_d, ix) = vault(&[
+            ("x.md", "root"),
+            ("sub/x.md", "sub"),
+            ("sub/n.md", "[r](x.md) [up](../x.md) [abs](/x.md) [sp](My%20File.md)"),
+            ("sub/My File.md", ""),
+        ]);
+        let out: Vec<_> = ix.outgoing("sub/n.md").into_iter().map(|(_, t)| t).collect();
+        assert_eq!(out, [Some("sub/x.md"), Some("x.md"), Some("x.md"), Some("sub/My File.md")]);
+    }
+
+    #[test]
+    fn backlinks_unresolved_tags_tasks() {
+        let (_d, ix) = vault(&[
+            ("A.md", "[[B]] [[B#Sec|b]] [[Nope]] #proj/x\n- [ ] one\n- [x] two\n"),
+            ("B.md", "---\ntags: [proj]\n---\n[[A]] [[nope]]\n"),
+            ("C.canvas", "{\"nodes\":[{\"id\":\"n\",\"type\":\"file\",\"file\":\"B.md\",\"x\":0,\"y\":0,\"width\":1,\"height\":1}],\"edges\":[]}"),
+        ]);
+        let bl: Vec<_> = ix.backlinks("B.md").iter().map(|b| (b.source, b.link.line)).collect();
+        assert_eq!(bl, [("A.md", 0), ("A.md", 0), ("C.canvas", 0)]);
+        let un = ix.unresolved();
+        assert_eq!(un.keys().collect::<Vec<_>>(), ["nope"]);
+        assert_eq!(un["nope"].len(), 2);
+        assert_eq!(ix.tags(), [("proj".to_string(), 2), ("proj/x".to_string(), 1)]);
+        assert_eq!(ix.tagged("#proj"), ["A.md", "B.md"]);
+        let open: Vec<_> = ix.tasks().filter(|(_, t)| t.is_open()).map(|(p, t)| (p, t.text.as_str())).collect();
+        assert_eq!(open, [("A.md", "one")]);
+    }
+
+    #[test]
+    fn changes_relink_the_vault() {
+        let (dir, mut ix) = vault(&[("A.md", "[[B]]"), ("old/C.md", "[[A]]")]);
+        assert!(ix.backlinks("B.md").is_empty());
+        std::fs::write(dir.path().join("B.md"), "[[A]]").unwrap();
+        let ch = ix.apply_changes(&[dir.path().join("B.md")]);
+        assert_eq!(ch.updated, ["B.md"]);
+        assert_eq!(ix.backlinks("B.md").len(), 1);
+        assert_eq!(ix.backlinks("A.md").len(), 2);
+
+        std::fs::remove_dir_all(dir.path().join("old")).unwrap();
+        let ch = ix.apply_changes(&[dir.path().join("old")]);
+        assert_eq!(ch.removed, ["old/C.md"]);
+        assert_eq!(ix.backlinks("A.md").len(), 1);
+
+        std::fs::create_dir_all(dir.path().join("new/deeper")).unwrap();
+        std::fs::write(dir.path().join("new/deeper/D.md"), "[[B]]").unwrap();
+        let ch = ix.apply_changes(&[dir.path().join("new")]);
+        assert_eq!(ch.updated, ["new/deeper/D.md"]);
+        assert_eq!(ix.backlinks("B.md").len(), 2);
+
+        // Unchanged files and hidden paths are no-ops.
+        let ch = ix.apply_changes(&[dir.path().join("A.md"), dir.path().join(".obsidian/x.json")]);
+        assert!(ch.is_empty());
+    }
+
+    #[test]
+    fn cache_is_reused_until_a_file_changes() {
+        let cache = tempfile::tempdir().unwrap();
+        // The only test that reads XDG_CACHE_HOME; every other index test
+        // opens with the cache off.
+        std::env::set_var("XDG_CACHE_HOME", cache.path());
+        let (dir, _) = vault(&[("A.md", "[[B]]"), ("B.md", "x")]);
+        let mut first = Index::open(dir.path(), true).unwrap();
+        assert_eq!((first.stats.parsed, first.stats.reused), (2, 0));
+        first.save_cache().unwrap();
+        let second = Index::open(dir.path(), true).unwrap();
+        assert_eq!((second.stats.parsed, second.stats.reused), (0, 2));
+        assert_eq!(second.backlinks("B.md").len(), 1);
+        std::fs::write(dir.path().join("B.md"), "changed, and longer").unwrap();
+        let third = Index::open(dir.path(), true).unwrap();
+        assert_eq!((third.stats.parsed, third.stats.reused), (1, 1));
+    }
+
+    #[test]
+    fn helpers() {
+        assert_eq!(normalize("a/b", "../c.md").as_deref(), Some("a/c.md"));
+        assert_eq!(normalize("", "../c.md"), None);
+        assert_eq!(stem("x/Note.md"), "Note");
+        assert_eq!(stem("x/pic.png"), "pic.png");
+        assert_eq!(FileKind::of("a/B.MD"), FileKind::Note);
+        assert_eq!(FileKind::of(".md"), FileKind::Attachment);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..bd8e4c7
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,45 @@
+//! The notes vault shared by cce apps.
+//!
+//! A vault is a folder of Markdown notes, canvases and attachments —
+//! byte-compatible with Obsidian, which can keep working on the same files.
+//! This crate is everything about that folder that is not UI:
+//!
+//! - [`parse`]: one note's properties, links, tags, headings, block ids
+//!   and tasks, with exact byte spans.
+//! - [`index`]: every file, where each link resolves, backlinks, tags and
+//!   tasks; cached on disk by mtime and patched as files change.
+//! - [`watch`]: a recursive, debounced watcher feeding the index.
+//! - [`search`]: fuzzy name matching, full-text search, unlinked mentions.
+//! - [`write`]: atomic writes, task toggling, and rename with link rewrite.
+//! - [`canvas`]: JSON Canvas read and write, byte-exact with Obsidian.
+//! - [`daily`]: daily notes from Obsidian's own settings and templates.
+//! - [`config`]: where the vault is (`CCE_VAULT`, or `vault { path }` in
+//!   `~/.config/cce/config.kdl`).
+//!
+//! It has no cce-ui dependency on purpose: the `cce-vault` CLI, tests and
+//! any future non-GUI tool use it without a Wayland stack. There is no
+//! daemon; each app embeds an [`Index`] and a [`VaultWatcher`].
+//!
+//! ```no_run
+//! let root = cce_vault::config::vault_root(None)?;
+//! let mut index = cce_vault::Index::open(&root, true)?;
+//! for b in index.backlinks("Projects/cce.md") {
+//!     println!("{}:{}", b.source, b.link.line + 1);
+//! }
+//! index.save_cache()?;
+//! # Ok::<(), Box<dyn std::error::Error>>(())
+//! ```
+
+pub mod canvas;
+pub mod config;
+pub mod daily;
+pub mod index;
+pub mod parse;
+pub mod search;
+pub mod watch;
+pub mod write;
+
+pub use index::{Backlink, Changes, Entry, FileKind, Index};
+pub use parse::{Link, LinkKind, Note, Task};
+pub use watch::VaultWatcher;
+pub use write::{LinkEdit, RenamePlan, WriteError};
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..897a9a7
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,438 @@
+//! `cce-vault`: the vault index from a shell — for scripts, agents and
+//! shadow tests, and the quickest way to check what the apps will see.
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use cce_vault::{config, search, FileKind, Index};
+use chrono::{Duration, Local, NaiveDate};
+use serde_json::{json, Value};
+
+const HELP: &str = "\
+cce-vault — query and edit the notes vault
+
+usage: cce-vault [--vault DIR] [--json] [--cache] <command> [args]
+
+  path                        the vault root
+  stats                       files, links, tags and tasks; index timing
+  find <query>                fuzzy-match note names, aliases and paths
+  search <query>              full text; every word must match, \"quote phrases\"
+  resolve <link> [--from N]   the file link text reaches (from note N)
+  links <note>                outgoing links and where each resolves
+  backlinks <note>            links pointing at the note
+  mentions <note>             unlinked mentions of the note's name or aliases
+  unresolved                  links that reach no file
+  tags [tag]                  tag counts, or the notes carrying a tag
+  tasks [--done|--all] [note] open tasks (vault-wide, or one note)
+  properties <note>           frontmatter as JSON
+  daily [date] [--create]     the daily note's path; date is YYYY-MM-DD,
+                              today, yesterday, tomorrow, or +N / -N days
+  rename <note> <to> [--dry-run]
+                              move a file and rewrite every link to it; a
+                              bare new name stays in the same folder
+  watch                       apply and print changes as they happen
+
+The vault is --vault, else $CCE_VAULT, else `vault { path \"…\" }` in
+~/.config/cce/config.kdl. Lines are printed 1-based. --cache reuses (and
+writes) the parse cache under ~/.cache/cce/vault; off by default, since a
+parallel parse of a warm vault is faster than loading it.
+";
+
+struct Opts {
+    vault: Option<PathBuf>,
+    json: bool,
+    cache: bool,
+    args: Vec<String>,
+}
+
+fn parse_opts() -> Result<Opts, String> {
+    let mut opts = Opts { vault: None, json: false, cache: false, args: Vec::new() };
+    let mut it = std::env::args().skip(1);
+    while let Some(a) = it.next() {
+        match a.as_str() {
+            "--vault" => opts.vault = Some(it.next().ok_or("--vault needs a directory")?.into()),
+            "--json" => opts.json = true,
+            "--cache" => opts.cache = true,
+            "-h" | "--help" | "help" => opts.args = vec!["help".into()],
+            _ => opts.args.push(a),
+        }
+    }
+    Ok(opts)
+}
+
+/// Pull `--flag` out of the positional args.
+fn take_flag(args: &mut Vec<String>, flag: &str) -> bool {
+    let before = args.len();
+    args.retain(|a| a != flag);
+    args.len() != before
+}
+
+fn take_value(args: &mut Vec<String>, flag: &str) -> Option<String> {
+    let i = args.iter().position(|a| a == flag)?;
+    args.remove(i);
+    (i < args.len()).then(|| args.remove(i))
+}
+
+fn main() -> ExitCode {
+    env_logger_lite();
+    let opts = match parse_opts() {
+        Ok(o) => o,
+        Err(e) => return fail(&e),
+    };
+    if opts.args.is_empty() || opts.args[0] == "help" {
+        print!("{HELP}");
+        return ExitCode::SUCCESS;
+    }
+    let root = match config::vault_root(opts.vault.as_deref()) {
+        Ok(r) => r,
+        Err(e) => return fail(&e.to_string()),
+    };
+    let mut index = match Index::open(&root, opts.cache) {
+        Ok(i) => i,
+        Err(e) => return fail(&format!("{}: {e}", root.display())),
+    };
+    let result = run(&mut index, &opts);
+    if opts.cache {
+        if let Err(e) = index.save_cache() {
+            log_warn(&format!("could not write the index cache: {e}"));
+        }
+    }
+    match result {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(e) => fail(&e),
+    }
+}
+
+fn fail(msg: &str) -> ExitCode {
+    eprintln!("cce-vault: {msg}");
+    ExitCode::FAILURE
+}
+
+fn log_warn(msg: &str) {
+    eprintln!("cce-vault: warning: {msg}");
+}
+
+/// `RUST_LOG=debug` shows the crate's log lines on stderr without pulling
+/// in a logging stack for a CLI.
+fn env_logger_lite() {
+    struct Stderr(log::LevelFilter);
+    impl log::Log for Stderr {
+        fn enabled(&self, m: &log::Metadata) -> bool {
+            m.level() <= self.0
+        }
+        fn log(&self, r: &log::Record) {
+            if self.enabled(r.metadata()) {
+                eprintln!("[{}] {}", r.level(), r.args());
+            }
+        }
+        fn flush(&self) {}
+    }
+    let level = match std::env::var("RUST_LOG").unwrap_or_default().as_str() {
+        "trace" => log::LevelFilter::Trace,
+        "debug" => log::LevelFilter::Debug,
+        "info" => log::LevelFilter::Info,
+        _ => log::LevelFilter::Warn,
+    };
+    let _ = log::set_logger(Box::leak(Box::new(Stderr(level)))).map(|_| log::set_max_level(level));
+}
+
+/// A note named on the command line, or an error that suggests names.
+fn note_arg(index: &Index, arg: Option<&String>) -> Result<String, String> {
+    let q = arg.ok_or("which note?")?;
+    if let Some(p) = index.lookup(q) {
+        return Ok(p);
+    }
+    let near: Vec<String> = index.find(q, 3).into_iter().map(|m| m.path).collect();
+    if near.is_empty() {
+        Err(format!("no note matches {q:?}"))
+    } else {
+        Err(format!("no note matches {q:?}; did you mean: {}", near.join(", ")))
+    }
+}
+
+fn out_json(v: Value) {
+    println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
+}
+
+fn run(index: &mut Index, opts: &Opts) -> Result<(), String> {
+    let mut args = opts.args.clone();
+    let cmd = args.remove(0);
+    let json = opts.json;
+    match cmd.as_str() {
+        "path" => println!("{}", index.root().display()),
+
+        "stats" => {
+            let count = |k| index.files().values().filter(|e| e.kind == k).count();
+            let links: usize = index.documents().map(|(_, n)| n.links.len()).sum();
+            let unresolved: usize = index.unresolved().values().map(Vec::len).sum();
+            let tasks: Vec<_> = index.tasks().collect();
+            let open = tasks.iter().filter(|(_, t)| t.is_open()).count();
+            let rows: Vec<(&str, Value)> = vec![
+                ("root", json!(index.root())),
+                ("notes", json!(count(FileKind::Note))),
+                ("canvases", json!(count(FileKind::Canvas))),
+                ("attachments", json!(count(FileKind::Attachment))),
+                ("links", json!(links)),
+                ("unresolved_links", json!(unresolved)),
+                ("tags", json!(index.tags().len())),
+                ("tasks", json!(tasks.len())),
+                ("open_tasks", json!(open)),
+                ("index.parsed", json!(index.stats.parsed)),
+                ("index.reused", json!(index.stats.reused)),
+                ("index.millis", json!(index.stats.millis)),
+            ];
+            if json {
+                out_json(Value::Object(rows.into_iter().map(|(k, v)| (k.to_string(), v)).collect()));
+            } else {
+                for (k, v) in rows {
+                    match v {
+                        Value::String(s) => println!("{k:18} {s}"),
+                        other => println!("{k:18} {other}"),
+                    }
+                }
+            }
+        }
+
+        "find" => {
+            let q = args.join(" ");
+            let hits = index.find(&q, 20);
+            if json {
+                out_json(json!(hits));
+            } else {
+                for h in hits {
+                    if h.matched == cce_vault::index::stem(&h.path) || h.matched == h.path {
+                        println!("{}", h.path);
+                    } else {
+                        println!("{}  (as {:?})", h.path, h.matched);
+                    }
+                }
+            }
+        }
+
+        "search" => {
+            let q = args.join(" ");
+            let hits = index.search(&q, 50);
+            print_hits(&hits, json);
+        }
+
+        "resolve" => {
+            let from = match take_value(&mut args, "--from") {
+                Some(f) => Some(note_arg(index, Some(&f))?),
+                None => None,
+            };
+            let text = args.join(" ");
+            let hit = index.resolve_text(from.as_deref(), &text);
+            if json {
+                out_json(json!({ "link": text, "from": from, "path": hit }));
+            } else {
+                println!("{}", hit.ok_or_else(|| format!("{text:?} reaches no file"))?);
+            }
+        }
+
+        "links" => {
+            let path = note_arg(index, args.first())?;
+            let out = index.outgoing(&path);
+            if json {
+                out_json(json!(out
+                    .iter()
+                    .map(|(l, t)| json!({ "line": l.line + 1, "link": l, "resolved": t }))
+                    .collect::<Vec<_>>()));
+            } else {
+                for (l, t) in out {
+                    let sub = l.subpath.as_ref().map(|s| format!("#{s}")).unwrap_or_default();
+                    let to = t.map(String::from).unwrap_or_else(|| "(unresolved)".into());
+                    println!("{path}:{}: {}{sub} -> {to}", l.line + 1, l.target);
+                }
+            }
+        }
+
+        "backlinks" => {
+            let path = note_arg(index, args.first())?;
+            let bl = index.backlinks(&path);
+            if json {
+                out_json(json!(bl
+                    .iter()
+                    .map(|b| json!({ "source": b.source, "line": b.link.line + 1, "link": b.link }))
+                    .collect::<Vec<_>>()));
+            } else {
+                for b in bl {
+                    let sub = b.link.subpath.as_ref().map(|s| format!("#{s}")).unwrap_or_default();
+                    let node = b.link.node.as_ref().map(|n| format!(" (node {n})")).unwrap_or_default();
+                    println!("{}:{}: {}{sub}{node}", b.source, b.link.line + 1, b.link.target);
+                }
+            }
+        }
+
+        "mentions" => {
+            let path = note_arg(index, args.first())?;
+            print_hits(&index.unlinked_mentions(&path), json);
+        }
+
+        "unresolved" => {
+            let un = index.unresolved();
+            if json {
+                out_json(json!(un
+                    .iter()
+                    .map(|(t, bl)| (
+                        t.clone(),
+                        json!(bl.iter().map(|b| json!({ "source": b.source, "line": b.link.line + 1 })).collect::<Vec<_>>())
+                    ))
+                    .collect::<serde_json::Map<_, _>>()));
+            } else {
+                for (target, bl) in un {
+                    let from: Vec<String> = bl.iter().map(|b| format!("{}:{}", b.source, b.link.line + 1)).collect();
+                    println!("{target}  <- {}", from.join(", "));
+                }
+            }
+        }
+
+        "tags" => match args.first() {
+            Some(tag) => {
+                let notes = index.tagged(tag);
+                if json {
+                    out_json(json!(notes));
+                } else {
+                    notes.iter().for_each(|n| println!("{n}"));
+                }
+            }
+            None => {
+                let tags = index.tags();
+                if json {
+                    out_json(json!(tags.iter().map(|(t, n)| (t.clone(), json!(n))).collect::<serde_json::Map<_, _>>()));
+                } else {
+                    for (t, n) in tags {
+                        println!("{n:5} #{t}");
+                    }
+                }
+            }
+        },
+
+        "tasks" => {
+            let done = take_flag(&mut args, "--done");
+            let all = take_flag(&mut args, "--all");
+            let only = match args.first() {
+                Some(a) => Some(note_arg(index, Some(a))?),
+                None => None,
+            };
+            let tasks: Vec<_> = index
+                .tasks()
+                .filter(|(p, _)| only.as_deref().is_none_or(|o| o == *p))
+                .filter(|(_, t)| all || (t.is_open() != done))
+                .collect();
+            if json {
+                out_json(json!(tasks
+                    .iter()
+                    .map(|(p, t)| json!({ "path": p, "line": t.line + 1, "status": t.status.to_string(), "text": t.text }))
+                    .collect::<Vec<_>>()));
+            } else {
+                for (p, t) in tasks {
+                    println!("{p}:{}: [{}] {}", t.line + 1, t.status, t.text);
+                }
+            }
+        }
+
+        "properties" => {
+            let path = note_arg(index, args.first())?;
+            let props = index.note(&path).map(|n| n.properties.clone()).unwrap_or_default();
+            println!("{}", serde_json::to_string_pretty(&props).unwrap_or_default());
+        }
+
+        "daily" => {
+            let create = take_flag(&mut args, "--create");
+            let date = parse_date(args.first().map(String::as_str).unwrap_or("today"))?;
+            let (path, created) = index.daily(date, create).map_err(|e| e.to_string())?;
+            if json {
+                let exists = index.entry(&path).is_some();
+                out_json(json!({ "path": path, "date": date.to_string(), "exists": exists, "created": created }));
+            } else {
+                println!("{path}");
+            }
+        }
+
+        "rename" => {
+            let dry = take_flag(&mut args, "--dry-run");
+            let from = note_arg(index, args.first())?;
+            let to_arg = args.get(1).ok_or("rename to what?")?;
+            let mut to = if to_arg.contains('/') {
+                to_arg.trim_start_matches('/').to_string()
+            } else {
+                match cce_vault::index::parent(&from) {
+                    "" => to_arg.clone(),
+                    dir => format!("{dir}/{to_arg}"),
+                }
+            };
+            // `rename Old New` for a note means New.md.
+            if FileKind::of(&from) == FileKind::Note && FileKind::of(&to) != FileKind::Note {
+                to.push_str(".md");
+            }
+            let plan = if dry { index.plan_rename(&from, &to) } else { index.rename(&from, &to) }
+                .map_err(|e| e.to_string())?;
+            if json {
+                out_json(json!({ "dry_run": dry, "plan": plan }));
+            } else {
+                println!("{}{} -> {}", if dry { "would move " } else { "moved " }, plan.from, plan.to);
+                for e in &plan.edits {
+                    println!("  {}:{}: {} -> {}", e.path, e.line + 1, e.old, e.new);
+                }
+                println!("{} link{} {}", plan.edits.len(), if plan.edits.len() == 1 { "" } else { "s" },
+                    if dry { "would change" } else { "rewritten" });
+            }
+        }
+
+        "watch" => {
+            let (tx, rx) = std::sync::mpsc::channel();
+            let _w = cce_vault::VaultWatcher::spawn(index.root(), move |batch| {
+                let _ = tx.send(batch);
+            })
+            .map_err(|e| e.to_string())?;
+            eprintln!("watching {} (ctrl-c to stop)", index.root().display());
+            for batch in rx {
+                let ch = index.apply_changes(&batch);
+                if ch.is_empty() {
+                    continue;
+                }
+                if json {
+                    println!("{}", json!(ch));
+                } else {
+                    ch.updated.iter().for_each(|p| println!("updated {p}"));
+                    ch.removed.iter().for_each(|p| println!("removed {p}"));
+                }
+                if opts.cache {
+                    let _ = index.save_cache();
+                }
+            }
+        }
+
+        other => return Err(format!("unknown command {other:?} (try --help)")),
+    }
+    Ok(())
+}
+
+fn print_hits(hits: &[search::FileHits], json: bool) {
+    if json {
+        out_json(json!(hits));
+        return;
+    }
+    for h in hits {
+        for l in &h.lines {
+            println!("{}:{}: {}", h.path, l.line + 1, l.text);
+        }
+        if h.total > h.lines.len() {
+            println!("{}: … {} more", h.path, h.total - h.lines.len());
+        }
+    }
+}
+
+fn parse_date(s: &str) -> Result<NaiveDate, String> {
+    let today = Local::now().date_naive();
+    match s {
+        "today" => Ok(today),
+        "yesterday" => Ok(today - Duration::days(1)),
+        "tomorrow" => Ok(today + Duration::days(1)),
+        _ if s.starts_with('+') || s.starts_with('-') => s
+            .parse::<i64>()
+            .map(|n| today + Duration::days(n))
+            .map_err(|_| format!("not a day offset: {s}")),
+        _ => NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| format!("not a date (YYYY-MM-DD): {s}")),
+    }
+}
diff --git a/src/parse.rs b/src/parse.rs
new file mode 100644
index 0000000..e7ecd52
--- /dev/null
+++ b/src/parse.rs
@@ -0,0 +1,799 @@
+//! One note's text, parsed into what the index needs: frontmatter
+//! properties, links, tags, headings, block ids and tasks.
+//!
+//! The split of labour is deliberate. pulldown-cmark knows CommonMark, so it
+//! decides what is code (fenced, indented, inline), what is math, where the
+//! headings are and where the inline `[text](dest)` links are. Everything
+//! Obsidian adds on top — `[[wikilinks]]`, `![[embeds]]`, `#tags`, `^block`
+//! ids, `%%comments%%`, task statuses other than `x` — is scanned from the
+//! raw bytes by hand, skipping the ranges pulldown called code. Scanning raw
+//! text rather than pulldown's `Text` events is what gives every link an
+//! exact byte span, which the rename path needs to rewrite a link in place
+//! without touching the bytes around it; `Text` events split at arbitrary
+//! punctuation and never carry the brackets.
+//!
+//! pulldown's own `ENABLE_WIKILINKS` stays off: it would turn `[[x]]` into
+//! link events we would then have to reconcile with the scanner, and it has
+//! no notion of `![[embed]]` or `\|` inside tables.
+
+use std::ops::Range;
+
+use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+/// Frontmatter properties, in file order. An `IndexMap` rather than
+/// `serde_json`'s `preserve_order` feature, which would unify across the
+/// workspace and reorder every other crate's JSON (see `canvas`). Maps
+/// nested inside a value are ordinary sorted `serde_json` maps.
+pub type Properties = indexmap::IndexMap<String, Value>;
+
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct Note {
+    pub properties: Properties,
+    /// Byte range of the whole frontmatter block, delimiters included.
+    pub frontmatter: Option<Range<usize>>,
+    pub links: Vec<Link>,
+    pub tags: Vec<NoteTag>,
+    pub headings: Vec<Heading>,
+    pub blocks: Vec<BlockId>,
+    pub tasks: Vec<Task>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LinkKind {
+    /// `[[target#sub|display]]`, or `![[...]]` when `embed`.
+    Wiki,
+    /// `[display](target)`, or `![alt](target)` when `embed`.
+    Markdown,
+    /// A canvas `file` node. Spans are empty: the rewrite edits the node's
+    /// `file` field instead (see `canvas`).
+    CanvasFile,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Link {
+    pub kind: LinkKind,
+    pub embed: bool,
+    /// The link path with any `#heading` / `#^block` subpath removed.
+    /// Markdown targets are percent-decoded.
+    pub target: String,
+    /// Heading or block reference, without the leading `#`
+    /// (`Heading`, `^block-id`).
+    pub subpath: Option<String>,
+    pub display: Option<String>,
+    /// The whole link, from `!` or the first bracket to the last.
+    pub span: Range<usize>,
+    /// The bytes of the link path alone, as written (still encoded for a
+    /// markdown link). A rename replaces exactly these bytes.
+    pub target_span: Range<usize>,
+    /// 0-based line of `span.start`.
+    pub line: usize,
+    /// The canvas node the link came from; `None` in a Markdown note.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub node: Option<String>,
+}
+
+/// A tag, without its `#`. (`Tag` is pulldown's name.)
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct NoteTag {
+    pub name: String,
+    /// `None` for a tag that came from the `tags` property.
+    pub line: Option<usize>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Heading {
+    pub level: u8,
+    pub text: String,
+    pub line: usize,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct BlockId {
+    pub id: String,
+    pub line: usize,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Task {
+    /// The character between the brackets: ' ' open, 'x' done, and the
+    /// custom statuses themes and the Tasks plugin use ('/', '-', '>', …).
+    pub status: char,
+    pub text: String,
+    pub line: usize,
+    /// Byte offset of the status character, for toggling in place.
+    pub status_at: usize,
+}
+
+impl Task {
+    /// Open means still to do: a blank box, or `/` (in progress) as the
+    /// Tasks plugin uses it. Everything else — `x`, `-` cancelled,
+    /// `>` forwarded — is closed.
+    pub fn is_open(&self) -> bool {
+        matches!(self.status, ' ' | '/')
+    }
+}
+
+/// Parse a note's full text.
+pub fn parse(src: &str) -> Note {
+    let lines = LineIndex::new(src);
+    let mut note = Note::default();
+
+    let body_start = match frontmatter_range(src) {
+        Some((whole, inner)) => {
+            note.properties = parse_properties(&src[inner]);
+            note.frontmatter = Some(whole.clone());
+            whole.end
+        }
+        None => 0,
+    };
+    for name in property_tags(&note.properties) {
+        note.tags.push(NoteTag { name, line: None });
+    }
+
+    // Everything code-like is invisible to the Obsidian scanners.
+    let mut excluded: Vec<Range<usize>> = Vec::new();
+    let mut md_links: Vec<Link> = Vec::new();
+    let mut heading: Option<(u8, usize, String)> = None;
+
+    let opts = Options::ENABLE_TABLES
+        | Options::ENABLE_FOOTNOTES
+        | Options::ENABLE_STRIKETHROUGH
+        | Options::ENABLE_TASKLISTS
+        | Options::ENABLE_MATH
+        | Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
+    for (event, range) in Parser::new_ext(src, opts).into_offset_iter() {
+        match event {
+            Event::Start(Tag::CodeBlock(_)) => excluded.push(range),
+            Event::Code(text) => {
+                excluded.push(range);
+                if let Some((_, _, buf)) = heading.as_mut() {
+                    buf.push_str(&text);
+                }
+            }
+            Event::InlineMath(_) | Event::DisplayMath(_) => excluded.push(range),
+            Event::Start(Tag::Heading { level, .. }) => {
+                heading = Some((level as u8, range.start, String::new()))
+            }
+            Event::Text(text) => {
+                if let Some((_, _, buf)) = heading.as_mut() {
+                    buf.push_str(&text);
+                }
+            }
+            Event::End(TagEnd::Heading(_)) => {
+                if let Some((level, start, text)) = heading.take() {
+                    note.headings.push(Heading {
+                        level,
+                        text: text.trim().to_string(),
+                        line: lines.line_of(start),
+                    });
+                }
+            }
+            Event::Start(Tag::Link { link_type: pulldown_cmark::LinkType::Inline, .. }) => {
+                if let Some(link) = markdown_link(src, range, false, &lines) {
+                    md_links.push(link);
+                }
+            }
+            Event::Start(Tag::Image { link_type: pulldown_cmark::LinkType::Inline, .. }) => {
+                if let Some(link) = markdown_link(src, range, true, &lines) {
+                    md_links.push(link);
+                }
+            }
+            _ => {}
+        }
+    }
+    excluded.sort_by_key(|r| r.start);
+    let comments = comment_ranges(src, body_start, &excluded);
+    excluded.extend(comments.iter().cloned());
+    excluded.sort_by_key(|r| r.start);
+    let skip = Ranges(excluded);
+
+    // Headings pulldown found inside a %%comment%% are not headings, and
+    // neither is anything it read in a frontmatter block it did not
+    // recognise as one (`---` can also be a rule or a setext underline).
+    let comment_skip = Ranges(comments);
+    note.headings.retain(|h| {
+        let at = lines.start_of(h.line);
+        at >= body_start && !comment_skip.contains(at)
+    });
+
+    // Wikilinks are scanned over the frontmatter too: Obsidian indexes a
+    // `related: "[[Other]]"` property as a link.
+    let wikis = wikilinks(src, &skip, &lines);
+    let mut links: Vec<Link> = wikis;
+    links.extend(md_links.into_iter().filter(|l| !skip.contains(l.span.start)));
+    links.sort_by_key(|l| l.span.start);
+    note.links = links;
+
+    let link_spans = Ranges(note.links.iter().map(|l| l.span.clone()).collect());
+    scan_tags(src, body_start, &skip, &link_spans, &lines, &mut note.tags);
+    scan_lines(src, body_start, &skip, &lines, &mut note);
+    note
+}
+
+/// `---\n...\n---` at the very top of the file: (whole block, inner YAML).
+pub fn frontmatter_range(src: &str) -> Option<(Range<usize>, Range<usize>)> {
+    let first_end = src.find('\n')?;
+    if src[..first_end].trim_end_matches('\r') != "---" {
+        return None;
+    }
+    let inner_start = first_end + 1;
+    let mut pos = inner_start;
+    while pos <= src.len() {
+        let end = src[pos..].find('\n').map(|i| pos + i).unwrap_or(src.len());
+        if src[pos..end].trim_end_matches('\r') == "---" {
+            let whole_end = if end < src.len() { end + 1 } else { end };
+            return Some((0..whole_end, inner_start..pos));
+        }
+        if end == src.len() {
+            break;
+        }
+        pos = end + 1;
+    }
+    None
+}
+
+/// YAML → JSON values. A block that does not parse, or is not a mapping,
+/// yields no properties rather than an error: Obsidian shows such a note
+/// with its frontmatter as plain text, and the index should still hold it.
+pub fn parse_properties(yaml: &str) -> Properties {
+    let docs = match yaml_rust2::YamlLoader::load_from_str(yaml) {
+        Ok(docs) => docs,
+        Err(_) => return Properties::new(),
+    };
+    match docs.into_iter().next() {
+        Some(yaml_rust2::Yaml::Hash(h)) => h
+            .into_iter()
+            .filter_map(|(k, v)| Some((yaml_key(k)?, yaml_to_json(v))))
+            .collect(),
+        _ => Properties::new(),
+    }
+}
+
+fn yaml_key(k: yaml_rust2::Yaml) -> Option<String> {
+    use yaml_rust2::Yaml;
+    match k {
+        Yaml::String(s) | Yaml::Real(s) => Some(s),
+        Yaml::Integer(i) => Some(i.to_string()),
+        Yaml::Boolean(b) => Some(b.to_string()),
+        _ => None,
+    }
+}
+
+fn yaml_to_json(y: yaml_rust2::Yaml) -> Value {
+    use yaml_rust2::Yaml;
+    match y {
+        Yaml::Real(s) => s
+            .parse::<f64>()
+            .ok()
+            .and_then(serde_json::Number::from_f64)
+            .map(Value::Number)
+            .unwrap_or(Value::String(s)),
+        Yaml::Integer(i) => Value::from(i),
+        Yaml::String(s) => Value::String(s),
+        Yaml::Boolean(b) => Value::Bool(b),
+        Yaml::Array(a) => Value::Array(a.into_iter().map(yaml_to_json).collect()),
+        Yaml::Hash(h) => Value::Object(
+            h.into_iter().filter_map(|(k, v)| Some((yaml_key(k)?, yaml_to_json(v)))).collect::<Map<_, _>>(),
+        ),
+        Yaml::Null | Yaml::Alias(_) | Yaml::BadValue => Value::Null,
+    }
+}
+
+/// `tags` (or the older `tag`) as a list, or as one string split on commas
+/// and whitespace. A leading `#` is tolerated and dropped.
+fn property_tags(props: &Properties) -> Vec<String> {
+    let mut out = Vec::new();
+    for key in ["tags", "tag"] {
+        let push = |s: &str, out: &mut Vec<String>| {
+            for part in s.split(|c: char| c == ',' || c.is_whitespace()) {
+                let t = part.trim().trim_start_matches('#');
+                if !t.is_empty() {
+                    out.push(t.to_string());
+                }
+            }
+        };
+        match props.get(key) {
+            Some(Value::String(s)) => push(s, &mut out),
+            Some(Value::Array(items)) => {
+                for item in items {
+                    match item {
+                        Value::String(s) => push(s, &mut out),
+                        Value::Number(n) => out.push(n.to_string()),
+                        _ => {}
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+    out
+}
+
+/// Aliases from the `aliases` (or `alias`) property.
+pub fn aliases(props: &Properties) -> Vec<String> {
+    let mut out = Vec::new();
+    for key in ["aliases", "alias"] {
+        match props.get(key) {
+            Some(Value::String(s)) => {
+                out.extend(s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from))
+            }
+            Some(Value::Array(items)) => {
+                out.extend(items.iter().filter_map(Value::as_str).map(String::from))
+            }
+            _ => {}
+        }
+    }
+    out
+}
+
+/// Byte ranges sorted by start, with a point query.
+struct Ranges(Vec<Range<usize>>);
+
+impl Ranges {
+    fn contains(&self, pos: usize) -> bool {
+        // Ranges can nest (a code span inside a comment), so any range that
+        // starts at or before `pos` may hold it. A note has a few dozen.
+        let idx = self.0.partition_point(|r| r.start <= pos);
+        self.0[..idx].iter().any(|r| r.contains(&pos))
+    }
+}
+
+/// Byte offset → 0-based line.
+pub struct LineIndex {
+    starts: Vec<usize>,
+}
+
+impl LineIndex {
+    pub fn new(src: &str) -> Self {
+        let mut starts = vec![0];
+        starts.extend(src.match_indices('\n').map(|(i, _)| i + 1));
+        LineIndex { starts }
+    }
+    pub fn line_of(&self, pos: usize) -> usize {
+        self.starts.partition_point(|&s| s <= pos) - 1
+    }
+    pub fn start_of(&self, line: usize) -> usize {
+        self.starts.get(line).copied().unwrap_or(0)
+    }
+}
+
+/// `%%...%%` comments outside code; an unclosed one runs to the end of the
+/// file, as Obsidian renders it.
+fn comment_ranges(src: &str, from: usize, code: &[Range<usize>]) -> Vec<Range<usize>> {
+    let code = Ranges(code.to_vec());
+    let mut out = Vec::new();
+    let mut pos = from;
+    while let Some(i) = src[pos..].find("%%") {
+        let start = pos + i;
+        if code.contains(start) {
+            pos = start + 2;
+            continue;
+        }
+        let end = src[start + 2..].find("%%").map(|j| start + 2 + j + 2).unwrap_or(src.len());
+        out.push(start..end);
+        pos = end;
+    }
+    out
+}
+
+fn wikilinks(src: &str, skip: &Ranges, lines: &LineIndex) -> Vec<Link> {
+    let bytes = src.as_bytes();
+    let mut out = Vec::new();
+    let mut pos = 0;
+    while let Some(i) = src[pos..].find("[[") {
+        let open = pos + i;
+        pos = open + 2;
+        if skip.contains(open) || (open > 0 && bytes[open - 1] == b'\\') {
+            continue;
+        }
+        // The link ends at the first `]]` on the same line.
+        let rest = &src[open + 2..];
+        let line_end = rest.find('\n').unwrap_or(rest.len());
+        let Some(close_rel) = rest[..line_end].find("]]") else { continue };
+        let inner_start = open + 2;
+        let inner = &src[inner_start..inner_start + close_rel];
+        let close = inner_start + close_rel + 2;
+        let embed = open > 0 && bytes[open - 1] == b'!';
+        let span_start = if embed { open - 1 } else { open };
+
+        // Inside a table the pipe is escaped as `\|`; the path then ends
+        // before the backslash.
+        let (path_part, display) = match inner.find('|') {
+            Some(p) => {
+                let path_end = if p > 0 && inner.as_bytes()[p - 1] == b'\\' { p - 1 } else { p };
+                (&inner[..path_end], Some(inner[p + 1..].to_string()))
+            }
+            None => (inner, None),
+        };
+        let (target_raw, subpath) = match path_part.find('#') {
+            Some(h) => (&path_part[..h], Some(path_part[h + 1..].trim().to_string())),
+            None => (path_part, None),
+        };
+        let lead = target_raw.len() - target_raw.trim_start().len();
+        let target = target_raw.trim();
+        if target.is_empty() {
+            // `[[#Heading]]` points into the note itself; nothing to index.
+            pos = close;
+            continue;
+        }
+        let t_start = inner_start + lead;
+        out.push(Link {
+            kind: LinkKind::Wiki,
+            embed,
+            target: target.to_string(),
+            subpath: subpath.filter(|s| !s.is_empty()),
+            display,
+            span: span_start..close,
+            target_span: t_start..t_start + target.len(),
+            line: lines.line_of(span_start),
+            node: None,
+        });
+        pos = close;
+    }
+    out
+}
+
+/// A `[text](dest)` link from pulldown's range, with the destination's own
+/// byte span found by re-reading the source: pulldown hands back the
+/// destination unescaped and gives no offset for it.
+fn markdown_link(src: &str, range: Range<usize>, embed: bool, lines: &LineIndex) -> Option<Link> {
+    let text = &src[range.clone()];
+    let bytes = text.as_bytes();
+    let mut i = if embed { 1 } else { 0 };
+    if bytes.get(i) != Some(&b'[') {
+        return None;
+    }
+    // Find the `]` closing the link text, honouring nesting and escapes.
+    let mut depth = 0i32;
+    let close_text = loop {
+        match bytes.get(i)? {
+            b'\\' => i += 1,
+            b'[' => depth += 1,
+            b']' => {
+                depth -= 1;
+                if depth == 0 {
+                    break i;
+                }
+            }
+            _ => {}
+        }
+        i += 1;
+    };
+    if bytes.get(close_text + 1) != Some(&b'(') {
+        return None;
+    }
+    let mut d = close_text + 2;
+    while bytes.get(d).is_some_and(|b| *b == b' ' || *b == b'\t' || *b == b'\n') {
+        d += 1;
+    }
+    let (dest_start, dest_end) = if bytes.get(d) == Some(&b'<') {
+        let end = text[d + 1..].find('>')? + d + 1;
+        (d + 1, end)
+    } else {
+        let mut e = d;
+        let mut parens = 0;
+        while let Some(&b) = bytes.get(e) {
+            match b {
+                b'\\' => e += 1,
+                b'(' => parens += 1,
+                b')' if parens == 0 => break,
+                b')' => parens -= 1,
+                b' ' | b'\t' | b'\n' => break,
+                _ => {}
+            }
+            e += 1;
+        }
+        (d, e.min(text.len()))
+    };
+    let raw_dest = &text[dest_start..dest_end];
+    if raw_dest.is_empty() || is_external(raw_dest) {
+        return None;
+    }
+    let (path_raw, subpath) = match raw_dest.find('#') {
+        Some(h) => (&raw_dest[..h], Some(decode(&raw_dest[h + 1..]))),
+        None => (raw_dest, None),
+    };
+    if path_raw.is_empty() {
+        return None;
+    }
+    let display = text[if embed { 2 } else { 1 }..close_text].to_string();
+    Some(Link {
+        kind: LinkKind::Markdown,
+        embed,
+        target: decode(path_raw),
+        subpath: subpath.filter(|s| !s.is_empty()),
+        display: Some(display),
+        span: range.clone(),
+        target_span: range.start + dest_start..range.start + dest_start + path_raw.len(),
+        line: lines.line_of(range.start),
+        node: None,
+    })
+}
+
+fn decode(s: &str) -> String {
+    percent_encoding::percent_decode_str(s).decode_utf8_lossy().into_owned()
+}
+
+/// A destination that leaves the vault: any `scheme:` (http, mailto,
+/// obsidian://, file:) — but not a Windows drive letter, which nobody writes
+/// in a vault link anyway.
+fn is_external(dest: &str) -> bool {
+    match dest.find(':') {
+        Some(c) if c > 1 => dest[..c]
+            .chars()
+            .all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.'),
+        _ => false,
+    }
+}
+
+fn is_tag_char(c: char) -> bool {
+    c.is_alphanumeric() || c == '_' || c == '-' || c == '/'
+}
+
+/// `#tag` in the body: `#` at a line start or after whitespace, then tag
+/// characters, at least one of them not a digit (`#123` is not a tag).
+/// `# Heading` never matches because the space is not a tag character.
+fn scan_tags(
+    src: &str,
+    from: usize,
+    skip: &Ranges,
+    links: &Ranges,
+    lines: &LineIndex,
+    out: &mut Vec<NoteTag>,
+) {
+    let mut prev: Option<char> = src[..from].chars().next_back();
+    let mut iter = src[from..].char_indices().map(|(i, c)| (i + from, c)).peekable();
+    while let Some((i, c)) = iter.next() {
+        let at_boundary = prev.is_none_or(|p| p.is_whitespace());
+        prev = Some(c);
+        if c != '#' || !at_boundary || skip.contains(i) || links.contains(i) {
+            continue;
+        }
+        let rest = &src[i + 1..];
+        let len: usize = rest.chars().take_while(|&c| is_tag_char(c)).map(char::len_utf8).sum();
+        let name = rest[..len].trim_end_matches('/');
+        if name.is_empty() || name.chars().all(|c| c.is_ascii_digit() || c == '/') {
+            continue;
+        }
+        out.push(NoteTag { name: name.to_string(), line: Some(lines.line_of(i)) });
+        // Skip past the tag so `#a#b` yields one tag, as Obsidian reads it.
+        while iter.peek().is_some_and(|&(j, _)| j <= i + len) {
+            prev = iter.next().map(|(_, c)| c);
+        }
+    }
+}
+
+/// Line-shaped things: tasks and `^block` ids.
+fn scan_lines(src: &str, from: usize, skip: &Ranges, lines: &LineIndex, note: &mut Note) {
+    let mut start = from;
+    for line in src[from..].split_inclusive('\n') {
+        let line_start = start;
+        start += line.len();
+        let text = line.trim_end_matches(['\n', '\r']);
+        if skip.contains(line_start) {
+            continue;
+        }
+        let line_no = lines.line_of(line_start);
+        if let Some((status_at, status, rest)) = task_parts(text) {
+            if !skip.contains(line_start + status_at) {
+                note.tasks.push(Task {
+                    status,
+                    text: rest.trim().to_string(),
+                    line: line_no,
+                    status_at: line_start + status_at,
+                });
+            }
+        }
+        if let Some(id) = block_id(text) {
+            if !skip.contains(line_start + text.len() - 1) {
+                note.blocks.push(BlockId { id: id.to_string(), line: line_no });
+            }
+        }
+    }
+}
+
+/// `- [ ] text`, `* [x] text`, `1. [/] text`, also inside `> ` quotes and
+/// callouts. Returns (byte offset of the status char in the line, status,
+/// the text after the box).
+pub fn task_parts(line: &str) -> Option<(usize, char, &str)> {
+    let b = line.as_bytes();
+    let mut i = 0;
+    loop {
+        while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
+            i += 1;
+        }
+        if i < b.len() && b[i] == b'>' {
+            i += 1;
+            continue;
+        }
+        break;
+    }
+    match b.get(i)? {
+        b'-' | b'*' | b'+' => i += 1,
+        b'0'..=b'9' => {
+            while b.get(i).is_some_and(u8::is_ascii_digit) {
+                i += 1;
+            }
+            if !matches!(b.get(i), Some(b'.') | Some(b')')) {
+                return None;
+            }
+            i += 1;
+        }
+        _ => return None,
+    }
+    if !matches!(b.get(i), Some(b' ') | Some(b'\t')) {
+        return None;
+    }
+    while matches!(b.get(i), Some(b' ') | Some(b'\t')) {
+        i += 1;
+    }
+    if b.get(i) != Some(&b'[') {
+        return None;
+    }
+    let status_at = i + 1;
+    let status = line[status_at..].chars().next()?;
+    let after = status_at + status.len_utf8();
+    if b.get(after) != Some(&b']') {
+        return None;
+    }
+    let rest = &line[after + 1..];
+    if !(rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t')) {
+        return None;
+    }
+    Some((status_at, status, rest))
+}
+
+/// A trailing ` ^block-id` (or a line that is only `^block-id`).
+fn block_id(line: &str) -> Option<&str> {
+    let t = line.trim_end();
+    let caret = t.rfind('^')?;
+    let id = &t[caret + 1..];
+    if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
+        return None;
+    }
+    if caret > 0 && !t[..caret].ends_with([' ', '\t']) {
+        return None;
+    }
+    Some(id)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn targets(n: &Note) -> Vec<&str> {
+        n.links.iter().map(|l| l.target.as_str()).collect()
+    }
+
+    #[test]
+    fn wikilink_forms() {
+        let src = "See [[Alpha]], [[folder/Beta#Intro|the beta]] and ![[pic.png]].\n\
+                   Block: [[Gamma#^abc123]]. Self: [[#Local]].\n";
+        let n = parse(src);
+        assert_eq!(targets(&n), ["Alpha", "folder/Beta", "pic.png", "Gamma"]);
+        let beta = &n.links[1];
+        assert_eq!(beta.subpath.as_deref(), Some("Intro"));
+        assert_eq!(beta.display.as_deref(), Some("the beta"));
+        assert_eq!(&src[beta.target_span.clone()], "folder/Beta");
+        assert_eq!(&src[beta.span.clone()], "[[folder/Beta#Intro|the beta]]");
+        assert!(n.links[2].embed);
+        assert_eq!(&src[n.links[2].span.clone()], "![[pic.png]]");
+        assert_eq!(n.links[3].subpath.as_deref(), Some("^abc123"));
+        assert_eq!(n.links[3].line, 1);
+    }
+
+    #[test]
+    fn table_escaped_pipe() {
+        let src = "| a | b |\n|---|---|\n| [[Note\\|shown]] | x |\n";
+        let n = parse(src);
+        assert_eq!(targets(&n), ["Note"]);
+        assert_eq!(n.links[0].display.as_deref(), Some("shown"));
+        assert_eq!(&src[n.links[0].target_span.clone()], "Note");
+    }
+
+    #[test]
+    fn code_and_comments_hide_links_and_tags() {
+        let src = "```\n[[InFence]] #infence\n```\n\
+                   `[[InCode]]` %% [[InComment]] #incomment %%\n\n\
+                   \x20   [[Indented]]\n\n[[Real]] #real\n";
+        let n = parse(src);
+        assert_eq!(targets(&n), ["Real"]);
+        let tags: Vec<_> = n.tags.iter().map(|t| t.name.as_str()).collect();
+        assert_eq!(tags, ["real"]);
+    }
+
+    #[test]
+    fn markdown_links() {
+        let src = "[a](My%20Note.md) [b](<Other Note.md#Part>) ![c](img/x.png)\n\
+                   [web](https://example.com) [mail](mailto:a@b.c) [here](#local)\n";
+        let n = parse(src);
+        assert_eq!(targets(&n), ["My Note.md", "Other Note.md", "img/x.png"]);
+        assert_eq!(&src[n.links[0].target_span.clone()], "My%20Note.md");
+        assert_eq!(&src[n.links[1].target_span.clone()], "Other Note.md");
+        assert_eq!(n.links[1].subpath.as_deref(), Some("Part"));
+        assert!(n.links[2].embed);
+        assert!(n.links.iter().all(|l| l.kind == LinkKind::Markdown));
+    }
+
+    #[test]
+    fn tag_rules() {
+        let src = "# Heading\n#top and #nested/child, #123 not, a#b not, \
+                   #under_score #日本 (see [[Note#Sec]]) #trail/\n";
+        let n = parse(src);
+        let tags: Vec<_> = n.tags.iter().map(|t| t.name.as_str()).collect();
+        assert_eq!(tags, ["top", "nested/child", "under_score", "日本", "trail"]);
+        assert_eq!(n.headings.len(), 1);
+    }
+
+    #[test]
+    fn frontmatter_properties_and_tags() {
+        let src = "---\ntitle: Hello\ncount: 3\ndone: true\ntags: [one, \"#two\"]\n\
+                   aliases:\n  - Hi\n  - Hey\nrelated: \"[[Other]]\"\n---\n# Body #three\n";
+        let n = parse(src);
+        assert_eq!(n.properties["title"], "Hello");
+        assert_eq!(n.properties["count"], 3);
+        assert_eq!(n.properties["done"], true);
+        assert_eq!(aliases(&n.properties), ["Hi", "Hey"]);
+        let tags: Vec<_> = n.tags.iter().map(|t| t.name.as_str()).collect();
+        assert_eq!(tags, ["one", "two", "three"]);
+        assert_eq!(targets(&n), ["Other"]);
+        assert_eq!(n.headings[0].text, "Body #three");
+        assert_eq!(n.headings[0].line, 10);
+        let keys: Vec<_> = n.properties.keys().collect();
+        assert_eq!(keys, ["title", "count", "done", "tags", "aliases", "related"]);
+    }
+
+    #[test]
+    fn tags_as_a_string_and_bad_yaml() {
+        let n = parse("---\ntags: a, b c\n---\nx\n");
+        let tags: Vec<_> = n.tags.iter().map(|t| t.name.as_str()).collect();
+        assert_eq!(tags, ["a", "b", "c"]);
+        let bad = parse("---\n: : [\n---\nbody [[Link]]\n");
+        assert!(bad.properties.is_empty());
+        assert!(bad.frontmatter.is_some());
+        assert_eq!(targets(&bad), ["Link"]);
+    }
+
+    #[test]
+    fn no_frontmatter_without_closing_fence() {
+        let n = parse("---\nnot: closed\n\n[[A]]\n");
+        assert!(n.frontmatter.is_none());
+    }
+
+    #[test]
+    fn tasks_and_statuses() {
+        let src = "- [ ] open one\n* [x] done\n1. [/] half\n> - [-] quoted cancel\n\
+                   - [] not a task\n-[ ] not either\n```\n- [ ] in code\n```\n";
+        let n = parse(src);
+        let got: Vec<_> = n.tasks.iter().map(|t| (t.status, t.text.as_str(), t.line)).collect();
+        assert_eq!(
+            got,
+            [(' ', "open one", 0), ('x', "done", 1), ('/', "half", 2), ('-', "quoted cancel", 3)]
+        );
+        assert_eq!(&src[n.tasks[1].status_at..n.tasks[1].status_at + 1], "x");
+        assert!(n.tasks[0].is_open() && n.tasks[2].is_open());
+        assert!(!n.tasks[1].is_open() && !n.tasks[3].is_open());
+    }
+
+    #[test]
+    fn block_ids_and_headings() {
+        let src = "Para one ^p1\n\n^standalone\n\nnot^block\n\n## Two `code`\nSetext\n===\n";
+        let n = parse(src);
+        let ids: Vec<_> = n.blocks.iter().map(|b| b.id.as_str()).collect();
+        assert_eq!(ids, ["p1", "standalone"]);
+        let hs: Vec<_> = n.headings.iter().map(|h| (h.level, h.text.as_str(), h.line)).collect();
+        assert_eq!(hs, [(2, "Two code", 6), (1, "Setext", 7)]);
+    }
+
+    #[test]
+    fn crlf_and_unicode_offsets() {
+        let src = "---\r\na: 1\r\n---\r\nÜber [[Zürich]]\r\n- [ ] tâche\r\n";
+        let n = parse(src);
+        assert_eq!(n.properties["a"], 1);
+        assert_eq!(&src[n.links[0].target_span.clone()], "Zürich");
+        assert_eq!(n.tasks[0].text, "tâche");
+    }
+}
diff --git a/src/search.rs b/src/search.rs
new file mode 100644
index 0000000..68dd416
--- /dev/null
+++ b/src/search.rs
@@ -0,0 +1,378 @@
+//! Finding notes: a fuzzy match on names for the quick switcher, a
+//! full-text scan for the search pane, and unlinked mentions for the
+//! backlinks pane.
+//!
+//! Full text is a scan of the files, not an index. Reading a few thousand
+//! notes from the page cache takes tens of milliseconds, spread over the
+//! machine's cores; a real index (tantivy) is worth its weight only once a
+//! scan is measurably slow on a real vault.
+
+use serde::Serialize;
+
+use crate::index::{par_map, stem, FileKind, Index};
+use crate::parse;
+
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct NameMatch {
+    pub path: String,
+    /// What matched: the note's name, one of its aliases, or its path.
+    pub matched: String,
+    pub score: i64,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct LineHit {
+    pub line: usize,
+    pub text: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize)]
+pub struct FileHits {
+    pub path: String,
+    /// Lines holding a match, capped at [`LINES_PER_FILE`].
+    pub lines: Vec<LineHit>,
+    /// Matching lines in all, including those past the cap.
+    pub total: usize,
+}
+
+pub const LINES_PER_FILE: usize = 5;
+
+/// Subsequence match of `query` in `candidate`, case-insensitive. Scores
+/// reward consecutive runs, matches at word starts and a match at the very
+/// start, and slightly penalise long candidates — the ordering a quick
+/// switcher needs so that `mt` finds "Meeting Topics" before "Mortgage".
+///
+/// The alignment is the best one, not the leftmost: a greedy match would
+/// spend the `t` of `mt` on "Mee*t*ing" and never see "*T*opics". A small
+/// dynamic programme over (query char, candidate position) finds it; names
+/// are short, so O(query × candidate) is nothing.
+pub fn fuzzy_score(query: &str, candidate: &str) -> Option<i64> {
+    let q: Vec<char> = query.chars().flat_map(char::to_lowercase).filter(|c| !c.is_whitespace()).collect();
+    if q.is_empty() {
+        return Some(0);
+    }
+    let c: Vec<char> = candidate.chars().collect();
+    let lower: Vec<char> = c.iter().map(|ch| ch.to_lowercase().next().unwrap_or(*ch)).collect();
+    let n = c.len();
+    let gain = |j: usize| -> i64 {
+        let word_start = j == 0 || !c[j - 1].is_alphanumeric() || (c[j].is_uppercase() && c[j - 1].is_lowercase());
+        1 + if word_start { 8 } else { 0 } + if j == 0 { 6 } else { 0 }
+    };
+    const NONE: i64 = i64::MIN / 4;
+    // prev[j]: best score with the previous query char matched at j.
+    let mut prev: Vec<i64> = (0..n).map(|j| if lower[j] == q[0] { gain(j) } else { NONE }).collect();
+    for &qc in &q[1..] {
+        let mut cur = vec![NONE; n];
+        let mut best_before = NONE; // max of prev[..j-1]
+        for j in 0..n {
+            if j >= 2 {
+                best_before = best_before.max(prev[j - 2]);
+            }
+            if lower[j] != qc {
+                continue;
+            }
+            let run = if j >= 1 && prev[j - 1] > NONE { prev[j - 1] + 5 } else { NONE };
+            let gap = best_before;
+            let base = run.max(gap);
+            if base > NONE {
+                cur[j] = base + gain(j);
+            }
+        }
+        prev = cur;
+    }
+    let best = prev.into_iter().max().filter(|&s| s > NONE)?;
+    Some(best * 10 - n as i64)
+}
+
+/// Terms of a search: words, and `"quoted phrases"` kept whole, lowercased.
+pub fn terms(query: &str) -> Vec<String> {
+    let mut out = Vec::new();
+    let mut rest = query.trim();
+    while !rest.is_empty() {
+        if let Some(r) = rest.strip_prefix('"') {
+            let end = r.find('"').unwrap_or(r.len());
+            out.push(r[..end].to_lowercase());
+            rest = r.get(end + 1..).unwrap_or("").trim_start();
+        } else {
+            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
+            out.push(rest[..end].to_lowercase());
+            rest = rest[end..].trim_start();
+        }
+    }
+    out.retain(|t| !t.is_empty());
+    out
+}
+
+/// Byte offsets where `needle` (already lowercase) occurs in `hay`,
+/// case-insensitively, as whole words when `words` is set.
+fn find_ci(hay: &str, needle: &str, words: bool) -> Vec<usize> {
+    let mut out = Vec::new();
+    if needle.is_empty() {
+        return out;
+    }
+    let first = needle.chars().next().unwrap();
+    for (i, ch) in hay.char_indices() {
+        if ch.to_lowercase().next() != Some(first) {
+            continue;
+        }
+        let mut hay_chars = hay[i..].chars().flat_map(char::to_lowercase);
+        let mut end = i;
+        let mut ok = true;
+        for n in needle.chars() {
+            match hay_chars.next() {
+                Some(h) if h == n => {}
+                _ => {
+                    ok = false;
+                    break;
+                }
+            }
+        }
+        if !ok {
+            continue;
+        }
+        // Find the byte end: count needle chars through the original.
+        let mut taken = 0;
+        for (j, ch) in hay[i..].char_indices() {
+            if taken >= needle.chars().count() {
+                end = i + j;
+                break;
+            }
+            taken += ch.to_lowercase().count();
+            end = i + j + ch.len_utf8();
+        }
+        if words {
+            let before = hay[..i].chars().next_back();
+            let after = hay[end..].chars().next();
+            if before.is_some_and(char::is_alphanumeric) || after.is_some_and(char::is_alphanumeric) {
+                continue;
+            }
+        }
+        out.push(i);
+    }
+    out
+}
+
+impl Index {
+    /// Notes (and other files) whose name, alias or path fuzzy-matches.
+    pub fn find(&self, query: &str, limit: usize) -> Vec<NameMatch> {
+        let mut out: Vec<NameMatch> = Vec::new();
+        for (path, entry) in self.files() {
+            let name = stem(path);
+            let mut best: Option<(i64, String)> = None;
+            let mut consider = |text: &str, bonus: i64| {
+                if let Some(s) = fuzzy_score(query, text) {
+                    let s = s + bonus;
+                    if best.as_ref().is_none_or(|(b, _)| s > *b) {
+                        best = Some((s, text.to_string()));
+                    }
+                }
+            };
+            consider(name, 0);
+            if let Some(note) = &entry.note {
+                for alias in parse::aliases(&note.properties) {
+                    consider(&alias, -5);
+                }
+            }
+            // Paths match too, so `proj/meet` narrows by folder, but a name
+            // match beats them.
+            consider(path, -40);
+            // Notes first: an attachment is rarely what a switcher wants.
+            let kind_bonus = if entry.kind == FileKind::Attachment { -30 } else { 0 };
+            if let Some((score, matched)) = best {
+                out.push(NameMatch { path: path.clone(), matched, score: score + kind_bonus });
+            }
+        }
+        out.sort_by(|a, b| b.score.cmp(&a.score).then(a.path.cmp(&b.path)));
+        out.truncate(limit);
+        out
+    }
+
+    /// Notes containing every term of `query`, anywhere in the text or the
+    /// path. Notes whose name holds a term come first, then those with the
+    /// most matching lines.
+    pub fn search(&self, query: &str, limit: usize) -> Vec<FileHits> {
+        let terms = terms(query);
+        if terms.is_empty() {
+            return Vec::new();
+        }
+        let paths: Vec<&str> = self.notes().map(|(p, _)| p).collect();
+        let mut hits = par_map(&paths, |path: &&str| {
+            let text = std::fs::read_to_string(self.abs(path)).ok()?;
+            let lower_text = text.to_lowercase();
+            let lower_path = path.to_lowercase();
+            if !terms.iter().all(|t| lower_text.contains(t.as_str()) || lower_path.contains(t.as_str())) {
+                return None;
+            }
+            let mut lines = Vec::new();
+            let mut total = 0;
+            for (n, line) in text.lines().enumerate() {
+                let l = line.to_lowercase();
+                if terms.iter().any(|t| l.contains(t.as_str())) {
+                    total += 1;
+                    if lines.len() < LINES_PER_FILE {
+                        lines.push(LineHit { line: n, text: line.trim().to_string() });
+                    }
+                }
+            }
+            Some(FileHits { path: path.to_string(), lines, total })
+        });
+        let name_hit = |h: &FileHits| {
+            let n = stem(&h.path).to_lowercase();
+            terms.iter().any(|t| n.contains(t.as_str()))
+        };
+        hits.sort_by(|a, b| {
+            name_hit(b).cmp(&name_hit(a)).then(b.total.cmp(&a.total)).then(a.path.cmp(&b.path))
+        });
+        hits.truncate(limit);
+        hits
+    }
+
+    /// Places in other notes that name `path` (by its name or an alias) as
+    /// a whole word, outside any link, code or frontmatter — the "unlinked
+    /// mentions" Obsidian offers to turn into links.
+    pub fn unlinked_mentions(&self, path: &str) -> Vec<FileHits> {
+        let mut names = vec![stem(path).to_lowercase()];
+        if let Some(note) = self.note(path) {
+            names.extend(parse::aliases(&note.properties).iter().map(|a| a.to_lowercase()));
+        }
+        names.retain(|n| n.chars().count() >= 2);
+        names.sort();
+        names.dedup();
+        if names.is_empty() {
+            return Vec::new();
+        }
+        let paths: Vec<&str> = self.notes().map(|(p, _)| p).filter(|p| *p != path).collect();
+        let mut out = par_map(&paths, |source: &&str| {
+            let text = std::fs::read_to_string(self.abs(source)).ok()?;
+            let lower = text.to_lowercase();
+            if !names.iter().any(|n| lower.contains(n.as_str())) {
+                return None;
+            }
+            // Parse fresh: the spans must match the text just read.
+            let note = parse::parse(&text);
+            let body_start = note.frontmatter.as_ref().map(|r| r.end).unwrap_or(0);
+            let linked = |at: usize| note.links.iter().any(|l| l.span.contains(&at));
+            let lines = parse::LineIndex::new(&text);
+            let code = code_ranges(&text);
+            let mut hit_lines: Vec<usize> = Vec::new();
+            for name in &names {
+                for at in find_ci(&text, name, true) {
+                    if at < body_start || linked(at) || code.iter().any(|r| r.contains(&at)) {
+                        continue;
+                    }
+                    hit_lines.push(lines.line_of(at));
+                }
+            }
+            hit_lines.sort();
+            hit_lines.dedup();
+            if hit_lines.is_empty() {
+                return None;
+            }
+            let all: Vec<&str> = text.lines().collect();
+            Some(FileHits {
+                path: source.to_string(),
+                total: hit_lines.len(),
+                lines: hit_lines
+                    .iter()
+                    .take(LINES_PER_FILE)
+                    .map(|&n| LineHit { line: n, text: all.get(n).unwrap_or(&"").trim().to_string() })
+                    .collect(),
+            })
+        });
+        out.sort_by(|a, b| a.path.cmp(&b.path));
+        out
+    }
+}
+
+/// Code blocks and spans, where a mention is not prose.
+fn code_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
+    use pulldown_cmark::{Event, Options, Parser, Tag};
+    Parser::new_ext(text, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS)
+        .into_offset_iter()
+        .filter_map(|(e, r)| match e {
+            Event::Start(Tag::CodeBlock(_)) | Event::Code(_) => Some(r),
+            _ => None,
+        })
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
+        let dir = tempfile::tempdir().unwrap();
+        for (path, text) in files {
+            let p = dir.path().join(path);
+            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+            std::fs::write(p, text).unwrap();
+        }
+        let index = Index::open(dir.path(), false).unwrap();
+        (dir, index)
+    }
+
+    #[test]
+    fn fuzzy_ordering() {
+        assert!(fuzzy_score("mtg", "Meeting notes").is_some());
+        assert!(fuzzy_score("xyz", "Meeting").is_none());
+        let a = fuzzy_score("mt", "Meeting Topics").unwrap();
+        let b = fuzzy_score("mt", "mortgage").unwrap();
+        assert!(a > b, "{a} {b}");
+        assert!(fuzzy_score("dn", "DailyNotes").unwrap() > fuzzy_score("dn", "Adenine").unwrap());
+    }
+
+    #[test]
+    fn find_names_aliases_paths() {
+        let (_d, ix) = vault(&[
+            ("Meeting Topics.md", ""),
+            ("Mortgage.md", ""),
+            ("people/Robert.md", "---\naliases: [Bob]\n---\n"),
+            ("img/meeting.png", ""),
+        ]);
+        let got: Vec<_> = ix.find("mt", 10).into_iter().map(|m| m.path).collect();
+        assert_eq!(got[0], "Meeting Topics.md");
+        let bob = ix.find("bob", 1);
+        assert_eq!((bob[0].path.as_str(), bob[0].matched.as_str()), ("people/Robert.md", "Bob"));
+        let meet: Vec<_> = ix.find("meeting", 10).into_iter().map(|m| m.path).collect();
+        assert_eq!(meet, ["Meeting Topics.md", "img/meeting.png"]);
+        assert_eq!(ix.find("people/rob", 1)[0].path, "people/Robert.md");
+    }
+
+    #[test]
+    fn full_text() {
+        let (_d, ix) = vault(&[
+            ("a.md", "The quick brown fox\nsecond line\nfox again"),
+            ("b.md", "quick but no animal"),
+            ("Fox facts.md", "nothing quick here"),
+        ]);
+        assert_eq!(terms(r#"quick "brown fox"  x"#), ["quick", "brown fox", "x"]);
+        let r = ix.search("quick fox", 10);
+        let paths: Vec<_> = r.iter().map(|h| h.path.as_str()).collect();
+        // A name hit ranks first; b.md lacks "fox".
+        assert_eq!(paths, ["Fox facts.md", "a.md"]);
+        assert_eq!(r[1].total, 2);
+        assert_eq!(r[1].lines[0], LineHit { line: 0, text: "The quick brown fox".into() });
+        assert!(ix.search("\"brown fox\"", 10).len() == 1);
+        assert!(ix.search("   ", 10).is_empty());
+    }
+
+    #[test]
+    fn unlinked() {
+        let (_d, ix) = vault(&[
+            ("Rust.md", "---\naliases: [rustlang]\n---\n"),
+            ("a.md", "---\ntopic: Rust\n---\nI like Rust.\n[[Rust]] is linked\n`Rust` in code\nTrusty is not\n"),
+            ("b.md", "all about RUSTLANG today"),
+            ("c.md", "nothing"),
+        ]);
+        let m = ix.unlinked_mentions("Rust.md");
+        let got: Vec<_> = m.iter().map(|h| (h.path.as_str(), h.lines.iter().map(|l| l.line).collect::<Vec<_>>())).collect();
+        assert_eq!(got, [("a.md", vec![3]), ("b.md", vec![0])]);
+    }
+
+    #[test]
+    fn case_insensitive_offsets() {
+        assert_eq!(find_ci("Straße STRASSE", "straße", true), [0]);
+        assert_eq!(find_ci("ÄRGER ärger", "ärger", true), [0, 7]);
+        assert_eq!(find_ci("arust rust", "rust", true), [6]);
+    }
+}
diff --git a/src/watch.rs b/src/watch.rs
new file mode 100644
index 0000000..528968f
--- /dev/null
+++ b/src/watch.rs
@@ -0,0 +1,124 @@
+//! A recursive watcher over the vault that hands back debounced batches
+//! of changed paths, ready for [`Index::apply_changes`].
+//!
+//! The debounce is cce-files' shape: wait for a first event, then keep
+//! collecting until 150 ms pass without another. A save from an editor is
+//! several events (temp file, rename, attribute change) and a sync client
+//! landing a folder is hundreds; either arrives as one batch. A stream
+//! that never goes quiet (a long sync) is still delivered once a second.
+//!
+//! The callback runs on the watcher's own thread. A cce-ui app forwards
+//! the batch to its event loop (a calloop `Sender`) and applies it there;
+//! the index is not shared across threads.
+//!
+//! [`Index::apply_changes`]: crate::Index::apply_changes
+
+use std::path::{Component, Path, PathBuf};
+use std::sync::mpsc;
+use std::time::Duration;
+
+use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
+
+const QUIET: Duration = Duration::from_millis(150);
+const MAX_WAIT: Duration = Duration::from_secs(1);
+
+pub struct VaultWatcher {
+    // Dropping the watcher closes the channel, which ends the thread.
+    _watcher: RecommendedWatcher,
+}
+
+impl VaultWatcher {
+    /// Watch `root` recursively. `on_change` receives each batch of
+    /// absolute paths, deduplicated and sorted, with anything in a hidden
+    /// folder or hidden file (`.obsidian/`, `.trash/`, this crate's own
+    /// `.name.cce-tmp` files) already dropped.
+    pub fn spawn<F>(root: &Path, mut on_change: F) -> notify::Result<VaultWatcher>
+    where
+        F: FnMut(Vec<PathBuf>) + Send + 'static,
+    {
+        let root = root.canonicalize().map_err(notify::Error::io)?;
+        let (tx, rx) = mpsc::channel::<PathBuf>();
+        let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
+            match res {
+                Ok(event) if !matches!(event.kind, EventKind::Access(_)) => {
+                    for p in event.paths {
+                        let _ = tx.send(p);
+                    }
+                }
+                Ok(_) => {}
+                Err(e) => log::warn!("vault watcher: {e}"),
+            }
+        })?;
+        watcher.watch(&root, RecursiveMode::Recursive)?;
+
+        let filter_root = root.clone();
+        std::thread::Builder::new()
+            .name("cce-vault-watch".into())
+            .spawn(move || {
+                while let Ok(first) = rx.recv() {
+                    let mut batch = vec![first];
+                    let started = std::time::Instant::now();
+                    while let Some(left) = MAX_WAIT.checked_sub(started.elapsed()) {
+                        match rx.recv_timeout(QUIET.min(left)) {
+                            Ok(p) => batch.push(p),
+                            Err(_) => break,
+                        }
+                    }
+                    batch.retain(|p| visible(&filter_root, p));
+                    batch.sort();
+                    batch.dedup();
+                    if !batch.is_empty() {
+                        on_change(batch);
+                    }
+                }
+            })
+            .map_err(notify::Error::io)?;
+        Ok(VaultWatcher { _watcher: watcher })
+    }
+}
+
+fn visible(root: &Path, path: &Path) -> bool {
+    match path.strip_prefix(root) {
+        Ok(rel) => rel.components().all(|c| match c {
+            Component::Normal(s) => !s.to_string_lossy().starts_with('.'),
+            _ => true,
+        }),
+        Err(_) => false,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::sync::{Arc, Mutex};
+    use std::time::Instant;
+
+    #[test]
+    fn batches_arrive_debounced_and_filtered() {
+        let dir = tempfile::tempdir().unwrap();
+        let root = dir.path().canonicalize().unwrap();
+        let seen: Arc<Mutex<Vec<Vec<PathBuf>>>> = Arc::default();
+        let sink = seen.clone();
+        let _w = VaultWatcher::spawn(&root, move |b| sink.lock().unwrap().push(b)).unwrap();
+
+        std::fs::create_dir_all(root.join(".obsidian")).unwrap();
+        std::fs::write(root.join(".obsidian/app.json"), "{}").unwrap();
+        crate::write::atomic_write(&root.join("sub/Note.md"), b"hello").unwrap();
+        std::fs::write(root.join("Other.md"), "x").unwrap();
+
+        let deadline = Instant::now() + Duration::from_secs(5);
+        loop {
+            let all: Vec<PathBuf> = seen.lock().unwrap().iter().flatten().cloned().collect();
+            // A file written into a brand-new folder can land before the
+            // watch on that folder exists; the folder's own event covers it
+            // (apply_changes walks a folder it is handed).
+            let sub = all.contains(&root.join("sub/Note.md")) || all.contains(&root.join("sub"));
+            if all.contains(&root.join("Other.md")) && sub {
+                assert!(all.iter().all(|p| visible(&root, p)), "{all:?}");
+                break;
+            }
+            assert!(Instant::now() < deadline, "no batch within 5 s: {all:?}");
+            std::thread::sleep(Duration::from_millis(20));
+        }
+    }
+}
diff --git a/src/write.rs b/src/write.rs
new file mode 100644
index 0000000..8865554
--- /dev/null
+++ b/src/write.rs
@@ -0,0 +1,597 @@
+//! Writing to the vault: atomic file writes, creating notes, ticking tasks,
+//! and renaming a file while rewriting every link that points at it.
+//!
+//! Every edit re-reads the file it changes and re-parses it at that moment
+//! rather than trusting the index's copy. The index may be a watcher batch
+//! behind — Obsidian or a sync client may have written the file a second
+//! ago — and a byte span taken from a stale parse would cut the wrong text.
+
+use std::io;
+use std::ops::Range;
+use std::path::Path;
+
+use crate::canvas;
+use crate::index::{parent, stem, FileKind, Index};
+use crate::parse::{self, Link, LinkKind};
+
+#[derive(Debug)]
+pub enum WriteError {
+    NotFound(String),
+    Exists(String),
+    InvalidPath(String),
+    NoTask { path: String, line: usize },
+    Canvas(String),
+    Io(io::Error),
+}
+
+impl std::fmt::Display for WriteError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            WriteError::NotFound(p) => write!(f, "no such file in the vault: {p}"),
+            WriteError::Exists(p) => write!(f, "already exists: {p}"),
+            WriteError::InvalidPath(p) => write!(f, "not a usable vault path: {p}"),
+            WriteError::NoTask { path, line } => write!(f, "{path}:{}: no task on that line", line + 1),
+            WriteError::Canvas(e) => write!(f, "{e}"),
+            WriteError::Io(e) => write!(f, "{e}"),
+        }
+    }
+}
+
+impl std::error::Error for WriteError {}
+
+impl From<io::Error> for WriteError {
+    fn from(e: io::Error) -> Self {
+        WriteError::Io(e)
+    }
+}
+
+/// Write via a hidden temp file in the same folder and a rename, so a
+/// reader (Obsidian, a sync client, another cce app) never sees half a
+/// file. The temp name starts with `.` so the vault walk and the watcher
+/// both ignore it.
+pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
+    let dir = path.parent().ok_or_else(|| io::Error::other("path has no parent"))?;
+    std::fs::create_dir_all(dir)?;
+    let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+    let tmp = dir.join(format!(".{name}.{}.cce-tmp", std::process::id()));
+    let result = std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path));
+    if result.is_err() {
+        let _ = std::fs::remove_file(&tmp);
+    }
+    result
+}
+
+/// One link rewritten by a rename.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct LinkEdit {
+    /// The file holding the link, under its name after the rename.
+    pub path: String,
+    /// 0-based line of the link (0 for a canvas file node).
+    pub line: usize,
+    pub old: String,
+    pub new: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
+pub struct RenamePlan {
+    pub from: String,
+    pub to: String,
+    pub edits: Vec<LinkEdit>,
+}
+
+/// A vault path a caller may create: relative, no `..`, no hidden part.
+fn check_new_path(to: &str) -> Result<String, WriteError> {
+    let clean = to.trim().trim_start_matches('/');
+    let bad = clean.is_empty()
+        || clean.ends_with('/')
+        || clean.split('/').any(|s| s.is_empty() || s == "." || s == ".." || s.starts_with('.'));
+    if bad {
+        return Err(WriteError::InvalidPath(to.to_string()));
+    }
+    Ok(clean.to_string())
+}
+
+fn read_text(path: &Path) -> io::Result<String> {
+    let bytes = std::fs::read(path)?;
+    String::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
+}
+
+/// Apply byte-range replacements, last first so earlier spans stay valid.
+fn splice(text: &str, mut edits: Vec<(Range<usize>, String)>) -> String {
+    edits.sort_by_key(|(r, _)| std::cmp::Reverse(r.start));
+    let mut out = text.to_string();
+    for (range, with) in edits {
+        out.replace_range(range, &with);
+    }
+    out
+}
+
+/// `../../x/y.md` from folder `from_dir` to vault path `to`.
+fn relative_path(from_dir: &str, to: &str) -> String {
+    let a: Vec<&str> = from_dir.split('/').filter(|s| !s.is_empty()).collect();
+    let b: Vec<&str> = to.split('/').collect();
+    let common = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
+    let mut parts: Vec<&str> = vec![".."; a.len() - common];
+    parts.extend(&b[common..]);
+    parts.join("/")
+}
+
+/// Percent-encode what would break a bare markdown destination.
+fn encode_dest(path: &str) -> String {
+    path.replace('%', "%25").replace(' ', "%20").replace('(', "%28").replace(')', "%29")
+}
+
+impl Index {
+    /// Create a new note (or any file) with this content.
+    pub fn create(&mut self, path: &str, content: &str) -> Result<String, WriteError> {
+        let rel = check_new_path(path)?;
+        if self.lookup_exact(&rel).is_some() {
+            return Err(WriteError::Exists(rel));
+        }
+        atomic_write(&self.abs(&rel), content.as_bytes())?;
+        self.refresh(std::slice::from_ref(&rel));
+        Ok(rel)
+    }
+
+    /// Append a paragraph to a note, creating the note if it is missing.
+    pub fn append(&mut self, path: &str, text: &str) -> Result<(), WriteError> {
+        let rel = check_new_path(path)?;
+        let abs = self.abs(&rel);
+        let mut body = match read_text(&abs) {
+            Ok(t) => t,
+            Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
+            Err(e) => return Err(e.into()),
+        };
+        if !body.is_empty() && !body.ends_with('\n') {
+            body.push('\n');
+        }
+        body.push_str(text);
+        if !text.ends_with('\n') {
+            body.push('\n');
+        }
+        atomic_write(&abs, body.as_bytes())?;
+        self.refresh(&[rel]);
+        Ok(())
+    }
+
+    /// Set the status character of the task on `line` (0-based) of a note:
+    /// `'x'` to tick it, `' '` to untick.
+    pub fn set_task(&mut self, path: &str, line: usize, status: char) -> Result<(), WriteError> {
+        if FileKind::of(path) != FileKind::Note {
+            return Err(WriteError::NoTask { path: path.to_string(), line });
+        }
+        let abs = self.abs(path);
+        let text = read_text(&abs).map_err(|e| match e.kind() {
+            io::ErrorKind::NotFound => WriteError::NotFound(path.to_string()),
+            _ => e.into(),
+        })?;
+        let note = parse::parse(&text);
+        let task = note
+            .tasks
+            .iter()
+            .find(|t| t.line == line)
+            .ok_or_else(|| WriteError::NoTask { path: path.to_string(), line })?;
+        let at = task.status_at;
+        let old_len = task.status.len_utf8();
+        let new = splice(&text, vec![(at..at + old_len, status.to_string())]);
+        atomic_write(&abs, new.as_bytes())?;
+        self.refresh(&[path.to_string()]);
+        Ok(())
+    }
+
+    fn lookup_exact(&self, rel: &str) -> Option<&str> {
+        let lower = rel.to_lowercase();
+        self.files().keys().find(|k| k.to_lowercase() == lower).map(String::as_str)
+    }
+
+    /// What [`rename`](Index::rename) would change, without changing it.
+    pub fn plan_rename(&self, from: &str, to: &str) -> Result<RenamePlan, WriteError> {
+        Ok(self.prepare_rename(from, to)?.plan)
+    }
+
+    /// Move `from` to `to` and rewrite every link that pointed at it, in
+    /// notes and canvases alike. Links keep their subpath, display text,
+    /// embed `!` and `.md` suffix; the path part is written in the shortest
+    /// form that still reaches the file, or in full when the link was
+    /// written in full. A markdown link keeps being relative or rooted, as
+    /// it was.
+    pub fn rename(&mut self, from: &str, to: &str) -> Result<RenamePlan, WriteError> {
+        let prepared = self.prepare_rename(from, to)?;
+        let before: Vec<Option<String>> =
+            self.outgoing(from).into_iter().map(|(_, t)| t.map(String::from)).collect();
+
+        let to_abs = self.abs(&prepared.plan.to);
+        if let Some(dir) = to_abs.parent() {
+            std::fs::create_dir_all(dir)?;
+        }
+        std::fs::rename(self.abs(from), &to_abs)?;
+        let mut touched = vec![from.to_string(), prepared.plan.to.clone()];
+        for (path, content) in &prepared.writes {
+            atomic_write(&self.abs(path), content.as_bytes())?;
+            touched.push(path.clone());
+        }
+        self.refresh(&touched);
+
+        // A move to another folder can change what the moved note's own
+        // short links reach (the same-folder preference). Pin any that now
+        // land elsewhere to the file they reached before.
+        let mut plan = prepared.plan;
+        let to = plan.to.clone();
+        let fixed = self.pin_moved_links(from, &to, &before)?;
+        plan.edits.extend(fixed);
+        Ok(plan)
+    }
+
+    fn pin_moved_links(
+        &mut self,
+        from: &str,
+        to: &str,
+        before: &[Option<String>],
+    ) -> Result<Vec<LinkEdit>, WriteError> {
+        if parent(from) == parent(to) || FileKind::of(to) != FileKind::Note {
+            return Ok(Vec::new());
+        }
+        let abs = self.abs(to);
+        let text = read_text(&abs)?;
+        let note = parse::parse(&text);
+        let mut edits = Vec::new();
+        let mut out = Vec::new();
+        for (link, was) in note.links.iter().zip(before) {
+            let Some(was) = was else { continue };
+            // A self-link was already rewritten to the new name.
+            let was = if was == from { to } else { was.as_str() };
+            if link.kind != LinkKind::Wiki || self.resolve(Some(to), link).as_deref() == Some(was) {
+                continue;
+            }
+            let keep_md = link.target.to_lowercase().ends_with(".md");
+            let new = full_form(was, keep_md);
+            out.push(LinkEdit {
+                path: to.to_string(),
+                line: link.line,
+                old: text[link.span.clone()].to_string(),
+                new: format!(
+                    "{}{}{}",
+                    &text[link.span.start..link.target_span.start],
+                    new,
+                    &text[link.target_span.end..link.span.end]
+                ),
+            });
+            edits.push((link.target_span.clone(), new));
+        }
+        if !edits.is_empty() {
+            atomic_write(&abs, splice(&text, edits).as_bytes())?;
+            self.refresh(&[to.to_string()]);
+        }
+        Ok(out)
+    }
+
+    fn prepare_rename(&self, from: &str, to: &str) -> Result<PreparedRename, WriteError> {
+        if self.entry(from).is_none() {
+            return Err(WriteError::NotFound(from.to_string()));
+        }
+        let to = check_new_path(to)?;
+        if let Some(existing) = self.lookup_exact(&to) {
+            // A case-only rename of the same file is fine.
+            if existing != from {
+                return Err(WriteError::Exists(existing.to_string()));
+            }
+        }
+        let mut plan = RenamePlan { from: from.to_string(), to: to.clone(), edits: Vec::new() };
+        let mut writes = Vec::new();
+
+        let mut sources: Vec<&str> = self.backlinks(from).iter().map(|b| b.source).collect();
+        // The moved note's own relative markdown links need re-rooting even
+        // when nothing links to it.
+        if FileKind::of(from) == FileKind::Note {
+            sources.push(from);
+        }
+        sources.sort();
+        sources.dedup();
+
+        for source in sources {
+            let after = if source == from { to.as_str() } else { source };
+            let abs = self.abs(source);
+            let text = read_text(&abs)?;
+            let new_text = match FileKind::of(source) {
+                FileKind::Canvas => self.rename_in_canvas(&text, source, after, from, &to, &mut plan)?,
+                _ => self.rename_in_text(&text, source, after, from, &to, None, &mut plan),
+            };
+            if let Some(new_text) = new_text {
+                writes.push((after.to_string(), new_text));
+            }
+        }
+        Ok(PreparedRename { plan, writes })
+    }
+
+    /// Rewrite the links in one note's text (or one canvas text node's).
+    /// `source` is where the text lives now, `after` where it will live.
+    #[allow(clippy::too_many_arguments)]
+    fn rename_in_text(
+        &self,
+        text: &str,
+        source: &str,
+        after: &str,
+        from: &str,
+        to: &str,
+        node: Option<&str>,
+        plan: &mut RenamePlan,
+    ) -> Option<String> {
+        let note = parse::parse(text);
+        let moved_dir = parent(source) != parent(after);
+        let mut edits = Vec::new();
+        for link in &note.links {
+            let Some(target) = self.resolve(Some(source), link) else { continue };
+            let new = if target == from {
+                self.new_link_path(link, text, after, from, to)
+            } else if moved_dir && link.kind == LinkKind::Markdown && is_relative_md(link, source, &target) {
+                // The note itself moved: re-root its relative links.
+                Some(markdown_dest(link, text, parent(after), &target))
+            } else {
+                None
+            };
+            let Some(new) = new else { continue };
+            if new == text[link.target_span.clone()] {
+                continue;
+            }
+            plan.edits.push(LinkEdit {
+                path: after.to_string(),
+                line: if node.is_some() { 0 } else { link.line },
+                old: text[link.span.clone()].to_string(),
+                new: format!(
+                    "{}{}{}",
+                    &text[link.span.start..link.target_span.start],
+                    new,
+                    &text[link.target_span.end..link.span.end]
+                ),
+            });
+            edits.push((link.target_span.clone(), new));
+        }
+        (!edits.is_empty()).then(|| splice(text, edits))
+    }
+
+    fn rename_in_canvas(
+        &self,
+        text: &str,
+        source: &str,
+        after: &str,
+        from: &str,
+        to: &str,
+        plan: &mut RenamePlan,
+    ) -> Result<Option<String>, WriteError> {
+        let mut board = canvas::from_str(text).map_err(|e| WriteError::Canvas(format!("{source}: {e}")))?;
+        let mut changed = false;
+        for node in board.nodes_mut() {
+            let id = node.str("id").unwrap_or_default();
+            match node.str("type").as_deref() {
+                Some("file") => {
+                    let Some(file) = node.str("file") else { continue };
+                    if self.resolve_path_exact(&file) == Some(from) {
+                        plan.edits.push(LinkEdit { path: after.to_string(), line: 0, old: file, new: to.to_string() });
+                        node.set_str("file", to);
+                        changed = true;
+                    }
+                }
+                Some("text") => {
+                    let body = node.str("text").unwrap_or_default();
+                    if let Some(new) = self.rename_in_text(&body, source, after, from, to, Some(&id), plan) {
+                        node.set_str("text", &new);
+                        changed = true;
+                    }
+                }
+                _ => {}
+            }
+        }
+        Ok(changed.then(|| canvas::to_string(&board)))
+    }
+
+    /// A canvas `file` field is always a full vault path.
+    fn resolve_path_exact(&self, path: &str) -> Option<&str> {
+        self.lookup_exact(path.trim_start_matches('/'))
+    }
+
+    /// The new path text for a link that pointed at the renamed file.
+    fn new_link_path(&self, link: &Link, text: &str, after: &str, from: &str, to: &str) -> Option<String> {
+        let written = &text[link.target_span.clone()];
+        match link.kind {
+            LinkKind::Wiki => {
+                let keep_md = written.to_lowercase().ends_with(".md");
+                if written.contains('/') || !self.short_name_reaches(after, from, to) {
+                    Some(full_form(to, keep_md))
+                } else {
+                    let name = stem(to);
+                    Some(if keep_md { format!("{name}.md") } else { name.to_string() })
+                }
+            }
+            LinkKind::Markdown => {
+                // Rooted if it was written rooted, relative otherwise.
+                if written.starts_with('/') {
+                    Some(format!("/{}", encode_like(link, text, to)))
+                } else {
+                    Some(markdown_dest(link, text, parent(after), to))
+                }
+            }
+            LinkKind::CanvasFile => Some(to.to_string()),
+        }
+    }
+
+    /// Whether `[[name]]` written in `source` would reach `to` once the
+    /// rename of `from` is done, with no other file of that name winning.
+    fn short_name_reaches(&self, source: &str, from: &str, to: &str) -> bool {
+        let key = stem(to).to_lowercase();
+        let dir = parent(source);
+        let mut rivals: Vec<&str> = self
+            .files()
+            .keys()
+            .map(String::as_str)
+            .filter(|p| *p != from && stem(p).to_lowercase() == key && FileKind::of(p) == FileKind::of(to))
+            .collect();
+        rivals.push(to);
+        rivals.sort_by(|a, b| {
+            (parent(b) == dir).cmp(&(parent(a) == dir)).then(a.len().cmp(&b.len())).then(a.cmp(b))
+        });
+        rivals.first() == Some(&to)
+    }
+}
+
+struct PreparedRename {
+    plan: RenamePlan,
+    /// (path after the rename, new content)
+    writes: Vec<(String, String)>,
+}
+
+/// `folder/Note` for a note (with `.md` only if the link had it), the full
+/// file name for anything else.
+fn full_form(path: &str, keep_md: bool) -> String {
+    if FileKind::of(path) == FileKind::Note && !keep_md {
+        path[..path.len() - 3].to_string()
+    } else {
+        path.to_string()
+    }
+}
+
+/// Was this markdown link written relative to its note (rather than from
+/// the vault root)?
+fn is_relative_md(link: &Link, source: &str, target: &str) -> bool {
+    let decoded = link.target.trim_start_matches("./");
+    !link.target.starts_with('/')
+        && (link.target.starts_with("../") || format!("{}/{decoded}", parent(source)).trim_start_matches('/') == target)
+}
+
+/// A markdown destination for `target` relative to `dir`, encoded the way
+/// the original link was.
+fn markdown_dest(link: &Link, text: &str, dir: &str, target: &str) -> String {
+    encode_like(link, text, &relative_path(dir, target))
+}
+
+/// Angle-bracketed destinations are written raw; bare ones are encoded.
+fn encode_like(link: &Link, text: &str, path: &str) -> String {
+    let angle = link.target_span.start > 0 && text.as_bytes()[link.target_span.start - 1] == b'<';
+    if angle {
+        path.to_string()
+    } else {
+        encode_dest(path)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
+        let dir = tempfile::tempdir().unwrap();
+        for (path, text) in files {
+            let p = dir.path().join(path);
+            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+            std::fs::write(p, text).unwrap();
+        }
+        let index = Index::open(dir.path(), false).unwrap();
+        (dir, index)
+    }
+
+    fn read(dir: &tempfile::TempDir, path: &str) -> String {
+        std::fs::read_to_string(dir.path().join(path)).unwrap()
+    }
+
+    #[test]
+    fn rename_rewrites_every_form() {
+        let (dir, mut ix) = vault(&[
+            ("Old.md", "self: [[Old#Top]]\n"),
+            (
+                "notes/A.md",
+                "[[Old]] [[Old#Sec|shown]] ![[Old]] [[Old.md]] [[old]] `[[Old]]` [[Other]]\n\
+                 [md](../Old.md) [md2](<../Old.md#Part>) [root](/Old.md)\n",
+            ),
+            ("B.canvas", "{\n\t\"nodes\":[\n\t\t{\"id\":\"f\",\"type\":\"file\",\"file\":\"Old.md\",\"x\":0,\"y\":0,\"width\":1,\"height\":1},\n\t\t{\"id\":\"t\",\"type\":\"text\",\"text\":\"see [[Old]]\",\"x\":0,\"y\":0,\"width\":1,\"height\":1}\n\t],\n\t\"edges\":[]\n}"),
+            ("Other.md", ""),
+        ]);
+        let plan = ix.rename("Old.md", "moved/New Name.md").unwrap();
+        assert_eq!(plan.to, "moved/New Name.md");
+        assert_eq!(
+            read(&dir, "notes/A.md"),
+            "[[New Name]] [[New Name#Sec|shown]] ![[New Name]] [[New Name.md]] [[New Name]] `[[Old]]` [[Other]]\n\
+             [md](../moved/New%20Name.md) [md2](<../moved/New Name.md#Part>) [root](/moved/New%20Name.md)\n"
+        );
+        assert_eq!(read(&dir, "moved/New Name.md"), "self: [[New Name#Top]]\n");
+        let canvas = read(&dir, "B.canvas");
+        assert!(canvas.contains("\"file\":\"moved/New Name.md\""), "{canvas}");
+        assert!(canvas.contains("\"text\":\"see [[New Name]]\""), "{canvas}");
+        assert!(!dir.path().join("Old.md").exists());
+        assert_eq!(ix.backlinks("moved/New Name.md").len(), 11);
+        assert!(ix.unresolved().is_empty(), "{:?}", ix.unresolved().keys().collect::<Vec<_>>());
+    }
+
+    #[test]
+    fn ambiguous_new_name_is_written_in_full() {
+        let (dir, mut ix) = vault(&[("x/Old.md", ""), ("Taken.md", ""), ("z/L.md", "[[Old]]")]);
+        ix.rename("x/Old.md", "x/Taken.md").unwrap();
+        // `[[Taken]]` from z/ would reach the shorter Taken.md at the root,
+        // so the link names the folder.
+        assert_eq!(read(&dir, "z/L.md"), "[[x/Taken]]");
+        assert_eq!(ix.resolve_text(Some("z/L.md"), "x/Taken").as_deref(), Some("x/Taken.md"));
+    }
+
+    #[test]
+    fn moved_note_keeps_its_own_links() {
+        let (dir, mut ix) = vault(&[
+            ("a/Mover.md", "[[Dup]] [rel](Sib.md) [[Sib]]"),
+            ("a/Dup.md", ""),
+            ("b/Dup.md", ""),
+            ("a/Sib.md", ""),
+        ]);
+        let plan = ix.rename("a/Mover.md", "b/Mover.md").unwrap();
+        // [[Dup]] reached a/Dup.md before; from b/ it would reach b/Dup.md.
+        assert_eq!(read(&dir, "b/Mover.md"), "[[a/Dup]] [rel](../a/Sib.md) [[Sib]]");
+        assert_eq!(plan.edits.len(), 2);
+        let out: Vec<_> = ix.outgoing("b/Mover.md").into_iter().map(|(_, t)| t).collect();
+        assert_eq!(out, [Some("a/Dup.md"), Some("a/Sib.md"), Some("a/Sib.md")]);
+    }
+
+    #[test]
+    fn rename_guards() {
+        let (_d, mut ix) = vault(&[("A.md", ""), ("B.md", "")]);
+        assert!(matches!(ix.rename("Nope.md", "C.md"), Err(WriteError::NotFound(_))));
+        assert!(matches!(ix.rename("A.md", "b.md"), Err(WriteError::Exists(_))));
+        assert!(matches!(ix.rename("A.md", "../C.md"), Err(WriteError::InvalidPath(_))));
+        assert!(matches!(ix.rename("A.md", ".hidden/C.md"), Err(WriteError::InvalidPath(_))));
+        // Case-only rename of the same file is allowed.
+        ix.rename("A.md", "a.md").unwrap();
+        assert!(ix.entry("a.md").is_some());
+    }
+
+    #[test]
+    fn plan_changes_nothing() {
+        let (dir, ix) = vault(&[("A.md", ""), ("L.md", "[[A]]")]);
+        let plan = ix.plan_rename("A.md", "Z.md").unwrap();
+        assert_eq!(plan.edits, [LinkEdit { path: "L.md".into(), line: 0, old: "[[A]]".into(), new: "[[Z]]".into() }]);
+        assert_eq!(read(&dir, "L.md"), "[[A]]");
+        assert!(dir.path().join("A.md").exists());
+    }
+
+    #[test]
+    fn tasks_create_append() {
+        let (dir, mut ix) = vault(&[("T.md", "- [ ] one\n- [x] two\n")]);
+        ix.set_task("T.md", 0, 'x').unwrap();
+        ix.set_task("T.md", 1, ' ').unwrap();
+        assert_eq!(read(&dir, "T.md"), "- [x] one\n- [ ] two\n");
+        assert!(matches!(ix.set_task("T.md", 5, 'x'), Err(WriteError::NoTask { .. })));
+        let open: Vec<_> = ix.tasks().filter(|(_, t)| t.is_open()).map(|(_, t)| t.text.clone()).collect();
+        assert_eq!(open, ["two"]);
+
+        ix.create("new/N.md", "hello [[T]]").unwrap();
+        assert_eq!(ix.backlinks("T.md").len(), 1);
+        assert!(matches!(ix.create("new/n.md", ""), Err(WriteError::Exists(_))));
+        ix.append("new/N.md", "more").unwrap();
+        assert_eq!(read(&dir, "new/N.md"), "hello [[T]]\nmore\n");
+        // No temp files left behind.
+        let stray: Vec<_> = std::fs::read_dir(dir.path().join("new")).unwrap().flatten()
+            .filter(|e| e.file_name().to_string_lossy().starts_with('.')).collect();
+        assert!(stray.is_empty());
+    }
+
+    #[test]
+    fn relative_paths() {
+        assert_eq!(relative_path("a/b", "a/c/x.md"), "../c/x.md");
+        assert_eq!(relative_path("", "x.md"), "x.md");
+        assert_eq!(relative_path("a", "x.md"), "../x.md");
+    }
+}