generated from eblume/project-template
Compare commits
No commits in common. "main" and "feature/attention-a1-a4" have entirely different histories.
main
...
feature/at
57 changed files with 177 additions and 3161 deletions
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -12,38 +12,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
|
||||
<!-- towncrier release notes start -->
|
||||
|
||||
## [v1.4.3] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- Merge conflicts are now actionable: `heph conflicts list` / `heph conflicts resolve <id> --keep local|remote` in the CLI, and a conflicts view in heph-tui (open with `C`) to review and settle divergent task scalars. Resolving with `--keep remote` actually applies the remote value.
|
||||
- `heph daemon status` now surfaces the daemon's runtime config — version, mode, hub URL, sync poll interval, OIDC issuer — and self-update state (enabled, interval, last check time and outcome).
|
||||
- `heph export` now expands bare `[[NODEID]]` wiki-links to `[[NODEID|Current Name]]` in exported bodies, so exported markdown is readable outside heph.
|
||||
- Path-A hub seeding: `hephd --owner-id <id>` establishes the owner on a fresh store, so a hub can be seeded from a device snapshot that shares its owner id; [[set-up-sync-hub]] documents the recipe.
|
||||
- Projects can be re-parented after creation: `heph project move <project> --parent <new-parent>` (or `--root`), and `m` on a sidebar project in heph-tui opens the parent picker. Cycle-creating moves are rejected.
|
||||
- `heph show` on a task now prints the canonical-context doc body alongside the task scalars, instead of a perpetually-null `body` field hiding the real content.
|
||||
- Sync observability: cycles that move ops log pulled/applied/pushed counts at info level, recovery after consecutive failures is logged explicitly, and repeated identical failures are throttled instead of spamming the log.
|
||||
- heph-tui exposes logs properly: `L` opens a scrollable full-history log view for the selected task (the preview pane previously showed only the last five lines).
|
||||
- heph-tui can rename a task in place (`R`), keeping its canonical-context doc's title in step; rename is undoable with `u`.
|
||||
- Undoable delete: a new `node.restore` op un-tombstones a node (last-writer-wins against tombstones by HLC, derived from the op-log — no schema migration). `heph node restore <id>` and `u` in heph-tui undo task and project deletes.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Deleting a task now also tombstones its canonical-context doc, so the orphaned doc no longer lingers in search (FTS) results; restoring the task brings the doc back.
|
||||
- The hephd sync client can now reach an https hub URL (rustls TLS was not compiled into the HTTP client), and sync errors name the phase and URL instead of a bare "error sending request".
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Manual creation of derived/internal link types is rejected: `links.add` (and so `heph link add`) errors on `wiki` (those rows are materialized from `[[…]]` in the body and were silently reconciled away on the next body write), `canonical-context`, and `log-of`. To make a durable wiki link, put `[[dst]]` in the body.
|
||||
|
||||
|
||||
## [v1.4.2] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- Attention is now set directly instead of cycled, and surfaces it as `a1`–`a4` (a1=red, a2=orange, a3=white, a4=blue) rather than the colour words. In heph-tui press `a` then `1`–`4` to set a band (the old `A` cycle and `b` push-to-blue are retired; quick-add moves to `n`); heph-quickadd and the PWA show the same `a1`–`a4` labels, and the PWA's Attn action now pops a band picker. Quick-add inline syntax changes from `p1`–`p4` to `a1`–`a4` across every capture surface. The `heph` CLI's `-a/--attention` flag now accepts `a1`–`a4`, a bare `1`–`4`, or a colour word (`red`/`orange`/`white`/`blue`). The colour mappings are unchanged.
|
||||
|
||||
|
||||
## [v1.4.1] - 2026-06-08
|
||||
|
||||
### Bug Fixes
|
||||
|
|
|
|||
313
Cargo.lock
generated
313
Cargo.lock
generated
|
|
@ -162,7 +162,7 @@ dependencies = [
|
|||
"android-properties",
|
||||
"bitflags 2.12.1",
|
||||
"cc",
|
||||
"jni 0.22.4",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
|
|
@ -512,28 +512,6 @@ version = "1.5.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
|
|
@ -814,12 +792,6 @@ dependencies = [
|
|||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
|
|
@ -923,15 +895,6 @@ dependencies = [
|
|||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.12.0"
|
||||
|
|
@ -1412,12 +1375,6 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
|
|
@ -1887,12 +1844,6 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
|
|
@ -1985,10 +1936,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2324,7 +2273,6 @@ dependencies = [
|
|||
"heph-core",
|
||||
"jsonwebtoken",
|
||||
"keyring-core",
|
||||
"proptest",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"rsa",
|
||||
|
|
@ -2441,22 +2389,6 @@ dependencies = [
|
|||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
|
|
@ -2704,22 +2636,6 @@ version = "1.0.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
|
|
@ -3005,12 +2921,6 @@ dependencies = [
|
|||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
|
|
@ -3691,12 +3601,6 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.0+3.6.2"
|
||||
|
|
@ -4188,62 +4092,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash 2.1.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
|
|
@ -4505,22 +4353,16 @@ dependencies = [
|
|||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
|
|
@ -4665,7 +4507,6 @@ version = "0.23.40"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
|
|
@ -4675,62 +4516,21 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
|
|
@ -4769,15 +4569,6 @@ dependencies = [
|
|||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
|
|
@ -5510,21 +5301,6 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
|
|
@ -5551,16 +5327,6 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
|
|
@ -6211,7 +5977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"jni 0.22.4",
|
||||
"jni",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"objc2 0.6.4",
|
||||
|
|
@ -6220,15 +5986,6 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
|
|
@ -6697,15 +6454,6 @@ dependencies = [
|
|||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
|
@ -6742,21 +6490,6 @@ dependencies = [
|
|||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6799,12 +6532,6 @@ dependencies = [
|
|||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6817,12 +6544,6 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6835,12 +6556,6 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6865,12 +6580,6 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6883,12 +6592,6 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6901,12 +6604,6 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -6919,12 +6616,6 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
|
|||
|
|
@ -55,13 +55,9 @@ dbus-secret-service-keyring-store = { version = "1", features = [
|
|||
"vendored",
|
||||
] }
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
# rustls: the spoke→hub sync client must reach an https hub url (e.g. a
|
||||
# Caddy front); without a TLS backend compiled in, reqwest fails every https
|
||||
# request with an opaque "error sending request".
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"json",
|
||||
"query",
|
||||
"rustls",
|
||||
] }
|
||||
semver = "1"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@ publish.workspace = true
|
|||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
# Exposes thin public wrappers over crate-private internals (the body CRDT) for
|
||||
# the cargo-fuzz targets in `fuzz/`. Never enabled in normal builds — the
|
||||
# wrappers are test scaffolding, not part of the public API.
|
||||
fuzzing = []
|
||||
|
||||
[dependencies]
|
||||
rusqlite.workspace = true
|
||||
ulid.workspace = true
|
||||
|
|
|
|||
5
crates/heph-core/fuzz/.gitignore
vendored
5
crates/heph-core/fuzz/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
|||
target/
|
||||
corpus/
|
||||
artifacts/
|
||||
coverage/
|
||||
Cargo.lock
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# cargo-fuzz harness for heph-core's parsing/CRDT surfaces. Its own workspace
|
||||
# (the empty `[workspace]` table) so it never pulls into the main build; it is
|
||||
# nightly-only and run ad-hoc via `mise run fuzz`. See docs/how-to/fuzz-testing.md.
|
||||
[package]
|
||||
name = "heph-core-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
|
||||
[dependencies.heph-core]
|
||||
path = ".."
|
||||
features = ["fuzzing"]
|
||||
|
||||
[[bin]]
|
||||
name = "crdt_merge"
|
||||
path = "fuzz_targets/crdt_merge.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "crdt_write"
|
||||
path = "fuzz_targets/crdt_write.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "extract"
|
||||
path = "fuzz_targets/extract.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
#![no_main]
|
||||
//! Fuzz `merge_body` with arbitrary `delta` bytes — the untrusted sync-ingest
|
||||
//! surface. A peer's update payload is decoded and applied here; a crash is a
|
||||
//! remote-input daemon crash. yrs 0.27 is known to `SIGABRT`/UB on some
|
||||
//! malformed inputs (see `crdt::merge_body`'s docs) — surfacing and shrinking
|
||||
//! such an input is exactly this target's job.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (state, body) = heph_core::crdt_fuzz::merge_body(None, data);
|
||||
// Idempotence: applying the same delta again must not change the body.
|
||||
let (_, body_again) = heph_core::crdt_fuzz::merge_body(Some(&state), data);
|
||||
assert_eq!(body, body_again, "merge of the same delta was not idempotent");
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
#![no_main]
|
||||
//! Fuzz `write_body`: diff two arbitrary bodies into the text CRDT and check
|
||||
//! the round-trip — the materialized body and the re-materialized stored state
|
||||
//! must both equal the new body exactly. Stresses the UTF-8 boundary alignment
|
||||
//! in the prefix/suffix diff with arbitrary (incl. multibyte) strings.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
const CLIENT: u64 = 0xAAAA;
|
||||
|
||||
fuzz_target!(|data: (String, String)| {
|
||||
let (prev, new) = data;
|
||||
let (base, _, _) = heph_core::crdt_fuzz::write_body(CLIENT, None, &prev);
|
||||
let (state, _delta, body) = heph_core::crdt_fuzz::write_body(CLIENT, Some(&base), &new);
|
||||
assert_eq!(body, new, "write did not materialize the new body");
|
||||
assert_eq!(
|
||||
heph_core::crdt_fuzz::body_of(&state),
|
||||
new,
|
||||
"stored state did not re-materialize to the new body"
|
||||
);
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
#![no_main]
|
||||
//! Fuzz `extract` over arbitrary markdown. Asserts the invariants promotion and
|
||||
//! the context-item index depend on: wiki-links are non-empty and de-duplicated,
|
||||
//! and `context_item_lines` stays 1:1 with `context_items`.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(s) = std::str::from_utf8(data) else {
|
||||
return;
|
||||
};
|
||||
let e = heph_core::extract(s);
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
for link in &e.wiki_links {
|
||||
assert!(!link.is_empty(), "empty wiki-link target");
|
||||
assert_eq!(link.trim(), link.as_str(), "untrimmed wiki-link target");
|
||||
assert!(seen.insert(link.clone()), "duplicate wiki-link {link:?}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
heph_core::extract::context_item_lines(s).len(),
|
||||
e.context_items.len(),
|
||||
"context_item_lines diverged from context_items",
|
||||
);
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 279ac0171bbd7d502fc4aa546b3b3fbfc8eb01314a5df24c086f0297460ed01c # shrinks to seed = "", delta = [1, 1, 0, 0, 64, 128, 128, 128, 128, 128, 128, 128, 16, 0]
|
||||
|
|
@ -109,71 +109,24 @@ pub(crate) struct BodyMerge {
|
|||
/// Merge a peer's `delta` update into the CRDT seeded from `prev_state`. The
|
||||
/// merging doc never authors, so its `client_id` is irrelevant. Commutative and
|
||||
/// idempotent — applying the same delta twice is a no-op.
|
||||
///
|
||||
/// `delta` arrives from sync peers, so it is untrusted. A delta that fails to
|
||||
/// decode is ignored (a no-op merge). yrs 0.27 is **not** robust to malformed
|
||||
/// update bytes: some inputs trip a `debug_assert!` in its block decoder
|
||||
/// (unwinding panic), and at least one class triggers genuine undefined
|
||||
/// behaviour (an invalid `char`), which surfaces as a non-unwinding `SIGABRT`
|
||||
/// under debug UB-checks and as silent UB in release. The `catch_unwind` below
|
||||
/// contains the unwinding subset so a corrupt payload degrades to a no-op
|
||||
/// merge rather than crashing a debug daemon; it cannot stop the abort/UB
|
||||
/// class. The blast radius is limited — `/sync/push` is authenticated — but a
|
||||
/// buggy or hostile *authenticated* peer can still feed bad bytes here. Beyond
|
||||
/// the unwinding panics, fuzzing also found a tiny delta (`[255,255,255,126]`)
|
||||
/// that drives yrs into a huge allocation (OOM) — `catch_unwind` can't help
|
||||
/// that. The real fix is upstream (or a pre-apply validator yrs doesn't yet
|
||||
/// expose). Findings and the `crdt_merge` fuzz target are documented in
|
||||
/// [[fuzz-testing]].
|
||||
pub(crate) fn merge_body(prev_state: Option<&[u8]>, delta: &[u8]) -> BodyMerge {
|
||||
let merged = std::panic::catch_unwind(|| {
|
||||
let doc = load(0, prev_state);
|
||||
if let Ok(update) = Update::decode_v1(delta) {
|
||||
let mut txn = doc.transact_mut();
|
||||
let _ = txn.apply_update(update);
|
||||
}
|
||||
BodyMerge {
|
||||
state: encode_state(&doc),
|
||||
body: materialize(&doc),
|
||||
}
|
||||
});
|
||||
merged.unwrap_or_else(|_| {
|
||||
let doc = load(0, prev_state);
|
||||
BodyMerge {
|
||||
state: encode_state(&doc),
|
||||
body: materialize(&doc),
|
||||
}
|
||||
})
|
||||
let doc = load(0, prev_state);
|
||||
if let Ok(update) = Update::decode_v1(delta) {
|
||||
let mut txn = doc.transact_mut();
|
||||
let _ = txn.apply_update(update);
|
||||
}
|
||||
BodyMerge {
|
||||
state: encode_state(&doc),
|
||||
body: materialize(&doc),
|
||||
}
|
||||
}
|
||||
|
||||
/// Materialize a stored CRDT state blob to its body text.
|
||||
#[cfg(any(test, feature = "fuzzing"))]
|
||||
#[cfg(test)]
|
||||
pub(crate) fn body_of(state: &[u8]) -> String {
|
||||
materialize(&load(0, Some(state)))
|
||||
}
|
||||
|
||||
/// Thin public wrappers over the crate-private CRDT for the cargo-fuzz targets
|
||||
/// (`fuzz/fuzz_targets/`). Compiled only under the `fuzzing` feature, re-exported
|
||||
/// from the crate root as `crdt_fuzz`. Tuples instead of the private `BodyWrite`
|
||||
/// / `BodyMerge` structs so the fuzz crate needs no access to those types.
|
||||
#[cfg(feature = "fuzzing")]
|
||||
pub mod fuzz {
|
||||
/// `(state, delta, body)` from diffing `new` into the CRDT seeded by `prev`.
|
||||
pub fn write_body(client: u64, prev: Option<&[u8]>, new: &str) -> (Vec<u8>, Vec<u8>, String) {
|
||||
let w = super::write_body(client, prev, new);
|
||||
(w.state, w.delta, w.body)
|
||||
}
|
||||
/// `(state, body)` from merging an untrusted `delta` into `prev`.
|
||||
pub fn merge_body(prev: Option<&[u8]>, delta: &[u8]) -> (Vec<u8>, String) {
|
||||
let m = super::merge_body(prev, delta);
|
||||
(m.state, m.body)
|
||||
}
|
||||
/// Materialize a stored state blob to its body text.
|
||||
pub fn body_of(state: &[u8]) -> String {
|
||||
super::body_of(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Common prefix/suffix diff over byte indices, cut points aligned to UTF-8
|
||||
/// char boundaries. Returns `(start, delete_len, inserted)` such that replacing
|
||||
/// `cur[start..start+delete_len]` with `inserted` yields `new`.
|
||||
|
|
@ -254,62 +207,4 @@ mod tests {
|
|||
assert_eq!(edit.body, "café au lait");
|
||||
assert_eq!(body_of(&edit.state), "café au lait");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_delta_is_a_noop_merge() {
|
||||
// Minimal panicking payload found by proptest: yrs 0.27 hits a debug
|
||||
// assertion in its block decoder on this delta instead of returning
|
||||
// Err. It must degrade to a no-op merge, not crash the daemon.
|
||||
let bad: &[u8] = &[1, 1, 0, 0, 64, 128, 128, 128, 128, 128, 128, 128, 16, 0];
|
||||
let base = write_body(A, None, "hello");
|
||||
let m = merge_body(Some(&base.state), bad);
|
||||
assert_eq!(m.body, "hello");
|
||||
assert_eq!(body_of(&m.state), "hello");
|
||||
}
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
/// A whole-buffer write always materializes exactly the new body — the
|
||||
/// diff's UTF-8 boundary alignment never mangles multibyte text
|
||||
/// (`\PC` generates arbitrary non-control chars, incl. multibyte).
|
||||
#[test]
|
||||
fn write_materializes_exactly(prev in "\\PC{0,80}", new in "\\PC{0,80}") {
|
||||
let base = write_body(A, None, &prev);
|
||||
let w = write_body(A, Some(&base.state), &new);
|
||||
prop_assert_eq!(&w.body, &new);
|
||||
prop_assert_eq!(body_of(&w.state), new);
|
||||
}
|
||||
|
||||
/// Concurrent edits converge to the same body regardless of which side
|
||||
/// merges the other's delta.
|
||||
#[test]
|
||||
fn concurrent_edits_converge(
|
||||
base in "\\PC{0,40}", ea in "\\PC{0,40}", eb in "\\PC{0,40}",
|
||||
) {
|
||||
let b = write_body(A, None, &base);
|
||||
let on_b = merge_body(None, &b.delta);
|
||||
let wa = write_body(A, Some(&b.state), &ea);
|
||||
let wb = write_body(B, Some(&on_b.state), &eb);
|
||||
let fa = merge_body(Some(&wa.state), &wb.delta);
|
||||
let fb = merge_body(Some(&wb.state), &wa.delta);
|
||||
prop_assert_eq!(fa.body, fb.body, "replicas did not converge");
|
||||
}
|
||||
|
||||
/// Applying the same delta twice is a no-op for arbitrary edits.
|
||||
#[test]
|
||||
fn merge_idempotent_for_arbitrary_edits(base in "\\PC{0,40}", new in "\\PC{0,40}") {
|
||||
let b = write_body(A, None, &base);
|
||||
let w = write_body(A, Some(&b.state), &new);
|
||||
let once = merge_body(Some(&b.state), &w.delta);
|
||||
let twice = merge_body(Some(&once.state), &w.delta);
|
||||
prop_assert_eq!(once.body, twice.body);
|
||||
}
|
||||
|
||||
// NB: robustness to *arbitrary* (non-yrs) delta bytes is deliberately
|
||||
// NOT asserted here. yrs 0.27 can `SIGABRT`/UB on malformed updates
|
||||
// (see `merge_body`'s docs and `corrupt_delta_is_a_noop_merge`), which
|
||||
// is uncatchable and would abort the whole test binary. That surface
|
||||
// is fuzzed in the non-blocking Tier 2 `crdt_merge` target instead.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,58 +47,50 @@ pub fn extract(body: &str) -> Extraction {
|
|||
let mut code_ranges: Vec<Range<usize>> = Vec::new();
|
||||
// Depth of nested code blocks; their inner text ranges are code.
|
||||
let mut code_depth: u32 = 0;
|
||||
// One frame per open list item: `Some(index into context_items)` once the
|
||||
// item turns out to carry a task marker. A stack (not a single slot) so a
|
||||
// checklist nested under a checklist item keeps both items — pushed in
|
||||
// marker order, which is what keeps `context_item_lines` aligned 1:1.
|
||||
let mut open_items: Vec<Option<usize>> = Vec::new();
|
||||
// Append `s` to the innermost open task item's label, if any.
|
||||
fn append(items: &mut [ContextItem], open: &[Option<usize>], s: &str) {
|
||||
if let Some(idx) = open.iter().rev().find_map(|f| *f) {
|
||||
items[idx].text.push_str(s);
|
||||
}
|
||||
}
|
||||
// The task item currently being collected, if any: (checked, accumulated text).
|
||||
let mut current: Option<(bool, String)> = None;
|
||||
|
||||
for (event, range) in Parser::new_ext(body, options).into_offset_iter() {
|
||||
match event {
|
||||
Event::Start(Tag::CodeBlock(_)) => code_depth += 1,
|
||||
Event::End(TagEnd::CodeBlock) => code_depth = code_depth.saturating_sub(1),
|
||||
|
||||
Event::Start(Tag::Item) => open_items.push(None),
|
||||
Event::TaskListMarker(checked) => {
|
||||
context_items.push(ContextItem {
|
||||
checked,
|
||||
text: String::new(),
|
||||
});
|
||||
if let Some(frame) = open_items.last_mut() {
|
||||
*frame = Some(context_items.len() - 1);
|
||||
}
|
||||
current = Some((checked, String::new()));
|
||||
}
|
||||
Event::End(TagEnd::Item) => {
|
||||
open_items.pop();
|
||||
if let Some((checked, text)) = current.take() {
|
||||
context_items.push(ContextItem {
|
||||
checked,
|
||||
text: text.trim().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Event::Text(text) => {
|
||||
if code_depth > 0 {
|
||||
code_ranges.push(range);
|
||||
}
|
||||
append(&mut context_items, &open_items, &text);
|
||||
if let Some((_, label)) = current.as_mut() {
|
||||
label.push_str(&text);
|
||||
}
|
||||
}
|
||||
// Inline code is part of an item's visible label, but its contents
|
||||
// are never a wiki-link source.
|
||||
Event::Code(code) => {
|
||||
code_ranges.push(range);
|
||||
append(&mut context_items, &open_items, &code);
|
||||
if let Some((_, label)) = current.as_mut() {
|
||||
label.push_str(&code);
|
||||
}
|
||||
}
|
||||
Event::SoftBreak | Event::HardBreak => {
|
||||
append(&mut context_items, &open_items, " ");
|
||||
if let Some((_, label)) = current.as_mut() {
|
||||
label.push(' ');
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for item in &mut context_items {
|
||||
item.text = item.text.trim().to_string();
|
||||
}
|
||||
|
||||
// Scan the raw body for wiki-links (CommonMark mangles `[[ ]]` brackets, so
|
||||
// we can't rely on Text events), excluding any that start inside code.
|
||||
|
|
@ -251,28 +243,6 @@ mod tests {
|
|||
assert_eq!(lines, vec![2, 8]); // 0-based lines of "- [ ] first" / "- [x] second"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_checkbox_items_are_both_extracted_in_order() {
|
||||
// A checklist nested under a checklist item: both are real items, in
|
||||
// document order, and `context_item_lines` must stay aligned 1:1.
|
||||
let body = "- [ ] outer\n - [x] inner\n";
|
||||
let e = extract(body);
|
||||
assert_eq!(
|
||||
e.context_items,
|
||||
vec![
|
||||
ContextItem {
|
||||
text: "outer".to_string(),
|
||||
checked: false
|
||||
},
|
||||
ContextItem {
|
||||
text: "inner".to_string(),
|
||||
checked: true
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(context_item_lines(body), vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extraction_is_idempotent() {
|
||||
let body = "# Mixed\n\n- [ ] do [[X]]\n- [x] done\n\nsee [[Y]]\n";
|
||||
|
|
@ -283,55 +253,4 @@ mod tests {
|
|||
fn body_without_links_or_items_yields_empty() {
|
||||
assert_eq!(extract("just prose, no structure"), Extraction::default());
|
||||
}
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// Bodies stitched from markdown-ish fragments — checklists (incl. nested),
|
||||
/// code fences, links (well-formed, empty, unterminated), and arbitrary
|
||||
/// text — to stress structure the unit tests don't enumerate.
|
||||
fn markdownish() -> impl Strategy<Value = String> {
|
||||
let frag = prop_oneof![
|
||||
Just("- [ ] feed birds\n".to_string()),
|
||||
Just("- [x] done [[Roof]]\n".to_string()),
|
||||
Just(" - [X] nested\n".to_string()),
|
||||
Just("* [ ] star\n".to_string()),
|
||||
Just("+ [x] plus\n".to_string()),
|
||||
Just("- plain item\n".to_string()),
|
||||
Just("```\n".to_string()),
|
||||
Just("# Heading\n".to_string()),
|
||||
Just("> quote\n".to_string()),
|
||||
Just("[[Roof]] ".to_string()),
|
||||
Just("[[Roof|the roof]] ".to_string()),
|
||||
Just("[[ ]] ".to_string()),
|
||||
Just("[[unterminated ".to_string()),
|
||||
Just("]] stray ".to_string()),
|
||||
Just("`- [ ] code`\n".to_string()),
|
||||
Just("\n".to_string()),
|
||||
"\\PC{0,12}",
|
||||
];
|
||||
proptest::collection::vec(frag, 0..12).prop_map(|v| v.concat())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Derivation is total (no panic) and idempotent for arbitrary input.
|
||||
#[test]
|
||||
fn extract_is_total_and_idempotent(body in "\\PC{0,300}") {
|
||||
prop_assert_eq!(extract(&body), extract(&body));
|
||||
}
|
||||
|
||||
/// Links are non-empty, trimmed, deduped; and `context_item_lines`
|
||||
/// aligns 1:1 with `context_items` — the invariant promotion's
|
||||
/// line-rewriting depends on (see [`context_item_lines`]).
|
||||
#[test]
|
||||
fn invariants_hold_for_markdownish_bodies(body in markdownish()) {
|
||||
let e = extract(&body);
|
||||
let mut seen = HashSet::new();
|
||||
for l in &e.wiki_links {
|
||||
prop_assert!(!l.is_empty());
|
||||
prop_assert_eq!(l.trim(), l.as_str());
|
||||
prop_assert!(seen.insert(l.clone()), "duplicate link {:?}", l);
|
||||
}
|
||||
prop_assert_eq!(context_item_lines(&body).len(), e.context_items.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,35 +89,4 @@ mod tests {
|
|||
let body = "---\nid: x\n---\nbody\n\n---\n\nmore\n";
|
||||
assert_eq!(strip(body), "body\n\n---\n\nmore\n");
|
||||
}
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// Frontmatter-shaped fragments: fences, key lines, prose, and noise.
|
||||
fn frontmatterish() -> impl Strategy<Value = String> {
|
||||
let frag = prop_oneof![
|
||||
Just("---\n".to_string()),
|
||||
Just("---".to_string()),
|
||||
Just("id: x\n".to_string()),
|
||||
Just("title: Roof\n".to_string()),
|
||||
Just("not a key line\n".to_string()),
|
||||
Just("\n".to_string()),
|
||||
Just("# Heading\n".to_string()),
|
||||
"\\PC{0,12}",
|
||||
];
|
||||
proptest::collection::vec(frag, 0..8).prop_map(|v| v.concat())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// `strip` is total and only ever removes a prefix: the result is
|
||||
/// always a suffix of the input, and a body with no opening fence is
|
||||
/// returned untouched.
|
||||
#[test]
|
||||
fn strip_returns_a_suffix(body in frontmatterish()) {
|
||||
let out = strip(&body);
|
||||
prop_assert!(body.ends_with(out));
|
||||
if !body.starts_with("---\n") {
|
||||
prop_assert_eq!(out, body.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,12 +218,5 @@ mod tests {
|
|||
let b = Hlc { physical: p2, counter: c2, origin: o2 };
|
||||
prop_assert_eq!(a.cmp(&b), a.encode().cmp(&b.encode()));
|
||||
}
|
||||
|
||||
/// Sync cursors arrive over the wire — parsing arbitrary strings must
|
||||
/// return an error, never panic.
|
||||
#[test]
|
||||
fn parse_never_panics(s in "\\PC{0,60}") {
|
||||
let _ = Hlc::parse(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("HEPH_BU
|
|||
|
||||
pub mod clock;
|
||||
mod crdt;
|
||||
/// Public CRDT wrappers for the cargo-fuzz targets (`fuzzing` feature only).
|
||||
#[cfg(feature = "fuzzing")]
|
||||
pub use crdt::fuzz as crdt_fuzz;
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod extract;
|
||||
|
|
|
|||
|
|
@ -19,10 +19,6 @@ pub mod op_type {
|
|||
pub const NODE_SET: &str = "node.set";
|
||||
/// A node was tombstoned. Payload: `{}`.
|
||||
pub const NODE_TOMBSTONE: &str = "node.tombstone";
|
||||
/// A tombstoned node was restored. Payload: `{}`. Tombstone/restore are an
|
||||
/// LWW pair keyed by their own op HLCs: the latest of the two op kinds
|
||||
/// targeting a node decides its tombstone state on merge.
|
||||
pub const NODE_RESTORE: &str = "node.restore";
|
||||
/// A task row was created. Payload: the task scalars.
|
||||
pub const TASK_CREATE: &str = "task.create";
|
||||
/// One or more task scalars were set (LWW). Payload: the changed scalars.
|
||||
|
|
|
|||
|
|
@ -181,31 +181,5 @@ mod tests {
|
|||
let once = reset_checkboxes(&body);
|
||||
prop_assert_eq!(reset_checkboxes(&once), once);
|
||||
}
|
||||
|
||||
/// For an infinite rule, the next occurrence exists and is strictly
|
||||
/// after `after` — roll-forward can never schedule into the past.
|
||||
#[test]
|
||||
fn next_is_strictly_after(
|
||||
freq in proptest::sample::select(vec!["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]),
|
||||
interval in 1u32..5,
|
||||
gap_days in 0i64..400,
|
||||
) {
|
||||
let rrule = format!("FREQ={freq};INTERVAL={interval}");
|
||||
let after = JAN1 + gap_days * ONE_DAY;
|
||||
let next = next_occurrence(&rrule, JAN1, after).unwrap();
|
||||
let t = next.expect("infinite rule always has a next instance");
|
||||
prop_assert!(t > after);
|
||||
}
|
||||
|
||||
/// RRULEs are stored strings that may come from old data or other
|
||||
/// writers — arbitrary input must error, never panic.
|
||||
#[test]
|
||||
fn arbitrary_rrule_never_panics(
|
||||
s in "\\PC{0,60}",
|
||||
anchor in proptest::num::i64::ANY,
|
||||
after in proptest::num::i64::ANY,
|
||||
) {
|
||||
let _ = next_occurrence(&s, anchor, after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@
|
|||
//! scalar value from a different device is recorded in `conflicts` (surfaced,
|
||||
//! not silently dropped).
|
||||
//! - **links:** OR-set add/remove keyed by the link's own id → no conflicts.
|
||||
//! - **tombstones:** an LWW pair with restores — the latest
|
||||
//! `node.tombstone`/`node.restore` op (by its own HLC, read from the op-log)
|
||||
//! decides a node's tombstone state. With no restore in play this degrades
|
||||
//! to the old monotonic rule: a tombstone stays.
|
||||
//! - **tombstones:** monotonic — once set, they stay.
|
||||
//!
|
||||
//! Idempotent: an op whose id we've already stored is a no-op. The local clock
|
||||
//! absorbs each op's HLC so future local stamps stay ahead.
|
||||
|
|
@ -22,9 +19,9 @@ use serde_json::Value;
|
|||
|
||||
use super::{absorb_remote_hlc, new_id, nodes, ops};
|
||||
use crate::crdt;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::Result;
|
||||
use crate::hlc::Hlc;
|
||||
use crate::model::{Conflict, TaskState};
|
||||
use crate::model::Conflict;
|
||||
use crate::oplog::{op_type, Op};
|
||||
|
||||
/// Open conflicts for `owner`, newest first.
|
||||
|
|
@ -49,71 +46,14 @@ pub(super) fn list_conflicts(conn: &Connection, owner: &str) -> Result<Vec<Confl
|
|||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Settle a conflict by the user's choice (`"local"`/`"remote"`): the chosen
|
||||
/// value is **applied** to the task and recorded as a new `task.set` op (so
|
||||
/// peers converge on the decision), then the row is marked resolved. The LWW
|
||||
/// winner may have been either side, so the chosen value is written
|
||||
/// unconditionally — a no-op write when it already matches.
|
||||
pub(super) fn resolve_conflict(
|
||||
conn: &mut Connection,
|
||||
owner: &str,
|
||||
now: i64,
|
||||
id: &str,
|
||||
choice: &str,
|
||||
) -> Result<()> {
|
||||
let row: Option<(String, String, Option<String>, Option<String>)> = conn
|
||||
.query_row(
|
||||
"SELECT node_id, field, local_val, remote_val
|
||||
FROM conflicts WHERE id = ?1 AND status = 'open'",
|
||||
[id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((node_id, field, local_val, remote_val)) = row else {
|
||||
return Err(Error::InvalidArg(format!("no open conflict {id}")));
|
||||
};
|
||||
let chosen = match choice {
|
||||
"local" => local_val,
|
||||
"remote" => remote_val,
|
||||
other => {
|
||||
return Err(Error::InvalidArg(format!(
|
||||
"choice must be \"local\" or \"remote\", got {other:?}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
match field.as_str() {
|
||||
"do_date" => {
|
||||
let v: Option<i64> =
|
||||
chosen.as_deref().map(str::parse).transpose().map_err(|_| {
|
||||
Error::Integrity(format!("conflict {id}: do_date not an integer"))
|
||||
})?;
|
||||
tx.execute(
|
||||
"UPDATE tasks SET do_date = ?1 WHERE node_id = ?2",
|
||||
(v, &node_id),
|
||||
)?;
|
||||
}
|
||||
"state" => {
|
||||
let v = chosen.as_deref().unwrap_or("outstanding");
|
||||
TaskState::parse(v)?; // validate before writing the raw string
|
||||
tx.execute(
|
||||
"UPDATE tasks SET state = ?1 WHERE node_id = ?2",
|
||||
(v, &node_id),
|
||||
)?;
|
||||
}
|
||||
other => {
|
||||
return Err(Error::Integrity(format!(
|
||||
"conflict {id} has unknown field {other:?}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
super::tasks::record_set(&tx, owner, now, &node_id)?;
|
||||
tx.execute(
|
||||
/// Settle a conflict. v1 records the user's choice by marking it resolved; the
|
||||
/// LWW winner is already materialized (choosing the loser's value is a
|
||||
/// follow-up — see [[design]]).
|
||||
pub(super) fn resolve_conflict(conn: &Connection, id: &str, _choice: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE conflicts SET status = 'resolved' WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -138,8 +78,8 @@ pub(super) fn apply(conn: &mut Connection, op: &Op) -> Result<bool> {
|
|||
node_upsert(&tx, op)?;
|
||||
true
|
||||
}
|
||||
op_type::NODE_TOMBSTONE | op_type::NODE_RESTORE => {
|
||||
node_tombstone_state(&tx, op)?;
|
||||
op_type::NODE_TOMBSTONE => {
|
||||
node_tombstone(&tx, op)?;
|
||||
true
|
||||
}
|
||||
op_type::TASK_CREATE | op_type::TASK_SET => {
|
||||
|
|
@ -264,44 +204,19 @@ fn node_upsert(tx: &Connection, op: &Op) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge a `node.tombstone` or `node.restore` op. The two are an LWW pair
|
||||
/// keyed by their **own** op HLCs (not the node's `hlc`, which unrelated
|
||||
/// scalar ops bump): the latest tombstone-state op targeting the node decides.
|
||||
/// Derived from the op-log already on hand, so no schema change (see
|
||||
/// [[hub-spoke-data-evolution]]). Bump the node hlc only if this op is newer.
|
||||
fn node_tombstone_state(tx: &Connection, op: &Op) -> Result<()> {
|
||||
let Some(existing) = nodes::get(tx, &op.target_id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// The latest tombstone-state op we already know of (this op isn't in the
|
||||
// log yet — `apply` inserts it after the handlers run).
|
||||
let known: Option<(String, String)> = tx
|
||||
.query_row(
|
||||
"SELECT hlc, op_type FROM oplog
|
||||
WHERE target_id = ?1 AND op_type IN (?2, ?3)
|
||||
ORDER BY hlc DESC LIMIT 1",
|
||||
(
|
||||
&op.target_id,
|
||||
op_type::NODE_TOMBSTONE,
|
||||
op_type::NODE_RESTORE,
|
||||
),
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let winner = match known {
|
||||
Some((h, t)) if h.as_str() > op.hlc.as_str() => t,
|
||||
_ => op.op_type.clone(),
|
||||
};
|
||||
let tombstoned = winner == op_type::NODE_TOMBSTONE;
|
||||
let hlc = if op.hlc.as_str() > existing.hlc.as_str() {
|
||||
op.hlc.clone()
|
||||
} else {
|
||||
existing.hlc
|
||||
};
|
||||
tx.execute(
|
||||
"UPDATE nodes SET tombstoned = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
|
||||
(tombstoned as i64, op_physical(op), hlc, &op.target_id),
|
||||
)?;
|
||||
fn node_tombstone(tx: &Connection, op: &Op) -> Result<()> {
|
||||
// Monotonic: tombstone always wins. Bump hlc only if this op is newer.
|
||||
if let Some(existing) = nodes::get(tx, &op.target_id)? {
|
||||
let hlc = if op.hlc.as_str() > existing.hlc.as_str() {
|
||||
op.hlc.clone()
|
||||
} else {
|
||||
existing.hlc.clone()
|
||||
};
|
||||
tx.execute(
|
||||
"UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3",
|
||||
(op_physical(op), hlc, &op.target_id),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,20 +23,9 @@ pub(super) fn export(conn: &Connection, owner: &str, dir: &Path) -> Result<usize
|
|||
|
||||
let mut count = 0;
|
||||
for id in ids {
|
||||
let Some(mut node) = nodes::get(conn, &id)? else {
|
||||
let Some(node) = nodes::get(conn, &id)? else {
|
||||
continue;
|
||||
};
|
||||
// Expand bare [[NODEID]] links to [[NODEID|Current Name]] (§8.4) so the
|
||||
// exported markdown reads outside heph; custom labels stay as-authored.
|
||||
if let Some(body) = node.body.as_deref() {
|
||||
node.body = Some(crate::wikilink::expand(body, |target| {
|
||||
nodes::get(conn, target)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|n| !n.tombstoned && n.owner_id == owner)
|
||||
.map(|n| n.title)
|
||||
}));
|
||||
}
|
||||
let task = if node.kind == NodeKind::Task {
|
||||
tasks::get(conn, &id)?
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -103,17 +103,6 @@ pub(super) fn outgoing(conn: &Connection, id: &str) -> Result<Vec<Link>> {
|
|||
query(conn, "src_id", id)
|
||||
}
|
||||
|
||||
/// A node's internal attachments — the docs reached by `canonical-context` /
|
||||
/// `log-of` links — which live and die with it: tombstoning the node cascades
|
||||
/// to them (no orphaned FTS leftovers) and restoring it revives them.
|
||||
pub(super) fn attachment_ids(conn: &Connection, src_id: &str) -> Result<Vec<String>> {
|
||||
Ok(outgoing(conn, src_id)?
|
||||
.into_iter()
|
||||
.filter(|l| matches!(l.link_type, LinkType::CanonicalContext | LinkType::LogOf))
|
||||
.map(|l| l.dst_id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// All non-tombstoned links pointing at `id`.
|
||||
pub(super) fn backlinks(conn: &Connection, id: &str) -> Result<Vec<Link>> {
|
||||
query(conn, "dst_id", id)
|
||||
|
|
|
|||
|
|
@ -199,28 +199,7 @@ impl Store for LocalStore {
|
|||
|
||||
fn tombstone_node(&mut self, id: &str) -> Result<()> {
|
||||
let now = self.clock.now_ms();
|
||||
let tx = self.conn.transaction()?;
|
||||
// A task's internal attachments (canonical-context doc, log doc) die
|
||||
// with it — otherwise they linger in FTS as orphans.
|
||||
let attachments = links::attachment_ids(&tx, id)?;
|
||||
nodes::tombstone(&tx, &self.owner_id, now, id)?;
|
||||
for doc in &attachments {
|
||||
nodes::tombstone(&tx, &self.owner_id, now, doc)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_node(&mut self, id: &str) -> Result<()> {
|
||||
let now = self.clock.now_ms();
|
||||
let tx = self.conn.transaction()?;
|
||||
let attachments = links::attachment_ids(&tx, id)?;
|
||||
nodes::restore(&tx, &self.owner_id, now, id)?;
|
||||
for doc in &attachments {
|
||||
nodes::restore(&tx, &self.owner_id, now, doc)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
nodes::tombstone(&self.conn, &self.owner_id, now, id)
|
||||
}
|
||||
|
||||
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
|
||||
|
|
@ -269,11 +248,6 @@ impl Store for LocalStore {
|
|||
tasks::delete_project(&mut self.conn, &self.owner_id, now, project_id)
|
||||
}
|
||||
|
||||
fn reparent_project(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
|
||||
let now = self.clock.now_ms();
|
||||
tasks::reparent_project(&mut self.conn, &self.owner_id, now, project_id, parent_id)
|
||||
}
|
||||
|
||||
fn promote(
|
||||
&mut self,
|
||||
container_id: &str,
|
||||
|
|
@ -345,27 +319,6 @@ impl Store for LocalStore {
|
|||
}
|
||||
|
||||
fn add_link(&mut self, src_id: &str, dst_id: &str, link_type: LinkType) -> Result<Link> {
|
||||
// The derived/internal link kinds are never created by hand: a manual
|
||||
// `wiki` row would be silently reconciled away on the next body write
|
||||
// (the body is the source of truth), and canonical-context / log-of
|
||||
// are task-creation internals. Internal materialization goes through
|
||||
// `links::add` directly and is unaffected.
|
||||
match link_type {
|
||||
LinkType::Wiki => {
|
||||
return Err(Error::InvalidArg(
|
||||
"wiki links are derived from the body — add [[<dst>]] to the node's body \
|
||||
instead (e.g. heph context <task> --append)"
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
LinkType::CanonicalContext | LinkType::LogOf => {
|
||||
return Err(Error::InvalidArg(format!(
|
||||
"{} links are internal task attachments and cannot be added by hand",
|
||||
link_type.as_str()
|
||||
)))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let now = self.clock.now_ms();
|
||||
links::add(&self.conn, &self.owner_id, now, src_id, dst_id, link_type)
|
||||
}
|
||||
|
|
@ -511,9 +464,7 @@ impl Store for LocalStore {
|
|||
}
|
||||
|
||||
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()> {
|
||||
let now = self.clock.now_ms();
|
||||
let owner = self.owner_id.clone();
|
||||
apply::resolve_conflict(&mut self.conn, &owner, now, id, choice)
|
||||
apply::resolve_conflict(&self.conn, id, choice)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -431,32 +431,17 @@ pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> {
|
|||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3); a
|
||||
/// tombstone can be undone by [`restore`], the two merging as an LWW pair by
|
||||
/// op HLC.
|
||||
/// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge
|
||||
/// monotonic (tech-spec §4.3).
|
||||
pub(super) fn tombstone(conn: &Connection, owner: &str, now: i64, id: &str) -> Result<()> {
|
||||
set_tombstoned(conn, owner, now, id, true)
|
||||
}
|
||||
|
||||
/// Restore (un-tombstone) a node — the undo of [`tombstone`].
|
||||
pub(super) fn restore(conn: &Connection, owner: &str, now: i64, id: &str) -> Result<()> {
|
||||
set_tombstoned(conn, owner, now, id, false)
|
||||
}
|
||||
|
||||
fn set_tombstoned(conn: &Connection, owner: &str, now: i64, id: &str, dead: bool) -> Result<()> {
|
||||
let hlc = next_hlc(conn, now)?;
|
||||
let updated = conn.execute(
|
||||
"UPDATE nodes SET tombstoned = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
|
||||
(dead as i64, now, &hlc, id),
|
||||
"UPDATE nodes SET tombstoned = 1, modified_at = ?1, hlc = ?2 WHERE id = ?3",
|
||||
(now, &hlc, id),
|
||||
)?;
|
||||
if updated == 0 {
|
||||
return Err(Error::NodeNotFound(id.to_string()));
|
||||
}
|
||||
let op = if dead {
|
||||
op_type::NODE_TOMBSTONE
|
||||
} else {
|
||||
op_type::NODE_RESTORE
|
||||
};
|
||||
ops::record(conn, owner, &hlc, op, id, json!({}))?;
|
||||
ops::record(conn, owner, &hlc, op_type::NODE_TOMBSTONE, id, json!({}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ fn scalar_payload(t: &Task) -> serde_json::Value {
|
|||
|
||||
/// Bump the task node's hlc/modified_at and record a `task.set` op snapshotting
|
||||
/// the task's current scalars (LWW unit, tech-spec §12).
|
||||
pub(super) fn record_set(conn: &Connection, owner: &str, now: i64, node_id: &str) -> Result<()> {
|
||||
fn record_set(conn: &Connection, owner: &str, now: i64, node_id: &str) -> Result<()> {
|
||||
let task = require(conn, node_id)?;
|
||||
let hlc = next_hlc(conn, now)?;
|
||||
conn.execute(
|
||||
|
|
@ -662,51 +662,6 @@ pub(super) fn delete_project(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Change a project's parent (re-parent), or detach it to the root when
|
||||
/// `parent_id` is `None` — the post-creation counterpart of
|
||||
/// `project add --parent` (§8.1). OR-set link semantics: tombstone the
|
||||
/// project's existing `parent` links, then add the new one. Rejects
|
||||
/// non-project endpoints and any `parent_id` inside the project's own subtree
|
||||
/// (which includes itself), so the tree stays acyclic.
|
||||
pub(super) fn reparent_project(
|
||||
conn: &mut Connection,
|
||||
owner: &str,
|
||||
now: i64,
|
||||
project_id: &str,
|
||||
parent_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let project =
|
||||
nodes::get(conn, project_id)?.ok_or_else(|| Error::NodeNotFound(project_id.into()))?;
|
||||
if project.tombstoned || project.kind != NodeKind::Project {
|
||||
return Err(Error::InvalidArg(format!(
|
||||
"{project_id} is not a project node"
|
||||
)));
|
||||
}
|
||||
if let Some(pid) = parent_id {
|
||||
let parent = nodes::get(conn, pid)?.ok_or_else(|| Error::NodeNotFound(pid.into()))?;
|
||||
if parent.tombstoned || parent.kind != NodeKind::Project {
|
||||
return Err(Error::InvalidArg(format!("{pid} is not a project node")));
|
||||
}
|
||||
if links::project_subtree(conn, project_id)?.contains(&pid.to_string()) {
|
||||
return Err(Error::InvalidArg(format!(
|
||||
"re-parenting {project_id} under {pid} would create a cycle"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
for link in links::outgoing(&tx, project_id)? {
|
||||
if link.link_type == LinkType::Parent {
|
||||
links::tombstone(&tx, owner, now, &link.id)?;
|
||||
}
|
||||
}
|
||||
if let Some(pid) = parent_id {
|
||||
links::add(&tx, owner, now, project_id, pid, LinkType::Parent)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a partial schedule update (do-date / late-on / recurrence) — the
|
||||
/// "reschedule" path (tech-spec §6). Reads the current row, overlays the
|
||||
/// present `patch` fields (a double-option per field: absent = leave, `null` =
|
||||
|
|
|
|||
|
|
@ -38,15 +38,9 @@ pub trait Store {
|
|||
body: Option<String>,
|
||||
) -> Result<Node>;
|
||||
|
||||
/// Tombstone (soft-delete) a node, cascading to its internal attachments
|
||||
/// (canonical-context doc, log doc). No hard deletes (tech-spec §4.3).
|
||||
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3).
|
||||
fn tombstone_node(&mut self, id: &str) -> Result<()>;
|
||||
|
||||
/// Restore (un-tombstone) a node and its internal attachments — the undo
|
||||
/// of [`Store::tombstone_node`]. On merge, tombstone/restore are an LWW
|
||||
/// pair keyed by their own op HLCs: the later op decides.
|
||||
fn restore_node(&mut self, id: &str) -> Result<()>;
|
||||
|
||||
/// List non-tombstoned nodes (owner-scoped), optionally filtered by `kind`,
|
||||
/// ordered by title. The enumeration surfaces (projects, tags) build on this
|
||||
/// (tech-spec §6 `node.list`).
|
||||
|
|
@ -104,12 +98,6 @@ pub trait Store {
|
|||
/// the project node. Tasks are preserved, never deleted.
|
||||
fn delete_project(&mut self, project_id: &str) -> Result<()>;
|
||||
|
||||
/// Change a project's parent, or detach it to the root when `parent_id` is
|
||||
/// `None` — the post-creation counterpart of `project add --parent`
|
||||
/// (tech-spec §8.1). Rejects non-project endpoints and cycle-creating
|
||||
/// moves.
|
||||
fn reparent_project(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()>;
|
||||
|
||||
/// Promote a `- [ ]` context-item line in `container_id`'s body into a
|
||||
/// committed task, rewriting that source line into a `[[link]]` to the new
|
||||
/// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the
|
||||
|
|
@ -257,8 +245,6 @@ pub trait Store {
|
|||
/// Open merge conflicts surfaced for the user (`heph conflicts`).
|
||||
fn conflicts_list(&self) -> Result<Vec<Conflict>>;
|
||||
|
||||
/// Settle a conflict by the user's choice (`"local"`/`"remote"`): the
|
||||
/// chosen value is applied to the task and recorded as a new op, so peers
|
||||
/// converge on the decision; the conflict row is marked resolved.
|
||||
/// Settle a conflict by the user's choice (`"local"`/`"remote"`).
|
||||
fn conflicts_resolve(&mut self, id: &str, choice: &str) -> Result<()>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,41 +148,4 @@ mod tests {
|
|||
assert_eq!(expand("dangling [[01ID", &t), "dangling [[01ID");
|
||||
assert_eq!(expand("", &t), "");
|
||||
}
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// Bodies stitched from canonical link forms (bare/labelled/legacy), broken
|
||||
/// fences, and bracket-free filler. Targets are unpadded — at-rest links
|
||||
/// are canonical — so the collapse∘expand law below holds exactly.
|
||||
fn linky() -> impl Strategy<Value = String> {
|
||||
let frag = prop_oneof![
|
||||
Just("[[01ID]]".to_string()),
|
||||
Just("[[02ID]]".to_string()),
|
||||
Just("[[01ID|Roof]]".to_string()),
|
||||
Just("[[02ID|Garden]]".to_string()),
|
||||
Just("[[01ID|custom label]]".to_string()),
|
||||
Just("[[unknown]]".to_string()),
|
||||
Just("[[Some Title|text]]".to_string()),
|
||||
Just("[[".to_string()),
|
||||
Just("]]".to_string()),
|
||||
Just(" plain text ".to_string()),
|
||||
"[^\\[\\]]{0,10}",
|
||||
];
|
||||
proptest::collection::vec(frag, 0..10).prop_map(|v| v.concat())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Both projections are idempotent, and the read→write round-trip law
|
||||
/// holds: what a client echoes back after an expand collapses to the
|
||||
/// same at-rest body a direct collapse would produce.
|
||||
#[test]
|
||||
fn expand_collapse_idempotent_and_round_trip(body in linky()) {
|
||||
let t = titles();
|
||||
let e = expand(&body, &t);
|
||||
prop_assert_eq!(expand(&e, &t), e.clone(), "expand not idempotent");
|
||||
let c = collapse(&body, &t);
|
||||
prop_assert_eq!(collapse(&c, &t), c.clone(), "collapse not idempotent");
|
||||
prop_assert_eq!(collapse(&e, &t), c, "collapse(expand(x)) != collapse(x)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -267,134 +267,3 @@ fn tombstones_propagate_and_are_monotonic() {
|
|||
// Tombstoned nodes drop out of search/next on B.
|
||||
assert!(b.search("doomed").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_undoes_a_tombstone_and_propagates() {
|
||||
let (mut a, ca) = replica(1000);
|
||||
let (mut b, _cb) = replica(1000);
|
||||
let n = a.create_node(NewNode::doc("phoenix", "rises")).unwrap();
|
||||
sync_one_way(&a, &mut b, None);
|
||||
|
||||
ca.set(2000);
|
||||
a.tombstone_node(&n.id).unwrap();
|
||||
ca.set(3000);
|
||||
a.restore_node(&n.id).unwrap();
|
||||
sync_one_way(&a, &mut b, None);
|
||||
|
||||
assert!(!a.get_node(&n.id).unwrap().unwrap().tombstoned);
|
||||
assert!(!b.get_node(&n.id).unwrap().unwrap().tombstoned);
|
||||
// Back in search on both replicas.
|
||||
assert!(!b.search("phoenix").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_tombstone_and_restore_converge_to_the_later_op() {
|
||||
// Tombstone/restore are an LWW pair keyed by their own op HLCs: whichever
|
||||
// was written later wins on every replica, regardless of arrival order.
|
||||
let (mut a, ca) = replica(1000);
|
||||
let (mut b, cb) = replica(1000);
|
||||
let n = a.create_node(NewNode::doc("contested", "")).unwrap();
|
||||
ca.set(1500);
|
||||
a.tombstone_node(&n.id).unwrap();
|
||||
sync_one_way(&a, &mut b, None);
|
||||
|
||||
// Offline: A restores at t=2000; B re-tombstones later at t=3000.
|
||||
ca.set(2000);
|
||||
a.restore_node(&n.id).unwrap();
|
||||
cb.set(3000);
|
||||
b.restore_node(&n.id).unwrap();
|
||||
cb.set(3500);
|
||||
b.tombstone_node(&n.id).unwrap();
|
||||
|
||||
sync_one_way(&a, &mut b, None);
|
||||
sync_one_way(&b, &mut a, None);
|
||||
|
||||
// B's tombstone (t=3500) is the latest tombstone-state op → dead on both.
|
||||
assert!(a.get_node(&n.id).unwrap().unwrap().tombstoned);
|
||||
assert!(b.get_node(&n.id).unwrap().unwrap().tombstoned);
|
||||
|
||||
// And the mirror image: a restore written later than a tombstone wins.
|
||||
let (mut c, cc) = replica(1000);
|
||||
let (mut d, cd) = replica(1000);
|
||||
let m = c.create_node(NewNode::doc("survivor", "")).unwrap();
|
||||
sync_one_way(&c, &mut d, None);
|
||||
cc.set(2000);
|
||||
c.tombstone_node(&m.id).unwrap();
|
||||
cd.set(3000);
|
||||
d.tombstone_node(&m.id).unwrap();
|
||||
cd.set(3500);
|
||||
d.restore_node(&m.id).unwrap();
|
||||
sync_one_way(&c, &mut d, None);
|
||||
sync_one_way(&d, &mut c, None);
|
||||
assert!(!c.get_node(&m.id).unwrap().unwrap().tombstoned);
|
||||
assert!(!d.get_node(&m.id).unwrap().unwrap().tombstoned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_a_conflict_applies_the_chosen_value_and_propagates() {
|
||||
let (mut a, ca) = replica(1000);
|
||||
let (mut b, cb) = replica(1000);
|
||||
let task = a
|
||||
.create_task(NewTask {
|
||||
title: "Water plants".into(),
|
||||
do_date: Some(5_000),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
sync_one_way(&a, &mut b, None);
|
||||
|
||||
// Divergent offline do_dates; B's is later → wins LWW on both sides.
|
||||
ca.set(2000);
|
||||
a.set_task_schedule(
|
||||
&task.node_id,
|
||||
heph_core::SchedulePatch {
|
||||
do_date: Some(Some(7_000)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
cb.set(3000);
|
||||
b.set_task_schedule(
|
||||
&task.node_id,
|
||||
heph_core::SchedulePatch {
|
||||
do_date: Some(Some(9_000)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
sync_one_way(&a, &mut b, None);
|
||||
sync_one_way(&b, &mut a, None);
|
||||
assert_eq!(
|
||||
a.get_task(&task.node_id).unwrap().unwrap().do_date,
|
||||
Some(9_000)
|
||||
);
|
||||
|
||||
// A resolves its conflict keeping the LOCAL (losing) value: the choice is
|
||||
// applied, not just recorded…
|
||||
let conflict = a
|
||||
.conflicts_list()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|c| c.field == "do_date")
|
||||
.expect("A recorded a do_date conflict");
|
||||
ca.set(4000);
|
||||
a.conflicts_resolve(&conflict.id, "local").unwrap();
|
||||
assert_eq!(
|
||||
a.get_task(&task.node_id).unwrap().unwrap().do_date,
|
||||
Some(7_000)
|
||||
);
|
||||
assert!(
|
||||
a.conflicts_list()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|c| c.id != conflict.id),
|
||||
"resolved conflict no longer listed"
|
||||
);
|
||||
|
||||
// …and the decision is an op, so B converges to it.
|
||||
sync_one_way(&a, &mut b, None);
|
||||
assert_eq!(
|
||||
b.get_task(&task.node_id).unwrap().unwrap().do_date,
|
||||
Some(7_000)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,24 +56,3 @@ fn export_excludes_tombstoned_nodes() {
|
|||
assert!(dir.path().join(format!("doc/{}.md", keep.id)).exists());
|
||||
assert!(!dir.path().join(format!("doc/{}.md", gone.id)).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_expands_bare_id_wiki_links_to_readable_labels() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut s = store();
|
||||
|
||||
let target = s.create_node(NewNode::doc("Roof", "shingles")).unwrap();
|
||||
let body = format!(
|
||||
"see [[{id}]] and [[{id}|my label]] and [[Unknown]]",
|
||||
id = target.id
|
||||
);
|
||||
let doc = s.create_node(NewNode::doc("Notes", &body)).unwrap();
|
||||
|
||||
s.export(dir.path()).unwrap();
|
||||
let text = std::fs::read_to_string(dir.path().join(format!("doc/{}.md", doc.id))).unwrap();
|
||||
// Bare known id gains a readable label; a custom label and an
|
||||
// unresolvable target are left as-authored.
|
||||
assert!(text.contains(&format!("[[{}|Roof]]", target.id)), "{text}");
|
||||
assert!(text.contains(&format!("[[{}|my label]]", target.id)));
|
||||
assert!(text.contains("[[Unknown]]"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -414,111 +414,3 @@ fn active_wiki(s: &LocalStore, id: &str) -> usize {
|
|||
.filter(|l| l.link_type == LinkType::Wiki)
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstoning_a_task_cascades_to_its_context_doc_and_restore_revives() {
|
||||
let mut s = store();
|
||||
let task = s
|
||||
.create_task(NewTask {
|
||||
title: "Fix gate".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let ctx = s
|
||||
.outgoing_links(&task.node_id)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|l| l.link_type == LinkType::CanonicalContext)
|
||||
.expect("task has a canonical context doc")
|
||||
.dst_id;
|
||||
// Give the context doc a body so it's findable in search.
|
||||
s.update_node(&ctx, None, Some("latch is rusted".into()))
|
||||
.unwrap();
|
||||
assert!(!s.search("latch").unwrap().is_empty());
|
||||
|
||||
s.tombstone_node(&task.node_id).unwrap();
|
||||
assert!(
|
||||
s.get_node(&ctx).unwrap().unwrap().tombstoned,
|
||||
"context doc dies with its task"
|
||||
);
|
||||
assert!(s.search("latch").unwrap().is_empty(), "no FTS leftover");
|
||||
|
||||
s.restore_node(&task.node_id).unwrap();
|
||||
assert!(!s.get_node(&task.node_id).unwrap().unwrap().tombstoned);
|
||||
assert!(
|
||||
!s.get_node(&ctx).unwrap().unwrap().tombstoned,
|
||||
"restore revives the attachment"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_project_moves_detaches_and_guards_cycles() {
|
||||
let mut s = store();
|
||||
let mk = |s: &mut LocalStore, title: &str| {
|
||||
s.create_node(NewNode {
|
||||
kind: NodeKind::Project,
|
||||
title: title.into(),
|
||||
body: None,
|
||||
})
|
||||
.unwrap()
|
||||
.id
|
||||
};
|
||||
let coding = mk(&mut s, "Coding");
|
||||
let heph = mk(&mut s, "Hephaestus");
|
||||
let sub = mk(&mut s, "Subproject");
|
||||
// Subproject starts under Hephaestus (child holds the parent link).
|
||||
s.add_link(&sub, &heph, LinkType::Parent).unwrap();
|
||||
|
||||
let parent_of = |s: &LocalStore, title: &str| {
|
||||
s.project_overview()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|p| p.title == title)
|
||||
.unwrap()
|
||||
.parent_id
|
||||
};
|
||||
|
||||
// Move Hephaestus under Coding.
|
||||
s.reparent_project(&heph, Some(&coding)).unwrap();
|
||||
assert_eq!(parent_of(&s, "Hephaestus"), Some(coding.clone()));
|
||||
|
||||
// Detach back to the root.
|
||||
s.reparent_project(&heph, None).unwrap();
|
||||
assert_eq!(parent_of(&s, "Hephaestus"), None);
|
||||
|
||||
// Cycle guard: with Coding > Hephaestus > Subproject, moving Coding under
|
||||
// Subproject would loop — rejected, as is self-parenting.
|
||||
s.reparent_project(&heph, Some(&coding)).unwrap();
|
||||
assert!(s.reparent_project(&coding, Some(&sub)).is_err());
|
||||
assert!(s.reparent_project(&heph, Some(&heph)).is_err());
|
||||
|
||||
// Only live project nodes may be parents (or children).
|
||||
let task = s
|
||||
.create_task(NewTask {
|
||||
title: "not a project".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.reparent_project(&heph, Some(&task.node_id)).is_err());
|
||||
assert!(s.reparent_project(&task.node_id, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_link_rejects_derived_and_internal_link_types() {
|
||||
let mut s = store();
|
||||
let a = s.create_node(NewNode::doc("A", "")).unwrap();
|
||||
let b = s.create_node(NewNode::doc("B", "")).unwrap();
|
||||
|
||||
// `wiki` rows are derived from the body (and reconciled away on the next
|
||||
// body write); canonical-context / log-of are task-creation internals.
|
||||
for t in [LinkType::Wiki, LinkType::CanonicalContext, LinkType::LogOf] {
|
||||
let err = s.add_link(&a.id, &b.id, t).unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("cannot be added by hand") || err.contains("derived from the body"),
|
||||
"{t:?}: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// The explicit relationship types still work.
|
||||
s.add_link(&a.id, &b.id, LinkType::Blocks).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,12 +107,6 @@ enum InputKind {
|
|||
},
|
||||
/// Full-text search query.
|
||||
Search,
|
||||
/// Rename the task (and its same-named canonical-context doc).
|
||||
Rename {
|
||||
task_id: String,
|
||||
context_id: Option<String>,
|
||||
before: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// An active full-text search: the query, its hits, and the highlighted row.
|
||||
|
|
@ -124,31 +118,6 @@ pub struct SearchView {
|
|||
pub cursor: usize,
|
||||
}
|
||||
|
||||
/// One row of the conflicts view: the conflict + the node's title for display.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConflictItem {
|
||||
pub conflict: heph_core::Conflict,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// The open-conflicts review (`C`): the rows and the highlighted one. While
|
||||
/// `Some`, the center pane lists conflicts; `l`/`r` settle the highlighted one.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConflictsView {
|
||||
pub items: Vec<ConflictItem>,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
/// The full task-log view (`L`): every log line for one task, scrollable —
|
||||
/// the preview pane shows only the last few. While `Some`, it replaces the
|
||||
/// center pane.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LogView {
|
||||
pub title: String,
|
||||
pub lines: Vec<String>,
|
||||
pub scroll: usize,
|
||||
}
|
||||
|
||||
/// A pending delete awaiting y/N confirmation (the most destructive gesture) —
|
||||
/// either a task (from the task pane) or a project (from the sidebar).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -204,7 +173,8 @@ impl From<&RankedTask> for TaskSnapshot {
|
|||
}
|
||||
|
||||
/// The original triage action, kept alongside its before-snapshot so redo can
|
||||
/// re-apply it without re-reading state.
|
||||
/// re-apply it without re-reading state. (Delete/tombstone is *not* here — it has
|
||||
/// no restore path yet, so it is excluded from undo and guarded by its y/N prompt.)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum TriageAction {
|
||||
State(&'static str), // "done" | "dropped"
|
||||
|
|
@ -213,53 +183,22 @@ enum TriageAction {
|
|||
Move(Option<String>), // re-file (or unfile) to a project id
|
||||
}
|
||||
|
||||
/// One reversible step. Scalar triage carries a before-snapshot; the
|
||||
/// structural actions (delete, rename, re-parent) carry exactly what their
|
||||
/// inverse needs.
|
||||
/// One reversible step: the task state before it + the action that changed it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum UndoEntry {
|
||||
/// A scalar triage action: the task state before it + the action.
|
||||
Triage {
|
||||
before: TaskSnapshot,
|
||||
action: TriageAction,
|
||||
},
|
||||
/// A task delete; undone by `node.restore` (which also revives the
|
||||
/// canonical-context doc).
|
||||
DeleteTask { task_id: String, title: String },
|
||||
/// A project delete; undone by restoring the project node and re-filing
|
||||
/// the tasks that were unfiled by the delete.
|
||||
DeleteProject {
|
||||
project_id: String,
|
||||
title: String,
|
||||
filed: Vec<String>,
|
||||
},
|
||||
/// A rename of a task (and its same-named context doc, when it has one).
|
||||
Rename {
|
||||
task_id: String,
|
||||
context_id: Option<String>,
|
||||
before: String,
|
||||
after: String,
|
||||
},
|
||||
/// A project re-parent; undone by re-parenting back.
|
||||
Reparent {
|
||||
project_id: String,
|
||||
title: String,
|
||||
before_parent: Option<String>,
|
||||
after_parent: Option<String>,
|
||||
},
|
||||
struct UndoEntry {
|
||||
before: TaskSnapshot,
|
||||
action: TriageAction,
|
||||
}
|
||||
|
||||
/// Cap on the undo history, so a long session can't grow it unbounded.
|
||||
const UNDO_CAP: usize = 200;
|
||||
|
||||
/// One choice in the move picker.
|
||||
/// One choice in the move-to-project picker.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MoveOption {
|
||||
/// Remove the task from any project.
|
||||
Unfile,
|
||||
/// Detach the project to the root (re-parent mode's "no parent").
|
||||
Root,
|
||||
/// File under (or re-parent to) an existing project.
|
||||
/// File under an existing project.
|
||||
Project { id: String, title: String },
|
||||
/// Create a new project named after the filter text, then file under it.
|
||||
Create { name: String },
|
||||
|
|
@ -270,59 +209,40 @@ impl MoveOption {
|
|||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
MoveOption::Unfile => "(Unfile)".to_string(),
|
||||
MoveOption::Root => "(Move to root)".to_string(),
|
||||
MoveOption::Project { title, .. } => title.clone(),
|
||||
MoveOption::Create { name } => format!("+ New project \"{name}\""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the move picker is moving — a task being re-filed, or a project being
|
||||
/// re-parented. Each carries what its undo needs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MoveKind {
|
||||
/// Re-file a task; `before` is its snapshot for undo.
|
||||
Task { before: TaskSnapshot },
|
||||
/// Re-parent a project; `current_parent` for undo.
|
||||
Project { current_parent: Option<String> },
|
||||
}
|
||||
|
||||
/// The move picker state: the subject (task or project), a live filter over the
|
||||
/// candidate projects, the matching choices, and the highlighted row. The picker
|
||||
/// is fzf-style — typing narrows the list; in task mode a non-matching name
|
||||
/// offers to create.
|
||||
/// The move-to-project picker state: which task is being re-filed, a live filter
|
||||
/// over the projects, the matching choices, and the highlighted row. The picker
|
||||
/// is fzf-style — typing narrows the list; a non-matching name offers to create.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MoveState {
|
||||
/// The node being moved (a task id in task mode, a project id otherwise).
|
||||
pub subject_id: String,
|
||||
pub subject_title: String,
|
||||
pub kind: MoveKind,
|
||||
/// The candidate projects, title-sorted — the source the `filter` narrows.
|
||||
/// In re-parent mode the subject and its descendants are pre-excluded.
|
||||
pub task_id: String,
|
||||
pub task_title: String,
|
||||
/// The task's state before the move, for undo.
|
||||
before: TaskSnapshot,
|
||||
/// All projects, title-sorted — the source the `filter` narrows.
|
||||
projects: Vec<Project>,
|
||||
/// The live filter query (fzf-style subsequence match).
|
||||
pub filter: String,
|
||||
/// The currently visible choices, recomputed whenever `filter` changes.
|
||||
/// The currently visible choices (`(Unfile)` + matching projects + an
|
||||
/// optional "create" row), recomputed whenever `filter` changes.
|
||||
pub options: Vec<MoveOption>,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
impl MoveState {
|
||||
/// Rebuild `options` from `projects` + `filter`: the no-project row
|
||||
/// (`(Unfile)` / `(Move to root)`), the fuzzy-matching projects, and — in
|
||||
/// task mode, when the text names no existing project — a "create" row.
|
||||
/// Clamps the cursor.
|
||||
/// Rebuild `options` from `projects` + `filter`: `(Unfile)` (when it matches
|
||||
/// or the filter is empty), the fuzzy-matching projects, and — when the text
|
||||
/// names no existing project — a "create" row. Clamps the cursor.
|
||||
fn recompute(&mut self) {
|
||||
let f = self.filter.trim();
|
||||
let is_task = matches!(self.kind, MoveKind::Task { .. });
|
||||
let (none_opt, none_label) = if is_task {
|
||||
(MoveOption::Unfile, "Unfile")
|
||||
} else {
|
||||
(MoveOption::Root, "Root")
|
||||
};
|
||||
let mut opts = Vec::new();
|
||||
if f.is_empty() || fuzzy_match(f, none_label) {
|
||||
opts.push(none_opt);
|
||||
if f.is_empty() || fuzzy_match(f, "Unfile") {
|
||||
opts.push(MoveOption::Unfile);
|
||||
}
|
||||
for p in &self.projects {
|
||||
if f.is_empty() || fuzzy_match(f, &p.title) {
|
||||
|
|
@ -332,8 +252,7 @@ impl MoveState {
|
|||
});
|
||||
}
|
||||
}
|
||||
if is_task
|
||||
&& !f.is_empty()
|
||||
if !f.is_empty()
|
||||
&& !self
|
||||
.projects
|
||||
.iter()
|
||||
|
|
@ -509,10 +428,6 @@ pub struct App<B: Backend> {
|
|||
pub sort_mode: SortMode,
|
||||
/// When `Some`, a full-text search overlays the task list.
|
||||
pub search: Option<SearchView>,
|
||||
/// When `Some`, the open-conflicts review overlays the task list.
|
||||
pub conflicts_view: Option<ConflictsView>,
|
||||
/// When `Some`, a task's full log overlays the task list.
|
||||
pub log_view: Option<LogView>,
|
||||
/// When `Some`, a delete is awaiting y/N confirmation.
|
||||
pub pending_delete: Option<PendingDelete>,
|
||||
/// When `true`, an attention chord is in progress: `a` was pressed and the
|
||||
|
|
@ -559,8 +474,6 @@ impl<B: Backend> App<B> {
|
|||
mode: Mode::Normal,
|
||||
sort_mode: SortMode::Default,
|
||||
search: None,
|
||||
conflicts_view: None,
|
||||
log_view: None,
|
||||
pending_delete: None,
|
||||
pending_attention: false,
|
||||
undo_stack: Vec::new(),
|
||||
|
|
@ -859,79 +772,22 @@ impl<B: Backend> App<B> {
|
|||
|
||||
// --- undo / redo (`u` / Ctrl-z) ---
|
||||
|
||||
/// Record a reversible scalar-triage step.
|
||||
/// Record a reversible step and invalidate the redo stack.
|
||||
fn push_undo(&mut self, before: TaskSnapshot, action: TriageAction) {
|
||||
self.push_entry(UndoEntry::Triage { before, action });
|
||||
}
|
||||
|
||||
/// Record any reversible step and invalidate the redo stack.
|
||||
fn push_entry(&mut self, entry: UndoEntry) {
|
||||
self.undo_stack.push(entry);
|
||||
self.undo_stack.push(UndoEntry { before, action });
|
||||
if self.undo_stack.len() > UNDO_CAP {
|
||||
self.undo_stack.remove(0);
|
||||
}
|
||||
self.redo_stack.clear();
|
||||
}
|
||||
|
||||
/// Undo the last reversible action.
|
||||
/// Undo the last triage action (restores the task's prior state).
|
||||
pub fn undo(&mut self) {
|
||||
let Some(entry) = self.undo_stack.pop() else {
|
||||
self.status = "nothing to undo".into();
|
||||
return;
|
||||
};
|
||||
match &entry {
|
||||
UndoEntry::Triage { before, .. } => {
|
||||
let (before, status) = (before.clone(), format!("undo: {}", before.title));
|
||||
self.restore(&before, status);
|
||||
}
|
||||
UndoEntry::DeleteTask { task_id, title } => {
|
||||
let id = task_id.clone();
|
||||
self.mutate(format!("undo delete: {title}"), move |b| b.restore(&id));
|
||||
}
|
||||
UndoEntry::DeleteProject {
|
||||
project_id,
|
||||
title,
|
||||
filed,
|
||||
} => {
|
||||
let (id, filed) = (project_id.clone(), filed.clone());
|
||||
self.mutate(format!("undo delete project: {title}"), move |b| {
|
||||
b.restore(&id)?;
|
||||
// The delete unfiled these tasks; put them back.
|
||||
for t in &filed {
|
||||
b.set_project(t, Some(&id))?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
self.rebuild_projects();
|
||||
}
|
||||
UndoEntry::Rename {
|
||||
task_id,
|
||||
context_id,
|
||||
before,
|
||||
..
|
||||
} => {
|
||||
let (id, ctx, title) = (task_id.clone(), context_id.clone(), before.clone());
|
||||
self.mutate(format!("undo rename: {title}"), move |b| {
|
||||
b.rename(&id, &title)?;
|
||||
if let Some(ctx) = &ctx {
|
||||
b.rename(ctx, &title)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
UndoEntry::Reparent {
|
||||
project_id,
|
||||
title,
|
||||
before_parent,
|
||||
..
|
||||
} => {
|
||||
let (id, parent) = (project_id.clone(), before_parent.clone());
|
||||
self.mutate(format!("undo move: {title}"), move |b| {
|
||||
b.reparent(&id, parent.as_deref())
|
||||
});
|
||||
self.rebuild_projects();
|
||||
}
|
||||
}
|
||||
self.restore(&entry.before, format!("undo: {}", entry.before.title));
|
||||
self.redo_stack.push(entry);
|
||||
}
|
||||
|
||||
|
|
@ -941,52 +797,8 @@ impl<B: Backend> App<B> {
|
|||
self.status = "nothing to redo".into();
|
||||
return;
|
||||
};
|
||||
match &entry {
|
||||
UndoEntry::Triage { before, action } => {
|
||||
let status = format!("redo: {}", before.title);
|
||||
self.apply_action(before.task_id.clone(), action.clone(), status);
|
||||
}
|
||||
UndoEntry::DeleteTask { task_id, title } => {
|
||||
let id = task_id.clone();
|
||||
self.mutate(format!("redo delete: {title}"), move |b| b.tombstone(&id));
|
||||
}
|
||||
UndoEntry::DeleteProject {
|
||||
project_id, title, ..
|
||||
} => {
|
||||
let id = project_id.clone();
|
||||
self.mutate(format!("redo delete project: {title}"), move |b| {
|
||||
b.delete_project(&id)
|
||||
});
|
||||
self.rebuild_projects();
|
||||
}
|
||||
UndoEntry::Rename {
|
||||
task_id,
|
||||
context_id,
|
||||
after,
|
||||
..
|
||||
} => {
|
||||
let (id, ctx, title) = (task_id.clone(), context_id.clone(), after.clone());
|
||||
self.mutate(format!("redo rename: {title}"), move |b| {
|
||||
b.rename(&id, &title)?;
|
||||
if let Some(ctx) = &ctx {
|
||||
b.rename(ctx, &title)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
UndoEntry::Reparent {
|
||||
project_id,
|
||||
title,
|
||||
after_parent,
|
||||
..
|
||||
} => {
|
||||
let (id, parent) = (project_id.clone(), after_parent.clone());
|
||||
self.mutate(format!("redo move: {title}"), move |b| {
|
||||
b.reparent(&id, parent.as_deref())
|
||||
});
|
||||
self.rebuild_projects();
|
||||
}
|
||||
}
|
||||
let status = format!("redo: {}", entry.before.title);
|
||||
self.apply_action(entry.before.task_id.clone(), entry.action.clone(), status);
|
||||
self.undo_stack.push(entry);
|
||||
}
|
||||
|
||||
|
|
@ -1050,39 +862,16 @@ impl<B: Backend> App<B> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Confirm the armed delete: tombstone the task or project, record the
|
||||
/// undo entry, and reload.
|
||||
/// Confirm the armed delete: tombstone the task or project and reload.
|
||||
pub fn confirm_delete(&mut self) {
|
||||
match self.pending_delete.take() {
|
||||
Some(PendingDelete::Task { task_id, title }) => {
|
||||
self.push_entry(UndoEntry::DeleteTask {
|
||||
task_id: task_id.clone(),
|
||||
title: title.clone(),
|
||||
});
|
||||
self.mutate(format!("deleted: {title} (u to undo)"), |b| {
|
||||
b.tombstone(&task_id)
|
||||
});
|
||||
self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id));
|
||||
}
|
||||
Some(PendingDelete::Project { project_id, title }) => {
|
||||
// Snapshot which tasks the delete is about to unfile, so undo
|
||||
// can re-file them after restoring the project node.
|
||||
let filed: Vec<String> = self
|
||||
.backend
|
||||
.list(&heph_core::ListFilter {
|
||||
scope: vec![project_id.clone()],
|
||||
..Default::default()
|
||||
})
|
||||
.map(|ts| ts.into_iter().map(|t| t.node_id).collect())
|
||||
.unwrap_or_default();
|
||||
self.push_entry(UndoEntry::DeleteProject {
|
||||
project_id: project_id.clone(),
|
||||
title: title.clone(),
|
||||
filed,
|
||||
self.mutate(format!("deleted project: {title} (tasks → Inbox)"), |b| {
|
||||
b.delete_project(&project_id)
|
||||
});
|
||||
self.mutate(
|
||||
format!("deleted project: {title} (tasks → Inbox; u to undo)"),
|
||||
|b| b.delete_project(&project_id),
|
||||
);
|
||||
self.rebuild_projects();
|
||||
self.reload();
|
||||
}
|
||||
|
|
@ -1106,11 +895,9 @@ impl<B: Backend> App<B> {
|
|||
return;
|
||||
};
|
||||
let mut state = MoveState {
|
||||
subject_id: t.node_id.clone(),
|
||||
subject_title: t.title.clone(),
|
||||
kind: MoveKind::Task {
|
||||
before: TaskSnapshot::from(&t),
|
||||
},
|
||||
task_id: t.node_id.clone(),
|
||||
task_title: t.title.clone(),
|
||||
before: TaskSnapshot::from(&t),
|
||||
projects: self.project_list(),
|
||||
filter: String::new(),
|
||||
options: Vec::new(),
|
||||
|
|
@ -1129,64 +916,6 @@ impl<B: Backend> App<B> {
|
|||
self.mode = Mode::MoveToProject(state);
|
||||
}
|
||||
|
||||
/// Open the picker in re-parent mode for the sidebar-selected project.
|
||||
/// Candidates exclude the project itself and its descendants (a tree can't
|
||||
/// contain itself); "(Move to root)" detaches it.
|
||||
pub fn begin_reparent(&mut self) {
|
||||
let Some(SidebarEntry::Project { id, title, .. }) =
|
||||
self.sidebar.get(self.sidebar_cursor).cloned()
|
||||
else {
|
||||
self.status = "select a project in the sidebar to move".into();
|
||||
return;
|
||||
};
|
||||
let overview = match self.backend.project_overview() {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
self.status = format!("error: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let current_parent = overview
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.and_then(|p| p.parent_id.clone());
|
||||
// The subject's subtree (itself + descendants) can't be its new parent.
|
||||
let mut excluded: std::collections::HashSet<String> = [id.clone()].into();
|
||||
loop {
|
||||
let before = excluded.len();
|
||||
for p in &overview {
|
||||
if p.parent_id
|
||||
.as_deref()
|
||||
.is_some_and(|pp| excluded.contains(pp))
|
||||
{
|
||||
excluded.insert(p.id.clone());
|
||||
}
|
||||
}
|
||||
if excluded.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let projects: Vec<Project> = overview
|
||||
.into_iter()
|
||||
.filter(|p| !excluded.contains(&p.id))
|
||||
.map(|p| Project {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
})
|
||||
.collect();
|
||||
let mut state = MoveState {
|
||||
subject_id: id,
|
||||
subject_title: title,
|
||||
kind: MoveKind::Project { current_parent },
|
||||
projects,
|
||||
filter: String::new(),
|
||||
options: Vec::new(),
|
||||
cursor: 0,
|
||||
};
|
||||
state.recompute();
|
||||
self.mode = Mode::MoveToProject(state);
|
||||
}
|
||||
|
||||
/// Move the picker cursor by `delta` (clamped).
|
||||
pub fn move_picker_move(&mut self, delta: isize) {
|
||||
if let Mode::MoveToProject(m) = &mut self.mode {
|
||||
|
|
@ -1213,8 +942,8 @@ impl<B: Backend> App<B> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply the highlighted choice and reload. Task mode: unfile, re-file, or
|
||||
/// create-then-file. Project mode: re-parent (or detach to the root).
|
||||
/// Apply the highlighted choice: unfile, re-file, or create-then-file, and
|
||||
/// reload.
|
||||
pub fn move_picker_submit(&mut self) {
|
||||
let Mode::MoveToProject(m) = &self.mode else {
|
||||
return;
|
||||
|
|
@ -1222,53 +951,30 @@ impl<B: Backend> App<B> {
|
|||
let Some(choice) = m.options.get(m.cursor).cloned() else {
|
||||
return;
|
||||
};
|
||||
let subject_id = m.subject_id.clone();
|
||||
let title = m.subject_title.clone();
|
||||
let kind = m.kind.clone();
|
||||
let task_id = m.task_id.clone();
|
||||
let title = m.task_title.clone();
|
||||
let before = m.before.clone();
|
||||
self.mode = Mode::Normal;
|
||||
match kind {
|
||||
MoveKind::Task { before } => match choice {
|
||||
MoveOption::Unfile => {
|
||||
self.push_undo(before, TriageAction::Move(None));
|
||||
self.mutate(format!("→ (Unfile): {title}"), |b| {
|
||||
b.set_project(&subject_id, None)
|
||||
});
|
||||
}
|
||||
MoveOption::Project { id, title: pt } => {
|
||||
self.push_undo(before, TriageAction::Move(Some(id.clone())));
|
||||
self.mutate(format!("→ {pt}: {title}"), move |b| {
|
||||
b.set_project(&subject_id, Some(&id))
|
||||
});
|
||||
}
|
||||
MoveOption::Create { name } => {
|
||||
// Creating + filing is constructive; not added to the undo history
|
||||
// (we'd have to track the new project's id to replay it).
|
||||
self.mutate(format!("→ new project \"{name}\": {title}"), move |b| {
|
||||
let id = b.create_project(&name)?;
|
||||
b.set_project(&subject_id, Some(&id))
|
||||
});
|
||||
self.rebuild_projects();
|
||||
}
|
||||
MoveOption::Root => {}
|
||||
},
|
||||
MoveKind::Project { current_parent } => {
|
||||
let after = match choice {
|
||||
MoveOption::Root => None,
|
||||
MoveOption::Project { id, .. } => Some(id),
|
||||
// Unfile/Create are not offered in re-parent mode.
|
||||
_ => return,
|
||||
};
|
||||
self.push_entry(UndoEntry::Reparent {
|
||||
project_id: subject_id.clone(),
|
||||
title: title.clone(),
|
||||
before_parent: current_parent,
|
||||
after_parent: after.clone(),
|
||||
match choice {
|
||||
MoveOption::Unfile => {
|
||||
self.push_undo(before, TriageAction::Move(None));
|
||||
self.mutate(format!("→ (Unfile): {title}"), |b| {
|
||||
b.set_project(&task_id, None)
|
||||
});
|
||||
}
|
||||
MoveOption::Project { id, title: pt } => {
|
||||
self.push_undo(before, TriageAction::Move(Some(id.clone())));
|
||||
self.mutate(format!("→ {pt}: {title}"), move |b| {
|
||||
b.set_project(&task_id, Some(&id))
|
||||
});
|
||||
}
|
||||
MoveOption::Create { name } => {
|
||||
// Creating + filing is constructive; not added to the undo history
|
||||
// (we'd have to track the new project's id to replay it).
|
||||
self.mutate(format!("→ new project \"{name}\": {title}"), move |b| {
|
||||
let id = b.create_project(&name)?;
|
||||
b.set_project(&task_id, Some(&id))
|
||||
});
|
||||
let status = match &after {
|
||||
Some(_) => format!("moved project: {title}"),
|
||||
None => format!("moved project to root: {title}"),
|
||||
};
|
||||
self.mutate(status, move |b| b.reparent(&subject_id, after.as_deref()));
|
||||
self.rebuild_projects();
|
||||
}
|
||||
}
|
||||
|
|
@ -1358,23 +1064,6 @@ impl<B: Backend> App<B> {
|
|||
});
|
||||
}
|
||||
|
||||
/// Start renaming the highlighted task in place (`R`). The buffer is
|
||||
/// prefilled with the current title for editing.
|
||||
pub fn begin_rename(&mut self) {
|
||||
let Some(t) = self.selected_task() else {
|
||||
return;
|
||||
};
|
||||
self.mode = Mode::Input(InputState {
|
||||
prompt: "Rename task".into(),
|
||||
buffer: t.title.clone(),
|
||||
kind: InputKind::Rename {
|
||||
task_id: t.node_id.clone(),
|
||||
context_id: t.canonical_context_id.clone(),
|
||||
before: t.title.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the full-text search prompt.
|
||||
pub fn begin_search(&mut self) {
|
||||
self.mode = Mode::Input(InputState {
|
||||
|
|
@ -1389,114 +1078,6 @@ impl<B: Backend> App<B> {
|
|||
self.search = None;
|
||||
}
|
||||
|
||||
// --- conflicts review (`C`) ---
|
||||
|
||||
/// Open (or refresh) the conflicts view: fetch the open conflicts and label
|
||||
/// each with its node's title.
|
||||
pub fn open_conflicts(&mut self) {
|
||||
match self.backend.conflicts() {
|
||||
Ok(rows) => {
|
||||
let items: Vec<ConflictItem> = rows
|
||||
.into_iter()
|
||||
.map(|conflict| {
|
||||
let title = self
|
||||
.backend
|
||||
.node_title(&conflict.node_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| conflict.node_id.clone());
|
||||
ConflictItem { conflict, title }
|
||||
})
|
||||
.collect();
|
||||
let cursor = self
|
||||
.conflicts_view
|
||||
.as_ref()
|
||||
.map(|v| v.cursor.min(items.len().saturating_sub(1)))
|
||||
.unwrap_or(0);
|
||||
self.status = if items.is_empty() {
|
||||
"no open conflicts".into()
|
||||
} else {
|
||||
format!("{} open conflict(s)", items.len())
|
||||
};
|
||||
self.conflicts_view = Some(ConflictsView { items, cursor });
|
||||
}
|
||||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the conflicts view.
|
||||
pub fn close_conflicts(&mut self) {
|
||||
self.conflicts_view = None;
|
||||
}
|
||||
|
||||
/// Move the conflicts cursor by `delta` (clamped).
|
||||
pub fn conflicts_move(&mut self, delta: isize) {
|
||||
if let Some(v) = &mut self.conflicts_view {
|
||||
if v.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max = v.items.len() as isize - 1;
|
||||
v.cursor = (v.cursor as isize + delta).clamp(0, max) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle the highlighted conflict keeping the `"local"` or `"remote"`
|
||||
/// value, then refresh the list (and the status-line conflict chip).
|
||||
pub fn conflicts_resolve(&mut self, choice: &str) {
|
||||
let Some(item) = self
|
||||
.conflicts_view
|
||||
.as_ref()
|
||||
.and_then(|v| v.items.get(v.cursor))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match self.backend.resolve_conflict(&item.conflict.id, choice) {
|
||||
Ok(()) => {
|
||||
self.status = format!("kept {choice}: {} ({})", item.title, item.conflict.field);
|
||||
self.open_conflicts();
|
||||
self.refresh_sync();
|
||||
self.reload();
|
||||
}
|
||||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- full task log (`L`) ---
|
||||
|
||||
/// How much history the full log view fetches (the preview shows 5 lines).
|
||||
const LOG_VIEW_LINES: usize = 1000;
|
||||
|
||||
/// Open the full, scrollable log of the highlighted task.
|
||||
pub fn open_log(&mut self) {
|
||||
let Some(t) = self.selected_task().cloned() else {
|
||||
return;
|
||||
};
|
||||
match self.backend.log_tail(&t.node_id, Self::LOG_VIEW_LINES) {
|
||||
Ok(lines) => {
|
||||
self.log_view = Some(LogView {
|
||||
title: t.title,
|
||||
lines,
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the log view.
|
||||
pub fn close_log(&mut self) {
|
||||
self.log_view = None;
|
||||
}
|
||||
|
||||
/// Scroll the log view by `delta` lines (clamped).
|
||||
pub fn log_scroll(&mut self, delta: isize) {
|
||||
if let Some(v) = &mut self.log_view {
|
||||
let max = v.lines.len().saturating_sub(1) as isize;
|
||||
v.scroll = (v.scroll as isize + delta).clamp(0, max) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the search-results cursor by `delta` (clamped).
|
||||
pub fn search_move(&mut self, delta: isize) {
|
||||
if let Some(s) = &mut self.search {
|
||||
|
|
@ -1606,32 +1187,6 @@ impl<B: Backend> App<B> {
|
|||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
}
|
||||
InputKind::Rename {
|
||||
task_id,
|
||||
context_id,
|
||||
before,
|
||||
} => {
|
||||
let after = buf;
|
||||
if after.is_empty() || after == before {
|
||||
self.status = "rename cancelled".into();
|
||||
return;
|
||||
}
|
||||
self.push_entry(UndoEntry::Rename {
|
||||
task_id: task_id.clone(),
|
||||
context_id: context_id.clone(),
|
||||
before,
|
||||
after: after.clone(),
|
||||
});
|
||||
self.mutate(format!("renamed: {after} (u to undo)"), move |b| {
|
||||
b.rename(&task_id, &after)?;
|
||||
// The context doc is created with the task's title — keep
|
||||
// them in step so resolution/search stay coherent.
|
||||
if let Some(ctx) = &context_id {
|
||||
b.rename(ctx, &after)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
InputKind::Reschedule { task_id } => {
|
||||
let patch = if buf.is_empty() {
|
||||
SchedulePatch {
|
||||
|
|
|
|||
|
|
@ -90,18 +90,6 @@ pub trait Backend {
|
|||
fn sync_status(&mut self) -> Result<SyncStatus> {
|
||||
Ok(SyncStatus::default())
|
||||
}
|
||||
/// A node's title, for labelling conflict rows. `None` if it's missing.
|
||||
fn node_title(&mut self, _id: &str) -> Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
/// Open merge conflicts (the `conflicts.list` RPC). Default: none.
|
||||
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
/// Settle a conflict keeping the `"local"` or `"remote"` value.
|
||||
fn resolve_conflict(&mut self, _id: &str, _choice: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- triage mutations (T2) ---
|
||||
|
||||
|
|
@ -112,13 +100,6 @@ pub trait Backend {
|
|||
/// Tombstone (soft-delete) a task node — removes it from every view,
|
||||
/// including recurring roll-forward. Distinct from `done`/`dropped`.
|
||||
fn tombstone(&mut self, node_id: &str) -> Result<()>;
|
||||
/// Restore (un-tombstone) a node — the undo of [`Backend::tombstone`] and
|
||||
/// of project deletion.
|
||||
fn restore(&mut self, node_id: &str) -> Result<()>;
|
||||
/// Rename a node (title only; body untouched).
|
||||
fn rename(&mut self, node_id: &str, title: &str) -> Result<()>;
|
||||
/// Re-parent a project (`None` detaches it to the root).
|
||||
fn reparent(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()>;
|
||||
/// Set a task's attention band.
|
||||
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()>;
|
||||
/// Patch a task's schedule (do-date / late-on / recurrence), §6 double-option.
|
||||
|
|
@ -243,43 +224,6 @@ impl Backend for ClientBackend {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn restore(&mut self, node_id: &str) -> Result<()> {
|
||||
self.call("node.restore", json!({ "id": node_id }))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rename(&mut self, node_id: &str, title: &str) -> Result<()> {
|
||||
self.call("node.update", json!({ "id": node_id, "title": title }))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reparent(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
|
||||
self.call(
|
||||
"project.reparent",
|
||||
json!({ "id": project_id, "parent_id": parent_id }),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node_title(&mut self, id: &str) -> Result<Option<String>> {
|
||||
let v = self.call("node.get", json!({ "id": id }))?;
|
||||
if v.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let node: heph_core::Node = serde_json::from_value(v)?;
|
||||
Ok(Some(node.title))
|
||||
}
|
||||
|
||||
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
|
||||
let v = self.call("conflicts.list", json!({}))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn resolve_conflict(&mut self, id: &str, choice: &str) -> Result<()> {
|
||||
self.call("conflicts.resolve", json!({ "id": id, "choice": choice }))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_attention(&mut self, task_id: &str, attention: Attention) -> Result<()> {
|
||||
self.call(
|
||||
"task.set_attention",
|
||||
|
|
|
|||
|
|
@ -159,34 +159,6 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
|||
return None;
|
||||
}
|
||||
|
||||
// While the full task log is shown, j/k scroll it.
|
||||
if app.log_view.is_some() {
|
||||
app.status.clear();
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('L') => app.close_log(),
|
||||
KeyCode::Char('j') | KeyCode::Down => app.log_scroll(1),
|
||||
KeyCode::Char('k') | KeyCode::Up => app.log_scroll(-1),
|
||||
KeyCode::Char('q') => app.should_quit = true,
|
||||
_ => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// While the conflicts review is shown, the center pane navigates/settles it.
|
||||
if app.conflicts_view.is_some() {
|
||||
app.status.clear();
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('C') => app.close_conflicts(),
|
||||
KeyCode::Char('j') | KeyCode::Down => app.conflicts_move(1),
|
||||
KeyCode::Char('k') | KeyCode::Up => app.conflicts_move(-1),
|
||||
KeyCode::Char('l') => app.conflicts_resolve("local"),
|
||||
KeyCode::Char('r') => app.conflicts_resolve("remote"),
|
||||
KeyCode::Char('q') => app.should_quit = true,
|
||||
_ => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// While search results are shown, the center pane navigates them.
|
||||
if app.search.is_some() {
|
||||
app.status.clear();
|
||||
|
|
@ -222,7 +194,6 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
|||
KeyCode::Char('s') => app.toggle_sort(),
|
||||
KeyCode::Char('u') => app.undo(),
|
||||
KeyCode::Char('z') if ctrl => app.redo(),
|
||||
KeyCode::Char('C') => app.open_conflicts(),
|
||||
// Pane-specific keys: triage acts on the task pane; the sidebar gets
|
||||
// project actions — so a stray `d`/`D` in the sidebar can't touch a task.
|
||||
_ => match app.focus {
|
||||
|
|
@ -233,16 +204,14 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
|||
KeyCode::Char('a') => app.begin_attention(),
|
||||
KeyCode::Char('e') => app.begin_reschedule(),
|
||||
KeyCode::Char('m') => app.begin_move(),
|
||||
KeyCode::Char('R') => app.begin_rename(),
|
||||
KeyCode::Char('L') => app.open_log(),
|
||||
KeyCode::Char('D') => app.begin_delete(),
|
||||
_ => {}
|
||||
},
|
||||
Focus::Sidebar => match key.code {
|
||||
KeyCode::Char('m') => app.begin_reparent(),
|
||||
KeyCode::Char('D') => app.begin_delete_project(),
|
||||
_ => {}
|
||||
},
|
||||
Focus::Sidebar => {
|
||||
if let KeyCode::Char('D') = key.code {
|
||||
app.begin_delete_project()
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
None
|
||||
|
|
|
|||
|
|
@ -19,18 +19,14 @@ use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local};
|
|||
|
||||
// Task-pane gestures (the focused pane shows its own hints, §8.1).
|
||||
const HINTS: &str =
|
||||
" j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn m move R rename L log D del u undo / search q quit";
|
||||
" j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn m move D del u undo / search q quit";
|
||||
|
||||
// Sidebar gestures: navigation + per-project actions (no task triage here).
|
||||
const SIDEBAR_HINTS: &str =
|
||||
" j/k move ⏎ open n add m move-project D del-project u undo s sort / search Tab tasks q quit";
|
||||
" j/k move ⏎ open n add D del-project u undo s sort / search Tab tasks q quit";
|
||||
|
||||
const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search";
|
||||
|
||||
const CONFLICT_HINTS: &str = " j/k move l keep local r keep remote Esc close";
|
||||
|
||||
const LOG_HINTS: &str = " j/k scroll Esc close";
|
||||
|
||||
/// Draw the whole UI for the current frame.
|
||||
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
||||
let outer = Layout::default()
|
||||
|
|
@ -48,11 +44,7 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
|||
.split(outer[0]);
|
||||
|
||||
render_sidebar(frame, app, panes[0]);
|
||||
if app.log_view.is_some() {
|
||||
render_log(frame, app, panes[1]);
|
||||
} else if app.conflicts_view.is_some() {
|
||||
render_conflicts(frame, app, panes[1]);
|
||||
} else if app.search.is_some() {
|
||||
if app.search.is_some() {
|
||||
render_search(frame, app, panes[1]);
|
||||
} else {
|
||||
render_tasks(frame, app, panes[1]);
|
||||
|
|
@ -95,7 +87,7 @@ fn render_move(frame: &mut Frame, state: &MoveState) {
|
|||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(format!(" Move \"{}\" to ", state.subject_title)),
|
||||
.title(format!(" Move \"{}\" to ", state.task_title)),
|
||||
);
|
||||
frame.render_widget(input, chunks[0]);
|
||||
|
||||
|
|
@ -482,86 +474,6 @@ fn render_search<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
/// The open-conflicts review in the center pane: one row per conflict, the
|
||||
/// node's title + which field diverged + both values.
|
||||
fn render_conflicts<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let Some(v) = &app.conflicts_view else { return };
|
||||
let today = today_local();
|
||||
let items: Vec<ListItem> = if v.items.is_empty() {
|
||||
vec![ListItem::new(Line::from(Span::styled(
|
||||
" (no open conflicts — Esc to close)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))]
|
||||
} else {
|
||||
v.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let selected = i == v.cursor;
|
||||
let title_style = if selected {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let cursor = if selected { "▌" } else { " " };
|
||||
let c = &item.conflict;
|
||||
let val = |v: &Option<String>| match (c.field.as_str(), v) {
|
||||
("do_date", Some(ms)) => ms
|
||||
.parse::<i64>()
|
||||
.map(|ms| fmt_date(ms, today))
|
||||
.unwrap_or_else(|_| ms.clone()),
|
||||
(_, Some(s)) => s.clone(),
|
||||
(_, None) => "(unset)".into(),
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(cursor, Style::default().fg(Color::Cyan)),
|
||||
Span::styled(item.title.clone(), title_style),
|
||||
Span::styled(
|
||||
format!(
|
||||
" {}: local {} · remote {}",
|
||||
c.field,
|
||||
val(&c.local_val),
|
||||
val(&c.remote_val)
|
||||
),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(format!(" Conflicts ({}) ", v.items.len())),
|
||||
);
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
/// A task's full log in the center pane, scrollable (the preview shows only
|
||||
/// the last few lines).
|
||||
fn render_log<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let Some(v) = &app.log_view else { return };
|
||||
let lines: Vec<Line> = if v.lines.is_empty() {
|
||||
vec![Line::from(Span::styled(
|
||||
" (no log entries — Esc to close)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))]
|
||||
} else {
|
||||
v.lines.iter().map(|l| Line::from(l.clone())).collect()
|
||||
};
|
||||
let para = Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((v.scroll as u16, 0))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(format!(" Log: {} ({} lines) ", v.title, v.lines.len())),
|
||||
);
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if !app.preview.title.is_empty() {
|
||||
|
|
@ -609,11 +521,7 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
);
|
||||
return;
|
||||
}
|
||||
let hints = if app.log_view.is_some() {
|
||||
LOG_HINTS
|
||||
} else if app.conflicts_view.is_some() {
|
||||
CONFLICT_HINTS
|
||||
} else if app.search.is_some() {
|
||||
let hints = if app.search.is_some() {
|
||||
SEARCH_HINTS
|
||||
} else if app.focus == Focus::Sidebar {
|
||||
SIDEBAR_HINTS
|
||||
|
|
|
|||
|
|
@ -28,13 +28,9 @@ struct Recorder {
|
|||
created: Vec<CreatedTask>,
|
||||
scheduled: Vec<(String, SchedulePatch)>,
|
||||
tombstoned: Vec<String>,
|
||||
restored: Vec<String>,
|
||||
renamed: Vec<(String, String)>,
|
||||
reparented: Vec<(String, Option<String>)>,
|
||||
refiled: Vec<(String, Option<String>)>,
|
||||
created_projects: Vec<String>,
|
||||
states: Vec<(String, String)>,
|
||||
resolved: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
|
||||
|
|
@ -61,8 +57,6 @@ struct Fake {
|
|||
bodies: HashMap<String, String>,
|
||||
search_hits: Vec<SearchHit>,
|
||||
contexts: HashMap<String, String>,
|
||||
conflicts: Vec<heph_core::Conflict>,
|
||||
logs: HashMap<String, Vec<String>>,
|
||||
rec: Rc<RefCell<Recorder>>,
|
||||
}
|
||||
|
||||
|
|
@ -80,23 +74,8 @@ impl Backend for Fake {
|
|||
fn node_body(&mut self, id: &str) -> Result<String> {
|
||||
Ok(self.bodies.get(id).cloned().unwrap_or_default())
|
||||
}
|
||||
fn log_tail(&mut self, task_id: &str, _n: usize) -> Result<Vec<String>> {
|
||||
Ok(self.logs.get(task_id).cloned().unwrap_or_default())
|
||||
}
|
||||
fn conflicts(&mut self) -> Result<Vec<heph_core::Conflict>> {
|
||||
Ok(self
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|c| !self.rec.borrow().resolved.iter().any(|(id, _)| id == &c.id))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
fn resolve_conflict(&mut self, id: &str, choice: &str) -> Result<()> {
|
||||
self.rec
|
||||
.borrow_mut()
|
||||
.resolved
|
||||
.push((id.into(), choice.into()));
|
||||
Ok(())
|
||||
fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
|
||||
Ok(self
|
||||
|
|
@ -120,24 +99,6 @@ impl Backend for Fake {
|
|||
self.rec.borrow_mut().tombstoned.push(node_id.into());
|
||||
Ok(())
|
||||
}
|
||||
fn restore(&mut self, node_id: &str) -> Result<()> {
|
||||
self.rec.borrow_mut().restored.push(node_id.into());
|
||||
Ok(())
|
||||
}
|
||||
fn rename(&mut self, node_id: &str, title: &str) -> Result<()> {
|
||||
self.rec
|
||||
.borrow_mut()
|
||||
.renamed
|
||||
.push((node_id.into(), title.into()));
|
||||
Ok(())
|
||||
}
|
||||
fn reparent(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
|
||||
self.rec
|
||||
.borrow_mut()
|
||||
.reparented
|
||||
.push((project_id.into(), parent_id.map(str::to_string)));
|
||||
Ok(())
|
||||
}
|
||||
fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -396,7 +357,7 @@ fn move_to_project_picker_refiles_the_selected_task() {
|
|||
app.begin_move();
|
||||
match &app.mode {
|
||||
Mode::MoveToProject(m) => {
|
||||
assert_eq!(m.subject_id, "t1");
|
||||
assert_eq!(m.task_id, "t1");
|
||||
assert_eq!(
|
||||
m.options.iter().map(|o| o.label()).collect::<Vec<_>>(),
|
||||
vec!["(Unfile)", "Camano"]
|
||||
|
|
@ -607,218 +568,3 @@ fn reschedule_with_blank_clears_the_do_date() {
|
|||
// do_date present-and-null = "clear" (the double-option).
|
||||
assert_eq!(scheduled[0].1.do_date, Some(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_task_is_undoable_via_restore_and_redoable() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
app.begin_delete();
|
||||
app.confirm_delete(); // tombstones t1
|
||||
assert_eq!(rec.borrow().tombstoned, vec!["t1".to_string()]);
|
||||
|
||||
app.undo(); // restores it
|
||||
assert_eq!(rec.borrow().restored, vec!["t1".to_string()]);
|
||||
|
||||
app.redo(); // tombstones it again
|
||||
assert_eq!(
|
||||
rec.borrow().tombstoned,
|
||||
vec!["t1".to_string(), "t1".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_project_undo_restores_node_and_refiles_its_tasks() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
// Step the sidebar to the Camano project (it holds task "pt").
|
||||
for _ in 0..6 {
|
||||
app.move_sidebar(1);
|
||||
}
|
||||
app.begin_delete_project();
|
||||
app.confirm_delete();
|
||||
assert_eq!(rec.borrow().tombstoned, vec!["p1".to_string()]);
|
||||
|
||||
app.undo();
|
||||
assert_eq!(rec.borrow().restored, vec!["p1".to_string()]);
|
||||
// The task that was filed under it is re-filed.
|
||||
assert_eq!(
|
||||
rec.borrow().refiled.last().unwrap(),
|
||||
&("pt".to_string(), Some("p1".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_prefills_renames_task_and_context_and_is_undoable() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
app.focus_tasks();
|
||||
|
||||
app.begin_rename(); // t1, title "red one", ctx c1
|
||||
// The buffer is prefilled; clear it and type a new title.
|
||||
for _ in 0.."red one".len() {
|
||||
app.input_backspace();
|
||||
}
|
||||
type_and_submit(&mut app, "crimson one");
|
||||
|
||||
{
|
||||
let renamed = &rec.borrow().renamed;
|
||||
assert_eq!(
|
||||
renamed.as_slice(),
|
||||
[
|
||||
("t1".to_string(), "crimson one".to_string()),
|
||||
("c1".to_string(), "crimson one".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
app.undo(); // back to "red one" on both nodes
|
||||
assert_eq!(
|
||||
rec.borrow().renamed.last().unwrap(),
|
||||
&("c1".to_string(), "red one".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_to_same_or_empty_title_is_a_noop() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
app.focus_tasks();
|
||||
|
||||
app.begin_rename();
|
||||
app.input_submit(); // unchanged prefill
|
||||
assert!(rec.borrow().renamed.is_empty());
|
||||
|
||||
app.begin_rename();
|
||||
for _ in 0.."red one".len() {
|
||||
app.input_backspace();
|
||||
}
|
||||
app.input_submit(); // emptied
|
||||
assert!(rec.borrow().renamed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_picker_excludes_subtree_and_reparents_with_undo() {
|
||||
use heph_tui::app::{Mode, MoveOption};
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
// A second project to be the new parent.
|
||||
fake.projects.push(Project {
|
||||
id: "p2".into(),
|
||||
title: "Coding".into(),
|
||||
});
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
// Sidebar → Camano (p1).
|
||||
for _ in 0..6 {
|
||||
app.move_sidebar(1);
|
||||
}
|
||||
assert_eq!(app.task_pane_title(), "Camano");
|
||||
|
||||
app.begin_reparent();
|
||||
match &app.mode {
|
||||
Mode::MoveToProject(m) => {
|
||||
assert_eq!(m.subject_id, "p1");
|
||||
let labels: Vec<String> = m.options.iter().map(|o| o.label()).collect();
|
||||
assert!(labels.contains(&"(Move to root)".to_string()), "{labels:?}");
|
||||
assert!(labels.contains(&"Coding".to_string()), "{labels:?}");
|
||||
assert!(
|
||||
!labels.contains(&"Camano".to_string()),
|
||||
"a project can't be its own parent: {labels:?}"
|
||||
);
|
||||
assert!(
|
||||
!m.options
|
||||
.iter()
|
||||
.any(|o| matches!(o, MoveOption::Create { .. })),
|
||||
"no create row in re-parent mode"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected the move picker"),
|
||||
}
|
||||
|
||||
// Pick Coding and submit.
|
||||
let coding_idx = match &app.mode {
|
||||
Mode::MoveToProject(m) => m
|
||||
.options
|
||||
.iter()
|
||||
.position(|o| matches!(o, MoveOption::Project { title, .. } if title == "Coding"))
|
||||
.unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
app.move_picker_move(coding_idx as isize);
|
||||
app.move_picker_submit();
|
||||
assert_eq!(
|
||||
rec.borrow().reparented.last().unwrap(),
|
||||
&("p1".to_string(), Some("p2".to_string()))
|
||||
);
|
||||
|
||||
app.undo(); // back to the root (no parent in the fixture overview)
|
||||
assert_eq!(
|
||||
rec.borrow().reparented.last().unwrap(),
|
||||
&("p1".to_string(), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_view_lists_and_resolves() {
|
||||
let mut fake = fixture();
|
||||
fake.conflicts = vec![heph_core::Conflict {
|
||||
id: "cf1".into(),
|
||||
node_id: "t1".into(),
|
||||
field: "do_date".into(),
|
||||
local_val: Some("1000".into()),
|
||||
remote_val: Some("2000".into()),
|
||||
local_hlc: String::new(),
|
||||
remote_hlc: String::new(),
|
||||
status: "open".into(),
|
||||
created_at: 0,
|
||||
}];
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
app.open_conflicts();
|
||||
let v = app.conflicts_view.as_ref().expect("conflicts view open");
|
||||
assert_eq!(v.items.len(), 1);
|
||||
|
||||
app.conflicts_resolve("remote");
|
||||
assert_eq!(
|
||||
rec.borrow().resolved.as_slice(),
|
||||
[("cf1".to_string(), "remote".to_string())]
|
||||
);
|
||||
|
||||
app.close_conflicts();
|
||||
assert!(app.conflicts_view.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_view_opens_scrolls_and_closes() {
|
||||
let mut fake = fixture();
|
||||
fake.logs.insert(
|
||||
"t1".into(),
|
||||
vec!["one".into(), "two".into(), "three".into()],
|
||||
);
|
||||
let mut app = App::new(fake).unwrap();
|
||||
app.focus_tasks();
|
||||
|
||||
app.open_log();
|
||||
assert_eq!(app.log_view.as_ref().unwrap().lines.len(), 3);
|
||||
|
||||
app.log_scroll(5); // clamped to the last line
|
||||
assert_eq!(app.log_view.as_ref().unwrap().scroll, 2);
|
||||
app.log_scroll(-9);
|
||||
assert_eq!(app.log_view.as_ref().unwrap().scroll, 0);
|
||||
|
||||
app.close_log();
|
||||
assert!(app.log_view.is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,12 +281,6 @@ enum NodeAction {
|
|||
/// Node id.
|
||||
id: String,
|
||||
},
|
||||
/// Restore (un-tombstone) a node — the undo of `rm`. A restored task gets
|
||||
/// its canonical-context doc back too.
|
||||
Restore {
|
||||
/// Node id.
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
|
|
@ -297,8 +291,7 @@ enum LinkAction {
|
|||
src: String,
|
||||
/// Destination node id.
|
||||
dst: String,
|
||||
/// Link type: blocks|parent|tagged|in-project|context-of. (wiki links
|
||||
/// are derived from `[[…]]` in the body and can't be added by hand.)
|
||||
/// Link type: blocks|parent|tagged|in-project|context-of|…
|
||||
link_type: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -315,17 +308,6 @@ enum ProjectAction {
|
|||
},
|
||||
/// List all projects.
|
||||
List,
|
||||
/// Re-parent a project: move it under another project, or to the root.
|
||||
Move {
|
||||
/// Project to move (name; fuzzy like `--project` elsewhere).
|
||||
name: String,
|
||||
/// New parent project name.
|
||||
#[arg(long, conflicts_with = "root")]
|
||||
parent: Option<String>,
|
||||
/// Detach to the root (no parent).
|
||||
#[arg(long)]
|
||||
root: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
|
|
@ -653,16 +635,6 @@ fn main() -> Result<()> {
|
|||
if node.get("kind").and_then(Value::as_str) == Some("task") {
|
||||
let task = client.call("task.get", json!({ "id": id }))?;
|
||||
println!("task: {}", serde_json::to_string_pretty(&task)?);
|
||||
// A task node's own `body` is always null — the real content
|
||||
// lives in its canonical-context doc, so show that too.
|
||||
if let Ok(doc_id) = canonical_context_id(&mut client, &id) {
|
||||
let body = context_body(&mut client, &doc_id)?;
|
||||
if body.trim().is_empty() {
|
||||
println!("context ({doc_id}): (empty)");
|
||||
} else {
|
||||
println!("context ({doc_id}):\n{}", body.trim_end());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Log { id, text, n } => {
|
||||
|
|
@ -743,10 +715,6 @@ fn main() -> Result<()> {
|
|||
client.call("node.tombstone", json!({ "id": id }))?;
|
||||
println!("Tombstoned {id}");
|
||||
}
|
||||
NodeAction::Restore { id } => {
|
||||
client.call("node.restore", json!({ "id": id }))?;
|
||||
println!("Restored {id}");
|
||||
}
|
||||
},
|
||||
Command::Get { id } => {
|
||||
let result = client.call("node.get", json!({ "id": id }))?;
|
||||
|
|
@ -820,26 +788,6 @@ fn main() -> Result<()> {
|
|||
println!("{} {}", n.id, n.title);
|
||||
}
|
||||
}
|
||||
ProjectAction::Move { name, parent, root } => {
|
||||
let id = resolve_project(&mut client, Some(&name))?
|
||||
.with_context(|| format!("no project named {name:?}"))?;
|
||||
let parent_id = match (&parent, root) {
|
||||
(Some(p), false) => Some(
|
||||
resolve_project(&mut client, Some(p))?
|
||||
.with_context(|| format!("no parent project named {p:?}"))?,
|
||||
),
|
||||
(None, true) => None,
|
||||
_ => bail!("pass exactly one of --parent <project> or --root"),
|
||||
};
|
||||
client.call(
|
||||
"project.reparent",
|
||||
json!({ "id": id, "parent_id": parent_id }),
|
||||
)?;
|
||||
match parent {
|
||||
Some(p) => println!("Moved {name} under {p}"),
|
||||
None => println!("Moved {name} to the root"),
|
||||
}
|
||||
}
|
||||
},
|
||||
Command::Tag { action } => match action {
|
||||
TagAction::Add { node, tag } => {
|
||||
|
|
|
|||
|
|
@ -691,86 +691,6 @@ fn print_status(installed: bool, running: bool, p: &Paths, service_file: &Path)
|
|||
println!("log : {}", p.log.display());
|
||||
if !running {
|
||||
println!("\n(start it with `heph daemon start`)");
|
||||
return;
|
||||
}
|
||||
print_runtime_status(&p.socket);
|
||||
}
|
||||
|
||||
/// Ask the live daemon (over its socket) for its runtime config + sync /
|
||||
/// self-update state, and print it under the service facts. Best-effort: a
|
||||
/// daemon that won't answer is reported, not an error.
|
||||
fn print_runtime_status(socket: &Path) {
|
||||
let status = hephd::Client::connect(socket)
|
||||
.and_then(|mut c| c.call("sync.status", serde_json::json!({})));
|
||||
let status = match status {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!("\n(daemon did not answer sync.status: {e})");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let s = |v: &serde_json::Value| v.as_str().map(str::to_string);
|
||||
let runtime = &status["runtime"];
|
||||
println!();
|
||||
if let Some(v) = s(&runtime["version"]) {
|
||||
println!("version : {v}");
|
||||
}
|
||||
if let Some(m) = s(&runtime["mode"]) {
|
||||
println!("mode : {m}");
|
||||
}
|
||||
match s(&status["hub_url"]) {
|
||||
Some(hub) => {
|
||||
let interval = runtime["sync_interval_secs"]
|
||||
.as_u64()
|
||||
.map(|n| format!(" (every {n}s)"))
|
||||
.unwrap_or_default();
|
||||
println!("hub : {hub}{interval}");
|
||||
if let Some(issuer) = s(&status["auth"]["issuer"]) {
|
||||
println!("oidc : {issuer}");
|
||||
}
|
||||
let health = &status["health"];
|
||||
match s(&health["last_error"]) {
|
||||
Some(err) => println!("sync : FAILING — {err}"),
|
||||
None => match health["last_success_ms"].as_i64() {
|
||||
Some(ms) => println!("sync : ok (last success {})", fmt_age(ms)),
|
||||
None => println!("sync : no exchange yet"),
|
||||
},
|
||||
}
|
||||
}
|
||||
None => println!("hub : (none — standalone)"),
|
||||
}
|
||||
if let Some(n) = status["conflicts"].as_u64() {
|
||||
if n > 0 {
|
||||
println!("conflicts : {n} open (see `heph conflicts list`)");
|
||||
}
|
||||
}
|
||||
match &runtime["self_update"] {
|
||||
serde_json::Value::Null => println!("selfupdate: off"),
|
||||
su => {
|
||||
let every = su["interval_secs"]
|
||||
.as_u64()
|
||||
.map(|n| format!("every {n}s"))
|
||||
.unwrap_or_default();
|
||||
let outcome = match (su["last_check_ms"].as_i64(), s(&su["last_outcome"])) {
|
||||
(Some(ms), Some(o)) => format!("; last check {}: {o}", fmt_age(ms)),
|
||||
_ => "; no check yet".to_string(),
|
||||
};
|
||||
println!("selfupdate: on, {every}{outcome}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Ns ago" for an epoch-ms timestamp (coarse, human-scale).
|
||||
fn fmt_age(epoch_ms: i64) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
let secs = ((now - epoch_ms) / 1000).max(0);
|
||||
match secs {
|
||||
0..=119 => format!("{secs}s ago"),
|
||||
120..=7199 => format!("{}m ago", secs / 60),
|
||||
_ => format!("{}h ago", secs / 3600),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -249,78 +249,3 @@ fn export_writes_markdown_files() {
|
|||
let text = std::fs::read_to_string(docs[0].path()).unwrap();
|
||||
assert!(text.contains("title: \"Roof log\""), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_rm_then_restore_round_trips() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let (out, ok) = heph(&socket, &["task", "Doomed", "--attention", "red"]);
|
||||
assert!(ok, "{out}");
|
||||
let id = out
|
||||
.split_whitespace()
|
||||
.find(|w| w.len() == 26) // the ULID in "Created task <id> …"
|
||||
.expect("task id in output")
|
||||
.to_string();
|
||||
|
||||
let (out, ok) = heph(&socket, &["node", "rm", &id]);
|
||||
assert!(ok, "{out}");
|
||||
let (out, _) = heph(&socket, &["list"]);
|
||||
assert!(
|
||||
!out.contains("Doomed"),
|
||||
"tombstoned task still listed: {out}"
|
||||
);
|
||||
|
||||
let (out, ok) = heph(&socket, &["node", "restore", &id]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("Restored"));
|
||||
let (out, _) = heph(&socket, &["list"]);
|
||||
assert!(out.contains("Doomed"), "restored task missing: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_move_reparents_and_detaches() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let (out, ok) = heph(&socket, &["project", "add", "Coding"]);
|
||||
assert!(ok, "{out}");
|
||||
let (out, ok) = heph(&socket, &["project", "add", "Hephaestus"]);
|
||||
assert!(ok, "{out}");
|
||||
|
||||
let (out, ok) = heph(
|
||||
&socket,
|
||||
&["project", "move", "Hephaestus", "--parent", "Coding"],
|
||||
);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("under Coding"), "{out}");
|
||||
|
||||
// A cycle is rejected with a real error.
|
||||
let (out, ok) = heph(
|
||||
&socket,
|
||||
&["project", "move", "Coding", "--parent", "Hephaestus"],
|
||||
);
|
||||
assert!(!ok, "cycle unexpectedly allowed: {out}");
|
||||
|
||||
let (out, ok) = heph(&socket, &["project", "move", "Hephaestus", "--root"]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("to the root"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_on_a_task_prints_its_context_doc_body() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let (out, ok) = heph(&socket, &["task", "Fix roof"]);
|
||||
assert!(ok, "{out}");
|
||||
let id = out
|
||||
.split_whitespace()
|
||||
.find(|w| w.len() == 26)
|
||||
.expect("task id in output")
|
||||
.to_string();
|
||||
let (out, ok) = heph(&socket, &["context", &id, "--body", "buy shingles first"]);
|
||||
assert!(ok, "{out}");
|
||||
|
||||
let (out, ok) = heph(&socket, &["show", &id]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(out.contains("\"kind\": \"task\""), "{out}");
|
||||
assert!(
|
||||
out.contains("buy shingles"),
|
||||
"show hid the context body: {out}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ dbus-secret-service-keyring-store.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
proptest = "1"
|
||||
# Auth tests generate a throwaway RSA key + JWKS at runtime (no key in the repo).
|
||||
rsa = "0.9"
|
||||
rand = "0.8"
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 9e3bbefcd78c4c389f8d6088d0a5462bf5516aed780ff5dd5952f636c3ae7ba2 # shrinks to s = "A0 a"
|
||||
|
|
@ -98,20 +98,12 @@ fn parse_offset(rest: &str, today: NaiveDate) -> Result<NaiveDate> {
|
|||
let n: u64 = num
|
||||
.parse()
|
||||
.with_context(|| format!("not a relative date offset: +{rest}"))?;
|
||||
// Checked throughout: a large `n` would otherwise overflow chrono's date
|
||||
// arithmetic and panic (the `+` operators do), so an out-of-range offset
|
||||
// must surface as a clean error instead of crashing the parse.
|
||||
let out = match unit.trim() {
|
||||
"" | "d" | "day" | "days" => today.checked_add_days(Days::new(n)),
|
||||
"w" | "wk" | "week" | "weeks" => n
|
||||
.checked_mul(7)
|
||||
.and_then(|days| today.checked_add_days(Days::new(days))),
|
||||
"m" | "mo" | "month" | "months" => u32::try_from(n)
|
||||
.ok()
|
||||
.and_then(|m| today.checked_add_months(Months::new(m))),
|
||||
match unit.trim() {
|
||||
"" | "d" | "day" | "days" => Ok(today + Days::new(n)),
|
||||
"w" | "wk" | "week" | "weeks" => Ok(today + Days::new(n * 7)),
|
||||
"m" | "mo" | "month" | "months" => Ok(today + Months::new(n as u32)),
|
||||
other => bail!("unknown offset unit {other:?} (use d, w, or m)"),
|
||||
};
|
||||
out.with_context(|| format!("date offset +{rest} is out of range"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a weekday name (full or common abbreviation) to a `Weekday`. Matches
|
||||
|
|
@ -266,10 +258,7 @@ fn parse_month_day(s: &str) -> Option<(u32, u32)> {
|
|||
return None;
|
||||
}
|
||||
let month = |t: &str| -> Option<u32> {
|
||||
// First three *chars* (not bytes): a multibyte token like "" would
|
||||
// make a byte slice land mid-codepoint and panic.
|
||||
let key: String = t.chars().take(3).collect();
|
||||
match key.as_str() {
|
||||
match &t[..t.len().min(3)] {
|
||||
"jan" => Some(1),
|
||||
"feb" => Some(2),
|
||||
"mar" => Some(3),
|
||||
|
|
@ -648,37 +637,4 @@ mod tests {
|
|||
assert_eq!(humanize_rrule(raw), raw, "should pass {raw} through");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_day_offset_does_not_panic() {
|
||||
// `+<huge>d` parses as a valid u64 then overflows the date — must be a
|
||||
// clean Err, not a chrono arithmetic panic.
|
||||
assert!(parse_date("+999999999999999999d", today()).is_err());
|
||||
assert!(parse_date("+999999999w", today()).is_err());
|
||||
assert!(parse_date("+999999999m", today()).is_err());
|
||||
}
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
/// Date parsing is total for any input — surfaces feed it raw user text.
|
||||
#[test]
|
||||
fn parse_date_never_panics(s in "\\PC{0,40}") {
|
||||
let _ = parse_date(&s, today());
|
||||
}
|
||||
|
||||
/// Offset/ISO forms that parse round-trip through `fmt_iso`-style ISO.
|
||||
#[test]
|
||||
fn offset_dates_round_trip_through_iso(n in 0u32..3650) {
|
||||
let date = parse_date(&format!("+{n}d"), today()).unwrap();
|
||||
let iso = date.format("%Y-%m-%d").to_string();
|
||||
prop_assert_eq!(parse_date(&iso, today()).unwrap(), date);
|
||||
}
|
||||
|
||||
/// Recurrence parsing is total for any input.
|
||||
#[test]
|
||||
fn parse_recurrence_never_panics(s in "\\PC{0,40}") {
|
||||
let _ = parse_recurrence(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,12 +84,6 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
oidc_client_id: Option<String>,
|
||||
|
||||
/// Adopt this canonical owner id at startup (idempotent once adopted).
|
||||
/// Path-A hub seeding: a fresh hub started with an existing device's owner
|
||||
/// id rebuilds from that spoke's first full op-log push — no snapshot copy.
|
||||
#[arg(long)]
|
||||
owner_id: Option<String>,
|
||||
|
||||
/// Opt-in (default off): periodically poll the forge for a newer release and
|
||||
/// auto-update this daemon. Off unless this flag is given.
|
||||
#[arg(long)]
|
||||
|
|
@ -160,9 +154,7 @@ async fn main() -> Result<()> {
|
|||
};
|
||||
(
|
||||
None,
|
||||
Daemon::new(store)
|
||||
.with_mode("client")
|
||||
.with_self_update(self_update.clone()),
|
||||
Daemon::new(store).with_self_update(self_update.clone()),
|
||||
)
|
||||
}
|
||||
Mode::Local | Mode::Server => {
|
||||
|
|
@ -173,21 +165,11 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
// Take the exclusive lock before opening the store (tech-spec §3.1).
|
||||
let lock = LockGuard::acquire(&db)?;
|
||||
let mut store = LocalStore::open(&db, Box::new(SystemClock))?;
|
||||
if let Some(owner) = &cli.owner_id {
|
||||
use heph_core::Store as _;
|
||||
store.adopt_owner(owner)?;
|
||||
tracing::info!(%owner, "adopted canonical owner id");
|
||||
}
|
||||
let store = LocalStore::open(&db, Box::new(SystemClock))?;
|
||||
let spoke = cli.hub_url.as_deref().and_then(|hub| {
|
||||
spoke_auth(hub, cli.oidc_issuer.as_ref(), cli.oidc_client_id.as_ref())
|
||||
});
|
||||
let daemon = Daemon::new(store)
|
||||
.with_mode(if cli.mode == Mode::Server {
|
||||
"server"
|
||||
} else {
|
||||
"local"
|
||||
})
|
||||
.with_hub(cli.hub_url.clone())
|
||||
.with_spoke_auth(spoke)
|
||||
.with_self_update(self_update.clone());
|
||||
|
|
|
|||
|
|
@ -240,25 +240,4 @@ mod tests {
|
|||
assert_eq!(r.title, "Review every report");
|
||||
assert_eq!(r.recurrence, None);
|
||||
}
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
/// Quick-add is total — it's the daemon's parse of raw capture text.
|
||||
#[test]
|
||||
fn parse_never_panics(s in "\\PC{0,60}") {
|
||||
let _ = parse(&s, today(), &projects());
|
||||
}
|
||||
|
||||
/// Every word in the title came from the input: the parser only ever
|
||||
/// drops recognized tokens, never invents text.
|
||||
#[test]
|
||||
fn title_words_are_a_subset_of_input_words(s in "[\\PC ]{0,60}") {
|
||||
let r = parse(&s, today(), &projects());
|
||||
let input: std::collections::HashSet<&str> = s.split_whitespace().collect();
|
||||
for w in r.title.split_whitespace() {
|
||||
prop_assert!(input.contains(w), "title word {w:?} not in input {s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,23 +133,11 @@ impl Store for RemoteStore {
|
|||
self.call("node.tombstone", json!({ "id": id })).map(|_| ())
|
||||
}
|
||||
|
||||
fn restore_node(&mut self, id: &str) -> Result<()> {
|
||||
self.call("node.restore", json!({ "id": id })).map(|_| ())
|
||||
}
|
||||
|
||||
fn delete_project(&mut self, project_id: &str) -> Result<()> {
|
||||
self.call("project.delete", json!({ "id": project_id }))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn reparent_project(&mut self, project_id: &str, parent_id: Option<&str>) -> Result<()> {
|
||||
self.call(
|
||||
"project.reparent",
|
||||
json!({ "id": project_id, "parent_id": parent_id }),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
|
||||
self.call_as("node.resolve", json!({ "title": title }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,14 +178,6 @@ struct SetProjectParams {
|
|||
project_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReparentParams {
|
||||
id: String,
|
||||
/// New parent project node id; `null`/absent detaches to the root.
|
||||
#[serde(default)]
|
||||
parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PromoteParams {
|
||||
container_id: String,
|
||||
|
|
@ -352,11 +344,6 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
|||
store.tombstone_node(&p.id)?;
|
||||
json!({ "ok": true })
|
||||
}
|
||||
"node.restore" => {
|
||||
let p: IdParam = parse(params)?;
|
||||
store.restore_node(&p.id)?;
|
||||
json!({ "ok": true })
|
||||
}
|
||||
"node.resolve" => {
|
||||
let p: ResolveParams = parse(params)?;
|
||||
json!(store.resolve_node(&p.title)?)
|
||||
|
|
@ -398,11 +385,6 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
|||
store.delete_project(&p.id)?;
|
||||
Value::Null
|
||||
}
|
||||
"project.reparent" => {
|
||||
let p: ReparentParams = parse(params)?;
|
||||
store.reparent_project(&p.id, p.parent_id.as_deref())?;
|
||||
json!({ "ok": true })
|
||||
}
|
||||
"task.skip" => {
|
||||
let p: IdParam = parse(params)?;
|
||||
json!(store.skip_recurrence(&p.id)?)
|
||||
|
|
|
|||
|
|
@ -32,29 +32,6 @@ impl SelfUpdateConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Observed poller state, shared with the daemon so `sync.status` (and through
|
||||
/// it `heph daemon status`) can report what self-update last did instead of
|
||||
/// only logging it. All times epoch ms; `None` means "no check yet".
|
||||
#[derive(Clone, Default, serde::Serialize)]
|
||||
pub struct SelfUpdateHealth {
|
||||
/// When the last release check completed.
|
||||
pub last_check_ms: Option<i64>,
|
||||
/// Human-readable outcome of that check.
|
||||
pub last_outcome: Option<String>,
|
||||
}
|
||||
|
||||
impl SelfUpdateHealth {
|
||||
fn record(health: &std::sync::Mutex<Self>, outcome: String) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
let mut h = health.lock().expect("self-update health mutex poisoned");
|
||||
h.last_check_ms = Some(now);
|
||||
h.last_outcome = Some(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
/// The forge releases feed for this project — the latest tagged release. The
|
||||
/// repo is public, so this is an unauthenticated GET on the canonical public
|
||||
/// host.
|
||||
|
|
@ -248,15 +225,13 @@ pub async fn apply_update(
|
|||
}
|
||||
|
||||
/// The background poll loop: tick on `interval`, check for a newer release, and
|
||||
/// when one is available, apply it. Each check's outcome is folded into
|
||||
/// `health` for `sync.status`. Runs forever; spawned as a task.
|
||||
/// when one is available, apply it. Runs forever; spawned as a task.
|
||||
pub async fn run_poll_loop<S: ReleaseSource>(
|
||||
source: S,
|
||||
installer: Arc<dyn Installer>,
|
||||
restarter: Arc<dyn Restarter>,
|
||||
interval: Duration,
|
||||
current: &'static str,
|
||||
health: Arc<std::sync::Mutex<SelfUpdateHealth>>,
|
||||
) {
|
||||
let mut tick = tokio::time::interval(interval);
|
||||
loop {
|
||||
|
|
@ -264,22 +239,14 @@ pub async fn run_poll_loop<S: ReleaseSource>(
|
|||
match check_release(&source, current).await {
|
||||
CheckOutcome::UpdateAvailable(tag) => {
|
||||
tracing::info!(%tag, current, "self-update: newer release available, applying");
|
||||
SelfUpdateHealth::record(&health, format!("applying update to {tag}"));
|
||||
// On success the restarter exits the process, so this only
|
||||
// returns on failure — log it and keep polling.
|
||||
if let Err(e) = apply_update(installer.clone(), restarter.clone(), &tag).await {
|
||||
tracing::error!("self-update: failed for {tag}: {e}");
|
||||
SelfUpdateHealth::record(&health, format!("install of {tag} failed: {e}"));
|
||||
}
|
||||
}
|
||||
CheckOutcome::UpToDate => {
|
||||
tracing::debug!(current, "self-update: up to date");
|
||||
SelfUpdateHealth::record(&health, format!("up to date ({current})"));
|
||||
}
|
||||
CheckOutcome::Failed(e) => {
|
||||
tracing::warn!("self-update: release check failed: {e}");
|
||||
SelfUpdateHealth::record(&health, format!("release check failed: {e}"));
|
||||
}
|
||||
CheckOutcome::UpToDate => tracing::debug!(current, "self-update: up to date"),
|
||||
CheckOutcome::Failed(e) => tracing::warn!("self-update: release check failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,12 +64,6 @@ struct Ctx {
|
|||
self_update: Option<SelfUpdateConfig>,
|
||||
/// Live sync health, shared between the background loop and `sync.status`.
|
||||
sync_health: Arc<Mutex<SyncHealth>>,
|
||||
/// Live self-update poller state, shared with `sync.status`.
|
||||
self_update_health: Arc<Mutex<selfupdate::SelfUpdateHealth>>,
|
||||
/// Runtime mode (`local`/`server`/`client`), for `sync.status`.
|
||||
mode: Option<String>,
|
||||
/// Background sync cadence, recorded when the loop is spawned.
|
||||
sync_interval_secs: Arc<Mutex<Option<u64>>>,
|
||||
}
|
||||
|
||||
/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure).
|
||||
|
|
@ -159,46 +153,6 @@ fn annotate_reauth(
|
|||
}
|
||||
}
|
||||
|
||||
/// Log every 1-in-N repeats of an identical sync failure (the first occurrence
|
||||
/// always logs).
|
||||
const REPEAT_LOG_EVERY: u32 = 10;
|
||||
|
||||
/// Per-loop log throttling state for background sync: announce recovery after a
|
||||
/// failure streak, and suppress repeats of an identical failure message so a
|
||||
/// down hub doesn't write the same warning every cycle.
|
||||
#[derive(Default)]
|
||||
struct SyncLoopLog {
|
||||
consecutive_failures: u32,
|
||||
last_error: Option<String>,
|
||||
repeats: u32,
|
||||
}
|
||||
|
||||
impl SyncLoopLog {
|
||||
/// Fold in a success. Returns `Some(n)` when this ends a streak of `n`
|
||||
/// failures — the recovery transition worth announcing.
|
||||
fn on_success(&mut self) -> Option<u32> {
|
||||
let failures = std::mem::take(&mut self.consecutive_failures);
|
||||
self.last_error = None;
|
||||
self.repeats = 0;
|
||||
(failures > 0).then_some(failures)
|
||||
}
|
||||
|
||||
/// Fold in a failure. Returns whether this occurrence should log at warn
|
||||
/// level: a new message always does; an identical repeat only every
|
||||
/// [`REPEAT_LOG_EVERY`]-th time.
|
||||
fn on_failure(&mut self, msg: &str) -> bool {
|
||||
self.consecutive_failures += 1;
|
||||
if self.last_error.as_deref() == Some(msg) {
|
||||
self.repeats += 1;
|
||||
self.repeats.is_multiple_of(REPEAT_LOG_EVERY)
|
||||
} else {
|
||||
self.last_error = Some(msg.to_string());
|
||||
self.repeats = 0;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
/// The current bearer token for hub sync (refreshing if expired). `Ok(None)`
|
||||
/// means this spoke has no auth configured / no token stored (it syncs
|
||||
|
|
@ -242,19 +196,10 @@ impl Daemon {
|
|||
auth: None,
|
||||
self_update: None,
|
||||
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
|
||||
self_update_health: Arc::new(Mutex::new(selfupdate::SelfUpdateHealth::default())),
|
||||
mode: None,
|
||||
sync_interval_secs: Arc::new(Mutex::new(None)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the runtime mode (`local`/`server`/`client`) for `sync.status`.
|
||||
pub fn with_mode(mut self, mode: impl Into<String>) -> Daemon {
|
||||
self.ctx.mode = Some(mode.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the hub this device syncs with (`sync.now` targets it).
|
||||
pub fn with_hub(mut self, hub_url: Option<String>) -> Daemon {
|
||||
self.ctx.hub_url = hub_url;
|
||||
|
|
@ -309,7 +254,6 @@ impl Daemon {
|
|||
current = heph_core::VERSION,
|
||||
"self-update enabled"
|
||||
);
|
||||
let health = self.ctx.self_update_health.clone();
|
||||
tokio::spawn(async move {
|
||||
selfupdate::run_poll_loop(
|
||||
source,
|
||||
|
|
@ -317,7 +261,6 @@ impl Daemon {
|
|||
restarter,
|
||||
cfg.interval,
|
||||
heph_core::VERSION,
|
||||
health,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
|
@ -330,15 +273,9 @@ impl Daemon {
|
|||
let Some(hub) = self.ctx.hub_url.clone() else {
|
||||
return;
|
||||
};
|
||||
*self
|
||||
.ctx
|
||||
.sync_interval_secs
|
||||
.lock()
|
||||
.expect("sync interval mutex poisoned") = Some(interval.as_secs());
|
||||
let ctx = self.ctx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(interval);
|
||||
let mut log = SyncLoopLog::default();
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let bearer = match ctx.bearer().await {
|
||||
|
|
@ -348,13 +285,7 @@ impl Daemon {
|
|||
// rejected refresh) and skip; sending an unauthenticated
|
||||
// request would only 401 and mask it.
|
||||
record_bearer_failure(&ctx, &e);
|
||||
let msg = format!("could not obtain bearer token: {e}");
|
||||
if log.on_failure(&msg) {
|
||||
tracing::warn!(
|
||||
consecutive = log.consecutive_failures,
|
||||
"background sync: {msg}"
|
||||
);
|
||||
}
|
||||
tracing::warn!("background sync: could not obtain bearer token: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
|
@ -362,35 +293,8 @@ impl Daemon {
|
|||
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
|
||||
record_sync_outcome(&ctx, &result);
|
||||
match result {
|
||||
Ok(report) => {
|
||||
if let Some(failures) = log.on_success() {
|
||||
tracing::info!(failures, "background sync recovered");
|
||||
}
|
||||
// Cycles that move ops log their volume + cursor advance
|
||||
// at info; idle cycles stay at debug.
|
||||
if report.pulled + report.pushed > 0 {
|
||||
tracing::info!(
|
||||
pulled = report.pulled,
|
||||
applied = report.applied,
|
||||
pushed = report.pushed,
|
||||
pull_cursor = report.pull_cursor.as_deref().unwrap_or("-"),
|
||||
push_cursor = report.push_cursor.as_deref().unwrap_or("-"),
|
||||
"background sync moved ops"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(?report, "background sync");
|
||||
}
|
||||
}
|
||||
// `:#` prints the anyhow context chain (phase + hub url).
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
if log.on_failure(&msg) {
|
||||
tracing::warn!(
|
||||
consecutive = log.consecutive_failures,
|
||||
"background sync failed: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(report) => tracing::debug!(?report, "background sync"),
|
||||
Err(e) => tracing::warn!("background sync failed: {e}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -511,10 +415,9 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
}
|
||||
|
||||
/// `sync.status` — the hub url, the current per-hub cursors, the observed sync
|
||||
/// health (last-success time / last error / auth-failure flag), the pending
|
||||
/// merge-conflict count, and the daemon's runtime config (version, mode, sync
|
||||
/// cadence, self-update state). A spoke that is silently failing is visible
|
||||
/// here (and, via it, in the TUI status line and `heph daemon status`).
|
||||
/// health (last-success time / last error / auth-failure flag), and the pending
|
||||
/// merge-conflict count. A spoke that is silently failing is visible here (and,
|
||||
/// via it, in the TUI status line).
|
||||
async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||
// Conflict count is meaningful even on a hub / standalone instance.
|
||||
let store = ctx.store.clone();
|
||||
|
|
@ -529,35 +432,8 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
})?
|
||||
.map_err(RpcError::from)?;
|
||||
|
||||
// Runtime config: launch-time facts a client can't otherwise see.
|
||||
let self_update = ctx.self_update.as_ref().map(|cfg| {
|
||||
let h = ctx
|
||||
.self_update_health
|
||||
.lock()
|
||||
.expect("self-update health mutex poisoned")
|
||||
.clone();
|
||||
json!({
|
||||
"interval_secs": cfg.interval.as_secs(),
|
||||
"last_check_ms": h.last_check_ms,
|
||||
"last_outcome": h.last_outcome,
|
||||
})
|
||||
});
|
||||
let runtime = json!({
|
||||
"version": heph_core::VERSION,
|
||||
"mode": ctx.mode,
|
||||
"sync_interval_secs": *ctx
|
||||
.sync_interval_secs
|
||||
.lock()
|
||||
.expect("sync interval mutex poisoned"),
|
||||
"self_update": self_update,
|
||||
});
|
||||
|
||||
let Some(hub_url) = ctx.hub_url.clone() else {
|
||||
return Ok(json!({
|
||||
"hub_url": Value::Null,
|
||||
"conflicts": conflicts,
|
||||
"runtime": runtime,
|
||||
}));
|
||||
return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts }));
|
||||
};
|
||||
|
||||
let store = ctx.store.clone();
|
||||
|
|
@ -596,42 +472,5 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
"health": health,
|
||||
"auth": auth,
|
||||
"reauth_command": reauth_command(Some(&hub_url), ctx.auth.as_ref()),
|
||||
"runtime": runtime,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sync_loop_log_announces_recovery_after_a_failure_streak() {
|
||||
let mut log = SyncLoopLog::default();
|
||||
assert_eq!(log.on_success(), None, "no streak, nothing to announce");
|
||||
assert!(log.on_failure("hub down"));
|
||||
assert!(!log.on_failure("hub down"));
|
||||
assert!(!log.on_failure("hub down"));
|
||||
assert_eq!(log.on_success(), Some(3));
|
||||
assert_eq!(log.on_success(), None, "recovery announced once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_loop_log_throttles_identical_failures_but_not_new_ones() {
|
||||
let mut log = SyncLoopLog::default();
|
||||
assert!(log.on_failure("hub down"), "first occurrence logs");
|
||||
for _ in 0..REPEAT_LOG_EVERY - 1 {
|
||||
assert!(!log.on_failure("hub down"), "repeats are suppressed");
|
||||
}
|
||||
assert!(
|
||||
log.on_failure("hub down"),
|
||||
"every {REPEAT_LOG_EVERY}th repeat logs"
|
||||
);
|
||||
assert!(
|
||||
log.on_failure("dns broke"),
|
||||
"a new message logs immediately"
|
||||
);
|
||||
assert!(!log.on_failure("dns broke"));
|
||||
// Back to a previously-seen message: it changed, so it logs.
|
||||
assert!(log.on_failure("hub down"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use axum::extract::{Query, Request, State};
|
||||
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
|
||||
use axum::middleware::{self, Next};
|
||||
|
|
@ -72,12 +72,6 @@ pub struct SyncReport {
|
|||
pub applied: usize,
|
||||
/// Ops sent to the hub.
|
||||
pub pushed: usize,
|
||||
/// The pull cursor (HLC) this exchange advanced to, if it moved.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pull_cursor: Option<String>,
|
||||
/// The push cursor (HLC) this exchange advanced to, if it moved.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub push_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Run `f` against the locked store on the blocking pool (DB calls never run on
|
||||
|
|
@ -397,22 +391,13 @@ pub async fn sync_once(
|
|||
if let Some(token) = bearer {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
let pulled: OpsBody = req
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("sync pull: request to {base}/sync/pull failed"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("sync pull: hub {base} rejected the request"))?
|
||||
.json()
|
||||
.await
|
||||
.with_context(|| format!("sync pull: decoding response from {base}"))?;
|
||||
let pulled: OpsBody = req.send().await?.error_for_status()?.json().await?;
|
||||
report.pulled = pulled.ops.len();
|
||||
if !pulled.ops.is_empty() {
|
||||
let (applied, max_pulled) = with_store(&store, move |s| apply_batch(s, pulled.ops)).await?;
|
||||
report.applied = applied;
|
||||
if let Some(cursor) = max_pulled {
|
||||
let hub = hub_url.to_string();
|
||||
report.pull_cursor = Some(cursor.clone());
|
||||
with_store(&store, move |s| s.record_sync(&hub, None, Some(&cursor))).await?;
|
||||
}
|
||||
}
|
||||
|
|
@ -432,14 +417,9 @@ pub async fn sync_once(
|
|||
if let Some(token) = bearer {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
req.send()
|
||||
.await
|
||||
.with_context(|| format!("sync push: request to {base}/sync/push failed"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("sync push: hub {base} rejected the request"))?;
|
||||
req.send().await?.error_for_status()?;
|
||||
if let Some(cursor) = max_pushed {
|
||||
let hub = hub_url.to_string();
|
||||
report.push_cursor = Some(cursor.clone());
|
||||
with_store(&store, move |s| s.record_sync(&hub, Some(&cursor), None)).await?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -658,88 +658,3 @@ fn list_takes_a_filter_and_view_runs_a_builtin_over_socket() {
|
|||
// An unknown view name is a reported RPC error.
|
||||
assert!(c.call("view", json!({ "name": "bogus" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_restore_undoes_a_tombstone_over_socket() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
let task = c
|
||||
.call("task.create", json!({ "title": "Doomed task" }))
|
||||
.unwrap();
|
||||
let id = task["node_id"].as_str().unwrap().to_string();
|
||||
|
||||
c.call("node.tombstone", json!({ "id": id })).unwrap();
|
||||
let dead = c.call("node.get", json!({ "id": id })).unwrap();
|
||||
assert_eq!(dead["tombstoned"], true);
|
||||
|
||||
c.call("node.restore", json!({ "id": id })).unwrap();
|
||||
let alive = c.call("node.get", json!({ "id": id })).unwrap();
|
||||
assert_eq!(alive["tombstoned"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_reparent_moves_and_rejects_cycles_over_socket() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
let mk = |c: &mut Client, title: &str| {
|
||||
c.call("node.create", json!({ "kind": "project", "title": title }))
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
let coding = mk(&mut c, "Coding");
|
||||
let heph = mk(&mut c, "Hephaestus");
|
||||
|
||||
c.call(
|
||||
"project.reparent",
|
||||
json!({ "id": heph, "parent_id": coding }),
|
||||
)
|
||||
.unwrap();
|
||||
let overview = c.call("project.overview", json!({})).unwrap();
|
||||
let heph_row = overview
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["title"] == "Hephaestus")
|
||||
.unwrap();
|
||||
assert_eq!(heph_row["parent_id"].as_str(), Some(coding.as_str()));
|
||||
|
||||
// A cycle-creating move errors.
|
||||
assert!(c
|
||||
.call(
|
||||
"project.reparent",
|
||||
json!({ "id": coding, "parent_id": heph })
|
||||
)
|
||||
.is_err());
|
||||
|
||||
// Detach to root with a null parent.
|
||||
c.call("project.reparent", json!({ "id": heph, "parent_id": null }))
|
||||
.unwrap();
|
||||
let overview = c.call("project.overview", json!({})).unwrap();
|
||||
let heph_row = overview
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["title"] == "Hephaestus")
|
||||
.unwrap();
|
||||
assert_eq!(heph_row["parent_id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_status_reports_runtime_config() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
let status = c.call("sync.status", json!({})).unwrap();
|
||||
// Standalone test daemon: no hub, self-update off, but the runtime block
|
||||
// always reports the version (mode is unset when not built via main()).
|
||||
assert_eq!(status["hub_url"], Value::Null);
|
||||
assert_eq!(
|
||||
status["runtime"]["version"].as_str(),
|
||||
Some(heph_core::VERSION)
|
||||
);
|
||||
assert_eq!(status["runtime"]["self_update"], Value::Null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,54 +204,3 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() {
|
|||
"B recorded no conflict"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_hub_seeded_with_owner_id_rebuilds_from_first_sync() {
|
||||
// Path-A seeding: a brand-new hub started with the device's owner id (the
|
||||
// `hephd --owner-id` flag) needs no snapshot copy — the spoke's first sync
|
||||
// replays its whole op-log into the hub, and the hub keeps its own fresh
|
||||
// device origin by construction.
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
// The device: its own generated owner, with pre-existing data.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut device =
|
||||
LocalStore::open(dir.path().join("heph.db"), Box::new(StepClock::new(1000))).unwrap();
|
||||
let owner = device.owner_id().to_string();
|
||||
let task = device
|
||||
.create_task(NewTask {
|
||||
title: "Pre-hub task".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let device = Arc::new(Mutex::new(device));
|
||||
|
||||
// The hub: a fresh store adopting the device's owner (what --owner-id does).
|
||||
let hub_dir = tempfile::tempdir().unwrap();
|
||||
let mut hub_store = LocalStore::open(
|
||||
hub_dir.path().join("heph.db"),
|
||||
Box::new(StepClock::new(1000)),
|
||||
)
|
||||
.unwrap();
|
||||
hub_store.adopt_owner(&owner).unwrap();
|
||||
let hub = Arc::new(Mutex::new(hub_store));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = sync::router(hub.clone(), None);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let hub_url = format!("http://{addr}");
|
||||
|
||||
let report = sync::sync_once(device.clone(), &hub_url, &http, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(report.pushed > 0, "device pushed nothing");
|
||||
let on_hub = hub
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_node(&task.node_id)
|
||||
.unwrap()
|
||||
.expect("task reached the hub");
|
||||
assert_eq!(on_hub.title, "Pre-hub task");
|
||||
}
|
||||
|
|
|
|||
1
docs/changelog.d/feature-attention-a1-a4.feature.md
Normal file
1
docs/changelog.d/feature-attention-a1-a4.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Attention is now set directly instead of cycled, and surfaces it as `a1`–`a4` (a1=red, a2=orange, a3=white, a4=blue) rather than the colour words. In heph-tui press `a` then `1`–`4` to set a band (the old `A` cycle and `b` push-to-blue are retired; quick-add moves to `n`); heph-quickadd and the PWA show the same `a1`–`a4` labels, and the PWA's Attn action now pops a band picker. Quick-add inline syntax changes from `p1`–`p4` to `a1`–`a4` across every capture surface. The `heph` CLI's `-a/--attention` flag now accepts `a1`–`a4`, a bare `1`–`4`, or a colour word (`red`/`orange`/`white`/`blue`). The colour mappings are unchanged.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Fixed two parser panics found by fuzzing: a relative date offset like `+999999999999d` overflowed chrono's date arithmetic, and an `every <Month> <day>` recurrence phrase containing a multibyte character (e.g. ``) sliced a string on a non-char boundary. Both now return a clean error or fall through instead of crashing the daemon's parse.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Added property-based tests (proptest) across the parsing and CRDT surfaces (extraction, wiki-link projection, body CRDT, frontmatter, recurrence, HLC, datespec, quick-add), runnable as part of `cargo test`. See [[fuzz-testing]].
|
||||
|
|
@ -1 +0,0 @@
|
|||
Added cargo-fuzz targets for the CRDT and extraction surfaces (`crates/heph-core/fuzz/`, behind heph-core's `fuzzing` feature) plus a `mise run fuzz` task. Nightly-only and ad-hoc, not wired into CI. These targets surfaced robustness gaps in `yrs` 0.27 on malformed sync deltas (OOM, abort/UB) — documented as a known limitation in [[fuzz-testing]].
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
---
|
||||
title: Fuzz Testing
|
||||
modified: 2026-06-09
|
||||
tags:
|
||||
- how-to
|
||||
---
|
||||
|
||||
# Fuzz Testing
|
||||
|
||||
heph's parsing layer is pure and clock-injected, which makes it a natural fit
|
||||
for randomized testing. Fuzzing runs at two tiers:
|
||||
|
||||
## Tier 1 — property tests (proptest, stable Rust, runs in CI)
|
||||
|
||||
Property-based tests live alongside the unit tests in each module and run as
|
||||
part of the normal `cargo test` suite — no extra tooling, and CI picks them up
|
||||
via the standard build hook.
|
||||
|
||||
Covered invariants:
|
||||
|
||||
| Module | Invariants |
|
||||
|--------|-----------|
|
||||
| `heph-core/src/extract.rs` | extraction never panics, is idempotent; links are non-empty/trimmed/deduped; `context_item_lines` aligns 1:1 with `context_items` |
|
||||
| `heph-core/src/wikilink.rs` | `expand`/`collapse` are idempotent; `collapse(expand(x)) == collapse(x)` |
|
||||
| `heph-core/src/crdt.rs` | a write materializes exactly; concurrent edits converge regardless of merge order; merge is idempotent; merging arbitrary garbage bytes never panics |
|
||||
| `heph-core/src/frontmatter.rs` | `strip` is idempotent and always returns a suffix of its input |
|
||||
| `heph-core/src/recurrence.rs` | checkbox reset properties (pre-existing); `next_occurrence` is strictly after `after`; arbitrary RRULE strings never panic |
|
||||
| `heph-core/src/hlc.rs` | HLC ordering properties (pre-existing); `parse` never panics |
|
||||
| `hephd/src/datespec.rs` | `parse_date` never panics (including huge `+N` offsets); offsets and ISO dates round-trip |
|
||||
| `hephd/src/quickadd.rs` | `parse` never panics; title words always come from the input |
|
||||
|
||||
Run them with the rest of the suite:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Tier 2 — coverage-guided fuzzing (cargo-fuzz, nightly, run ad-hoc)
|
||||
|
||||
libFuzzer targets live in `crates/heph-core/fuzz/`. These are for the surfaces
|
||||
where coverage guidance beats random generation — chiefly the CRDT layer, which
|
||||
decodes attacker-controllable bytes arriving via sync (`yrs` update payloads in
|
||||
op-log entries).
|
||||
|
||||
Targets:
|
||||
|
||||
- `crdt_merge` — feeds arbitrary `(state, delta)` byte pairs to `merge_body`;
|
||||
asserts no panic and merge idempotence. This is the remote-input surface.
|
||||
- `crdt_write` — arbitrary `(prev, new)` string pairs through `write_body`;
|
||||
asserts the diff/CRDT round-trip materializes `new` exactly (UTF-8 boundary
|
||||
stress).
|
||||
- `extract` — arbitrary markdown through `extract` + `context_item_lines`;
|
||||
asserts the 1:1 alignment invariant promotion depends on.
|
||||
|
||||
Requirements: `rustup toolchain install nightly` and `cargo install cargo-fuzz`.
|
||||
The fuzz targets reach crate-private CRDT internals through the `fuzzing`
|
||||
cargo feature of `heph-core`, which exposes thin public wrappers — the feature
|
||||
is never enabled in normal builds.
|
||||
|
||||
Run all targets briefly (default 60s each), or one target for longer:
|
||||
|
||||
```bash
|
||||
mise run fuzz # all targets, 60s each
|
||||
mise run fuzz 300 # all targets, 5 min each
|
||||
cargo +nightly fuzz run crdt_merge --fuzz-dir crates/heph-core/fuzz -- -max_total_time=3600
|
||||
```
|
||||
|
||||
Crash artifacts land in `crates/heph-core/fuzz/artifacts/<target>/`; the corpus
|
||||
accumulates in `crates/heph-core/fuzz/corpus/<target>/` (both gitignored).
|
||||
Reproduce a crash with
|
||||
`cargo +nightly fuzz run <target> --fuzz-dir crates/heph-core/fuzz <artifact-path>`.
|
||||
|
||||
Tier 2 is deliberately not wired into CI: it needs nightly and meaningful wall
|
||||
clock to earn its keep. Run it ad-hoc after touching `crdt.rs`, `extract.rs`,
|
||||
or the sync payload path. If it ever moves to CI, a scheduled (not per-push)
|
||||
workflow with a persistent corpus is the right shape.
|
||||
|
||||
## Findings so far
|
||||
|
||||
The first runs paid for themselves. Tier 1 proptests found two reachable
|
||||
panics on user input, both fixed in the same change:
|
||||
|
||||
- **`datespec::parse_offset`** panicked on a large relative offset (e.g.
|
||||
`+999999999999d`) because chrono's `+` overflows; now uses checked
|
||||
arithmetic and returns an out-of-range error.
|
||||
- **`datespec::parse_month_day`** sliced a token on a non-char boundary for
|
||||
multibyte input (e.g. an `every <Month> <day>` phrase containing ``); now
|
||||
takes the first three *chars*.
|
||||
|
||||
Tier 2 (`crdt_merge`) surfaced **robustness gaps in `yrs` 0.27 on malformed
|
||||
update bytes**, reachable through the authenticated `/sync/push` path:
|
||||
|
||||
- a tiny delta `[255, 255, 255, 126]` triggers a huge allocation → **OOM**;
|
||||
- some inputs trip a `debug_assert!` in the yrs block decoder (unwinding
|
||||
panic — contained by the `catch_unwind` in `merge_body`);
|
||||
- at least one class hits genuine UB (an invalid `char`) → `SIGABRT` under
|
||||
debug UB-checks, silent UB in release.
|
||||
|
||||
These are not fully fixable in-tree: `yrs` exposes no pre-apply validator, and
|
||||
the OOM/abort classes are uncatchable. The blast radius is limited (the sync
|
||||
endpoint is authenticated), but a buggy or hostile authenticated peer can still
|
||||
crash a daemon. The `catch_unwind` in `merge_body` is partial mitigation;
|
||||
durable fixes need upstream `yrs` work or a bounded decoder. Until then this is
|
||||
a known limitation, tracked here and reproduced by the `crdt_merge` target.
|
||||
|
||||
## Why these targets
|
||||
|
||||
The high-value surfaces, ranked when this was set up:
|
||||
|
||||
1. **`crdt::merge_body`** — decodes untrusted bytes from sync peers; a panic
|
||||
here is a remote-input daemon crash.
|
||||
2. **`extract`** — custom scanning logic layered over pulldown-cmark; promotion
|
||||
rewrites body lines based on its output, so misalignment corrupts bodies.
|
||||
3. **`wikilink` rewriting** — span arithmetic where off-by-ones hide.
|
||||
4. **`datespec`/`quickadd`** — user-typed input parsed inside the daemon
|
||||
process.
|
||||
|
||||
Crashes found in dependencies (`yrs`, `rrule`, `pulldown-cmark`) are still
|
||||
real `hephd` crashes — handle by validating/catching before the call, and
|
||||
report upstream.
|
||||
|
||||
## Related
|
||||
|
||||
- [[v1-prototype-tech-spec]] — testing strategy
|
||||
- [[design]] — sync/CRDT rationale
|
||||
|
|
@ -23,4 +23,3 @@ Task-oriented guides for common operations.
|
|||
- [[self-update]] — Opt-in `hephd` self-update: poll the forge for new releases and auto-update
|
||||
- [[heph-pwa]] — The mobile app: an installable PWA mirror of heph-tui (browse, triage, fast quick-add, voice)
|
||||
- [[host-heph-pwa]] — Serve the mobile app from the hub (indri) with OIDC, in the hub/spoke deployment
|
||||
- [[fuzz-testing]] — Property tests (proptest, in `cargo test`) and cargo-fuzz targets (`mise run fuzz`) for the parsing/CRDT surfaces
|
||||
|
|
|
|||
|
|
@ -73,37 +73,23 @@ avoid re-authenticating often, set generous validities on the heph provider:
|
|||
|
||||
## 2. Bring up the hub on `indri`
|
||||
|
||||
**Seed it from `gilbert` (Path A) with `--owner-id`.** No snapshot copy: start
|
||||
the hub on a **fresh, empty store** that adopts `gilbert`'s `owner_id`, and the
|
||||
spoke's first sync replays its entire op-log into the hub (sync is op-based, so
|
||||
a hub that shares the owner rebuilds completely from ops). The hub gets its own
|
||||
device origin by construction — no origin-reset step, and `gilbert` is never
|
||||
rewritten.
|
||||
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
|
||||
copy its store to `indri`, and give `indri` its **own device origin** so the two
|
||||
replicas don't share one (see *Current gaps* — this seeding step is the bit the
|
||||
blumeops deployment finalizes). `indri` now holds `gilbert`'s data under the same
|
||||
`owner_id`.
|
||||
|
||||
Find the device's owner id on `gilbert` (any node row carries it):
|
||||
|
||||
```bash
|
||||
heph show "$(heph list --json | jq -r '.[0].node_id')" | grep owner_id
|
||||
# or directly: sqlite3 ~/.local/share/heph/heph.db 'SELECT id FROM users'
|
||||
```
|
||||
|
||||
Run the hub with that owner and auth enabled (issuer **and** audience together
|
||||
turn auth on; omit both only for local dev):
|
||||
Run the hub with auth enabled (issuer **and** audience together turn auth on;
|
||||
omit both only for local dev):
|
||||
|
||||
```bash
|
||||
hephd --mode server \
|
||||
--http-addr 0.0.0.0:8787 \
|
||||
--db /var/lib/heph/heph.db \
|
||||
--owner-id <gilbert-owner-id> \
|
||||
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
|
||||
--oidc-audience <heph-client-id>
|
||||
```
|
||||
|
||||
`--owner-id` is idempotent once adopted, so it is safe baked into the service
|
||||
unit. (Copying the SQLite snapshot still works as a seeding shortcut for huge
|
||||
stores, but then the copy shares `gilbert`'s device origin and must have it
|
||||
reset — prefer the flag.)
|
||||
|
||||
The first identity to authenticate **claims** the hub's owner; thereafter only
|
||||
that identity is served (single-owner today — see [[design]] and the
|
||||
`Adoption + multi-tenant` task for the multi-tenancy seam).
|
||||
|
|
@ -164,6 +150,15 @@ The spoke then can't refresh on its own and needs a re-login — but this is
|
|||
|
||||
Run the printed `heph auth login …` command to restore sync.
|
||||
|
||||
## Current gaps (finalized by the blumeops deployment)
|
||||
|
||||
The flag-level flow above works today; one enabler makes it a clean, managed
|
||||
deployment rather than a hand-run process — tracked in the `Hephaestus` project:
|
||||
|
||||
- **Path A seeding is manual** (copy the store + reset the device origin). A
|
||||
small enabler — seed a hub from a snapshot with a fresh origin, or
|
||||
`hephd --owner-id` — would make this one step.
|
||||
|
||||
> `heph daemon start`/`restart` can now bake the spoke/hub config (`--hub-url`,
|
||||
> `--mode server`, `--http-addr`, `--oidc-*`) into the generated service (see
|
||||
> [[run-the-daemon]]). The canonical hub on `indri` is still provisioned via the
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ Technical reference material for the repository tooling that ships with this pro
|
|||
| `mise run docs-check-links` | Validate wiki-links against existing doc filenames |
|
||||
| `mise run docs-mikado` | Inspect active Mikado chains and resume C2 work |
|
||||
| `mise run docs-preview <tarball>` | Extract and serve a released docs tarball locally |
|
||||
| `mise run fuzz [seconds] [target]` | Run the nightly cargo-fuzz targets briefly — see [[fuzz-testing]] |
|
||||
| `mise run import-todoist` | Seed a heph store from Todoist (dry-run by default; `-- --commit` to write) — see [[import-todoist]] |
|
||||
| `mise run mikado-branch-invariant-check` | Validate `mikado/*` branch commit discipline |
|
||||
| `mise run pr-comments <pr_number>` | List unresolved PR comments |
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#MISE description="Run the cargo-fuzz targets briefly (nightly). Usage: mise run fuzz [seconds] [target]"
|
||||
|
||||
# Tier 2 fuzzing (see docs/how-to/fuzz-testing.md). Nightly-only and ad-hoc —
|
||||
# not part of `cargo test` or CI. Runs each libFuzzer target for a bounded time
|
||||
# so it terminates; the corpus under fuzz/corpus/ persists between runs.
|
||||
#
|
||||
# mise run fuzz # all targets, 60s each
|
||||
# mise run fuzz 300 # all targets, 5 min each
|
||||
# mise run fuzz 600 crdt_merge # one target, 10 min
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FUZZ_DIR="$ROOT/crates/heph-core/fuzz"
|
||||
|
||||
SECONDS_PER="${1:-60}"
|
||||
ONLY="${2:-}"
|
||||
|
||||
if ! command -v cargo-fuzz >/dev/null 2>&1 && ! cargo +nightly fuzz --version >/dev/null 2>&1; then
|
||||
echo "cargo-fuzz not found. Install with:" >&2
|
||||
echo " rustup toolchain install nightly && cargo install cargo-fuzz" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$ONLY" ]]; then
|
||||
targets=("$ONLY")
|
||||
else
|
||||
# No `mapfile` — macOS ships bash 3.2. Target names are bare words.
|
||||
# shellcheck disable=SC2207
|
||||
targets=($(cargo +nightly fuzz list --fuzz-dir "$FUZZ_DIR"))
|
||||
fi
|
||||
|
||||
rc=0
|
||||
for t in "${targets[@]}"; do
|
||||
echo "=== fuzzing $t for ${SECONDS_PER}s ==="
|
||||
if ! cargo +nightly fuzz run "$t" --fuzz-dir "$FUZZ_DIR" -- -max_total_time="$SECONDS_PER"; then
|
||||
echo "!!! $t produced a crash — artifact in $FUZZ_DIR/artifacts/$t/" >&2
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$rc"
|
||||
Loading…
Add table
Add a link
Reference in a new issue