notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git
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 ¬e.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 `` 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(¬e.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>) \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(¬e.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(¬e.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 ¬e.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");
+ }
+}