From c9786f4606182f80b993935ab6a33a96aeb88122 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 29 Aug 2026 15:33:20 -0700 Subject: [PATCH 01/12] feat: add native Rust Lite evaluation runtime --- .github/workflows/rust-lite-ci.yml | 41 + .github/workflows/rust-lite-release.yml | 73 + .gitignore | 3 + docs/rust-lite-parity.md | 103 ++ lite-rs/bin/.gitkeep | 1 + lite-rs/herdr-annotate.version | 1 + lite-rs/herdr-plugin.toml | 57 + lite-rs/scripts/fetch-herdr-annotate.ps1 | 52 + lite-rs/scripts/fetch-herdr-annotate.sh | 70 + lite-rs/scripts/stage-local.sh | 12 + rust/Cargo.lock | 1820 ++++++++++++++++++++++ rust/Cargo.toml | 55 + rust/src/archive_workflow.rs | 373 +++++ rust/src/cli.rs | 192 +++ rust/src/clipboard.rs | 141 ++ rust/src/editor.rs | 411 +++++ rust/src/format.rs | 164 ++ rust/src/handoff.rs | 131 ++ rust/src/herdr.rs | 55 + rust/src/layout.rs | 83 + rust/src/lib.rs | 19 + rust/src/main.rs | 15 + rust/src/manager.rs | 858 ++++++++++ rust/src/manager_copy.rs | 91 ++ rust/src/paths.rs | 80 + rust/src/store.rs | 533 +++++++ rust/src/types.rs | 289 ++++ rust/src/width.rs | 96 ++ rust/tests/commands.rs | 145 ++ scripts/smoke-rust-lite.sh | 97 ++ 30 files changed, 6061 insertions(+) create mode 100644 .github/workflows/rust-lite-ci.yml create mode 100644 .github/workflows/rust-lite-release.yml create mode 100644 docs/rust-lite-parity.md create mode 100644 lite-rs/bin/.gitkeep create mode 100644 lite-rs/herdr-annotate.version create mode 100644 lite-rs/herdr-plugin.toml create mode 100644 lite-rs/scripts/fetch-herdr-annotate.ps1 create mode 100755 lite-rs/scripts/fetch-herdr-annotate.sh create mode 100755 lite-rs/scripts/stage-local.sh create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/src/archive_workflow.rs create mode 100644 rust/src/cli.rs create mode 100644 rust/src/clipboard.rs create mode 100644 rust/src/editor.rs create mode 100644 rust/src/format.rs create mode 100644 rust/src/handoff.rs create mode 100644 rust/src/herdr.rs create mode 100644 rust/src/layout.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/main.rs create mode 100644 rust/src/manager.rs create mode 100644 rust/src/manager_copy.rs create mode 100644 rust/src/paths.rs create mode 100644 rust/src/store.rs create mode 100644 rust/src/types.rs create mode 100644 rust/src/width.rs create mode 100644 rust/tests/commands.rs create mode 100755 scripts/smoke-rust-lite.sh diff --git a/.github/workflows/rust-lite-ci.yml b/.github/workflows/rust-lite-ci.yml new file mode 100644 index 0000000..f722147 --- /dev/null +++ b/.github/workflows/rust-lite-ci.yml @@ -0,0 +1,41 @@ +name: rust-lite-ci + +on: + pull_request: + merge_group: + +concurrency: + group: rust-lite-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + - run: cargo fmt --manifest-path rust/Cargo.toml --all --check + - run: cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings + - run: cargo test --manifest-path rust/Cargo.toml --all-targets + + check-windows: + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + - run: cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings diff --git a/.github/workflows/rust-lite-release.yml b/.github/workflows/rust-lite-release.yml new file mode 100644 index 0000000..e16bc4e --- /dev/null +++ b/.github/workflows/rust-lite-release.yml @@ -0,0 +1,73 @@ +name: rust-lite-release + +on: + push: + tags: ["rust-lite-v*"] + +permissions: + contents: write + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: tag and pins match Cargo.toml + run: | + version=$(sed -n 's/^version = "\(.*\)"/\1/p' rust/Cargo.toml | head -1) + test "rust-lite-v$version" = "${GITHUB_REF_NAME}" + test "$version" = "$(tr -d '[:space:]' < lite-rs/herdr-annotate.version)" + + build: + needs: verify + strategy: + fail-fast: false + matrix: + include: + - { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04 } + - { target: aarch64-unknown-linux-gnu, os: ubuntu-22.04-arm } + - { target: x86_64-apple-darwin, os: macos-15-intel } + - { target: aarch64-apple-darwin, os: macos-15 } + - { target: x86_64-pc-windows-msvc, os: windows-latest } + - { target: aarch64-pc-windows-msvc, os: windows-11-arm } + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + key: ${{ matrix.target }} + - run: cargo build --manifest-path rust/Cargo.toml --release --target ${{ matrix.target }} + - name: name asset by target + shell: bash + run: | + mkdir -p dist + if [ "${{ runner.os }}" = "Windows" ]; then + cp "rust/target/${{ matrix.target }}/release/herdr-annotate.exe" "dist/herdr-annotate-${{ matrix.target }}.exe" + else + cp "rust/target/${{ matrix.target }}/release/herdr-annotate" "dist/herdr-annotate-${{ matrix.target }}" + fi + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: dist/* + if-no-files-found: error + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: checksums + run: cd dist && sha256sum herdr-annotate-* > SHA256SUMS && cat SHA256SUMS + - name: publish evaluation binaries + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --prerelease --title "${GITHUB_REF_NAME}" --generate-notes dist/* diff --git a/.gitignore b/.gitignore index 903085f..9096fb9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules/ .DS_Store bin/* !bin/.gitkeep +/rust/target/ +/lite-rs/bin/* +!/lite-rs/bin/.gitkeep diff --git a/docs/rust-lite-parity.md b/docs/rust-lite-parity.md new file mode 100644 index 0000000..63822b5 --- /dev/null +++ b/docs/rust-lite-parity.md @@ -0,0 +1,103 @@ +# Rust Lite parity checklist + +This document maps the TypeScript Lite implementation at `origin/main` to the native evaluation +crate. Checked items have a Rust implementation and automated coverage. Live checks are recorded +separately so test coverage is not confused with behavior observed inside Herdr. + +## Entrypoints and plugin contract + +- [x] `capture.ts` → `herdr-annotate capture`: selection precedence is invocation context, fresh + one-shot handoff file, then platform clipboard; blank selections notify without failing; pending + JSON is mode 0600; pane-open failure removes it. +- [x] `export.ts` → `herdr-annotate copy-context`: loads newest first, formats Markdown, copies, and + sends the same singular/plural notifications and errors. +- [x] `open-manager.ts` → `herdr-annotate manage`: requires `HERDR_PLUGIN_ROOT` and opens the same + focused 100×30 popup. +- [x] `editor.ts` → `herdr-annotate editor`: pending-file and invocation-context fallback, delete-on- + successful-read behavior, multiline Unicode editing, wide-cell cursor layout, Ctrl+S validation, + save delay, Esc/Ctrl+C cancellation, raw terminal restoration. +- [x] `manager.ts` → `herdr-annotate manager`: active/archive views, newest-first lists, detail panes, + navigation, copy one/all, copy-and-archive, delete, double-confirm clear, restore, double-confirm + permanent archive deletion, reload, status messages, Esc/Tab/q/Ctrl+C behavior. +- [x] `lite-rs/herdr-plugin.toml` preserves plugin id `annotate`, action ids, pane ids, placements, + dimensions, contexts, and supported platform declarations. All commands are the one native binary. + +## Modules and compatibility boundaries + +- [x] `types.ts`: retained Herdr fields, permissive context projection, required pending fields, + non-empty saved fields, complete version-1 archive validation, unknown-field tolerance. +- [x] `paths.ts`: state/root environment variables, Windows extended drive and UNC normalization, + `annotations.jsonl` and `archives.jsonl` names. +- [x] `store.ts`: append-order JSONL, exact camelCase field names and TypeScript field order, trailing + newline, mode 0600, whole-store invalid-data rejection, ID merge/remove, atomic temporary replace, + per-store directory locks, owner tokens, 30-second stale recovery, ownership-checked release. +- [x] `format.ts`: control sanitization, four-space tabs, CRLF normalization, explicit-newline and + terminal-cell wrapping, Markdown headings/source/fences/blank-line shape, safe longer backtick fence. +- [x] `width.ts` and `layout.ts`: the same wide ranges, zero-width controls/combining marks, non- + splitting truncation/wrapping, terminal-cell cursor coordinates. +- [x] `handoff.ts`: per-user runtime/temp path, 15-second freshness, read-once removal, blank rejection. +- [x] `clipboard.ts`: `pbpaste`/`pbcopy`; PowerShell raw get/set; Linux Wayland then xclip then xsel. +- [x] `herdr.ts`: `HERDR_BIN_PATH` override, stderr projection, best-effort notifications. +- [x] `archive-workflow.ts` and `manager-copy.ts`: operation order and partial-failure states, including + preserving a concurrently saved annotation. + +`plannotator-tui-schema` is intentionally not a dependency: its `Annotation` is the Full product's +document-anchor/API wire shape, not Lite's existing terminal-selection JSONL shape. + +## TypeScript test mapping + +| TypeScript spec | Rust counterpart | +|---|---| +| `test/types.test.ts` | `types::tests` (8 tests, including JSON-number and JS-trim edges) | +| `test/paths.test.ts` | `paths::tests` (2 tests; all five TS path cases covered) | +| `test/handoff.test.ts` | `handoff::tests` (4 tests, including future-clock skew) | +| `test/format.test.ts` | `format::tests` plus malformed-boundary store/type tests | +| `test/width.test.ts` | `width::tests` (3 grouped tests covering every assertion) | +| `test/layout.test.ts` | `layout::tests` (3 grouped tests covering every assertion) | +| `test/store.test.ts` | `store::tests` (6 tests) | +| `test/archive-workflow.test.ts` | `archive_workflow::tests` (8 tests) | +| `test/manager-copy.test.ts` | `manager_copy::tests` (3 tests) | + +Additional Rust-only coverage: + +- `editor::tests`: headless 86×22 `TestBackend` frame, Unicode edit keys, empty-save validation, quit. +- `manager::tests`: headless 98×28 active/archive frames, newest-first detail, confirmation, real clear. +- `rust/tests/commands.rs`: subprocess-level capture/manage/copy-context commands with a fake Herdr, + including exact pane argv, pending JSON, notifications, and pending cleanup on pane-open failure. +- `types::tests::serialization_matches_the_typescript_field_names_and_order`: literal JSON byte shape. + +## Distribution and verification + +- [x] Rust 2024, Rust 1.96, repository lint policy, pedantic Clippy with `-D warnings`, no production + `unwrap()`, release LTO/strip, and `cargo fmt`. +- [x] Checksummed release assets for macOS (Intel/Apple Silicon), Linux (x86_64/aarch64), and Windows + are defined by `.github/workflows/rust-lite-release.yml`. +- [x] Unix and PowerShell installers verify `SHA256SUMS`; `lite-rs/scripts/stage-local.sh` builds and + stages the binary before `herdr plugin link` (link intentionally does not run manifest build hooks). +- [x] `scripts/smoke-rust-lite.sh` refuses the default session, preserves/restores the globally + installed `annotate` plugin, checks the native manifest/binary, and renders/closes the manager + entrypoint through Herdr. +- [ ] Release download mode is defined and tested structurally, but cannot be exercised before an + unmerged evaluation tag/release exists. +- [ ] Windows behavior is covered by platform-specific code and Windows CI, but was not live-tested + on this macOS development host. + +## Live verification + +On 2026-08-29, `scripts/smoke-rust-lite.sh` completed with zero failures against only the disposable +`rust-lite-test` session on macOS. It built and staged the release binary, linked `lite-rs`, verified +all three action commands and the bundled version, rendered the native manager popup through Herdr, +and closed it. The previously installed GitHub `annotate` plugin was restored at its exact commit, +and the disposable session was stopped afterward. + +The capture/editor save flow, system clipboard adapters, archive mutations, and every manager key +path were verified by automated unit, subprocess, and `TestBackend` tests rather than mutating live +annotation or clipboard data. Windows remains CI-only until it is exercised on a Windows host. + +## Deliberate display-level difference + +JavaScript's `Date.toLocaleString()` delegates to the host's full locale database. The Rust manager +uses the same local timezone but an en-US-style `M/D/YYYY, h:mm:ss AM/PM` string. Persisted timestamps, +sorting, and export do not change; only manager timestamp presentation can differ for non-en-US users. + +No TypeScript source was changed. diff --git a/lite-rs/bin/.gitkeep b/lite-rs/bin/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lite-rs/bin/.gitkeep @@ -0,0 +1 @@ + diff --git a/lite-rs/herdr-annotate.version b/lite-rs/herdr-annotate.version new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/lite-rs/herdr-annotate.version @@ -0,0 +1 @@ +0.1.0 diff --git a/lite-rs/herdr-plugin.toml b/lite-rs/herdr-plugin.toml new file mode 100644 index 0000000..f9e2e95 --- /dev/null +++ b/lite-rs/herdr-plugin.toml @@ -0,0 +1,57 @@ +# Herdr Annotate Lite, native Rust evaluation build. +# Link locally after staging a build with scripts/stage-local.sh. + +id = "annotate" +name = "Annotate" +version = "0.3.0-rust.1" +min_herdr_version = "0.8.0" +description = "Comment on terminal selections and copy annotations as agent context. Native Rust evaluation build." +platforms = ["linux", "macos", "windows"] + +[[build]] +platforms = ["macos", "linux"] +command = ["bash", "scripts/fetch-herdr-annotate.sh"] + +[[build]] +platforms = ["windows"] +command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "scripts/fetch-herdr-annotate.ps1"] + +# Herdr resolves explicit relative programs against $HERDR_PLUGIN_ROOT. The common `.exe` +# suffix lets one argv-only manifest work on all three platforms; it is an ordinary executable +# filename on macOS/Linux and the required executable suffix on Windows. +[[actions]] +id = "capture" +title = "Annotate selection" +description = "Open a comment dialog for the current terminal selection." +contexts = ["pane"] +command = ["./bin/herdr-annotate.exe", "capture"] + +[[actions]] +id = "copy-context" +title = "Copy annotations as context" +description = "Copy all saved annotations to the clipboard as Markdown." +contexts = ["global"] +command = ["./bin/herdr-annotate.exe", "copy-context"] + +[[actions]] +id = "manage" +title = "Manage annotations" +description = "Browse, copy, archive, restore, and delete annotations." +contexts = ["global"] +command = ["./bin/herdr-annotate.exe", "manage"] + +[[panes]] +id = "editor" +title = "Annotate" +placement = "popup" +width = 88 +height = 24 +command = ["./bin/herdr-annotate.exe", "editor"] + +[[panes]] +id = "manager" +title = "Annotations" +placement = "popup" +width = 100 +height = 30 +command = ["./bin/herdr-annotate.exe", "manager"] diff --git a/lite-rs/scripts/fetch-herdr-annotate.ps1 b/lite-rs/scripts/fetch-herdr-annotate.ps1 new file mode 100644 index 0000000..01d5a09 --- /dev/null +++ b/lite-rs/scripts/fetch-herdr-annotate.ps1 @@ -0,0 +1,52 @@ +$ErrorActionPreference = "Stop" +Set-Location (Join-Path $PSScriptRoot "..") + +$version = (Get-Content "herdr-annotate.version" -Raw).Trim() +if (-not $version) { throw "herdr-annotate.version is empty" } +New-Item -ItemType Directory -Force "bin" | Out-Null +$destination = Join-Path "bin" "herdr-annotate.exe" +$stamp = Join-Path "bin" "herdr-annotate.version" +$installed = if (Test-Path $stamp) { (Get-Content $stamp -Raw).Trim() } else { "" } + +if ((Test-Path $destination) -and $installed -eq $version -and -not $env:HERDR_ANNOTATE_BIN) { + Write-Output "herdr-annotate $version already installed" + exit 0 +} + +if ($env:HERDR_ANNOTATE_BIN) { + if (-not (Test-Path $env:HERDR_ANNOTATE_BIN -PathType Leaf)) { + throw "HERDR_ANNOTATE_BIN is not a file: $env:HERDR_ANNOTATE_BIN" + } + Copy-Item -Force $env:HERDR_ANNOTATE_BIN "$destination.tmp" + Move-Item -Force "$destination.tmp" $destination + Set-Content -NoNewline $stamp $version + Write-Output "installed herdr-annotate from $env:HERDR_ANNOTATE_BIN (local build, stamped $version)" + exit 0 +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$target = switch ($architecture) { + "X64" { "x86_64-pc-windows-msvc" } + "Arm64" { "aarch64-pc-windows-msvc" } + default { throw "no native Herdr Annotate Lite build for Windows/$architecture" } +} +$asset = "herdr-annotate-$target.exe" +$base = "https://github.com/plannotator/herdr-annotate/releases/download/rust-lite-v$version" +$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("herdr-annotate-" + [guid]::NewGuid()) +New-Item -ItemType Directory $temporary | Out-Null +try { + Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile (Join-Path $temporary $asset) + Invoke-WebRequest -UseBasicParsing "$base/SHA256SUMS" -OutFile (Join-Path $temporary "SHA256SUMS") + $line = Get-Content (Join-Path $temporary "SHA256SUMS") | Where-Object { $_ -match "\s$([regex]::Escape($asset))$" } | Select-Object -First 1 + if (-not $line) { throw "$asset is not listed in $base/SHA256SUMS" } + $expected = ($line -split "\s+")[0].ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $temporary $asset)).Hash.ToLowerInvariant() + if ($actual -ne $expected) { throw "sha256 mismatch for $asset: expected $expected, got $actual" } + Copy-Item -Force (Join-Path $temporary $asset) "$destination.tmp" + Move-Item -Force "$destination.tmp" $destination + Set-Content -NoNewline $stamp $version + Write-Output "installed herdr-annotate $version ($target)" +} +finally { + Remove-Item -Recurse -Force $temporary -ErrorAction SilentlyContinue +} diff --git a/lite-rs/scripts/fetch-herdr-annotate.sh b/lite-rs/scripts/fetch-herdr-annotate.sh new file mode 100755 index 0000000..8c8e949 --- /dev/null +++ b/lite-rs/scripts/fetch-herdr-annotate.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Put the pinned native Lite runtime into bin/. Herdr runs this with cwd = plugin root. +# +# Modes, in order: +# 1. matching binary already installed -> exit 0 +# 2. HERDR_ANNOTATE_BIN=/path/to/local/build is set -> copy it +# 3. download the release asset and verify SHA256SUMS -> install it +set -euo pipefail + +cd "$(dirname "$0")/.." +version="$(tr -d '[:space:]' < herdr-annotate.version)" +[ -n "$version" ] || { echo "herdr-annotate.version is empty" >&2; exit 1; } +mkdir -p bin +installed="$(cat bin/herdr-annotate.version 2>/dev/null || true)" + +if [ -x bin/herdr-annotate.exe ] && [ "$installed" = "$version" ] && [ -z "${HERDR_ANNOTATE_BIN:-}" ]; then + echo "herdr-annotate $version already installed" + exit 0 +fi + +if [ -n "${HERDR_ANNOTATE_BIN:-}" ]; then + [ -x "$HERDR_ANNOTATE_BIN" ] || { echo "HERDR_ANNOTATE_BIN is not executable: $HERDR_ANNOTATE_BIN" >&2; exit 1; } + cp "$HERDR_ANNOTATE_BIN" bin/herdr-annotate.exe.tmp + chmod +x bin/herdr-annotate.exe.tmp + mv bin/herdr-annotate.exe.tmp bin/herdr-annotate.exe + echo "$version" > bin/herdr-annotate.version + echo "installed herdr-annotate from $HERDR_ANNOTATE_BIN (local build, stamped $version)" + exit 0 +fi + +case "$(uname -s)/$(uname -m)" in + Darwin/arm64) target=aarch64-apple-darwin ;; + Darwin/x86_64) target=x86_64-apple-darwin ;; + Linux/x86_64) target=x86_64-unknown-linux-gnu ;; + Linux/aarch64|Linux/arm64) target=aarch64-unknown-linux-gnu ;; + *) echo "no native Herdr Annotate Lite build for $(uname -s)/$(uname -m)" >&2; exit 1 ;; +esac + +asset="herdr-annotate-$target" +base="https://github.com/plannotator/herdr-annotate/releases/download/rust-lite-v$version" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +fetch() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$2" "$1" + else + echo "need curl or wget" >&2 + return 1 + fi +} + +echo "downloading $base/$asset" +fetch "$base/$asset" "$tmp/$asset" +fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS" +expected="$(grep " $asset\$" "$tmp/SHA256SUMS" | awk '{print $1}')" +[ -n "$expected" ] || { echo "$asset is not listed in $base/SHA256SUMS" >&2; exit 1; } +if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$tmp/$asset" | awk '{print $1}')" +else + actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')" +fi +[ "$actual" = "$expected" ] || { echo "sha256 mismatch for $asset: expected $expected, got $actual" >&2; exit 1; } + +chmod +x "$tmp/$asset" +mv "$tmp/$asset" bin/herdr-annotate.exe +echo "$version" > bin/herdr-annotate.version +echo "installed herdr-annotate $version ($target)" diff --git a/lite-rs/scripts/stage-local.sh b/lite-rs/scripts/stage-local.sh new file mode 100755 index 0000000..7ea9727 --- /dev/null +++ b/lite-rs/scripts/stage-local.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Build and stage the native runtime for `herdr plugin link`, which intentionally skips +# manifest build hooks. Run from any directory in the checkout. +set -euo pipefail + +plugin_root="$(cd "$(dirname "$0")/.." && pwd)" +repository_root="$(cd "$plugin_root/.." && pwd)" +cargo build --manifest-path "$repository_root/rust/Cargo.toml" --release +HERDR_ANNOTATE_BIN="$repository_root/rust/target/release/herdr-annotate" \ + bash "$plugin_root/scripts/fetch-herdr-annotate.sh" +echo "staged $plugin_root/bin/herdr-annotate.exe" +echo "link with: herdr plugin link $plugin_root" diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..bcb9264 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1820 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.4", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "herdr-annotate" +version = "0.1.0" +dependencies = [ + "chrono", + "ratatui", + "rustix", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..4d44177 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "herdr-annotate" +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +license = "MIT" +repository = "https://github.com/plannotator/herdr-annotate" +description = "Native Herdr Annotate Lite runtime." +publish = false + +[[bin]] +name = "herdr-annotate" +path = "src/main.rs" + +[dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } +ratatui = "0.30" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1", features = ["process"] } + +[lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" +unreachable_pub = "warn" +unused_qualifications = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +unwrap_used = "warn" +expect_used = "warn" +indexing_slicing = "warn" +panic = "warn" +dbg_macro = "warn" +todo = "warn" +print_stdout = "warn" +print_stderr = "warn" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +must_use_candidate = "allow" +cast_possible_truncation = "allow" +cast_sign_loss = "allow" +cast_possible_wrap = "allow" +cast_precision_loss = "allow" +too_many_arguments = "allow" +too_many_lines = "allow" + +[profile.release] +lto = "thin" +strip = true diff --git a/rust/src/archive_workflow.rs b/rust/src/archive_workflow.rs new file mode 100644 index 0000000..c7c8825 --- /dev/null +++ b/rust/src/archive_workflow.rs @@ -0,0 +1,373 @@ +//! Recoverable copy/archive and restore transitions. + +use crate::format::format_annotations; +use crate::store::{StoreResult, newest_first_annotations}; +use crate::types::{Annotation, ArchivedAnnotationSet}; + +/// Dependencies for one copy-and-archive transition. +#[derive(Debug)] +pub struct CopyAndArchiveDependencies { + pub load_active: Load, + pub write_clipboard: Write, + pub save_archive: Save, + pub remove_active: Remove, + pub create_archive_id: Id, + pub now: Now, +} + +/// The manager transition produced by a copy-and-archive attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CopyAndArchiveOutcome { + Close { archived_count: usize }, + StayOpen { message: String }, + ArchivedActiveRetained { message: String }, +} + +/// Copy the active annotations, persist a recoverable archive, then remove active IDs. +pub fn copy_and_archive_annotations( + dependencies: CopyAndArchiveDependencies, +) -> CopyAndArchiveOutcome +where + Load: FnOnce() -> StoreResult>, + Write: FnOnce(String) -> Result<(), String>, + Save: FnOnce(ArchivedAnnotationSet) -> StoreResult<()>, + Remove: FnOnce(Vec) -> StoreResult<()>, + Id: FnOnce() -> String, + Now: FnOnce() -> String, +{ + let active = match (dependencies.load_active)() { + Ok(active) => active, + Err(message) => return CopyAndArchiveOutcome::StayOpen { message }, + }; + if active.is_empty() { + return CopyAndArchiveOutcome::StayOpen { + message: "Nothing to copy and archive.".to_owned(), + }; + } + if let Err(message) = + (dependencies.write_clipboard)(format_annotations(&newest_first_annotations(&active))) + { + return CopyAndArchiveOutcome::StayOpen { message }; + } + let archive = ArchivedAnnotationSet { + version: 1, + id: (dependencies.create_archive_id)(), + archived_at: (dependencies.now)(), + annotations: active.clone(), + }; + if let Err(message) = (dependencies.save_archive)(archive) { + return CopyAndArchiveOutcome::StayOpen { message }; + } + let ids = active + .iter() + .map(|annotation| annotation.id.clone()) + .collect::>(); + if let Err(message) = (dependencies.remove_active)(ids) { + return CopyAndArchiveOutcome::ArchivedActiveRetained { message }; + } + CopyAndArchiveOutcome::Close { + archived_count: active.len(), + } +} + +/// Dependencies for one restore transition. +#[derive(Debug)] +pub struct RestoreArchiveDependencies { + pub merge_active: Merge, + pub remove_archive: Remove, +} + +/// The manager transition produced by restoring one archived annotation set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RestoreArchivedSetOutcome { + Restored { + restored_count: usize, + }, + StayOpen { + message: String, + }, + RestoredArchiveRetained { + restored_count: usize, + message: String, + }, +} + +/// Merge an archived set into active annotations, then remove its archive record. +pub fn restore_archived_set( + archive: &ArchivedAnnotationSet, + dependencies: RestoreArchiveDependencies, +) -> RestoreArchivedSetOutcome +where + Merge: FnOnce(Vec) -> StoreResult, + Remove: FnOnce(String) -> StoreResult<()>, +{ + let restored_count = match (dependencies.merge_active)(archive.annotations.clone()) { + Ok(count) => count, + Err(message) => return RestoreArchivedSetOutcome::StayOpen { message }, + }; + match (dependencies.remove_archive)(archive.id.clone()) { + Ok(()) => RestoreArchivedSetOutcome::Restored { restored_count }, + Err(message) => RestoreArchivedSetOutcome::RestoredArchiveRetained { + restored_count, + message, + }, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "tests assert by panicking")] + + use std::cell::{Cell, RefCell}; + use std::fs; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::store::{ + append_annotation, append_archived_set, load_annotations, load_archived_sets, + merge_annotations, remove_annotations_by_id, remove_archived_set, + }; + use crate::types::InvocationContext; + + use super::*; + + static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + + fn temporary_directory() -> PathBuf { + let sequence = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "herdr-annotate-workflow-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("temporary directory"); + dir + } + + fn annotation(id: &str) -> Annotation { + Annotation { + selected_text: format!("selection {id}"), + context: InvocationContext::default(), + captured_at: "2026-08-08T00:00:00Z".to_owned(), + id: id.to_owned(), + comment: format!("comment {id}"), + created_at: "2026-08-08T00:00:01Z".to_owned(), + } + } + + fn archive(ids: &[&str]) -> ArchivedAnnotationSet { + ArchivedAnnotationSet { + version: 1, + id: "archive-one".to_owned(), + archived_at: "2026-08-26T23:32:00Z".to_owned(), + annotations: ids.iter().map(|id| annotation(id)).collect(), + } + } + + #[test] + fn copy_archives_in_order_before_removing_active_ids() { + let events = RefCell::new(Vec::new()); + let clipboard = RefCell::new(String::new()); + let saved = RefCell::new(None); + let removed = RefCell::new(Vec::new()); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || { + events.borrow_mut().push("load"); + Ok(vec![annotation("one"), annotation("two")]) + }, + write_clipboard: |text: String| { + events.borrow_mut().push("copy"); + text.clone_into(&mut clipboard.borrow_mut()); + Ok(()) + }, + save_archive: |archive: ArchivedAnnotationSet| { + events.borrow_mut().push("archive"); + saved.replace(Some(archive)); + Ok(()) + }, + remove_active: |ids: Vec| { + events.borrow_mut().push("remove"); + ids.clone_into(&mut removed.borrow_mut()); + Ok(()) + }, + create_archive_id: || "archive-one".to_owned(), + now: || "2026-08-26T23:32:00Z".to_owned(), + }); + assert_eq!(outcome, CopyAndArchiveOutcome::Close { archived_count: 2 }); + assert_eq!(*events.borrow(), ["load", "copy", "archive", "remove"]); + assert!( + clipboard.borrow().find("selection two") < clipboard.borrow().find("selection one") + ); + assert_eq!(saved.borrow().as_ref(), Some(&archive(&["one", "two"]))); + assert_eq!(*removed.borrow(), ["one", "two"]); + } + + #[test] + fn copy_failure_does_not_archive_or_clear() { + let archived = Cell::new(false); + let removed = Cell::new(false); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || Ok(vec![annotation("one")]), + write_clipboard: |_| Err("Clipboard unavailable".to_owned()), + save_archive: |_| { + archived.set(true); + Ok(()) + }, + remove_active: |_| { + removed.set(true); + Ok(()) + }, + create_archive_id: || "archive-one".to_owned(), + now: || "now".to_owned(), + }); + assert_eq!( + outcome, + CopyAndArchiveOutcome::StayOpen { + message: "Clipboard unavailable".to_owned() + } + ); + assert!(!archived.get() && !removed.get()); + } + + #[test] + fn archive_failure_does_not_clear_active() { + let removed = Cell::new(false); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || Ok(vec![annotation("one")]), + write_clipboard: |_| Ok(()), + save_archive: |_| Err("Archive unavailable".to_owned()), + remove_active: |_| { + removed.set(true); + Ok(()) + }, + create_archive_id: || "archive-one".to_owned(), + now: || "now".to_owned(), + }); + assert_eq!( + outcome, + CopyAndArchiveOutcome::StayOpen { + message: "Archive unavailable".to_owned() + } + ); + assert!(!removed.get()); + } + + #[test] + fn clear_failure_reports_retained_active_data() { + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || Ok(vec![annotation("one")]), + write_clipboard: |_| Ok(()), + save_archive: |_| Ok(()), + remove_active: |_| Err("Active store unavailable".to_owned()), + create_archive_id: || "archive-one".to_owned(), + now: || "now".to_owned(), + }); + assert_eq!( + outcome, + CopyAndArchiveOutcome::ArchivedActiveRetained { + message: "Active store unavailable".to_owned() + } + ); + } + + #[test] + fn restore_merges_before_removing_archive_and_reports_partial_failure() { + let events = RefCell::new(Vec::new()); + let restored = restore_archived_set( + &archive(&["one", "two"]), + RestoreArchiveDependencies { + merge_active: |_| { + events.borrow_mut().push("merge"); + Ok(2) + }, + remove_archive: |_| { + events.borrow_mut().push("remove"); + Ok(()) + }, + }, + ); + assert_eq!( + restored, + RestoreArchivedSetOutcome::Restored { restored_count: 2 } + ); + assert_eq!(*events.borrow(), ["merge", "remove"]); + + let retained = restore_archived_set( + &archive(&["one"]), + RestoreArchiveDependencies { + merge_active: |_| Ok(1), + remove_archive: |_| Err("Archive unavailable".to_owned()), + }, + ); + assert_eq!( + retained, + RestoreArchivedSetOutcome::RestoredArchiveRetained { + restored_count: 1, + message: "Archive unavailable".to_owned() + } + ); + } + + #[test] + fn restore_failure_keeps_archive() { + let removed = Cell::new(false); + let outcome = restore_archived_set( + &archive(&["one"]), + RestoreArchiveDependencies { + merge_active: |_| Err("Active store unavailable".to_owned()), + remove_archive: |_| { + removed.set(true); + Ok(()) + }, + }, + ); + assert_eq!( + outcome, + RestoreArchivedSetOutcome::StayOpen { + message: "Active store unavailable".to_owned() + } + ); + assert!(!removed.get()); + } + + #[test] + fn real_stores_archive_clear_restore_and_preserve_concurrent_saves() { + let dir = temporary_directory(); + append_annotation(&dir, &annotation("snapshot")).expect("append"); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || load_annotations(&dir), + write_clipboard: |_| Ok(()), + save_archive: |set| { + append_archived_set(&dir, &set)?; + append_annotation(&dir, &annotation("concurrent")) + }, + remove_active: |ids: Vec| remove_annotations_by_id(&dir, &ids), + create_archive_id: || "archive-one".to_owned(), + now: || "2026-08-26T23:32:00Z".to_owned(), + }); + assert_eq!(outcome, CopyAndArchiveOutcome::Close { archived_count: 1 }); + assert_eq!( + load_annotations(&dir).expect("active"), + [annotation("concurrent")] + ); + let stored = load_archived_sets(&dir).expect("archives"); + assert_eq!( + stored.first().map(|set| set.annotations.as_slice()), + Some([annotation("snapshot")].as_slice()) + ); + let target = stored.first().expect("archive"); + let restored = restore_archived_set( + target, + RestoreArchiveDependencies { + merge_active: |items: Vec| merge_annotations(&dir, &items), + remove_archive: |id: String| remove_archived_set(&dir, &id), + }, + ); + assert_eq!( + restored, + RestoreArchivedSetOutcome::Restored { restored_count: 1 } + ); + assert_eq!(load_archived_sets(&dir).expect("archives"), []); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/rust/src/cli.rs b/rust/src/cli.rs new file mode 100644 index 0000000..12adbeb --- /dev/null +++ b/rust/src/cli.rs @@ -0,0 +1,192 @@ +//! One native command boundary for the five Herdr entrypoints. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use chrono::{SecondsFormat, Utc}; +use serde_json::Value; + +use crate::clipboard::{read_clipboard, write_clipboard}; +use crate::format::format_annotations; +use crate::handoff::take_default_handoff; +use crate::herdr::{notify, run_herdr}; +use crate::paths::{normalize_windows_path, plugin_root, state_dir}; +use crate::store::{load_annotations, newest_first_annotations}; +use crate::types::{ + PendingAnnotation, javascript_trim, parse_invocation_context, selected_text_from_invocation, +}; + +const USAGE: &str = "Usage: herdr-annotate "; + +/// Dispatch one native binary subcommand. +pub fn run(args: &[String]) -> Result<(), String> { + match args.first().map(String::as_str) { + Some("capture") if args.len() == 1 => capture().inspect_err(|message| { + notify("Annotate failed", Some(message)); + }), + Some("copy-context") if args.len() == 1 => copy_context().inspect_err(|message| { + notify("Copy failed", Some(message)); + }), + Some("manage") if args.len() == 1 => manage().inspect_err(|message| { + notify("Unable to open annotations", Some(message)); + }), + Some("editor") if args.len() == 1 => crate::editor::run(), + Some("manager") if args.len() == 1 => crate::manager::run(), + Some("--version" | "-V") if args.len() == 1 => { + #[allow(clippy::print_stdout, reason = "the version command prints its result")] + { + println!("herdr-annotate {}", env!("CARGO_PKG_VERSION")); + } + Ok(()) + } + _ => Err(USAGE.to_owned()), + } +} + +fn invocation_context() -> Value { + std::env::var("HERDR_PLUGIN_CONTEXT_JSON") + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_else(|| Value::Object(serde_json::Map::new())) +} + +fn capture() -> Result<(), String> { + let decoded = invocation_context(); + let context = parse_invocation_context(&decoded); + let mut selected_text = selected_text_from_invocation(&decoded); + let dir = state_dir().ok_or_else(|| "HERDR_PLUGIN_STATE_DIR is not set".to_owned())?; + let root = plugin_root().ok_or_else(|| "HERDR_PLUGIN_ROOT is not set".to_owned())?; + if selected_text.is_none() { + selected_text = take_default_handoff(); + } + if selected_text.is_none() { + selected_text = Some(read_clipboard()?); + } + let selected_text = selected_text.unwrap_or_default(); + if javascript_trim(&selected_text).is_empty() { + notify( + "Nothing to annotate", + Some("Select text in Herdr or copy text to the clipboard."), + ); + return Ok(()); + } + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let pending = PendingAnnotation { + selected_text, + context, + captured_at: now_iso(), + }; + let millis = unix_millis(); + let raw_path = dir.join(format!("pending-{millis}-{}.json", std::process::id())); + let pending_path = + std::path::PathBuf::from(normalize_windows_path(&raw_path.to_string_lossy())); + write_pending(&pending_path, &pending)?; + + let opened = run_herdr(&[ + "plugin".to_owned(), + "pane".to_owned(), + "open".to_owned(), + "--cwd".to_owned(), + root.to_string_lossy().into_owned(), + "--plugin".to_owned(), + "annotate".to_owned(), + "--entrypoint".to_owned(), + "editor".to_owned(), + "--placement".to_owned(), + "popup".to_owned(), + "--width".to_owned(), + "88".to_owned(), + "--height".to_owned(), + "24".to_owned(), + "--env".to_owned(), + format!("HERDR_ANNOTATE_PENDING={}", pending_path.display()), + "--focus".to_owned(), + ]); + if let Err(message) = opened { + let _ = std::fs::remove_file(pending_path); + return Err(message); + } + Ok(()) +} + +fn copy_context() -> Result<(), String> { + let dir = state_dir().ok_or_else(|| "HERDR_PLUGIN_STATE_DIR is not set".to_owned())?; + let annotations = newest_first_annotations(&load_annotations(&dir)?); + if annotations.is_empty() { + notify("No annotations", Some("There is nothing to copy yet.")); + return Ok(()); + } + write_clipboard(&format_annotations(&annotations))?; + notify( + "Annotations copied", + Some(&format!( + "{} annotation{} copied as Markdown.", + annotations.len(), + if annotations.len() == 1 { "" } else { "s" } + )), + ); + Ok(()) +} + +fn manage() -> Result<(), String> { + let root = plugin_root().ok_or_else(|| "HERDR_PLUGIN_ROOT is not set".to_owned())?; + run_herdr(&[ + "plugin".to_owned(), + "pane".to_owned(), + "open".to_owned(), + "--cwd".to_owned(), + root.to_string_lossy().into_owned(), + "--plugin".to_owned(), + "annotate".to_owned(), + "--entrypoint".to_owned(), + "manager".to_owned(), + "--placement".to_owned(), + "popup".to_owned(), + "--width".to_owned(), + "100".to_owned(), + "--height".to_owned(), + "30".to_owned(), + "--focus".to_owned(), + ]) +} + +fn now_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +fn unix_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn write_pending(path: &Path, pending: &PendingAnnotation) -> Result<(), String> { + let mut options = OpenOptions::new(); + options.create(true).write(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|error| error.to_string())?; + serde_json::to_writer(&mut file, pending).map_err(|error| error.to_string())?; + file.write_all(b"\n").map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_or_extra_arguments_report_the_single_binary_usage() { + assert_eq!(run(&[]), Err(USAGE.to_owned())); + assert_eq!(run(&["unknown".to_owned()]), Err(USAGE.to_owned())); + assert_eq!( + run(&["capture".to_owned(), "extra".to_owned()]), + Err(USAGE.to_owned()) + ); + } +} diff --git a/rust/src/clipboard.rs b/rust/src/clipboard.rs new file mode 100644 index 0000000..37ba215 --- /dev/null +++ b/rust/src/clipboard.rs @@ -0,0 +1,141 @@ +//! Per-platform clipboard adapters. + +use std::io::Write; +use std::process::{Command, Stdio}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ClipboardCommand { + command: &'static str, + args: &'static [&'static str], +} + +fn read_commands() -> Vec { + if cfg!(target_os = "macos") { + return vec![ClipboardCommand { + command: "pbpaste", + args: &[], + }]; + } + if cfg!(windows) { + return vec![ClipboardCommand { + command: "powershell.exe", + args: &[ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-Clipboard -Raw", + ], + }]; + } + vec![ + ClipboardCommand { + command: "wl-paste", + args: &["--no-newline"], + }, + ClipboardCommand { + command: "xclip", + args: &["-selection", "clipboard", "-out"], + }, + ClipboardCommand { + command: "xsel", + args: &["--clipboard", "--output"], + }, + ] +} + +fn write_commands() -> Vec { + if cfg!(target_os = "macos") { + return vec![ClipboardCommand { + command: "pbcopy", + args: &[], + }]; + } + if cfg!(windows) { + return vec![ClipboardCommand { + command: "powershell.exe", + args: &[ + "-NoProfile", + "-NonInteractive", + "-Command", + "$input | Set-Clipboard", + ], + }]; + } + vec![ + ClipboardCommand { + command: "wl-copy", + args: &[], + }, + ClipboardCommand { + command: "xclip", + args: &["-selection", "clipboard", "-in"], + }, + ClipboardCommand { + command: "xsel", + args: &["--clipboard", "--input"], + }, + ] +} + +fn process(command: &str) -> Command { + let value = Command::new(command); + #[cfg(windows)] + let value = { + use std::os::windows::process::CommandExt; + let mut value = value; + value.creation_flags(0x0800_0000); + value + }; + value +} + +/// Read text from the first clipboard adapter available on the current platform. +pub fn read_clipboard() -> Result { + for candidate in read_commands() { + let result = process(candidate.command) + .args(candidate.args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output(); + if let Ok(output) = result + && output.status.success() + { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + } + Err("No supported clipboard reader is available".to_owned()) +} + +/// Write text through the first clipboard adapter available on the current platform. +pub fn write_clipboard(text: &str) -> Result<(), String> { + for candidate in write_commands() { + let spawned = process(candidate.command) + .args(candidate.args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + let Ok(mut child) = spawned else { continue }; + let wrote = child + .stdin + .take() + .is_some_and(|mut stdin| stdin.write_all(text.as_bytes()).is_ok()); + if wrote && child.wait().is_ok_and(|status| status.success()) { + return Ok(()); + } + let _ = child.kill(); + let _ = child.wait(); + } + Err("No supported clipboard writer is available".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_platform_has_read_and_write_candidates() { + assert!(!read_commands().is_empty()); + assert!(!write_commands().is_empty()); + } +} diff --git a/rust/src/editor.rs b/rust/src/editor.rs new file mode 100644 index 0000000..a00e0a8 --- /dev/null +++ b/rust/src/editor.rs @@ -0,0 +1,411 @@ +//! Interactive comment editor pane. + +use std::path::Path; +use std::time::Duration; + +use chrono::{SecondsFormat, Utc}; +use ratatui::Frame; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Position, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Clear, Paragraph}; +use serde_json::Value; +use uuid::Uuid; + +use crate::format::{sanitize_terminal_text, wrap_text}; +use crate::layout::layout_comment; +use crate::paths::state_dir; +use crate::store::append_annotation; +use crate::types::{ + Annotation, PendingAnnotation, javascript_trim, parse_pending_annotation, + pending_annotation_from_invocation, +}; +use crate::width::{char_width, string_width, truncate_to_width}; + +#[cfg(test)] +const DEFAULT_COLS: u16 = 86; +#[cfg(test)] +const DEFAULT_ROWS: u16 = 22; + +/// Editor state, independent of the terminal backend. +#[derive(Debug)] +pub struct EditorApp { + pending: PendingAnnotation, + comment: Vec, + cursor: usize, + status: String, + quit: bool, +} + +impl EditorApp { + /// Start an empty comment for a captured selection. + pub fn new(pending: PendingAnnotation) -> Self { + Self { + pending, + comment: Vec::new(), + cursor: 0, + status: String::new(), + quit: false, + } + } + + /// Draw the same selected-text, comment, and footer regions as the TypeScript editor. + pub fn draw(&self, frame: &mut Frame<'_>) { + let area = frame.area(); + frame.render_widget(Clear, area); + let cols = usize::from(area.width.max(20)); + let rows = usize::from(area.height.max(10)); + let left = 2usize; + let inner_width = cols.saturating_sub(4).max(1); + let selection_rows = ((rows.saturating_sub(6)) / 2).clamp(3, 7); + let editor_rows = rows.saturating_sub(selection_rows + 5).max(1); + let wrapped_selection = wrap_text( + &sanitize_terminal_text(&self.pending.selected_text), + inner_width, + ); + let selected = wrapped_selection.iter().take(selection_rows); + let editing = layout_comment(&self.comment, self.cursor, inner_width); + let editor_start = editing + .cursor_row + .saturating_sub(editor_rows.saturating_sub(1)); + + render_line( + frame, + left, + 1, + "Selected text", + inner_width, + Style::default().add_modifier(Modifier::BOLD), + ); + for (index, line) in selected.enumerate() { + render_line( + frame, + left, + 2 + index, + line, + inner_width, + Style::default().add_modifier(Modifier::DIM), + ); + } + if wrapped_selection.len() > selection_rows { + render_line( + frame, + left + inner_width.saturating_sub(1), + 1 + selection_rows, + "…", + 1, + Style::default().add_modifier(Modifier::DIM), + ); + } + + let comment_title_row = 2 + selection_rows; + render_line( + frame, + left, + comment_title_row, + "Comment", + inner_width, + Style::default().add_modifier(Modifier::BOLD), + ); + for (index, line) in editing + .lines + .iter() + .skip(editor_start) + .take(editor_rows) + .enumerate() + { + render_line( + frame, + left, + comment_title_row + 1 + index, + line, + inner_width, + Style::default(), + ); + } + let footer = if self.status.is_empty() { + "Ctrl+S save · Esc cancel · Enter new line" + } else { + &self.status + }; + render_line( + frame, + left, + rows.saturating_sub(1), + &truncate_to_width(footer, inner_width), + inner_width, + Style::default().add_modifier(Modifier::DIM), + ); + + let visual_row = editing.cursor_row.saturating_sub(editor_start); + if editing.cursor_row >= editor_start && visual_row < editor_rows { + let x = left.saturating_add(editing.cursor_col); + let y = comment_title_row + 1 + visual_row; + if let (Ok(x), Ok(y)) = (u16::try_from(x), u16::try_from(y)) + && x < area.width + && y < area.height + { + frame.set_cursor_position(Position::new(x, y)); + } + } + } + + /// Handle one keyboard event. Returns `true` when a save should be attempted. + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if key.kind == KeyEventKind::Release { + return false; + } + self.status.clear(); + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.quit = true; + return false; + } + if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) { + return true; + } + match key.code { + KeyCode::Esc => self.quit = true, + KeyCode::Backspace => { + if self.cursor > 0 { + self.cursor -= 1; + self.comment.remove(self.cursor); + } + } + KeyCode::Delete => { + if self.cursor < self.comment.len() { + self.comment.remove(self.cursor); + } + } + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.comment.len()), + KeyCode::Up => self.move_cursor_vertical(-1), + KeyCode::Down => self.move_cursor_vertical(1), + KeyCode::Home => { + while self.cursor > 0 && self.comment.get(self.cursor - 1) != Some(&'\n') { + self.cursor -= 1; + } + } + KeyCode::End => { + while self.cursor < self.comment.len() + && self.comment.get(self.cursor) != Some(&'\n') + { + self.cursor += 1; + } + } + KeyCode::Enter => self.insert('\n'), + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.insert(character); + } + _ => {} + } + false + } + + fn insert(&mut self, character: char) { + self.comment.insert(self.cursor, character); + self.cursor += 1; + } + + fn move_cursor_vertical(&mut self, delta: isize) { + let before = self.comment.iter().take(self.cursor).collect::(); + let row = before.split('\n').count().saturating_sub(1); + let col = before.rsplit('\n').next().map_or(0, string_width); + let joined = self.comment.iter().collect::(); + let lines = joined.split('\n').collect::>(); + let target_row = row + .saturating_add_signed(delta) + .min(lines.len().saturating_sub(1)); + let mut next = lines + .iter() + .take(target_row) + .map(|line| line.chars().count() + 1) + .sum::(); + let mut used = 0; + for character in lines.get(target_row).copied().unwrap_or_default().chars() { + let width = char_width(character); + if used + width > col { + break; + } + used += width; + next += 1; + } + self.cursor = next; + } + + fn save(&mut self, dir: Option<&Path>) -> bool { + let value = self.comment.iter().collect::(); + let value = javascript_trim(&value).to_owned(); + if value.is_empty() { + "Write a comment before saving.".clone_into(&mut self.status); + return false; + } + let Some(dir) = dir else { + "Plugin state directory is unavailable.".clone_into(&mut self.status); + return false; + }; + let annotation = Annotation::from_pending( + self.pending.clone(), + Uuid::new_v4().to_string(), + value, + now_iso(), + ); + match append_annotation(dir, &annotation) { + Ok(()) => { + "Saved.".clone_into(&mut self.status); + true + } + Err(message) => { + self.status = message; + false + } + } + } +} + +fn render_line(frame: &mut Frame<'_>, x: usize, y: usize, text: &str, width: usize, style: Style) { + let (Ok(x), Ok(y), Ok(width)) = (u16::try_from(x), u16::try_from(y), u16::try_from(width)) + else { + return; + }; + let area = frame.area(); + if x >= area.width || y >= area.height { + return; + } + let width = width.min(area.width.saturating_sub(x)); + frame.render_widget( + Paragraph::new(Line::styled(text.to_owned(), style)), + Rect::new(x, y, width, 1), + ); +} + +fn now_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +pub(crate) fn now_iso_for_manager() -> String { + now_iso() +} + +fn invocation_context() -> Value { + std::env::var("HERDR_PLUGIN_CONTEXT_JSON") + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_else(|| Value::Object(serde_json::Map::new())) +} + +fn pending_from_env() -> Result { + let invocation = invocation_context(); + let Some(path) = std::env::var_os("HERDR_ANNOTATE_PENDING").filter(|value| !value.is_empty()) + else { + return pending_annotation_from_invocation(&invocation, now_iso()) + .ok_or_else(|| "Missing pending annotation".to_owned()); + }; + let path = std::path::PathBuf::from(path); + let text = std::fs::read_to_string(&path).map_err(|error| error.to_string())?; + let decoded = serde_json::from_str::(&text).map_err(|error| error.to_string())?; + let pending = parse_pending_annotation(&decoded) + .ok_or_else(|| "Pending annotation is invalid".to_owned())?; + std::fs::remove_file(path).map_err(|error| error.to_string())?; + Ok(pending) +} + +/// Run the interactive editor pane from Herdr's environment. +pub fn run() -> Result<(), String> { + let mut app = EditorApp::new(pending_from_env()?); + let dir = state_dir(); + let mut terminal = ratatui::init(); + let result = (|| -> Result<(), String> { + while !app.quit { + terminal + .draw(|frame| app.draw(frame)) + .map_err(|error| error.to_string())?; + let event = event::read().map_err(|error| error.to_string())?; + if let Event::Key(key) = event + && app.handle_key(key) + && app.save(dir.as_deref()) + { + terminal + .draw(|frame| app.draw(frame)) + .map_err(|error| error.to_string())?; + std::thread::sleep(Duration::from_millis(250)); + app.quit = true; + } + } + Ok(()) + })(); + ratatui::restore(); + result +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "tests assert by panicking")] + + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use crate::types::InvocationContext; + + use super::*; + + fn app() -> EditorApp { + EditorApp::new(PendingAnnotation { + selected_text: "first selected line\nsecond line".to_owned(), + context: InvocationContext::default(), + captured_at: "captured".to_owned(), + }) + } + + fn draw(app: &EditorApp) -> Vec { + let mut terminal = + Terminal::new(TestBackend::new(DEFAULT_COLS, DEFAULT_ROWS)).expect("terminal"); + terminal.draw(|frame| app.draw(frame)).expect("draw"); + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .filter_map(|x| buffer.cell((x, y))) + .map(|cell| cell.symbol().to_owned()) + .collect() + }) + .collect() + } + + #[test] + fn headless_frame_contains_selection_editor_and_keys() { + let rows = draw(&app()); + assert!(rows.iter().any(|row| row.contains("Selected text"))); + assert!(rows.iter().any(|row| row.contains("first selected line"))); + assert!(rows.iter().any(|row| row.contains("Comment"))); + assert!(rows.iter().any(|row| row.contains("Ctrl+S save"))); + } + + #[test] + fn editing_keys_and_empty_save_match_the_typescript_editor() { + let mut editor = app(); + assert!(!editor.handle_key(KeyEvent::from(KeyCode::Char('한')))); + assert!(!editor.handle_key(KeyEvent::from(KeyCode::Char('a')))); + assert_eq!(editor.comment, ['한', 'a']); + editor.handle_key(KeyEvent::from(KeyCode::Left)); + editor.handle_key(KeyEvent::from(KeyCode::Backspace)); + assert_eq!(editor.comment, ['a']); + let mut empty = app(); + assert!(!empty.save(None)); + assert_eq!(empty.status, "Write a comment before saving."); + } + + #[test] + fn escape_and_control_c_quit() { + let mut escape = app(); + escape.handle_key(KeyEvent::from(KeyCode::Esc)); + assert!(escape.quit); + let mut control = app(); + control.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(control.quit); + } +} diff --git a/rust/src/format.rs b/rust/src/format.rs new file mode 100644 index 0000000..2a16a95 --- /dev/null +++ b/rust/src/format.rs @@ -0,0 +1,164 @@ +//! Terminal-safe text and Markdown export. + +use crate::types::Annotation; +use crate::width::char_width; + +/// Remove terminal control characters while retaining useful whitespace. +pub fn sanitize_terminal_text(text: &str) -> String { + text.chars() + .flat_map(|character| { + if character == '\t' { + " ".chars().collect::>() + } else if (character <= '\u{0008}') + || matches!(character, '\u{000b}' | '\u{000c}') + || ('\u{000e}'..='\u{001f}').contains(&character) + || character == '\u{007f}' + { + Vec::new() + } else { + vec![character] + } + }) + .collect() +} + +/// Wrap text to terminal-cell-width lines while preserving explicit newlines. +pub fn wrap_text(text: &str, width: usize) -> Vec { + let safe_width = width.max(1); + let normalized = text.replace("\r\n", "\n"); + let mut output = Vec::new(); + for source_line in normalized.split('\n') { + if source_line.is_empty() { + output.push(String::new()); + continue; + } + let mut line = String::new(); + let mut used = 0; + for character in source_line.chars() { + let cells = char_width(character); + if used + cells > safe_width && !line.is_empty() { + output.push(line); + line = String::new(); + used = 0; + } + line.push(character); + used += cells; + } + output.push(line); + } + output +} + +fn fence_for(text: &str) -> String { + let mut longest = 0; + let mut current = 0; + for character in text.chars() { + if character == '`' { + current += 1; + longest = longest.max(current); + } else { + current = 0; + } + } + "`".repeat((longest + 1).max(3)) +} + +/// Format saved annotations as portable, agent-neutral Markdown context. +pub fn format_annotations(annotations: &[Annotation]) -> String { + let sections = annotations + .iter() + .enumerate() + .map(|(index, annotation)| { + let source = [ + annotation.context.workspace_label.as_deref(), + annotation.context.tab_label.as_deref(), + ] + .into_iter() + .flatten() + .collect::>() + .join(" / "); + let fence = fence_for(&annotation.selected_text); + let metadata = if source.is_empty() { + String::new() + } else { + format!("\nSource: {source}\n") + }; + let lines = vec![ + format!("## Annotation {}", index + 1), + metadata, + "Selected text:".to_owned(), + String::new(), + fence.clone(), + annotation.selected_text.clone(), + fence, + String::new(), + "Comment:".to_owned(), + String::new(), + annotation.comment.clone(), + ]; + let mut filtered = Vec::new(); + for line in lines { + if line.is_empty() && filtered.last().is_some_and(String::is_empty) { + continue; + } + filtered.push(line); + } + filtered.join("\n") + }) + .collect::>() + .join("\n\n"); + format!("# Annotated context\n\n{sections}\n") +} + +#[cfg(test)] +mod tests { + use crate::types::{Annotation, InvocationContext}; + + use super::*; + + fn annotation(selection: &str) -> Annotation { + Annotation { + selected_text: selection.to_owned(), + context: InvocationContext { + workspace_label: Some("api".to_owned()), + tab_label: Some("server".to_owned()), + ..InvocationContext::default() + }, + captured_at: "captured".to_owned(), + id: "one".to_owned(), + comment: "Check the database first.".to_owned(), + created_at: "created".to_owned(), + } + } + + #[test] + fn wrapping_preserves_newlines_and_uses_cells() { + assert_eq!(wrap_text("abcdef\nxy", 3), ["abc", "def", "xy"]); + assert_eq!(wrap_text("한글한글", 4), ["한글", "한글"]); + assert_eq!(wrap_text("한글한", 5), ["한글", "한"]); + assert_eq!(wrap_text("a한b한", 4), ["a한b", "한"]); + } + + #[test] + fn terminal_display_strips_control_characters() { + assert_eq!( + sanitize_terminal_text("safe\u{001b}[2J\ttext\nnext"), + "safe[2J text\nnext" + ); + } + + #[test] + fn markdown_contains_source_selection_and_comment() { + let output = format_annotations(&[annotation("failed to connect")]); + assert!(output.contains("# Annotated context")); + assert!(output.contains("Source: api / server")); + assert!(output.contains("failed to connect")); + assert!(output.contains("Check the database first.")); + } + + #[test] + fn markdown_uses_a_longer_fence_for_backticks() { + let output = format_annotations(&[annotation("```example```")]); + assert!(output.contains("````\n```example```\n````")); + } +} diff --git a/rust/src/handoff.rs b/rust/src/handoff.rs new file mode 100644 index 0000000..40f772a --- /dev/null +++ b/rust/src/handoff.rs @@ -0,0 +1,131 @@ +//! One-shot selection handoff for remote/headless Herdr sessions. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use crate::types::javascript_trim; + +/// A handed-off selection older than this is ignored. +pub const HANDOFF_MAX_AGE: Duration = Duration::from_secs(15); + +/// `$XDG_RUNTIME_DIR` when set, else the system temp dir, plus the uid. +pub fn handoff_path() -> PathBuf { + let base = std::env::var("XDG_RUNTIME_DIR") + .ok() + .filter(|value| !value.trim().is_empty()) + .map_or_else(std::env::temp_dir, PathBuf::from); + base.join(format!("herdr-annotate-{}", current_user_id())) + .join("selection") +} + +#[cfg(unix)] +fn current_user_id() -> String { + rustix::process::getuid().as_raw().to_string() +} + +#[cfg(not(unix))] +fn current_user_id() -> String { + "user".to_owned() +} + +/// Return fresh, non-blank handed-off text and remove the file whether fresh or stale. +pub fn take_handoff(file: &Path, now: SystemTime, max_age: Duration) -> Option { + let metadata = std::fs::metadata(file).ok()?; + let fresh = metadata.is_file() + && metadata + .modified() + .ok() + .is_some_and(|modified| now.duration_since(modified).unwrap_or_default() <= max_age); + let text = fresh.then(|| std::fs::read_to_string(file).ok()).flatten(); + let _ = std::fs::remove_file(file); + text.filter(|value| !javascript_trim(value).is_empty()) +} + +/// Take a selection from the default handoff file. +pub fn take_default_handoff() -> Option { + take_handoff(&handoff_path(), SystemTime::now(), HANDOFF_MAX_AGE) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "tests assert by panicking")] + + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + static NEXT_FILE: AtomicUsize = AtomicUsize::new(0); + + fn temporary_file() -> PathBuf { + let sequence = NEXT_FILE.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "herdr-annotate-handoff-{}-{sequence}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temporary directory"); + dir.join("selection") + } + + #[test] + fn handoff_path_is_per_user_and_prefers_runtime_directory_when_present() { + let path = handoff_path(); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("selection") + ); + assert!( + path.parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("herdr-annotate-")) + ); + } + + #[test] + fn fresh_text_is_returned_and_removed() { + let file = temporary_file(); + std::fs::write(&file, "hello\nworld\n").expect("fixture"); + assert_eq!( + take_handoff(&file, SystemTime::now(), HANDOFF_MAX_AGE).as_deref(), + Some("hello\nworld\n") + ); + assert!(!file.exists()); + } + + #[test] + fn future_timestamps_are_fresh_like_the_typescript_age_check() { + let file = temporary_file(); + std::fs::write(&file, "new").expect("fixture"); + assert_eq!( + take_handoff(&file, SystemTime::UNIX_EPOCH, HANDOFF_MAX_AGE).as_deref(), + Some("new") + ); + assert!(!file.exists()); + } + + #[test] + fn stale_blank_and_missing_files_are_ignored_and_removed() { + let stale = temporary_file(); + std::fs::write(&stale, "old").expect("fixture"); + assert_eq!( + take_handoff( + &stale, + SystemTime::now() + HANDOFF_MAX_AGE + Duration::from_secs(1), + HANDOFF_MAX_AGE + ), + None + ); + assert!(!stale.exists()); + let blank = temporary_file(); + std::fs::write(&blank, " \n").expect("fixture"); + assert_eq!( + take_handoff(&blank, SystemTime::now(), HANDOFF_MAX_AGE), + None + ); + assert_eq!( + take_handoff(&blank, SystemTime::now(), HANDOFF_MAX_AGE), + None + ); + } +} diff --git a/rust/src/herdr.rs b/rust/src/herdr.rs new file mode 100644 index 0000000..3d7b069 --- /dev/null +++ b/rust/src/herdr.rs @@ -0,0 +1,55 @@ +//! Calls back into the Herdr CLI. + +use std::process::{Command, Stdio}; + +fn binary() -> std::ffi::OsString { + std::env::var_os("HERDR_BIN_PATH").unwrap_or_else(|| "herdr".into()) +} + +fn process() -> Command { + let value = Command::new(binary()); + #[cfg(windows)] + let value = { + use std::os::windows::process::CommandExt; + let mut value = value; + value.creation_flags(0x0800_0000); + value + }; + value +} + +/// Invoke Herdr synchronously and return a safe error projection on failure. +pub fn run_herdr(args: &[String]) -> Result<(), String> { + let result = process() + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output(); + match result { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + if stderr.is_empty() { + Err(format!("herdr {} failed", args.join(" "))) + } else { + Err(stderr) + } + } + Err(_) => Err(format!("herdr {} failed", args.join(" "))), + } +} + +/// Best-effort user notification; failures are intentionally non-fatal. +pub fn notify(title: &str, body: Option<&str>) { + let mut args = vec!["notification", "show", title]; + if let Some(body) = body { + args.extend(["--body", body]); + } + let _ = process() + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} diff --git a/rust/src/layout.rs b/rust/src/layout.rs new file mode 100644 index 0000000..19b817e --- /dev/null +++ b/rust/src/layout.rs @@ -0,0 +1,83 @@ +//! Comment-editor layout in terminal cells. + +use crate::width::char_width; + +/// Laid-out comment lines and cursor position. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommentLayout { + pub lines: Vec, + pub cursor_row: usize, + pub cursor_col: usize, +} + +/// Lay the comment out and report the cursor in terminal cells. +pub fn layout_comment(comment: &[char], cursor: usize, width: usize) -> CommentLayout { + let safe_width = width.max(1); + let mut lines = vec![String::new()]; + let mut row = 0; + let mut col = 0; + let mut cursor_row = 0; + let mut cursor_col = 0; + for index in 0..=comment.len() { + let cells = comment.get(index).copied().map_or(0, char_width); + if col > 0 && col + cells > safe_width { + lines.push(String::new()); + row += 1; + col = 0; + } + if index == cursor { + cursor_row = row; + cursor_col = col; + } + let Some(character) = comment.get(index).copied() else { + break; + }; + if character == '\n' { + lines.push(String::new()); + row += 1; + col = 0; + } else { + if let Some(line) = lines.get_mut(row) { + line.push(character); + } + col += cells; + } + } + CommentLayout { + lines, + cursor_row, + cursor_col, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_columns_count_terminal_cells() { + let wide = "한글".chars().collect::>(); + assert_eq!(layout_comment(&wide, wide.len(), 40).cursor_col, 4); + let narrow = "abc".chars().collect::>(); + assert_eq!(layout_comment(&narrow, narrow.len(), 40).cursor_col, 3); + let mixed = "a한b".chars().collect::>(); + assert_eq!(layout_comment(&mixed, mixed.len(), 40).cursor_col, 4); + assert_eq!(layout_comment(&mixed, 2, 40).cursor_col, 3); + } + + #[test] + fn wide_characters_do_not_straddle_the_edge() { + let comment = "한글한".chars().collect::>(); + let result = layout_comment(&comment, comment.len(), 5); + assert_eq!(result.lines, ["한글", "한"]); + assert_eq!((result.cursor_row, result.cursor_col), (1, 2)); + } + + #[test] + fn explicit_newlines_are_preserved() { + let comment = "한\n글".chars().collect::>(); + let result = layout_comment(&comment, comment.len(), 40); + assert_eq!(result.lines, ["한", "글"]); + assert_eq!((result.cursor_row, result.cursor_col), (1, 2)); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..b018417 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,19 @@ +//! Native implementation of Herdr Annotate Lite. + +pub mod archive_workflow; +pub mod clipboard; +pub mod editor; +pub mod format; +pub mod handoff; +pub mod herdr; +pub mod layout; +pub mod manager; +pub mod manager_copy; +pub mod paths; +pub mod store; +pub mod types; +pub mod width; + +mod cli; + +pub use cli::run; diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..1befe77 --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,15 @@ +//! `herdr-annotate`: the native Herdr Annotate Lite runtime. + +fn main() -> std::process::ExitCode { + let args = std::env::args().skip(1).collect::>(); + match herdr_annotate::run(&args) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + #[allow(clippy::print_stderr, reason = "the command boundary reports failures")] + { + eprintln!("{error}"); + } + std::process::ExitCode::FAILURE + } + } +} diff --git a/rust/src/manager.rs b/rust/src/manager.rs new file mode 100644 index 0000000..27d848f --- /dev/null +++ b/rust/src/manager.rs @@ -0,0 +1,858 @@ +//! Interactive annotation manager pane. + +use std::path::PathBuf; + +use chrono::{DateTime, Local}; +use ratatui::Frame; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Clear, Paragraph}; +use uuid::Uuid; + +use crate::archive_workflow::{ + CopyAndArchiveDependencies, CopyAndArchiveOutcome, RestoreArchiveDependencies, + RestoreArchivedSetOutcome, copy_and_archive_annotations, restore_archived_set, +}; +use crate::clipboard::write_clipboard; +use crate::editor::now_iso_for_manager; +use crate::format::{sanitize_terminal_text, wrap_text}; +use crate::manager_copy::{ManagerCopyOutcome, copy_annotations}; +use crate::paths::state_dir; +use crate::store::{ + append_archived_set, load_annotations, load_archived_sets, merge_annotations, + newest_first_annotations, newest_first_archived_sets, remove_annotations_by_id, + remove_archived_set, +}; +use crate::types::{Annotation, ArchivedAnnotationSet}; +use crate::width::{string_width, truncate_to_width}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManagerView { + Active, + Archives, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Confirmation { + None, + ClearActive, + DeleteArchive { archive_id: String }, +} + +/// Manager state, independent of the terminal backend. +#[derive(Debug)] +pub struct ManagerApp { + dir: PathBuf, + annotations: Vec, + archives: Vec, + active_selected: usize, + archive_selected: usize, + view: ManagerView, + status: String, + confirmation: Confirmation, + quit: bool, +} + +impl ManagerApp { + /// Load both stores and start in the active-annotation view. + pub fn load(dir: PathBuf) -> Self { + let mut app = Self { + dir, + annotations: Vec::new(), + archives: Vec::new(), + active_selected: 0, + archive_selected: 0, + view: ManagerView::Active, + status: String::new(), + confirmation: Confirmation::None, + quit: false, + }; + app.reload_active(); + app.reload_archives(); + app + } + + fn reload_active(&mut self) -> bool { + match load_annotations(&self.dir) { + Ok(annotations) => { + self.annotations = newest_first_annotations(&annotations); + self.active_selected = + clamp_selection(self.active_selected, self.annotations.len()); + true + } + Err(message) => { + self.status = message; + false + } + } + } + + fn reload_archives(&mut self) -> bool { + match load_archived_sets(&self.dir) { + Ok(archives) => { + self.archives = newest_first_archived_sets(&archives); + self.archive_selected = clamp_selection(self.archive_selected, self.archives.len()); + true + } + Err(message) => { + self.status = message; + false + } + } + } + + /// Draw the list/detail manager layout and context-specific footer. + pub fn draw(&self, frame: &mut Frame<'_>) { + let area = frame.area(); + frame.render_widget(Clear, area); + let cols = usize::from(area.width.max(50)); + let rows = usize::from(area.height.max(14)); + let list_width = ((cols * 36) / 100).clamp(22, 36); + let detail_left = list_width + 2; + let detail_width = cols.saturating_sub(detail_left + 1).max(1); + let list_rows = rows.saturating_sub(4).max(1); + + for row in 1..rows.saturating_sub(1) { + render_line( + frame, + list_width, + row, + "│", + 1, + Style::default().add_modifier(Modifier::DIM), + ); + } + match self.view { + ManagerView::Active => { + self.draw_active( + frame, + rows, + list_width, + detail_left, + detail_width, + list_rows, + ); + } + ManagerView::Archives => { + self.draw_archives( + frame, + rows, + list_width, + detail_left, + detail_width, + list_rows, + ); + } + } + render_line( + frame, + 1, + rows.saturating_sub(1), + &clipped(&self.footer_text(), cols.saturating_sub(3)), + cols.saturating_sub(2), + Style::default().add_modifier(Modifier::DIM), + ); + } + + fn draw_active( + &self, + frame: &mut Frame<'_>, + rows: usize, + list_width: usize, + detail_left: usize, + detail_width: usize, + list_rows: usize, + ) { + render_line( + frame, + 1, + 0, + &format!("Annotations ({}) newest first", self.annotations.len()), + list_width.saturating_sub(1), + Style::default().add_modifier(Modifier::BOLD), + ); + if self.annotations.is_empty() { + render_line( + frame, + 1, + 2, + "No active annotations.", + list_width.saturating_sub(1), + Style::default().add_modifier(Modifier::DIM), + ); + return; + } + let first = first_visible_index(self.active_selected, self.annotations.len(), list_rows); + for (index, annotation) in self + .annotations + .iter() + .skip(first) + .take(list_rows) + .enumerate() + { + let absolute = first + index; + let style = if absolute == self.active_selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let prefix = if absolute == self.active_selected { + "› " + } else { + " " + }; + render_line( + frame, + 1, + 1 + index, + &format!( + "{prefix}{}", + clipped(&annotation.selected_text, list_width.saturating_sub(4)) + ), + list_width.saturating_sub(1), + style, + ); + } + let Some(current) = self.annotations.get(self.active_selected) else { + return; + }; + render_line( + frame, + detail_left, + 1, + "Selected text", + detail_width, + Style::default().add_modifier(Modifier::BOLD), + ); + let selected_lines = wrap_text( + &sanitize_terminal_text(¤t.selected_text), + detail_width, + ) + .into_iter() + .take(7) + .collect::>(); + for (index, line) in selected_lines.iter().enumerate() { + render_line( + frame, + detail_left, + 2 + index, + line, + detail_width, + Style::default().add_modifier(Modifier::DIM), + ); + } + let comment_row = 3 + selected_lines.len().max(3); + render_line( + frame, + detail_left, + comment_row, + "Comment", + detail_width, + Style::default().add_modifier(Modifier::BOLD), + ); + for (index, line) in wrap_text(&sanitize_terminal_text(¤t.comment), detail_width) + .into_iter() + .take(rows.saturating_sub(comment_row + 4).max(1)) + .enumerate() + { + render_line( + frame, + detail_left, + comment_row + 1 + index, + &line, + detail_width, + Style::default(), + ); + } + let source = [ + current.context.workspace_label.as_deref(), + current.context.tab_label.as_deref(), + ] + .into_iter() + .flatten() + .collect::>() + .join(" / "); + let metadata = [ + (!source.is_empty()).then_some(source), + Some(format_timestamp(¤t.created_at)), + ] + .into_iter() + .flatten() + .collect::>() + .join(" · "); + if !metadata.is_empty() { + render_line( + frame, + detail_left, + rows.saturating_sub(3), + &clipped(&metadata, detail_width), + detail_width, + Style::default().add_modifier(Modifier::DIM), + ); + } + } + + fn draw_archives( + &self, + frame: &mut Frame<'_>, + rows: usize, + list_width: usize, + detail_left: usize, + detail_width: usize, + list_rows: usize, + ) { + render_line( + frame, + 1, + 0, + &format!("Archives ({}) newest first", self.archives.len()), + list_width.saturating_sub(1), + Style::default().add_modifier(Modifier::BOLD), + ); + if self.archives.is_empty() { + render_line( + frame, + 1, + 2, + "No archived sets.", + list_width.saturating_sub(1), + Style::default().add_modifier(Modifier::DIM), + ); + return; + } + let first = first_visible_index(self.archive_selected, self.archives.len(), list_rows); + for (index, archive) in self.archives.iter().skip(first).take(list_rows).enumerate() { + let absolute = first + index; + let style = if absolute == self.archive_selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let prefix = if absolute == self.archive_selected { + "› " + } else { + " " + }; + let label = format!( + "{} · {}", + format_timestamp(&archive.archived_at), + count_label(archive.annotations.len()) + ); + render_line( + frame, + 1, + 1 + index, + &format!("{prefix}{}", clipped(&label, list_width.saturating_sub(4))), + list_width.saturating_sub(1), + style, + ); + } + let Some(current) = self.archives.get(self.archive_selected) else { + return; + }; + render_line( + frame, + detail_left, + 1, + "Archived set", + detail_width, + Style::default().add_modifier(Modifier::BOLD), + ); + render_line( + frame, + detail_left, + 2, + &clipped(&format_timestamp(¤t.archived_at), detail_width), + detail_width, + Style::default().add_modifier(Modifier::DIM), + ); + render_line( + frame, + detail_left, + 4, + &count_label(current.annotations.len()), + detail_width, + Style::default().add_modifier(Modifier::BOLD), + ); + let visible = newest_first_annotations(¤t.annotations); + let preview_rows = rows.saturating_sub(8).max(1); + for (index, annotation) in visible.iter().take(preview_rows).enumerate() { + render_line( + frame, + detail_left, + 5 + index, + &clipped( + &format!("{}. {}", index + 1, annotation.selected_text), + detail_width, + ), + detail_width, + Style::default(), + ); + } + if visible.len() > preview_rows { + render_line( + frame, + detail_left, + rows.saturating_sub(3), + &format!("… {} more", visible.len() - preview_rows), + detail_width, + Style::default().add_modifier(Modifier::DIM), + ); + } + } + + fn footer_text(&self) -> String { + match &self.confirmation { + Confirmation::ClearActive => { + return "Press Shift+D again to clear all active annotations · Esc cancel" + .to_owned(); + } + Confirmation::DeleteArchive { .. } => { + return "Press d again to permanently delete this archive · Esc cancel".to_owned(); + } + Confirmation::None => {} + } + if !self.status.is_empty() { + return self.status.clone(); + } + match self.view { + ManagerView::Active => { + "j/k · y copy · c all · Shift+C copy+archive · d delete · Shift+D clear · Tab archives · q".to_owned() + } + ManagerView::Archives => { + "j/k · y copy · u restore · d twice delete · Tab active · q".to_owned() + } + } + } + + /// Handle one keyboard event and any requested store/clipboard transition. + pub fn handle_key(&mut self, key: KeyEvent) { + if key.kind == KeyEventKind::Release { + return; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.quit = true; + return; + } + if key.code == KeyCode::Esc { + if self.confirmation == Confirmation::None { + self.quit = true; + } else { + self.confirmation = Confirmation::None; + self.status.clear(); + } + return; + } + if key.code == KeyCode::Char('q') { + self.quit = true; + return; + } + if key.code == KeyCode::Tab { + self.switch_view(); + return; + } + self.status.clear(); + match self.view { + ManagerView::Active => self.handle_active_key(key), + ManagerView::Archives => self.handle_archive_key(key), + } + } + + fn handle_active_key(&mut self, key: KeyEvent) { + if key.code == KeyCode::Char('D') { + if self.confirmation == Confirmation::ClearActive { + self.clear_active(); + } else { + self.confirmation = Confirmation::ClearActive; + } + return; + } + self.confirmation = Confirmation::None; + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.active_selected = self.active_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + self.active_selected = + (self.active_selected + 1).min(self.annotations.len().saturating_sub(1)); + } + KeyCode::Char('C') => self.copy_and_archive(), + KeyCode::Char('y') => { + let items = self + .annotations + .get(self.active_selected) + .cloned() + .into_iter() + .collect::>(); + self.copy(&items); + } + KeyCode::Char('c') => self.copy(&self.annotations.clone()), + KeyCode::Char('d') => self.delete_selected_annotation(), + KeyCode::Char('r') if self.reload_active() => { + "Reloaded.".clone_into(&mut self.status); + } + _ => {} + } + } + + fn handle_archive_key(&mut self, key: KeyEvent) { + if key.code == KeyCode::Char('d') { + let Some(current) = self.archives.get(self.archive_selected) else { + self.confirmation = Confirmation::None; + "No archive selected.".clone_into(&mut self.status); + return; + }; + let archive_id = current.id.clone(); + if self.confirmation + == (Confirmation::DeleteArchive { + archive_id: archive_id.clone(), + }) + { + self.delete_selected_archive(&archive_id); + } else { + self.confirmation = Confirmation::DeleteArchive { archive_id }; + } + return; + } + self.confirmation = Confirmation::None; + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.archive_selected = self.archive_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + self.archive_selected = + (self.archive_selected + 1).min(self.archives.len().saturating_sub(1)); + } + KeyCode::Char('y') => { + let items = self + .archives + .get(self.archive_selected) + .map(|archive| newest_first_annotations(&archive.annotations)) + .unwrap_or_default(); + self.copy(&items); + } + KeyCode::Char('u') => self.restore_selected_archive(), + KeyCode::Char('r') if self.reload_archives() => { + "Reloaded.".clone_into(&mut self.status); + } + _ => {} + } + } + + fn copy(&mut self, items: &[Annotation]) { + match copy_annotations(items, write_clipboard) { + ManagerCopyOutcome::Close => self.quit = true, + ManagerCopyOutcome::StayOpen { message } => self.status = message, + } + } + + fn copy_and_archive(&mut self) { + let dir = self.dir.clone(); + let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies { + load_active: || load_annotations(&dir), + write_clipboard: |text: String| write_clipboard(&text), + save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive), + remove_active: |ids: Vec| remove_annotations_by_id(&dir, &ids), + create_archive_id: || Uuid::new_v4().to_string(), + now: now_iso_for_manager, + }); + match outcome { + CopyAndArchiveOutcome::Close { .. } => self.quit = true, + CopyAndArchiveOutcome::StayOpen { message } => self.status = message, + CopyAndArchiveOutcome::ArchivedActiveRetained { message } => { + self.reload_archives(); + self.status = + format!("Copied and archived, but active annotations remain: {message}"); + } + } + } + + fn delete_selected_annotation(&mut self) { + let Some(target) = self.annotations.get(self.active_selected) else { + return; + }; + match remove_annotations_by_id(&self.dir, std::slice::from_ref(&target.id)) { + Ok(()) => { + if self.reload_active() { + "Annotation deleted.".clone_into(&mut self.status); + } + } + Err(message) => self.status = message, + } + } + + fn clear_active(&mut self) { + let ids = self + .annotations + .iter() + .map(|item| item.id.clone()) + .collect::>(); + match remove_annotations_by_id(&self.dir, &ids) { + Ok(()) => { + self.confirmation = Confirmation::None; + if self.reload_active() { + "All active annotations cleared.".clone_into(&mut self.status); + } + } + Err(message) => self.status = message, + } + } + + fn restore_selected_archive(&mut self) { + let Some(target) = self.archives.get(self.archive_selected).cloned() else { + "No archive selected.".clone_into(&mut self.status); + return; + }; + let outcome = restore_archived_set( + &target, + RestoreArchiveDependencies { + merge_active: |items: Vec| merge_annotations(&self.dir, &items), + remove_archive: |id: String| remove_archived_set(&self.dir, &id), + }, + ); + match outcome { + RestoreArchivedSetOutcome::StayOpen { message } => self.status = message, + RestoreArchivedSetOutcome::RestoredArchiveRetained { message, .. } => { + if !self.reload_active() || !self.reload_archives() { + return; + } + self.status = format!("Annotations restored, but the archive remains: {message}"); + } + RestoreArchivedSetOutcome::Restored { restored_count } => { + if !self.reload_active() || !self.reload_archives() { + return; + } + self.status = if restored_count == 0 { + "Archive removed; its annotations were already active.".to_owned() + } else { + format!("{} restored.", count_label(restored_count)) + }; + } + } + } + + fn delete_selected_archive(&mut self, archive_id: &str) { + match remove_archived_set(&self.dir, archive_id) { + Ok(()) => { + self.confirmation = Confirmation::None; + if self.reload_archives() { + "Archive permanently deleted.".clone_into(&mut self.status); + } + } + Err(message) => self.status = message, + } + } + + fn switch_view(&mut self) { + self.confirmation = Confirmation::None; + self.status.clear(); + match self.view { + ManagerView::Active => { + self.view = ManagerView::Archives; + self.reload_archives(); + } + ManagerView::Archives => { + self.view = ManagerView::Active; + self.reload_active(); + } + } + } +} + +fn clamp_selection(selected: usize, length: usize) -> usize { + selected.min(length.saturating_sub(1)) +} + +fn first_visible_index(selected: usize, length: usize, visible_rows: usize) -> usize { + selected + .saturating_sub(visible_rows / 2) + .min(length.saturating_sub(visible_rows)) +} + +fn count_label(count: usize) -> String { + format!("{count} annotation{}", if count == 1 { "" } else { "s" }) +} + +fn clipped(text: &str, width: usize) -> String { + let sanitized = sanitize_terminal_text(text); + let value = sanitized.split_whitespace().collect::>().join(" "); + if string_width(&value) <= width { + value + } else { + format!("{}…", truncate_to_width(&value, width.saturating_sub(1))) + } +} + +fn format_timestamp(value: &str) -> String { + DateTime::parse_from_rfc3339(value).map_or_else( + |_| "Invalid Date".to_owned(), + |date| { + date.with_timezone(&Local) + .format("%-m/%-d/%Y, %-I:%M:%S %p") + .to_string() + }, + ) +} + +fn render_line(frame: &mut Frame<'_>, x: usize, y: usize, text: &str, width: usize, style: Style) { + let (Ok(x), Ok(y), Ok(width)) = (u16::try_from(x), u16::try_from(y), u16::try_from(width)) + else { + return; + }; + let area = frame.area(); + if x >= area.width || y >= area.height { + return; + } + let width = width.min(area.width.saturating_sub(x)); + frame.render_widget( + Paragraph::new(Line::styled(text.to_owned(), style)), + Rect::new(x, y, width, 1), + ); +} + +/// Run the interactive manager pane from Herdr's environment. +pub fn run() -> Result<(), String> { + let dir = state_dir().ok_or_else(|| "HERDR_PLUGIN_STATE_DIR is not set".to_owned())?; + let mut app = ManagerApp::load(dir); + let mut terminal = ratatui::init(); + let result = (|| -> Result<(), String> { + while !app.quit { + terminal + .draw(|frame| app.draw(frame)) + .map_err(|error| error.to_string())?; + if let Event::Key(key) = event::read().map_err(|error| error.to_string())? { + app.handle_key(key); + } + } + Ok(()) + })(); + ratatui::restore(); + result +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "tests assert by panicking")] + + use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use crate::store::{append_annotation, append_archived_set}; + use crate::types::InvocationContext; + + use super::*; + + static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + + fn directory() -> PathBuf { + let sequence = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "herdr-annotate-manager-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("temporary directory"); + dir + } + + fn annotation(id: &str) -> Annotation { + Annotation { + selected_text: format!("selection {id}"), + context: InvocationContext { + workspace_label: Some("api".to_owned()), + tab_label: Some("server".to_owned()), + ..InvocationContext::default() + }, + captured_at: "2026-08-08T00:00:00Z".to_owned(), + id: id.to_owned(), + comment: format!("comment {id}"), + created_at: "2026-08-08T00:00:01Z".to_owned(), + } + } + + fn rows(app: &ManagerApp) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(98, 28)).expect("terminal"); + terminal.draw(|frame| app.draw(frame)).expect("draw"); + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .filter_map(|x| buffer.cell((x, y))) + .map(|cell| cell.symbol().to_owned()) + .collect() + }) + .collect() + } + + #[test] + fn active_frame_is_newest_first_and_has_detail_and_keys() { + let dir = directory(); + append_annotation(&dir, &annotation("one")).expect("one"); + append_annotation(&dir, &annotation("two")).expect("two"); + let app = ManagerApp::load(dir.clone()); + let frame = rows(&app); + assert!( + frame + .first() + .is_some_and(|row| row.contains("Annotations (2)")) + ); + let one = frame + .iter() + .position(|row| row.contains("selection one")) + .expect("one"); + let two = frame + .iter() + .position(|row| row.contains("selection two")) + .expect("two"); + assert!(two < one); + assert!(frame.iter().any(|row| row.contains("comment two"))); + assert!(frame.iter().any(|row| row.contains("Shift+C copy+archive"))); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn archive_frame_and_delete_confirmation_render_headlessly() { + let dir = directory(); + append_archived_set( + &dir, + &ArchivedAnnotationSet { + version: 1, + id: "archive-one".to_owned(), + archived_at: "2026-08-26T23:32:00Z".to_owned(), + annotations: vec![annotation("one")], + }, + ) + .expect("archive"); + let mut app = ManagerApp::load(dir.clone()); + app.handle_key(KeyEvent::from(KeyCode::Tab)); + let frame = rows(&app); + assert!( + frame + .first() + .is_some_and(|row| row.contains("Archives (1)")) + ); + assert!(frame.iter().any(|row| row.contains("Archived set"))); + app.handle_key(KeyEvent::from(KeyCode::Char('d'))); + assert!(rows(&app).iter().any(|row| row.contains("Press d again"))); + app.handle_key(KeyEvent::from(KeyCode::Esc)); + assert!(!app.quit); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn clear_requires_shift_d_twice() { + let dir = directory(); + append_annotation(&dir, &annotation("one")).expect("annotation"); + let mut app = ManagerApp::load(dir.clone()); + app.handle_key(KeyEvent::from(KeyCode::Char('D'))); + assert_eq!(load_annotations(&dir).expect("still active").len(), 1); + app.handle_key(KeyEvent::from(KeyCode::Char('D'))); + assert!(load_annotations(&dir).expect("cleared").is_empty()); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/rust/src/manager_copy.rs b/rust/src/manager_copy.rs new file mode 100644 index 0000000..b1832e0 --- /dev/null +++ b/rust/src/manager_copy.rs @@ -0,0 +1,91 @@ +//! Annotation-manager copy transition. + +use crate::format::format_annotations; +use crate::types::Annotation; + +/// Whether the annotation manager should close or remain visible after a copy attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManagerCopyOutcome { + Close, + StayOpen { message: String }, +} + +/// Format and copy annotations without changing the supplied annotations or their store. +pub fn copy_annotations( + annotations: &[Annotation], + write_clipboard: impl FnOnce(&str) -> Result<(), String>, +) -> ManagerCopyOutcome { + if annotations.is_empty() { + return ManagerCopyOutcome::StayOpen { + message: "Nothing to copy.".to_owned(), + }; + } + match write_clipboard(&format_annotations(annotations)) { + Ok(()) => ManagerCopyOutcome::Close, + Err(message) => ManagerCopyOutcome::StayOpen { message }, + } +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + + use crate::types::{Annotation, InvocationContext}; + + use super::*; + + fn annotation(id: &str) -> Annotation { + Annotation { + selected_text: format!("selection {id}"), + context: InvocationContext::default(), + captured_at: "captured".to_owned(), + id: id.to_owned(), + comment: format!("comment {id}"), + created_at: "created".to_owned(), + } + } + + #[test] + fn successful_copy_closes_without_changing_annotations() { + let annotations = vec![annotation("one"), annotation("two")]; + let original = annotations.clone(); + let clipboard = RefCell::new(String::new()); + let outcome = copy_annotations(&annotations, |text| { + text.clone_into(&mut clipboard.borrow_mut()); + Ok(()) + }); + assert_eq!(outcome, ManagerCopyOutcome::Close); + assert!(clipboard.borrow().contains("selection one")); + assert!(clipboard.borrow().contains("selection two")); + assert_eq!(annotations, original); + } + + #[test] + fn clipboard_failure_stays_open() { + let outcome = copy_annotations(&[annotation("one")], |_| { + Err("Clipboard unavailable".to_owned()) + }); + assert_eq!( + outcome, + ManagerCopyOutcome::StayOpen { + message: "Clipboard unavailable".to_owned() + } + ); + } + + #[test] + fn empty_copy_stays_open_without_writing() { + let writes = Cell::new(0); + let outcome = copy_annotations(&[], |_| { + writes.set(writes.get() + 1); + Ok(()) + }); + assert_eq!( + outcome, + ManagerCopyOutcome::StayOpen { + message: "Nothing to copy.".to_owned() + } + ); + assert_eq!(writes.get(), 0); + } +} diff --git a/rust/src/paths.rs b/rust/src/paths.rs new file mode 100644 index 0000000..244de8c --- /dev/null +++ b/rust/src/paths.rs @@ -0,0 +1,80 @@ +//! Plugin and persisted-store paths. + +use std::path::{Path, PathBuf}; + +/// Remove Windows extended-path prefixes that process launchers cannot use as a cwd. +pub fn normalize_windows_path(value: &str) -> String { + if let Some(without_prefix) = value.strip_prefix(r"\\?\") { + return without_prefix + .get(..4) + .filter(|prefix| prefix.eq_ignore_ascii_case("UNC\\")) + .map_or_else( + || without_prefix.to_owned(), + |_| format!(r"\\{}", without_prefix.get(4..).unwrap_or_default()), + ); + } + if let Some(without_prefix) = value.strip_prefix("//?/") { + return without_prefix + .get(..4) + .filter(|prefix| prefix.eq_ignore_ascii_case("UNC/")) + .map_or_else( + || without_prefix.to_owned(), + |_| format!("//{}", without_prefix.get(4..).unwrap_or_default()), + ); + } + value.to_owned() +} + +/// Return Herdr's plugin-owned state directory when the runtime supplied one. +pub fn state_dir() -> Option { + std::env::var_os("HERDR_PLUGIN_STATE_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +/// Return Herdr's plugin root in a process-safe form. +pub fn plugin_root() -> Option { + std::env::var("HERDR_PLUGIN_ROOT") + .ok() + .filter(|value| !value.is_empty()) + .map(|value| PathBuf::from(normalize_windows_path(&value))) +} + +/// Resolve the JSONL store inside a plugin state directory. +pub fn annotations_path(dir: &Path) -> PathBuf { + dir.join("annotations.jsonl") +} + +/// Resolve the archived-set JSONL store inside a plugin state directory. +pub fn archives_path(dir: &Path) -> PathBuf { + dir.join("archives.jsonl") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extended_windows_paths_are_normalized() { + assert_eq!(normalize_windows_path(r"\\?\C:\foo"), r"C:\foo"); + assert_eq!( + normalize_windows_path(r"\\?\UNC\server\share\foo"), + r"\\server\share\foo" + ); + assert_eq!(normalize_windows_path("//?/C:/foo"), "C:/foo"); + assert_eq!( + normalize_windows_path("//?/UNC/server/share/foo"), + "//server/share/foo" + ); + } + + #[test] + fn ordinary_and_empty_paths_are_unchanged() { + assert_eq!(normalize_windows_path(r"C:\foo"), r"C:\foo"); + assert_eq!( + normalize_windows_path("/home/user/plugin"), + "/home/user/plugin" + ); + assert_eq!(normalize_windows_path(""), ""); + } +} diff --git a/rust/src/store.rs b/rust/src/store.rs new file mode 100644 index 0000000..21e8fe7 --- /dev/null +++ b/rust/src/store.rs @@ -0,0 +1,533 @@ +//! Concurrent, byte-compatible JSONL annotation stores. + +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::paths::{annotations_path, archives_path}; +use crate::types::{ + Annotation, ArchivedAnnotationSet, parse_annotation, parse_archived_annotation_set, +}; + +const STALE_LOCK: Duration = Duration::from_secs(30); + +/// The result of an expected annotation-store operation. +pub type StoreResult = Result; + +#[derive(Debug, Clone, Copy)] +enum StoreName { + Annotations, + Archives, +} + +impl StoreName { + const fn lower(self) -> &'static str { + match self { + Self::Annotations => "annotations", + Self::Archives => "archives", + } + } + + const fn capitalized(self) -> &'static str { + match self { + Self::Annotations => "Annotations", + Self::Archives => "Archives", + } + } +} + +#[derive(Debug)] +struct StoreLockLease { + path: PathBuf, + owner: String, +} + +impl Drop for StoreLockLease { + fn drop(&mut self) { + let owner_path = self.path.join("owner"); + let Ok(current_owner) = fs::read_to_string(owner_path) else { + return; + }; + if current_owner.trim() == self.owner { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +/// Present append-ordered annotations with the most recently saved first. +pub fn newest_first_annotations(annotations: &[Annotation]) -> Vec { + annotations.iter().rev().cloned().collect() +} + +/// Load the complete active store, rejecting malformed records instead of dropping data. +pub fn load_annotations(dir: &Path) -> StoreResult> { + with_store_lock(dir, StoreName::Annotations, || { + load_annotations_unlocked(dir) + }) +} + +/// Append one annotation without rewriting existing records. +pub fn append_annotation(dir: &Path, annotation: &Annotation) -> StoreResult<()> { + with_store_lock(dir, StoreName::Annotations, || { + let mut file = append_file(&annotations_path(dir)) + .map_err(|error| safe_file_error("Unable to save annotation", &error))?; + let record = serde_json::to_string(annotation) + .map_err(|_| "Unable to save annotation".to_owned())?; + writeln!(file, "{record}") + .map_err(|error| safe_file_error("Unable to save annotation", &error)) + }) +} + +/// Remove selected annotation IDs without racing concurrent annotation saves. +pub fn remove_annotations_by_id(dir: &Path, annotation_ids: &[String]) -> StoreResult<()> { + with_store_lock(dir, StoreName::Annotations, || { + let loaded = load_annotations_unlocked(dir)?; + let removed = annotation_ids + .iter() + .map(String::as_str) + .collect::>(); + let retained = loaded + .into_iter() + .filter(|annotation| !removed.contains(annotation.id.as_str())) + .collect::>(); + replace_annotations_unlocked(dir, &retained) + }) +} + +/// Merge annotations into the active list without duplicating existing annotation IDs. +pub fn merge_annotations(dir: &Path, annotations: &[Annotation]) -> StoreResult { + with_store_lock(dir, StoreName::Annotations, || { + let mut loaded = load_annotations_unlocked(dir)?; + let existing_ids = loaded + .iter() + .map(|item| item.id.as_str()) + .collect::>(); + let additions = annotations + .iter() + .filter(|item| !existing_ids.contains(item.id.as_str())) + .cloned() + .collect::>(); + if additions.is_empty() { + return Ok(0); + } + let count = additions.len(); + loaded.extend(additions); + replace_annotations_unlocked(dir, &loaded)?; + Ok(count) + }) +} + +/// Present append-ordered archive sets with the most recently archived first. +pub fn newest_first_archived_sets( + archives: &[ArchivedAnnotationSet], +) -> Vec { + archives.iter().rev().cloned().collect() +} + +/// Load complete, parsed annotation sets from the archive store. +pub fn load_archived_sets(dir: &Path) -> StoreResult> { + with_store_lock(dir, StoreName::Archives, || { + load_archived_sets_unlocked(dir) + }) +} + +/// Atomically append one complete set to the archive store. +pub fn append_archived_set(dir: &Path, archive: &ArchivedAnnotationSet) -> StoreResult<()> { + with_store_lock(dir, StoreName::Archives, || { + let mut loaded = load_archived_sets_unlocked(dir)?; + loaded.push(archive.clone()); + replace_archived_sets_unlocked(dir, &loaded) + }) +} + +/// Permanently remove one archived set by its archive ID. +pub fn remove_archived_set(dir: &Path, archive_id: &str) -> StoreResult<()> { + with_store_lock(dir, StoreName::Archives, || { + let retained = load_archived_sets_unlocked(dir)? + .into_iter() + .filter(|archive| archive.id != archive_id) + .collect::>(); + replace_archived_sets_unlocked(dir, &retained) + }) +} + +fn load_annotations_unlocked(dir: &Path) -> StoreResult> { + load_json_lines(&annotations_path(dir), "annotations", parse_annotation) +} + +fn replace_annotations_unlocked(dir: &Path, annotations: &[Annotation]) -> StoreResult<()> { + replace_json_lines( + dir, + &annotations_path(dir), + "annotations", + annotations, + "Unable to update annotations", + ) +} + +fn load_archived_sets_unlocked(dir: &Path) -> StoreResult> { + load_json_lines( + &archives_path(dir), + "archives", + parse_archived_annotation_set, + ) +} + +fn replace_archived_sets_unlocked( + dir: &Path, + archives: &[ArchivedAnnotationSet], +) -> StoreResult<()> { + replace_json_lines( + dir, + &archives_path(dir), + "archives", + archives, + "Unable to update archives", + ) +} + +fn load_json_lines( + file: &Path, + label: &str, + parse: fn(&Value) -> Option, +) -> StoreResult> { + let opened = match File::open(file) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(safe_file_error(&format!("Unable to read {label}"), &error)), + }; + let mut records = Vec::new(); + for line in BufReader::new(opened).lines() { + let line = + line.map_err(|error| safe_file_error(&format!("Unable to read {label}"), &error))?; + if line.is_empty() { + continue; + } + let decoded = serde_json::from_str::(&line) + .map_err(|_| format!("Unable to read {label} (invalid data)"))?; + let record = + parse(&decoded).ok_or_else(|| format!("Unable to read {label} (invalid data)"))?; + records.push(record); + } + Ok(records) +} + +fn replace_json_lines( + dir: &Path, + file: &Path, + temporary_label: &str, + records: &[T], + error_message: &str, +) -> StoreResult<()> { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let temporary = dir.join(format!( + ".{temporary_label}-{}-{millis}.tmp", + std::process::id() + )); + let result = (|| -> std::io::Result<()> { + let mut output = private_file(&temporary, false)?; + for record in records { + serde_json::to_writer(&mut output, record).map_err(std::io::Error::other)?; + output.write_all(b"\n")?; + } + output.flush()?; + drop(output); + fs::rename(&temporary, file) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(safe_file_error(error_message, &error)); + } + Ok(()) +} + +fn with_store_lock( + dir: &Path, + store: StoreName, + operation: impl FnOnce() -> StoreResult, +) -> StoreResult { + create_private_dir_all(dir) + .map_err(|error| safe_file_error(&format!("Unable to access {}", store.lower()), &error))?; + let _lease = acquire_store_lock(&dir.join(format!(".{}.lock", store.lower())), store)?; + operation() +} + +fn acquire_store_lock(lock: &Path, store: StoreName) -> StoreResult { + let owner = format!("{}:{}", std::process::id(), Uuid::new_v4()); + match create_store_lock(lock, &owner) { + Ok(()) => { + return Ok(StoreLockLease { + path: lock.to_path_buf(), + owner, + }); + } + Err(error) if error.kind() != std::io::ErrorKind::AlreadyExists => { + return Err(safe_file_error( + &format!("Unable to lock {}", store.lower()), + &error, + )); + } + Err(_) => {} + } + if !is_stale_lock(lock) { + return Err(format!("{} are busy; try again.", store.capitalized())); + } + fs::remove_dir_all(lock) + .map_err(|error| safe_file_error(&format!("Unable to lock {}", store.lower()), &error))?; + match create_store_lock(lock, &owner) { + Ok(()) => Ok(StoreLockLease { + path: lock.to_path_buf(), + owner, + }), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + Err(format!("{} are busy; try again.", store.capitalized())) + } + Err(error) => Err(safe_file_error( + &format!("Unable to lock {}", store.lower()), + &error, + )), + } +} + +fn create_store_lock(lock: &Path, owner: &str) -> std::io::Result<()> { + create_private_dir(lock)?; + let owner_path = lock.join("owner"); + let result = private_file(&owner_path, false) + .and_then(|mut file| file.write_all(format!("{owner}\n").as_bytes())); + if let Err(error) = result { + let _ = fs::remove_dir_all(lock); + return Err(error); + } + Ok(()) +} + +fn is_stale_lock(lock: &Path) -> bool { + fs::metadata(lock) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= STALE_LOCK) +} + +fn append_file(path: &Path) -> std::io::Result { + let mut options = OpenOptions::new(); + options.create(true).append(true); + set_private_open_options(&mut options); + options.open(path) +} + +fn private_file(path: &Path, append: bool) -> std::io::Result { + let mut options = OpenOptions::new(); + options + .create(true) + .write(true) + .truncate(!append) + .append(append); + set_private_open_options(&mut options); + options.open(path) +} + +#[cfg(unix)] +fn set_private_open_options(options: &mut OpenOptions) { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); +} + +#[cfg(not(unix))] +fn set_private_open_options(_options: &mut OpenOptions) {} + +#[cfg(unix)] +fn create_private_dir(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700).create(path) +} + +#[cfg(not(unix))] +fn create_private_dir(path: &Path) -> std::io::Result<()> { + fs::create_dir(path) +} + +#[cfg(unix)] +fn create_private_dir_all(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700).create(path) +} + +#[cfg(not(unix))] +fn create_private_dir_all(path: &Path) -> std::io::Result<()> { + fs::create_dir_all(path) +} + +fn safe_file_error(prefix: &str, error: &std::io::Error) -> String { + error.raw_os_error().map_or_else( + || prefix.to_owned(), + |code| format!("{prefix} ({})", os_error_code(error.kind(), code)), + ) +} + +fn os_error_code(kind: std::io::ErrorKind, raw: i32) -> String { + match kind { + std::io::ErrorKind::NotFound => "ENOENT".to_owned(), + std::io::ErrorKind::PermissionDenied => "EACCES".to_owned(), + std::io::ErrorKind::AlreadyExists => "EEXIST".to_owned(), + std::io::ErrorKind::IsADirectory => "EISDIR".to_owned(), + std::io::ErrorKind::NotADirectory => "ENOTDIR".to_owned(), + std::io::ErrorKind::DirectoryNotEmpty => "ENOTEMPTY".to_owned(), + std::io::ErrorKind::StorageFull => "ENOSPC".to_owned(), + std::io::ErrorKind::ReadOnlyFilesystem => "EROFS".to_owned(), + std::io::ErrorKind::InvalidInput => "EINVAL".to_owned(), + _ => format!("OS error {raw}"), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "tests assert by panicking")] + + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::types::InvocationContext; + + use super::*; + + static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + + fn temporary_directory() -> PathBuf { + let sequence = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "herdr-annotate-store-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("temporary directory"); + dir + } + + fn annotation(id: &str) -> Annotation { + Annotation { + selected_text: format!("selection {id}"), + context: InvocationContext::default(), + captured_at: "2026-08-08T00:00:00Z".to_owned(), + id: id.to_owned(), + comment: format!("comment {id}"), + created_at: "2026-08-08T00:00:01Z".to_owned(), + } + } + + fn archive(id: &str, annotation_ids: &[&str]) -> ArchivedAnnotationSet { + ArchivedAnnotationSet { + version: 1, + id: id.to_owned(), + archived_at: format!("2026-08-26T23:32:0{}Z", id.len()), + annotations: annotation_ids.iter().map(|item| annotation(item)).collect(), + } + } + + #[test] + fn newest_first_does_not_mutate_storage_order() { + let stored = vec![annotation("one"), annotation("two"), annotation("three")]; + let newest = newest_first_annotations(&stored); + assert_eq!( + newest + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["three", "two", "one"] + ); + assert_eq!( + stored + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["one", "two", "three"] + ); + } + + #[test] + fn annotations_append_load_remove_and_merge() { + let dir = temporary_directory(); + append_annotation(&dir, &annotation("one")).expect("append one"); + append_annotation(&dir, &annotation("two")).expect("append two"); + assert_eq!( + load_annotations(&dir).expect("load"), + [annotation("one"), annotation("two")] + ); + remove_annotations_by_id(&dir, &["one".to_owned()]).expect("remove"); + assert_eq!(load_annotations(&dir).expect("load"), [annotation("two")]); + assert_eq!( + merge_annotations(&dir, &[annotation("two"), annotation("three")]).expect("merge"), + 1 + ); + assert_eq!( + load_annotations(&dir).expect("load"), + [annotation("two"), annotation("three")] + ); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn malformed_active_data_is_rejected() { + let dir = temporary_directory(); + fs::write(annotations_path(&dir), "{broken\n").expect("fixture"); + assert_eq!( + load_annotations(&dir), + Err("Unable to read annotations (invalid data)".to_owned()) + ); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn contention_fails_safely_and_abandoned_locks_recover() { + let dir = temporary_directory(); + let lock = dir.join(".annotations.lock"); + fs::create_dir(&lock).expect("lock"); + assert_eq!( + append_annotation(&dir, &annotation("one")), + Err("Annotations are busy; try again.".to_owned()) + ); + let stale = SystemTime::now() - Duration::from_secs(31); + let file = File::open(&lock).expect("lock handle"); + file.set_times(fs::FileTimes::new().set_modified(stale)) + .expect("age lock"); + append_annotation(&dir, &annotation("one")).expect("recover"); + assert_eq!(load_annotations(&dir).expect("load"), [annotation("one")]); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn archive_sets_persist_newest_first_and_remove_individually() { + let dir = temporary_directory(); + let first = archive("one", &["annotation-one"]); + let second = archive("two", &["annotation-two", "annotation-three"]); + append_archived_set(&dir, &first).expect("archive one"); + append_archived_set(&dir, &second).expect("archive two"); + let loaded = load_archived_sets(&dir).expect("load archives"); + assert_eq!(loaded, [first, second.clone()]); + assert_eq!(newest_first_archived_sets(&loaded).first(), Some(&second)); + remove_archived_set(&dir, "one").expect("remove archive"); + assert_eq!(load_archived_sets(&dir).expect("load"), [second]); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn malformed_archive_data_is_rejected() { + let dir = temporary_directory(); + fs::write(archives_path(&dir), "{broken\n").expect("fixture"); + assert_eq!( + load_archived_sets(&dir), + Err("Unable to read archives (invalid data)".to_owned()) + ); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/rust/src/types.rs b/rust/src/types.rs new file mode 100644 index 0000000..a9e56ff --- /dev/null +++ b/rust/src/types.rs @@ -0,0 +1,289 @@ +//! Persisted annotation and Herdr invocation wire types. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Herdr invocation fields retained as useful annotation provenance. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InvocationContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_agent: Option, +} + +/// Clipboard text and provenance waiting for a user comment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingAnnotation { + pub selected_text: String, + pub context: InvocationContext, + pub captured_at: String, +} + +/// A saved annotation with a non-empty user comment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Annotation { + pub selected_text: String, + pub context: InvocationContext, + pub captured_at: String, + pub id: String, + pub comment: String, + pub created_at: String, +} + +impl Annotation { + /// Convert a pending record and comment to the persisted field order used by TypeScript. + pub fn from_pending( + pending: PendingAnnotation, + id: String, + comment: String, + created_at: String, + ) -> Self { + Self { + selected_text: pending.selected_text, + context: pending.context, + captured_at: pending.captured_at, + id, + comment, + created_at, + } + } +} + +/// One recoverable set of annotations moved out of the active list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedAnnotationSet { + pub version: u8, + pub id: String, + pub archived_at: String, + pub annotations: Vec, +} + +fn optional_string(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_str).map(str::to_owned) +} + +/// Match ECMAScript `String.prototype.trim` for blank-value checks. +pub(crate) fn javascript_trim(value: &str) -> &str { + value.trim_matches(|character: char| character.is_whitespace() || character == '\u{feff}') +} + +/// Parse untrusted Herdr context JSON into the small provenance shape the plugin stores. +pub fn parse_invocation_context(value: &Value) -> InvocationContext { + if !value.is_object() { + return InvocationContext::default(); + } + InvocationContext { + workspace_id: optional_string(value, "workspace_id"), + workspace_label: optional_string(value, "workspace_label"), + tab_id: optional_string(value, "tab_id"), + tab_label: optional_string(value, "tab_label"), + focused_pane_id: optional_string(value, "focused_pane_id"), + focused_pane_cwd: optional_string(value, "focused_pane_cwd"), + focused_pane_agent: optional_string(value, "focused_pane_agent"), + } +} + +/// Read a non-empty terminal selection from Herdr's plugin invocation context. +pub fn selected_text_from_invocation(value: &Value) -> Option { + let selected = optional_string(value, "selected_text")?; + (!javascript_trim(&selected).is_empty()).then_some(selected) +} + +/// Build a pending annotation from selected text supplied by a Herdr pane invocation. +pub fn pending_annotation_from_invocation( + value: &Value, + captured_at: impl Into, +) -> Option { + Some(PendingAnnotation { + selected_text: selected_text_from_invocation(value)?, + context: parse_invocation_context(value), + captured_at: captured_at.into(), + }) +} + +/// Parse a pending-annotation file, returning `None` when required fields are invalid. +pub fn parse_pending_annotation(value: &Value) -> Option { + if !value.is_object() { + return None; + } + Some(PendingAnnotation { + selected_text: optional_string(value, "selectedText")?, + context: value + .get("context") + .map_or_else(InvocationContext::default, parse_invocation_context), + captured_at: optional_string(value, "capturedAt")?, + }) +} + +/// Parse one persisted JSONL record, returning `None` for malformed records. +pub fn parse_annotation(value: &Value) -> Option { + let pending = parse_pending_annotation(value)?; + let id = optional_string(value, "id")?; + let comment = optional_string(value, "comment")?; + let created_at = optional_string(value, "createdAt")?; + if id.is_empty() || javascript_trim(&comment).is_empty() || created_at.is_empty() { + return None; + } + Some(Annotation::from_pending(pending, id, comment, created_at)) +} + +/// Parse one persisted archive record without accepting partial annotation sets. +pub fn parse_archived_annotation_set(value: &Value) -> Option { + if value.get("version").and_then(Value::as_f64) != Some(1.0) { + return None; + } + let id = optional_string(value, "id")?; + let archived_at = optional_string(value, "archivedAt")?; + let items = value.get("annotations")?.as_array()?; + if id.is_empty() || archived_at.is_empty() || items.is_empty() { + return None; + } + let annotations = items + .iter() + .map(parse_annotation) + .collect::>>()?; + Some(ArchivedAnnotationSet { + version: 1, + id, + archived_at, + annotations, + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "tests assert by panicking")] + + use serde_json::json; + + use super::*; + + fn persisted_annotation(id: &str) -> Value { + json!({ + "id": id, + "selectedText": format!("selection {id}"), + "comment": format!("comment {id}"), + "capturedAt": "2026-08-08T00:00:00Z", + "createdAt": "2026-08-08T00:00:01Z", + "context": {} + }) + } + + #[test] + fn terminal_selection_is_returned_without_changes() { + assert_eq!( + selected_text_from_invocation(&json!({"selected_text": " selected text\n"})), + Some(" selected text\n".to_owned()) + ); + } + + #[test] + fn missing_invalid_and_empty_selections_are_ignored() { + for value in [ + json!({}), + json!({"selected_text": 42}), + json!({"selected_text": " \n\t"}), + json!({"selected_text": "\u{feff}"}), + Value::Null, + ] { + assert_eq!(selected_text_from_invocation(&value), None); + } + } + + #[test] + fn editor_fallback_retains_invocation_context() { + let pending = pending_annotation_from_invocation( + &json!({ + "selected_text": "selected text", + "workspace_id": "workspace-1", + "focused_pane_cwd": "C:\\work" + }), + "2026-08-27T00:00:00Z", + ) + .expect("pending"); + assert_eq!(pending.selected_text, "selected text"); + assert_eq!(pending.context.workspace_id.as_deref(), Some("workspace-1")); + assert_eq!( + pending.context.focused_pane_cwd.as_deref(), + Some("C:\\work") + ); + } + + #[test] + fn editor_fallback_rejects_missing_or_empty_selection() { + assert!(pending_annotation_from_invocation(&json!({}), "now").is_none()); + assert!( + pending_annotation_from_invocation(&json!({"selected_text": " \n"}), "now").is_none() + ); + } + + #[test] + fn complete_versioned_archive_parses() { + let parsed = parse_archived_annotation_set(&json!({ + "version": 1, + "id": "archive-one", + "archivedAt": "2026-08-26T23:32:00Z", + "annotations": [persisted_annotation("one")] + })) + .expect("archive"); + assert_eq!(parsed.id, "archive-one"); + assert_eq!( + parsed.annotations.first().map(|item| item.id.as_str()), + Some("one") + ); + } + + #[test] + fn json_number_spelling_does_not_change_archive_version_semantics() { + let parsed = parse_archived_annotation_set(&json!({ + "version": 1.0, + "id": "archive-one", + "archivedAt": "2026-08-26T23:32:00Z", + "annotations": [persisted_annotation("one")] + })); + assert!(parsed.is_some(), "JSON.parse treats 1.0 as the number 1"); + } + + #[test] + fn empty_partial_and_unknown_archives_are_rejected() { + for value in [ + json!({"version": 1, "id": "empty", "archivedAt": "now", "annotations": []}), + json!({"version": 1, "id": "partial", "archivedAt": "now", "annotations": [persisted_annotation("one"), {"id": "broken"}]}), + json!({"version": 2, "id": "future", "archivedAt": "now", "annotations": [persisted_annotation("one")]}), + ] { + assert!(parse_archived_annotation_set(&value).is_none()); + } + } + + #[test] + fn serialization_matches_the_typescript_field_names_and_order() { + let annotation = Annotation::from_pending( + PendingAnnotation { + selected_text: "selection".to_owned(), + context: InvocationContext::default(), + captured_at: "captured".to_owned(), + }, + "id".to_owned(), + "comment".to_owned(), + "created".to_owned(), + ); + assert_eq!( + serde_json::to_string(&annotation).expect("json"), + r#"{"selectedText":"selection","context":{},"capturedAt":"captured","id":"id","comment":"comment","createdAt":"created"}"# + ); + } +} diff --git a/rust/src/width.rs b/rust/src/width.rs new file mode 100644 index 0000000..093d962 --- /dev/null +++ b/rust/src/width.rs @@ -0,0 +1,96 @@ +//! Terminal cell-width helpers matching the TypeScript implementation. + +const WIDE_RANGES: &[(u32, u32)] = &[ + (0x1100, 0x115f), + (0x2e80, 0x303e), + (0x3041, 0x33ff), + (0x3400, 0x4dbf), + (0x4e00, 0x9fff), + (0xa000, 0xa4cf), + (0xa960, 0xa97f), + (0xac00, 0xd7a3), + (0xf900, 0xfaff), + (0xfe10, 0xfe19), + (0xfe30, 0xfe6f), + (0xff00, 0xff60), + (0xffe0, 0xffe6), + (0x1f300, 0x1f64f), + (0x1f900, 0x1f9ff), + (0x20000, 0x3fffd), +]; + +fn is_wide(code_point: u32) -> bool { + for &(start, end) in WIDE_RANGES { + if code_point < start { + return false; + } + if code_point <= end { + return true; + } + } + false +} + +/// Cells occupied by a single character. Control characters count as zero. +pub fn char_width(character: char) -> usize { + let code_point = u32::from(character); + if code_point < 0x20 || (0x7f..0xa0).contains(&code_point) { + return 0; + } + if (0x0300..=0x036f).contains(&code_point) || (0x200b..=0x200f).contains(&code_point) { + return 0; + } + if is_wide(code_point) { 2 } else { 1 } +} + +/// Cells occupied by a string. +pub fn string_width(text: &str) -> usize { + text.chars().map(char_width).sum() +} + +/// Longest prefix of `text` that fits without splitting a character. +pub fn truncate_to_width(text: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + let mut used = 0; + text.chars() + .take_while(|character| { + let next = char_width(*character); + let fits = used + next <= width; + if fits { + used += next; + } + fits + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn character_widths_match_the_typescript_ranges() { + for character in ['한', 'ㄱ', '漢', 'あ', 'A'] { + assert_eq!(char_width(character), 2); + } + for character in ['a', '·'] { + assert_eq!(char_width(character), 1); + } + assert_eq!(char_width('\u{0007}'), 0); + } + + #[test] + fn string_width_adds_character_cells() { + assert_eq!(string_width("한글abc"), 7); + assert_eq!(string_width(""), 0); + } + + #[test] + fn truncation_never_splits_wide_characters() { + assert_eq!(truncate_to_width("한글", 3), "한"); + assert_eq!(truncate_to_width("한글", 4), "한글"); + assert_eq!(truncate_to_width("한", 0), ""); + } +} diff --git a/rust/tests/commands.rs b/rust/tests/commands.rs new file mode 100644 index 0000000..116d930 --- /dev/null +++ b/rust/tests/commands.rs @@ -0,0 +1,145 @@ +//! Native command-boundary tests with a fake Herdr executable. + +#![cfg(unix)] +#![allow(clippy::expect_used, reason = "tests assert by panicking")] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use serde_json::Value; + +static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + +fn directory() -> PathBuf { + let sequence = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "herdr-annotate-command-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("temporary directory"); + dir +} + +fn fake_herdr(dir: &Path) -> PathBuf { + let script = dir.join("fake-herdr"); + fs::write( + &script, + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HERDR_TEST_LOG\"\nexit \"${HERDR_TEST_EXIT:-0}\"\n", + ) + .expect("fake Herdr"); + fs::set_permissions(&script, fs::Permissions::from_mode(0o700)).expect("executable"); + script +} + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_herdr-annotate")) +} + +fn command(dir: &Path, subcommand: &str) -> (Command, PathBuf) { + let log = dir.join("herdr.log"); + let mut command = Command::new(binary()); + command + .arg(subcommand) + .env("HERDR_BIN_PATH", fake_herdr(dir)) + .env("HERDR_TEST_LOG", &log) + .env("HERDR_PLUGIN_STATE_DIR", dir.join("state")) + .env("HERDR_PLUGIN_ROOT", dir.join("plugin")); + (command, log) +} + +#[test] +fn copy_context_with_an_empty_store_notifies_and_succeeds() { + let dir = directory(); + let (mut command, log) = command(&dir, "copy-context"); + let output = command.output().expect("run"); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + fs::read_to_string(log).expect("notification"), + "notification\nshow\nNo annotations\n--body\nThere is nothing to copy yet.\n" + ); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn manage_opens_the_manager_pane_with_the_typescript_arguments() { + let dir = directory(); + let root = dir.join("plugin"); + fs::create_dir_all(&root).expect("plugin root"); + let (mut command, log) = command(&dir, "manage"); + let output = command.output().expect("run"); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + fs::read_to_string(log).expect("Herdr call"), + format!( + "plugin\npane\nopen\n--cwd\n{}\n--plugin\nannotate\n--entrypoint\nmanager\n--placement\npopup\n--width\n100\n--height\n30\n--focus\n", + root.display() + ) + ); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn capture_persists_selection_and_context_then_opens_the_editor() { + let dir = directory(); + let root = dir.join("plugin"); + fs::create_dir_all(&root).expect("plugin root"); + let (mut command, log) = command(&dir, "capture"); + command.env( + "HERDR_PLUGIN_CONTEXT_JSON", + r#"{"selected_text":" selected text\n","workspace_id":"workspace-1","tab_label":"server"}"#, + ); + let output = command.output().expect("run"); + assert!(output.status.success(), "{output:?}"); + + let pending = fs::read_dir(dir.join("state")) + .expect("state") + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("pending-")) + .expect("pending file") + .path(); + let value: Value = + serde_json::from_str(&fs::read_to_string(&pending).expect("pending contents")) + .expect("pending json"); + assert_eq!( + value.get("selectedText").and_then(Value::as_str), + Some(" selected text\n") + ); + assert_eq!( + value + .pointer("/context/workspace_id") + .and_then(Value::as_str), + Some("workspace-1") + ); + assert!(value.get("capturedAt").and_then(Value::as_str).is_some()); + + let invocation = fs::read_to_string(log).expect("Herdr call"); + assert!(invocation.contains("plugin\npane\nopen\n")); + assert!(invocation.contains("--entrypoint\neditor\n")); + assert!(invocation.contains(&format!("HERDR_ANNOTATE_PENDING={}\n", pending.display()))); + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn failed_editor_open_removes_the_pending_file_and_reports_failure() { + let dir = directory(); + let (mut command, _) = command(&dir, "capture"); + command + .env( + "HERDR_PLUGIN_CONTEXT_JSON", + r#"{"selected_text":"selection"}"#, + ) + .env("HERDR_TEST_EXIT", "1"); + let output = command.output().expect("run"); + assert!(!output.status.success()); + let pending_count = fs::read_dir(dir.join("state")) + .expect("state") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("pending-")) + .count(); + assert_eq!(pending_count, 0); + let _ = fs::remove_dir_all(dir); +} diff --git a/scripts/smoke-rust-lite.sh b/scripts/smoke-rust-lite.sh new file mode 100755 index 0000000..ac0f34a --- /dev/null +++ b/scripts/smoke-rust-lite.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Smoke-test the native Lite build in a disposable named Herdr session. This links the +# evaluation manifest, confirms its binary-backed entrypoints, renders the manager pane, +# then restores the machine's prior `annotate` install. +# +# HERDR_SESSION=rust-lite-test bash scripts/smoke-rust-lite.sh +set -euo pipefail + +[ -n "${HERDR_SESSION:-}" ] || { echo "set HERDR_SESSION to a disposable named session" >&2; exit 2; } +[ "$HERDR_SESSION" != default ] || { echo "refusing to run against the default session" >&2; exit 2; } + +root="$(cd "$(dirname "$0")/.." && pwd)" +failures=0 + +plugin_json() { herdr plugin list --json | python3 -c ' +import json,sys +for p in json.load(sys.stdin)["result"]["plugins"]: + if p["plugin_id"] == "annotate": + print(json.dumps(p)); break'; } +field() { python3 -c "import json,sys; p=json.load(sys.stdin); print(eval(sys.argv[1]))" "$1"; } +check() { + if [ "$2" = "$3" ]; then + echo " ok $1: $2" + else + echo " FAIL $1: got '$2', want '$3'" >&2 + failures=$((failures+1)) + fi +} + +before="$(plugin_json || true)" +restore() { + echo "== restore" + herdr plugin uninstall annotate >/dev/null 2>&1 || true + if [ -z "$before" ]; then + echo " (nothing was installed)" + return + fi + kind="$(printf '%s' "$before" | field "p['source']['kind']")" + if [ "$kind" = github ]; then + owner="$(printf '%s' "$before" | field "p['source']['owner']")" + repo="$(printf '%s' "$before" | field "p['source']['repo']")" + subdir="$(printf '%s' "$before" | field "p['source'].get('subdir') or ''")" + commit="$(printf '%s' "$before" | field "p['source']['resolved_commit']")" + herdr plugin install "$owner/$repo${subdir:+/$subdir}" --ref "$commit" --yes >/dev/null + echo " restored $owner/$repo${subdir:+/$subdir}@${commit:0:7}" + else + prior_root="$(printf '%s' "$before" | field "p['plugin_root']")" + herdr plugin link "$prior_root" >/dev/null + echo " restored link $prior_root" + fi +} +trap restore EXIT + +echo "== build native Lite" +bash "$root/lite-rs/scripts/stage-local.sh" >/dev/null +binary="$root/rust/target/release/herdr-annotate" +check "binary version" "$("$binary" --version)" "herdr-annotate $(tr -d '[:space:]' < "$root/lite-rs/herdr-annotate.version")" + +echo "== link native manifest" +herdr plugin link "$root/lite-rs" >/dev/null +installed="$(plugin_json)" +check "plugin root" "$(printf '%s' "$installed" | field "p['plugin_root']")" "$root/lite-rs" +actions="$(herdr plugin action list --plugin annotate | python3 -c ' +import json,sys +print(",".join(sorted(a["action_id"] for a in json.load(sys.stdin)["result"]["actions"])))')" +check "actions" "$actions" "capture,copy-context,manage" +commands="$(printf '%s' "$installed" | python3 -c ' +import json,sys +p=json.load(sys.stdin) +print(",".join(sorted(a["command"][0] for a in p["actions"])))')" +check "native action commands" "$commands" "./bin/herdr-annotate.exe,./bin/herdr-annotate.exe,./bin/herdr-annotate.exe" +check "bundled binary" "$("$root/lite-rs/bin/herdr-annotate.exe" --version)" "herdr-annotate $(tr -d '[:space:]' < "$root/lite-rs/herdr-annotate.version")" + +echo "== manager pane renders in $HERDR_SESSION" +herdr plugin pane open --plugin annotate --entrypoint manager --placement overlay --focus >/dev/null +pane="" +for _ in $(seq 1 20); do + pane="$(herdr pane list | python3 -c ' +import json,sys +ps=[p for p in json.load(sys.stdin)["result"]["panes"] if p.get("label") == "Annotations"] +print(ps[-1]["pane_id"] if ps else "")')" + [ -n "$pane" ] && break + sleep 0.25 +done +if [ -n "$pane" ] && herdr pane wait-output "$pane" --match "Annotations (" --timeout 8000 >/dev/null 2>&1; then + echo " ok manager pane $pane rendered" +else + echo " FAIL manager pane did not render" >&2 + [ -n "$pane" ] && herdr pane read "$pane" >&2 || true + herdr pane list >&2 || true + herdr plugin log list --plugin annotate --limit 5 >&2 || true + failures=$((failures+1)) +fi +[ -n "$pane" ] && herdr plugin pane close "$pane" >/dev/null 2>&1 || true + +echo "== result: $failures failure(s)" +[ "$failures" -eq 0 ] From da32dfc83f01636b81d1c64544ec84771df1cb80 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 29 Aug 2026 15:35:18 -0700 Subject: [PATCH 02/12] fix: satisfy latest stable clippy --- rust/src/handoff.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rust/src/handoff.rs b/rust/src/handoff.rs index 40f772a..d07ce43 100644 --- a/rust/src/handoff.rs +++ b/rust/src/handoff.rs @@ -34,8 +34,7 @@ pub fn take_handoff(file: &Path, now: SystemTime, max_age: Duration) -> Option Date: Sat, 29 Aug 2026 18:34:55 -0700 Subject: [PATCH 03/12] fix: address Rust parity review --- .github/workflows/rust-lite-ci.yml | 5 +++++ docs/rust-lite-parity.md | 13 ++++++++----- rust/src/manager.rs | 26 +++++++++++++++++++++++++- rust/src/types.rs | 23 ++++++++++++++--------- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/.github/workflows/rust-lite-ci.yml b/.github/workflows/rust-lite-ci.yml index f722147..41214c7 100644 --- a/.github/workflows/rust-lite-ci.yml +++ b/.github/workflows/rust-lite-ci.yml @@ -2,6 +2,11 @@ name: rust-lite-ci on: pull_request: + paths: + - "rust/**" + - "lite-rs/**" + - "scripts/smoke-rust-lite.sh" + - ".github/workflows/rust-lite-*.yml" merge_group: concurrency: diff --git a/docs/rust-lite-parity.md b/docs/rust-lite-parity.md index 63822b5..cf931bd 100644 --- a/docs/rust-lite-parity.md +++ b/docs/rust-lite-parity.md @@ -28,9 +28,10 @@ separately so test coverage is not confused with behavior observed inside Herdr. non-empty saved fields, complete version-1 archive validation, unknown-field tolerance. - [x] `paths.ts`: state/root environment variables, Windows extended drive and UNC normalization, `annotations.jsonl` and `archives.jsonl` names. -- [x] `store.ts`: append-order JSONL, exact camelCase field names and TypeScript field order, trailing - newline, mode 0600, whole-store invalid-data rejection, ID merge/remove, atomic temporary replace, - per-store directory locks, owner tokens, 30-second stale recovery, ownership-checked release. +- [x] `store.ts`: append-order JSONL, exact camelCase field names and distinct TypeScript pending and + saved-record field orders, trailing newline, mode 0600, whole-store invalid-data rejection, ID + merge/remove, atomic temporary replace, per-store directory locks, owner tokens, 30-second stale + recovery, ownership-checked release. - [x] `format.ts`: control sanitization, four-space tabs, CRLF normalization, explicit-newline and terminal-cell wrapping, Markdown headings/source/fences/blank-line shape, safe longer backtick fence. - [x] `width.ts` and `layout.ts`: the same wide ranges, zero-width controls/combining marks, non- @@ -61,10 +62,12 @@ document-anchor/API wire shape, not Lite's existing terminal-selection JSONL sha Additional Rust-only coverage: - `editor::tests`: headless 86×22 `TestBackend` frame, Unicode edit keys, empty-save validation, quit. -- `manager::tests`: headless 98×28 active/archive frames, newest-first detail, confirmation, real clear. +- `manager::tests`: headless 98×28 active/archive frames, newest-first detail, exact TypeScript detail + width at the clipping boundary, confirmation, real clear. - `rust/tests/commands.rs`: subprocess-level capture/manage/copy-context commands with a fake Herdr, including exact pane argv, pending JSON, notifications, and pending cleanup on pane-open failure. -- `types::tests::serialization_matches_the_typescript_field_names_and_order`: literal JSON byte shape. +- `types::tests::serialization_matches_the_typescript_pending_and_saved_field_order`: literal pending + and saved JSON byte shapes. ## Distribution and verification diff --git a/rust/src/manager.rs b/rust/src/manager.rs index 27d848f..7df8894 100644 --- a/rust/src/manager.rs +++ b/rust/src/manager.rs @@ -111,7 +111,7 @@ impl ManagerApp { let rows = usize::from(area.height.max(14)); let list_width = ((cols * 36) / 100).clamp(22, 36); let detail_left = list_width + 2; - let detail_width = cols.saturating_sub(detail_left + 1).max(1); + let detail_width = cols.saturating_sub(detail_left + 2).max(1); let list_rows = rows.saturating_sub(4).max(1); for row in 1..rows.saturating_sub(1) { @@ -844,6 +844,30 @@ mod tests { let _ = fs::remove_dir_all(dir); } + #[test] + fn archive_preview_uses_the_typescript_detail_width() { + let dir = directory(); + let mut item = annotation("long"); + item.selected_text = "x".repeat(100); + append_archived_set( + &dir, + &ArchivedAnnotationSet { + version: 1, + id: "archive-long".to_owned(), + archived_at: "2026-08-26T23:32:00Z".to_owned(), + annotations: vec![item], + }, + ) + .expect("archive"); + let mut app = ManagerApp::load(dir.clone()); + app.handle_key(KeyEvent::from(KeyCode::Tab)); + let frame = rows(&app); + let preview = frame.get(5).expect("archive preview row"); + assert_eq!(preview.chars().nth(95), Some('…')); + assert_eq!(preview.chars().nth(96), Some(' ')); + let _ = fs::remove_dir_all(dir); + } + #[test] fn clear_requires_shift_d_twice() { let dir = directory(); diff --git a/rust/src/types.rs b/rust/src/types.rs index a9e56ff..fedfbf1 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -36,8 +36,8 @@ pub struct PendingAnnotation { #[serde(rename_all = "camelCase")] pub struct Annotation { pub selected_text: String, - pub context: InvocationContext, pub captured_at: String, + pub context: InvocationContext, pub id: String, pub comment: String, pub created_at: String, @@ -53,8 +53,8 @@ impl Annotation { ) -> Self { Self { selected_text: pending.selected_text, - context: pending.context, captured_at: pending.captured_at, + context: pending.context, id, comment, created_at, @@ -270,20 +270,25 @@ mod tests { } #[test] - fn serialization_matches_the_typescript_field_names_and_order() { + fn serialization_matches_the_typescript_pending_and_saved_field_order() { + let pending = PendingAnnotation { + selected_text: "selection".to_owned(), + context: InvocationContext::default(), + captured_at: "captured".to_owned(), + }; + assert_eq!( + serde_json::to_string(&pending).expect("pending json"), + r#"{"selectedText":"selection","context":{},"capturedAt":"captured"}"# + ); let annotation = Annotation::from_pending( - PendingAnnotation { - selected_text: "selection".to_owned(), - context: InvocationContext::default(), - captured_at: "captured".to_owned(), - }, + pending, "id".to_owned(), "comment".to_owned(), "created".to_owned(), ); assert_eq!( serde_json::to_string(&annotation).expect("json"), - r#"{"selectedText":"selection","context":{},"capturedAt":"captured","id":"id","comment":"comment","createdAt":"created"}"# + r#"{"selectedText":"selection","capturedAt":"captured","context":{},"id":"id","comment":"comment","createdAt":"created"}"# ); } } From b50f388138c1cbc753f2a50f7250933b7f47b488 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 30 Aug 2026 17:45:31 -0700 Subject: [PATCH 04/12] fix(rust-lite): match store directory permissions Parity proof: Filesystem effects / store directory creation. --- rust/src/store.rs | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/rust/src/store.rs b/rust/src/store.rs index 21e8fe7..0b0c3e8 100644 --- a/rust/src/store.rs +++ b/rust/src/store.rs @@ -357,14 +357,6 @@ fn create_private_dir(path: &Path) -> std::io::Result<()> { fs::create_dir(path) } -#[cfg(unix)] -fn create_private_dir_all(path: &Path) -> std::io::Result<()> { - use std::os::unix::fs::DirBuilderExt; - let mut builder = fs::DirBuilder::new(); - builder.recursive(true).mode(0o700).create(path) -} - -#[cfg(not(unix))] fn create_private_dir_all(path: &Path) -> std::io::Result<()> { fs::create_dir_all(path) } @@ -476,6 +468,32 @@ mod tests { let _ = fs::remove_dir_all(dir); } + #[cfg(unix)] + #[test] + fn store_creation_uses_the_process_default_directory_mode() { + use std::os::unix::fs::PermissionsExt; + + let sequence = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let parent = std::env::temp_dir().join(format!( + "herdr-annotate-store-parent-{}-{sequence}", + std::process::id() + )); + let dir = parent.join("state"); + let _ = fs::remove_dir_all(&parent); + fs::create_dir_all(&parent).expect("temporary parent"); + + assert!(load_annotations(&dir).expect("load").is_empty()); + assert_eq!( + fs::metadata(&dir) + .expect("state metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + let _ = fs::remove_dir_all(parent); + } + #[test] fn malformed_active_data_is_rejected() { let dir = temporary_directory(); From 28e31318143ab6bbd61b6869af264261dab05255 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 30 Aug 2026 17:46:28 -0700 Subject: [PATCH 05/12] fix(rust-lite): preserve fallback record key order Parity proof: Filesystem effects / saved annotation field order. --- rust/src/editor.rs | 62 ++++++++++++++++++++++++++++++++++++++++++---- rust/src/store.rs | 49 +++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/rust/src/editor.rs b/rust/src/editor.rs index a00e0a8..511386f 100644 --- a/rust/src/editor.rs +++ b/rust/src/editor.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use crate::format::{sanitize_terminal_text, wrap_text}; use crate::layout::layout_comment; use crate::paths::state_dir; -use crate::store::append_annotation; +use crate::store::{append_annotation, append_annotation_context_first}; use crate::types::{ Annotation, PendingAnnotation, javascript_trim, parse_pending_annotation, pending_annotation_from_invocation, @@ -32,17 +32,29 @@ const DEFAULT_ROWS: u16 = 22; #[derive(Debug)] pub struct EditorApp { pending: PendingAnnotation, + saved_field_order: SavedFieldOrder, comment: Vec, cursor: usize, status: String, quit: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SavedFieldOrder { + CapturedAtThenContext, + ContextThenCapturedAt, +} + impl EditorApp { /// Start an empty comment for a captured selection. pub fn new(pending: PendingAnnotation) -> Self { + Self::with_field_order(pending, SavedFieldOrder::CapturedAtThenContext) + } + + fn with_field_order(pending: PendingAnnotation, saved_field_order: SavedFieldOrder) -> Self { Self { pending, + saved_field_order, comment: Vec::new(), cursor: 0, status: String::new(), @@ -254,7 +266,13 @@ impl EditorApp { value, now_iso(), ); - match append_annotation(dir, &annotation) { + let saved = match self.saved_field_order { + SavedFieldOrder::CapturedAtThenContext => append_annotation(dir, &annotation), + SavedFieldOrder::ContextThenCapturedAt => { + append_annotation_context_first(dir, &annotation) + } + }; + match saved { Ok(()) => { "Saved.".clone_into(&mut self.status); true @@ -298,11 +316,12 @@ fn invocation_context() -> Value { .unwrap_or_else(|| Value::Object(serde_json::Map::new())) } -fn pending_from_env() -> Result { +fn pending_from_env() -> Result<(PendingAnnotation, SavedFieldOrder), String> { let invocation = invocation_context(); let Some(path) = std::env::var_os("HERDR_ANNOTATE_PENDING").filter(|value| !value.is_empty()) else { return pending_annotation_from_invocation(&invocation, now_iso()) + .map(|pending| (pending, SavedFieldOrder::ContextThenCapturedAt)) .ok_or_else(|| "Missing pending annotation".to_owned()); }; let path = std::path::PathBuf::from(path); @@ -311,12 +330,13 @@ fn pending_from_env() -> Result { let pending = parse_pending_annotation(&decoded) .ok_or_else(|| "Pending annotation is invalid".to_owned())?; std::fs::remove_file(path).map_err(|error| error.to_string())?; - Ok(pending) + Ok((pending, SavedFieldOrder::CapturedAtThenContext)) } /// Run the interactive editor pane from Herdr's environment. pub fn run() -> Result<(), String> { - let mut app = EditorApp::new(pending_from_env()?); + let (pending, saved_field_order) = pending_from_env()?; + let mut app = EditorApp::with_field_order(pending, saved_field_order); let dir = state_dir(); let mut terminal = ratatui::init(); let result = (|| -> Result<(), String> { @@ -346,6 +366,8 @@ pub fn run() -> Result<(), String> { mod tests { #![allow(clippy::expect_used, reason = "tests assert by panicking")] + use std::sync::atomic::{AtomicUsize, Ordering}; + use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -353,6 +375,8 @@ mod tests { use super::*; + static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + fn app() -> EditorApp { EditorApp::new(PendingAnnotation { selected_text: "first selected line\nsecond line".to_owned(), @@ -408,4 +432,32 @@ mod tests { control.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); assert!(control.quit); } + + #[test] + fn invocation_fallback_save_uses_its_typescript_property_order() { + let sequence = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "herdr-annotate-editor-{}-{sequence}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temporary directory"); + let mut editor = EditorApp::with_field_order( + PendingAnnotation { + selected_text: "selection".to_owned(), + context: InvocationContext::default(), + captured_at: "captured".to_owned(), + }, + SavedFieldOrder::ContextThenCapturedAt, + ); + editor.comment = "comment".chars().collect(); + editor.cursor = editor.comment.len(); + + assert!(editor.save(Some(&dir))); + let saved = std::fs::read_to_string(dir.join("annotations.jsonl")).expect("saved record"); + assert!(saved.starts_with( + "{\"selectedText\":\"selection\",\"context\":{},\"capturedAt\":\"captured\"," + )); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/rust/src/store.rs b/rust/src/store.rs index 0b0c3e8..78dc074 100644 --- a/rust/src/store.rs +++ b/rust/src/store.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use crate::paths::{annotations_path, archives_path}; use crate::types::{ - Annotation, ArchivedAnnotationSet, parse_annotation, parse_archived_annotation_set, + Annotation, ArchivedAnnotationSet, InvocationContext, parse_annotation, + parse_archived_annotation_set, }; const STALE_LOCK: Duration = Duration::from_secs(30); @@ -74,6 +75,26 @@ pub fn load_annotations(dir: &Path) -> StoreResult> { /// Append one annotation without rewriting existing records. pub fn append_annotation(dir: &Path, annotation: &Annotation) -> StoreResult<()> { + append_annotation_record(dir, annotation) +} + +/// Append using the property insertion order of TypeScript's invocation-context editor fallback. +pub(crate) fn append_annotation_context_first( + dir: &Path, + annotation: &Annotation, +) -> StoreResult<()> { + let context_first = ContextFirstAnnotation { + selected_text: &annotation.selected_text, + context: &annotation.context, + captured_at: &annotation.captured_at, + id: &annotation.id, + comment: &annotation.comment, + created_at: &annotation.created_at, + }; + append_annotation_record(dir, &context_first) +} + +fn append_annotation_record(dir: &Path, annotation: &impl Serialize) -> StoreResult<()> { with_store_lock(dir, StoreName::Annotations, || { let mut file = append_file(&annotations_path(dir)) .map_err(|error| safe_file_error("Unable to save annotation", &error))?; @@ -84,6 +105,17 @@ pub fn append_annotation(dir: &Path, annotation: &Annotation) -> StoreResult<()> }) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ContextFirstAnnotation<'a> { + selected_text: &'a str, + context: &'a InvocationContext, + captured_at: &'a str, + id: &'a str, + comment: &'a str, + created_at: &'a str, +} + /// Remove selected annotation IDs without racing concurrent annotation saves. pub fn remove_annotations_by_id(dir: &Path, annotation_ids: &[String]) -> StoreResult<()> { with_store_lock(dir, StoreName::Annotations, || { @@ -468,6 +500,21 @@ mod tests { let _ = fs::remove_dir_all(dir); } + #[test] + fn invocation_fallback_append_preserves_typescript_property_order() { + let dir = temporary_directory(); + append_annotation_context_first(&dir, &annotation("one")).expect("append"); + assert_eq!( + fs::read_to_string(annotations_path(&dir)).expect("record"), + concat!( + "{\"selectedText\":\"selection one\",\"context\":{},", + "\"capturedAt\":\"2026-08-08T00:00:00Z\",\"id\":\"one\",", + "\"comment\":\"comment one\",\"createdAt\":\"2026-08-08T00:00:01Z\"}\n" + ) + ); + let _ = fs::remove_dir_all(dir); + } + #[cfg(unix)] #[test] fn store_creation_uses_the_process_default_directory_mode() { From 1d75160dbded91e0e448553ede4a7915dd8655ba Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 30 Aug 2026 17:51:01 -0700 Subject: [PATCH 06/12] fix(rust-lite): match lossy handoff decoding Parity proof: Filesystem effects / handoff take. --- rust/src/handoff.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/rust/src/handoff.rs b/rust/src/handoff.rs index d07ce43..647e5f1 100644 --- a/rust/src/handoff.rs +++ b/rust/src/handoff.rs @@ -35,7 +35,13 @@ pub fn take_handoff(file: &Path, now: SystemTime, max_age: Duration) -> Option Date: Sun, 30 Aug 2026 17:54:29 -0700 Subject: [PATCH 07/12] fix(rust-lite): restore terminals on pane signals Parity proof: Process effects / terminal signal cleanup. --- rust/Cargo.lock | 1 + rust/Cargo.toml | 1 + rust/src/editor.rs | 11 ++++++++++- rust/src/lib.rs | 1 + rust/src/manager.rs | 12 +++++++++++- rust/src/termination.rs | 43 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 rust/src/termination.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index bcb9264..7660eed 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -512,6 +512,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "signal-hook", "uuid", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4d44177..776114f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -21,6 +21,7 @@ uuid = { version = "1", features = ["v4"] } [target.'cfg(unix)'.dependencies] rustix = { version = "1", features = ["process"] } +signal-hook = "0.3" [lints.rust] unsafe_code = "forbid" diff --git a/rust/src/editor.rs b/rust/src/editor.rs index 511386f..f20626e 100644 --- a/rust/src/editor.rs +++ b/rust/src/editor.rs @@ -17,6 +17,7 @@ use crate::format::{sanitize_terminal_text, wrap_text}; use crate::layout::layout_comment; use crate::paths::state_dir; use crate::store::{append_annotation, append_annotation_context_first}; +use crate::termination::Termination; use crate::types::{ Annotation, PendingAnnotation, javascript_trim, parse_pending_annotation, pending_annotation_from_invocation, @@ -338,12 +339,20 @@ pub fn run() -> Result<(), String> { let (pending, saved_field_order) = pending_from_env()?; let mut app = EditorApp::with_field_order(pending, saved_field_order); let dir = state_dir(); + let termination = Termination::install(); let mut terminal = ratatui::init(); let result = (|| -> Result<(), String> { - while !app.quit { + while !app.quit && !termination.requested() { terminal .draw(|frame| app.draw(frame)) .map_err(|error| error.to_string())?; + while !termination.requested() + && !event::poll(Duration::from_millis(50)).map_err(|error| error.to_string())? + { + } + if termination.requested() { + break; + } let event = event::read().map_err(|error| error.to_string())?; if let Event::Key(key) = event && app.handle_key(key) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index b018417..296b188 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -15,5 +15,6 @@ pub mod types; pub mod width; mod cli; +mod termination; pub use cli::run; diff --git a/rust/src/manager.rs b/rust/src/manager.rs index 7df8894..ea5ae26 100644 --- a/rust/src/manager.rs +++ b/rust/src/manager.rs @@ -1,6 +1,7 @@ //! Interactive annotation manager pane. use std::path::PathBuf; +use std::time::Duration; use chrono::{DateTime, Local}; use ratatui::Frame; @@ -25,6 +26,7 @@ use crate::store::{ newest_first_annotations, newest_first_archived_sets, remove_annotations_by_id, remove_archived_set, }; +use crate::termination::Termination; use crate::types::{Annotation, ArchivedAnnotationSet}; use crate::width::{string_width, truncate_to_width}; @@ -716,12 +718,20 @@ fn render_line(frame: &mut Frame<'_>, x: usize, y: usize, text: &str, width: usi pub fn run() -> Result<(), String> { let dir = state_dir().ok_or_else(|| "HERDR_PLUGIN_STATE_DIR is not set".to_owned())?; let mut app = ManagerApp::load(dir); + let termination = Termination::install(); let mut terminal = ratatui::init(); let result = (|| -> Result<(), String> { - while !app.quit { + while !app.quit && !termination.requested() { terminal .draw(|frame| app.draw(frame)) .map_err(|error| error.to_string())?; + while !termination.requested() + && !event::poll(Duration::from_millis(50)).map_err(|error| error.to_string())? + { + } + if termination.requested() { + break; + } if let Event::Key(key) = event::read().map_err(|error| error.to_string())? { app.handle_key(key); } diff --git a/rust/src/termination.rs b/rust/src/termination.rs new file mode 100644 index 0000000..7481ca6 --- /dev/null +++ b/rust/src/termination.rs @@ -0,0 +1,43 @@ +//! Graceful terminal cleanup for the signals handled by the TypeScript panes. + +#[cfg(unix)] +use std::sync::Arc; +#[cfg(unix)] +use std::sync::atomic::{AtomicBool, Ordering}; + +/// A process signal flag that can be checked without work in the signal handler. +#[derive(Debug)] +pub(crate) struct Termination { + #[cfg(unix)] + requested: Arc, +} + +impl Termination { + /// Handle the same graceful-exit signals as the TypeScript terminal panes. + pub(crate) fn install() -> Self { + #[cfg(unix)] + { + use signal_hook::consts::signal::{SIGHUP, SIGTERM}; + + let requested = Arc::new(AtomicBool::new(false)); + let _ = signal_hook::flag::register(SIGTERM, Arc::clone(&requested)); + let _ = signal_hook::flag::register(SIGHUP, Arc::clone(&requested)); + Self { requested } + } + #[cfg(not(unix))] + { + Self {} + } + } + + pub(crate) fn requested(&self) -> bool { + #[cfg(unix)] + { + self.requested.load(Ordering::Relaxed) + } + #[cfg(not(unix))] + { + false + } + } +} From 53ef811ebe74d84daa97c932c5bdc1102b8b4d2a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 30 Aug 2026 17:57:02 -0700 Subject: [PATCH 08/12] fix(rust-lite): match forceful pending cleanup Parity proof: Filesystem effects / pending handoff deletion. --- rust/src/editor.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/rust/src/editor.rs b/rust/src/editor.rs index f20626e..2bec8f8 100644 --- a/rust/src/editor.rs +++ b/rust/src/editor.rs @@ -330,10 +330,18 @@ fn pending_from_env() -> Result<(PendingAnnotation, SavedFieldOrder), String> { let decoded = serde_json::from_str::(&text).map_err(|error| error.to_string())?; let pending = parse_pending_annotation(&decoded) .ok_or_else(|| "Pending annotation is invalid".to_owned())?; - std::fs::remove_file(path).map_err(|error| error.to_string())?; + remove_pending_file(&path)?; Ok((pending, SavedFieldOrder::CapturedAtThenContext)) } +fn remove_pending_file(path: &Path) -> Result<(), String> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + /// Run the interactive editor pane from Herdr's environment. pub fn run() -> Result<(), String> { let (pending, saved_field_order) = pending_from_env()?; @@ -469,4 +477,14 @@ mod tests { )); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn pending_removal_is_forceful_like_typescript() { + let missing = std::env::temp_dir().join(format!( + "herdr-annotate-missing-pending-{}", + std::process::id() + )); + let _ = std::fs::remove_file(&missing); + assert_eq!(remove_pending_file(&missing), Ok(())); + } } From 3cb49ec26a06494860b982fd89176b93c7a678e2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 30 Aug 2026 17:57:37 -0700 Subject: [PATCH 09/12] fix(rust-lite): propagate handoff removal failures Parity proof: Filesystem effects / handoff take and removal. --- rust/src/cli.rs | 2 +- rust/src/handoff.rs | 47 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 12adbeb..4d950fa 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -59,7 +59,7 @@ fn capture() -> Result<(), String> { let dir = state_dir().ok_or_else(|| "HERDR_PLUGIN_STATE_DIR is not set".to_owned())?; let root = plugin_root().ok_or_else(|| "HERDR_PLUGIN_ROOT is not set".to_owned())?; if selected_text.is_none() { - selected_text = take_default_handoff(); + selected_text = take_default_handoff()?; } if selected_text.is_none() { selected_text = Some(read_clipboard()?); diff --git a/rust/src/handoff.rs b/rust/src/handoff.rs index 647e5f1..69f41d8 100644 --- a/rust/src/handoff.rs +++ b/rust/src/handoff.rs @@ -29,8 +29,14 @@ fn current_user_id() -> String { } /// Return fresh, non-blank handed-off text and remove the file whether fresh or stale. -pub fn take_handoff(file: &Path, now: SystemTime, max_age: Duration) -> Option { - let metadata = std::fs::metadata(file).ok()?; +pub fn take_handoff( + file: &Path, + now: SystemTime, + max_age: Duration, +) -> Result, String> { + let Ok(metadata) = std::fs::metadata(file) else { + return Ok(None); + }; let fresh = metadata.is_file() && metadata .modified() @@ -42,12 +48,16 @@ pub fn take_handoff(file: &Path, now: SystemTime, max_age: Duration) -> Option {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + Ok(text.filter(|value| !javascript_trim(value).is_empty())) } /// Take a selection from the default handoff file. -pub fn take_default_handoff() -> Option { +pub fn take_default_handoff() -> Result, String> { take_handoff(&handoff_path(), SystemTime::now(), HANDOFF_MAX_AGE) } @@ -92,7 +102,9 @@ mod tests { let file = temporary_file(); std::fs::write(&file, "hello\nworld\n").expect("fixture"); assert_eq!( - take_handoff(&file, SystemTime::now(), HANDOFF_MAX_AGE).as_deref(), + take_handoff(&file, SystemTime::now(), HANDOFF_MAX_AGE) + .expect("take") + .as_deref(), Some("hello\nworld\n") ); assert!(!file.exists()); @@ -103,7 +115,9 @@ mod tests { let file = temporary_file(); std::fs::write(&file, "new").expect("fixture"); assert_eq!( - take_handoff(&file, SystemTime::UNIX_EPOCH, HANDOFF_MAX_AGE).as_deref(), + take_handoff(&file, SystemTime::UNIX_EPOCH, HANDOFF_MAX_AGE) + .expect("take") + .as_deref(), Some("new") ); assert!(!file.exists()); @@ -114,7 +128,9 @@ mod tests { let file = temporary_file(); std::fs::write(&file, b"invalid-\xff-handoff").expect("fixture"); assert_eq!( - take_handoff(&file, SystemTime::now(), HANDOFF_MAX_AGE).as_deref(), + take_handoff(&file, SystemTime::now(), HANDOFF_MAX_AGE) + .expect("take") + .as_deref(), Some("invalid-�-handoff") ); assert!(!file.exists()); @@ -130,18 +146,27 @@ mod tests { SystemTime::now() + HANDOFF_MAX_AGE + Duration::from_secs(1), HANDOFF_MAX_AGE ), - None + Ok(None) ); assert!(!stale.exists()); let blank = temporary_file(); std::fs::write(&blank, " \n").expect("fixture"); assert_eq!( take_handoff(&blank, SystemTime::now(), HANDOFF_MAX_AGE), - None + Ok(None) ); assert_eq!( take_handoff(&blank, SystemTime::now(), HANDOFF_MAX_AGE), - None + Ok(None) ); } + + #[cfg(unix)] + #[test] + fn removal_failure_is_reported_like_typescript() { + let directory = temporary_file(); + std::fs::create_dir(&directory).expect("fixture directory"); + assert!(take_handoff(&directory, SystemTime::now(), HANDOFF_MAX_AGE).is_err()); + let _ = std::fs::remove_dir_all(directory.parent().expect("fixture parent")); + } } From 0ea00c0bc5d415d89287d071a7304f94533a41f1 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 30 Aug 2026 18:06:36 -0700 Subject: [PATCH 10/12] test(rust-lite): make directory mode check umask-safe --- rust/src/store.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/src/store.rs b/rust/src/store.rs index 78dc074..bbf207c 100644 --- a/rust/src/store.rs +++ b/rust/src/store.rs @@ -526,8 +526,10 @@ mod tests { std::process::id() )); let dir = parent.join("state"); + let reference = parent.join("reference"); let _ = fs::remove_dir_all(&parent); fs::create_dir_all(&parent).expect("temporary parent"); + fs::create_dir(&reference).expect("reference directory"); assert!(load_annotations(&dir).expect("load").is_empty()); assert_eq!( @@ -536,7 +538,11 @@ mod tests { .permissions() .mode() & 0o777, - 0o755 + fs::metadata(reference) + .expect("reference metadata") + .permissions() + .mode() + & 0o777 ); let _ = fs::remove_dir_all(parent); } From 0a78d56ce2ee6dfdbeb61573dea1fbaa81ea181c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sun, 30 Aug 2026 18:07:22 -0700 Subject: [PATCH 11/12] test(rust-lite): add differential parity proof Compare Bun and Rust process, PTY screen, and JSONL store observables on Ubuntu and macOS CI. --- .github/workflows/rust-lite-ci.yml | 24 + docs/rust-lite-parity-proof.md | 300 ++++++ scripts/parity-lite.py | 1437 ++++++++++++++++++++++++++++ scripts/parity-lite.sh | 19 + 4 files changed, 1780 insertions(+) create mode 100644 docs/rust-lite-parity-proof.md create mode 100755 scripts/parity-lite.py create mode 100755 scripts/parity-lite.sh diff --git a/.github/workflows/rust-lite-ci.yml b/.github/workflows/rust-lite-ci.yml index 41214c7..8d9683e 100644 --- a/.github/workflows/rust-lite-ci.yml +++ b/.github/workflows/rust-lite-ci.yml @@ -5,6 +5,9 @@ on: paths: - "rust/**" - "lite-rs/**" + - "docs/rust-lite-parity-proof.md" + - "scripts/parity-lite.py" + - "scripts/parity-lite.sh" - "scripts/smoke-rust-lite.sh" - ".github/workflows/rust-lite-*.yml" merge_group: @@ -44,3 +47,24 @@ jobs: with: workspaces: rust - run: cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings + + parity: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: bash scripts/parity-lite.sh diff --git a/docs/rust-lite-parity-proof.md b/docs/rust-lite-parity-proof.md new file mode 100644 index 0000000..18ee937 --- /dev/null +++ b/docs/rust-lite-parity-proof.md @@ -0,0 +1,300 @@ +# Rust Lite parity proof + +This is the re-runnable proof behind the Rust Lite evaluation. It maps the externally observable +TypeScript Lite surface to the Rust call path at function granularity and names the differential +case that compares the result. The current local result is: + +```text +Parity Lite: 401 observables compared, 92 screens diffed, zero divergences / 1 deliberate +``` + +Run it from the repository root with `bash scripts/parity-lite.sh`. The shell wrapper stages a fresh +release binary; `scripts/parity-lite.py` then runs the Bun entrypoints and the staged native binary +against separate copies of the same fixtures. A failure retains its temporary inputs and unified +diffs and prints their path. A green run removes its temporary workspace. + +## What the harness compares + +- Process cases compare exit code, stdout, stderr, every fake `herdr`/clipboard command and argument, + notification arguments, clipboard bytes, pending bytes and mode, and resulting filesystem trees. +- Screen cases use real PTYs at 86×22 (editor) and 98×28 (manager). The ANSI parser at + `scripts/parity-lite.py:146` ignores style escapes but retains the terminal cell grid, including + wide-character continuation cells. It snapshots the initial frame and the frame after every input. +- Store cases byte-compare JSONL, modes, and leftover lock/temp files after scripted editor and + manager mutations. `store.cross-read` makes Bun export the Rust editor's store and Rust export the + Bun editor's store, then compares the Markdown and subprocess traces. +- The only normalized values are each case's deliberately different temporary root, generated UUIDs, + generated ISO timestamps, and pid/time components in pending and temporary filenames + (`scripts/parity-lite.py:446`). Seed timestamps are allow-listed and remain literal. Product files + are never rewritten or filtered. Screen cells and clipboard bytes are never normalized. +- The harness runs on the host's real adapter branch. macOS therefore proves `pbpaste`/`pbcopy` and + Ubuntu proves the Wayland → xclip → xsel chain. Windows is intentionally outside this PTY harness + and remains in the separate build/promotion track. + +The parity surface is the five commands wired by the Lite manifest. Rust's single-binary dispatch +usage error and `--version` output are packaging controls outside that surface; no valid manifest +invocation reaches them. Conversely, TypeScript dynamic-import loader failures have no native +counterpart because those modules are linked into the binary. Their underlying clipboard/store +failures do have mapped counterparts below. + +## Entrypoints + +### `capture` + +| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | +|---|---|---|---| +| Manifest invocation | `lite/herdr-plugin.toml:17` starts `bun ../src/capture.ts`. | `lite-rs/herdr-plugin.toml:27` starts `herdr-annotate capture`; `rust/src/main.rs:3` passes argv to `cli::run` at `rust/src/cli.rs:24`, then `capture` at `rust/src/cli.rs:55`. | Every `process.capture.*` case. | +| Context decode | Top-level `src/capture.ts:12` parses `HERDR_PLUGIN_CONTEXT_JSON`; invalid JSON falls back to an empty object. `parseInvocationContext` and `selectedTextFromInvocation` are `src/types.ts:44` and `src/types.ts:58`. | `invocation_context` at `rust/src/cli.rs:48`, then `parse_invocation_context` and `selected_text_from_invocation` at `rust/src/types.rs:85` and `rust/src/types.rs:101`. | `process.capture.context`, `process.capture.invalid-context`. | +| Required paths | `stateDir`/`pluginRoot` at `src/paths.ts:27` and `src/paths.ts:32`, checked at `src/capture.ts:21`. | `state_dir`/`plugin_root` at `rust/src/paths.rs:29` and `rust/src/paths.rs:36`, checked at `rust/src/cli.rs:59`. | `process.capture.missing-state`, `process.capture.missing-root`. | +| Selection precedence | Invocation selection at `src/capture.ts:15`; if absent, `takeHandoff` at `src/handoff.ts:24`; if absent/blank/stale, `readClipboard` at `src/clipboard.ts:48`. | The same branches at `rust/src/cli.rs:61`, through `take_default_handoff`/`take_handoff` at `rust/src/handoff.rs:60`/`:32`, then `read_clipboard` at `rust/src/clipboard.rs:93`. | `process.capture.context` seeds all three sources and leaves the skipped handoff untouched; `process.capture.handoff` seeds a competing clipboard; stale, blank, invalid-UTF-8, and clipboard cases exercise the remaining decisions. | +| Empty selection | `src/capture.ts:38` sends `Nothing to annotate`, creates no pending file, exits 0. | `rust/src/cli.rs:68` sends the same notification and returns success. | `process.capture.empty`. | +| Pending record | `src/capture.ts:42` creates the state directory; `:44` constructs the record; `:49` names it; `:52` writes it. | `rust/src/cli.rs:75` constructs it; `:82` names it; `write_pending` at `:166` writes it. | All successful capture cases compare literal JSON after generated time/name normalization and assert mode 0600. | +| Editor pane | `runHerdr` at `src/herdr.ts:11` receives the argv built at `src/capture.ts:54`. Failure removes pending at `:74`, then the catch at `:78` notifies, prints, and exits 1. | `run_herdr` at `rust/src/herdr.rs:22` receives the argv at `rust/src/cli.rs:87`. Failure removes pending at `:107`; `cli::run` notifies at `:26`; `main` prints/exits 1 at `rust/src/main.rs:3`. | `process.capture.context` compares success argv; `process.capture.open-failure` compares cleanup, notification, stderr, and exit. | + +The structural difference is exception flow versus `Result`. Both converge on the same process +contract: success and blank input exit 0; a defined failure produces one stderr line, one best-effort +`Annotate failed` notification, and exit 1. + +### `copy-context` + +| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | +|---|---|---|---| +| Manifest and state | `lite/herdr-plugin.toml:24` → top-level `src/export.ts:7`; state is required at `:8`. | `lite-rs/herdr-plugin.toml:34` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:24` → `copy_context` at `:114`. | Every `process.copy.*` case. | +| Load and ordering | `loadAnnotations` at `src/store.ts:37` locks/parses; `newestFirstAnnotations` at `:32` reverses a copy; `src/export.ts:10` propagates load failure. | `load_annotations` at `rust/src/store.rs:70` locks/parses; `newest_first_annotations` at `:65` reverses clones; `rust/src/cli.rs:116` propagates failure. | Empty, populated, invalid-store, busy-lock, and stale-lock cases. | +| Empty store | `src/export.ts:14` notifies `No annotations` / `There is nothing to copy yet.` and exits 0. | `rust/src/cli.rs:117` sends the same notification and returns success. | `process.copy.empty`, including the newly created state-directory mode. | +| Markdown and clipboard | `formatAnnotations` at `src/format.ts:44`, then `writeClipboard` at `src/clipboard.ts:63`. | `format_annotations` at `rust/src/format.rs:67`, then `write_clipboard` at `rust/src/clipboard.rs:110`. | `process.copy.single`, `process.copy.populated`, `process.copy.no-clipboard`, and `store.cross-read`; clipboard bytes are unnormalized. | +| Success/failure reporting | `src/export.ts:21` sends singular/plural `Annotations copied`; catch at `:25` sends `Copy failed`, prints, exits 1. | `rust/src/cli.rs:122` sends the same success notification; `cli::run` at `:29` sends `Copy failed`; `main` prints/exits 1. | Single and populated cases prove grammar; no-clipboard, invalid-store, busy-lock, and missing-state prove failure outputs and exits. | + +### `manage` + +| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | +|---|---|---|---| +| Manifest and root | `lite/herdr-plugin.toml:31` → `src/open-manager.ts:5` → `pluginRoot` at `src/paths.ts:32`. | `lite-rs/herdr-plugin.toml:41` → `rust/src/main.rs:3` → `cli::run` at `rust/src/cli.rs:24` → `manage` at `:133` → `plugin_root` at `rust/src/paths.rs:36`. | `process.manage.success`, `process.manage.missing-root`. | +| Manager pane | The argv literal is `src/open-manager.ts:13`; `runHerdr` is `src/herdr.ts:11`. Errors notify/print/exit at `src/open-manager.ts:32`. | The argv literal is `rust/src/cli.rs:135`; `run_herdr` is `rust/src/herdr.rs:22`; `cli::run`/`main` handle notify, stderr, and exit. | Success, child-stderr failure, empty-child-stderr fallback, and missing-root cases. | + +### `editor` + +| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | +|---|---|---|---| +| Manifest and pending selection | `lite/herdr-plugin.toml:39` → top-level `src/editor.ts:17`. `invocationContext` at `:20`; pending-file parse at `:34`; fallback `pendingAnnotationFromInvocation` at `src/types.ts:65`; parsed-file canonicalization at `src/types.ts:79`. | `lite-rs/herdr-plugin.toml:49` → dispatcher → `editor::run` at `rust/src/editor.rs:346`; `pending_from_env` at `:320`; invocation fallback and pending parsing at `rust/src/types.rs:107` and `:119`. | Missing/invalid process cases; `screen.editor.pending-file-save`; the other editor PTY cases use invocation fallback. | +| Terminal and screen | `render` at `src/editor.ts:77` uses `sanitizeTerminalText`, `wrapText`, `layoutComment`, and width helpers; alternate-screen setup is `:207`. | `EditorApp::draw` at `rust/src/editor.rs:67` calls the corresponding helpers at `rust/src/format.rs:7`/`:26`, `rust/src/layout.rs:14`, and `rust/src/width.rs:35`; `editor::run` owns the Ratatui terminal. | Initial frame and every frame in `screen.editor.*`; fixed 86×22 cells include wide Hangul. | +| Input and save | Key dispatch is `src/editor.ts:172`; vertical movement is `:52`; `save` is `:122`; successful save renders, waits 250 ms, and exits. | `EditorApp::handle_key` at `rust/src/editor.rs:168`; vertical movement at `:227`; `save` at `:253`; `run` at `:346` renders the saved state, waits 250 ms, and exits. | `screen.editor.edit-save`, empty-save, missing-state, Esc, and Ctrl+C cases. Store bytes are compared after save. | +| Cleanup and signals | `cleanup`/`exit` at `src/editor.ts:110`/`:117`; SIGTERM/SIGHUP handlers at `:160`. | `Termination::install`/`requested` at `rust/src/termination.rs:17`/`:33`; the polling loop at `rust/src/editor.rs:346` reaches `ratatui::restore`. | `screen.editor.sigterm` compares exit 0 and the restored terminal grid. | + +### `manager` + +| Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | +|---|---|---|---| +| Manifest, state, initial load | `lite/herdr-plugin.toml:47` → `requireStateDir` at `src/manager.ts:29`; `reloadActive`/`reloadArchives` at `:53`/`:64`. | `lite-rs/herdr-plugin.toml:57` → dispatcher → `manager::run` at `rust/src/manager.rs:718`; `ManagerApp::load` at `:62`; reload methods at `:79`/`:94`. | `process.manager.missing-state`; initial screens in all manager PTY cases. | +| Active screen | `render` at `src/manager.ts:196` → `renderActive` at `:93`, with `clipped` at `:75`, formatting helpers, newest-first state, source and timestamp metadata. | `ManagerApp::draw` at `rust/src/manager.rs:109` → `draw_active` at `:161`, with `clipped` at `:680` and `format_timestamp` at `:690`. | Every active-view snapshot, including the detail-width regression fixture in `screen.manager.all-views`. | +| Archive screen | `render` → `renderArchives` at `src/manager.ts:136`; archive annotations are previewed newest first. | `ManagerApp::draw` → `draw_archives` at `rust/src/manager.rs:299`; same preview ordering and clipping. | Every archive-view snapshot and scripted archive mutation. | +| Input and mutations | Top-level key dispatch is `src/manager.ts:402`; active/archive handlers are `:318`/`:344`; action functions are `:216`–`:316`. | `ManagerApp::handle_key` is `rust/src/manager.rs:433`; view handlers are `:465`/`:502`; action methods are `:546`–`:650`. | `screen.manager.all-views`, empty-actions, success-copy sessions, and the exit/signal sessions. Resulting JSONL and clipboard bytes are compared. | +| Cleanup and signals | `cleanup`/`exit` at `src/manager.ts:376`/`:383`; signal handlers at `:390`. | `Termination` at `rust/src/termination.rs:17`; polling/restore at `rust/src/manager.rs:718`. | `screen.manager.sighup` compares exit 0 and restored cells. | + +## Every editor key + +All key paths clear the prior status before acting. TypeScript dispatch is `src/editor.ts:172`; +Rust dispatch is `EditorApp::handle_key` at `rust/src/editor.rs:168`; both render again after the +transition. + +| Key | TypeScript → Rust call path | Required state/store/screen effect | Harness step | +|---|---|---|---| +| Character input, including wide text | `src/editor.ts:199` uses `Array.from` and `splice` → `rust/src/editor.rs:210` calls `insert` at `:222`. | Insert Unicode scalar(s) at cursor, advance by character count, render using cell width. | `screen.editor.edit-save`: `chars`, `chars-second-line`. | +| Enter | `src/editor.ts:196` → Rust `KeyCode::Enter` at `rust/src/editor.rs:209` → `insert`. | Insert `\n`, move cursor, preserve explicit blank/line layout. | `enter`. | +| Backspace | `src/editor.ts:180` → `rust/src/editor.rs:182`. | If cursor > 0, remove the character before it and move left; otherwise no change. | `backspace`. | +| Delete | `src/editor.ts:182` → `rust/src/editor.rs:188`. | Remove the character at cursor if present; cursor stays. | `delete`. | +| Left / Right | `src/editor.ts:184`/`:186` → `rust/src/editor.rs:193`/`:194`. | Move one character, clamped to `[0, length]`. | `left`, `right`. | +| Up / Down | `moveCursorVertical` at `src/editor.ts:52` → `move_cursor_vertical` at `rust/src/editor.rs:227`. | Preserve terminal-cell column as closely as possible on the adjacent line; clamp first/last row and never split a wide glyph. | `up`, `down`. | +| Home / End | `src/editor.ts:192`/`:194` → `rust/src/editor.rs:197`/`:202`. | Move to start/end of the current logical line. | `home`, `end`. | +| Ctrl+S | `src/editor.ts:175` → `save` at `:122`; Rust control branch at `rust/src/editor.rs:177` → `save` at `:253`. | Blank comment: `Write a comment before saving.` and remain. Missing state: `Plugin state directory is unavailable.` and remain. Store error: display it and remain. Success: append exact JSONL, display `Saved.`, wait 250 ms, cleanly exit 0. | `screen.editor.edit-save`, `empty-save-escape`, `missing-state`. | +| Esc | `src/editor.ts:179` → `exit`/`cleanup`; Rust `rust/src/editor.rs:181` sets quit and `run` restores. | No store write; cursor shown, screen restored, exit 0. | `screen.editor.empty-save-escape`. | +| Ctrl+C | `src/editor.ts:174` → `exit`/`cleanup`; Rust `rust/src/editor.rs:173` sets quit. | Same cancellation effect as Esc. | `screen.editor.control-c`. | + +## Every manager key in both views + +The common TypeScript dispatcher is `src/manager.ts:402`; Rust's is `rust/src/manager.rs:433`. +Each non-exit transition re-renders the full grid. Arrow Up/Down are aliases of `k`/`j` and are +also fed by `screen.manager.all-views`. + +| Key | Active view: TypeScript → Rust and effect | Archives view: TypeScript → Rust and effect | Evidence | +|---|---|---|---| +| `j` / Down | `handleActiveKey` `src/manager.ts:328` → `handle_active_key` `rust/src/manager.rs:477`; increment/clamp active selection, changing list highlight and detail. | `handleArchiveKey` `src/manager.ts:364` → `handle_archive_key` `rust/src/manager.rs:526`; increment/clamp archive selection and detail. | `active-j`, `active-arrow-down`, `archives-j`, `archives-arrow-down`. | +| `k` / Up | `src/manager.ts:326` → `rust/src/manager.rs:474`; decrement/saturate. | `src/manager.ts:362` → `rust/src/manager.rs:523`; decrement/saturate. | Corresponding `k` and arrow-up steps. | +| `y` | `copy` at `src/manager.ts:216` receives the selected annotation → `ManagerApp::copy` at `rust/src/manager.rs:546`; exact one-item Markdown is copied; success exits 0, failure/empty displays status. | Same helpers receive the selected archive's annotations in newest-first order (`src/manager.ts:366`, `rust/src/manager.rs:528`). | Failure frames in all-views, success sessions `store.manager.active-y-success` / `archives-y-success`, and empty-actions. | +| `c` | `src/manager.ts:335` copies the displayed newest-first active list → `rust/src/manager.rs:491`; success exits, failure remains. | Not handled by `src/manager.ts:344` or `rust/src/manager.rs:502`; clears any prior confirmation/status through normal dispatch, otherwise store/view unchanged. | Active failure/success and `archives-c-ignored`. | +| `C` | `copyAndArchive` at `src/manager.ts:225` → `copyAndArchiveAnnotations` at `src/archive-workflow.ts:29`; Rust `rust/src/manager.rs:553` → `copy_and_archive_annotations` at `rust/src/archive_workflow.rs:27`. Order is load → copy → append archive → remove captured active IDs. Success exits; partial failure is reported without data loss. | Not handled; same no-op/confirmation-clear behavior as archive `c`. | Failure in all-views; success and byte-diff in `store.manager.copy-archive`; `archives-C-ignored`; workflow failure ordering has paired TS/Rust unit specs. | +| `d` | `deleteSelectedAnnotation` at `src/manager.ts:243` → `rust/src/manager.rs:574`; remove selected ID through locked atomic rewrite, reload, status `Annotation deleted.` | First press records the selected archive id and renders `Press d again…`; second matching press calls `deleteSelectedArchive` (`src/manager.ts:295`, `rust/src/manager.rs:638`) and atomically removes it. No selection displays `No archive selected.` Esc or another ordinary key cancels confirmation. | Active delete, archive confirm/cancel/double-confirm in all-views; empty-actions. | +| `D` | `src/manager.ts:319` / `rust/src/manager.rs:466`: first press renders `Press Shift+D again…`; second calls `clearActive` (`src/manager.ts:255`, `rust/src/manager.rs:588`), rewrites active JSONL empty, reloads, and displays `All active annotations cleared.` | Uppercase `D` is not an archive action; it cancels a pending archive confirmation like any non-`d` archive key, otherwise no store effect. | Active confirm/cancel/double-confirm and `archives-D-ignored` in all-views. | +| `r` | `reloadActive` (`src/manager.ts:53`, `rust/src/manager.rs:79`); success status `Reloaded.`, failure status is the store error. | `reloadArchives` (`src/manager.ts:64`, `rust/src/manager.rs:94`) with the same status rule. | Both reload steps in all-views; invalid/busy store process cases cover propagated store errors. | +| `u` | Not handled in active view; clears transient status/confirmation, leaves selection and stores unchanged. | `restoreSelectedArchive` at `src/manager.ts:269` → `restoreArchivedSet` at `src/archive-workflow.ts:71`; Rust `rust/src/manager.rs:605` → `restore_archived_set` at `rust/src/archive_workflow.rs:96`. Order is merge missing annotation IDs, then remove archive; partial removal failure keeps the archive and reports it. | `active-u-ignored`; archive restore in all-views; no-selection in empty-actions; paired workflow unit specs cover partial failures and concurrent active records. | +| Tab | `switchView` at `src/manager.ts:306` → `rust/src/manager.rs:650`; clear confirmation/status, switch view, reload destination store. | Same in reverse. | Both Tab directions in all-views and all archive exit sessions. | +| Esc | `src/manager.ts:404`: if confirming, clear confirmation/status and stay; otherwise cleanly exit. Rust `rust/src/manager.rs:439` is identical. | Same. | Active confirmation cancel and active exit; archive confirmation cancel and archive exit. | +| `q` | Common dispatcher exits 0 through cleanup. | Same. | Active `q` in all-views; `screen.manager.q-archives`. | +| Ctrl+C | Common dispatcher exits 0 through cleanup. | Same. | `screen.manager.control-c-active` and `control-c-archives`. | + +## Rendered and exported products + +| Product | TypeScript call path | Rust call path | Observable proof | +|---|---|---|---| +| Markdown export | `formatAnnotations` at `src/format.ts:44`; `fenceFor` at `:38`. | `format_annotations` at `rust/src/format.rs:67`; `fence_for` at `:52`. | Both emit `# Annotated context`, then newest-first `## Annotation N` sections. Optional source is `workspace_label / tab_label`; selected text and comment retain line breaks; selected text uses a backtick fence one longer than its longest run (minimum three); duplicate blank lines are collapsed; the document ends in exactly one `\n`. Populated/single copy and cross-read compare raw clipboard bytes using backticks, multiline text, and wide characters. | +| Terminal-safe text | `sanitizeTerminalText`/`wrapText` at `src/format.ts:5`/`:12`. | `sanitize_terminal_text`/`wrap_text` at `rust/src/format.rs:7`/`:26`. | Control characters are removed except newline/tab, tabs become four spaces, CRLF becomes LF, explicit newlines are preserved, and wrapping uses terminal cells. Editor and both manager views compare resulting cells. | +| Width and clipping | `charWidth`, `stringWidth`, `truncateToWidth` at `src/width.ts:37`/`:48`/`:55`; manager `clipped` at `src/manager.ts:75`. | `char_width`, `string_width`, `truncate_to_width` at `rust/src/width.rs:35`/`:47`/`:52`; manager `clipped` at `rust/src/manager.rs:680`. | Same zero-width controls/combining ranges and same wide ranges; truncation never splits a wide glyph and adds exactly one ellipsis cell. Wide input occurs in editor, list, detail, metadata, and archive snapshots. | +| Editor geometry | `render` at `src/editor.ts:77` and `layoutComment` at `src/layout.ts:9`. | `EditorApp::draw` at `rust/src/editor.rs:67` and `layout_comment` at `rust/src/layout.rs:14`. | At 86×22, identical selected-text cap/overflow marker, comment viewport, cursor cell, footer/status placement, and full clears. Every editor input has a post-step grid diff. | +| Manager geometry and labels | `render`/active/archive/footer at `src/manager.ts:196`/`:93`/`:136`/`:182`. | `ManagerApp::draw`/active/archive/footer at `rust/src/manager.rs:109`/`:161`/`:299`/`:408`. | At 98×28, identical 36%-clamped list, divider, selected marker/reverse cell region, detail width, newest-first labels, counts, metadata line, preview overflow, empty-state text, confirmation footer, help footer, and transient status. The archive clipping boundary that exposed the earlier one-cell defect is in every seeded archive screen. | + +## Filesystem effects + +| Observable | TypeScript call path | Rust call path | Exact product and evidence | +|---|---|---|---| +| State directory creation | `fs.mkdirSync(..., {recursive:true})` at `src/capture.ts:42` and `withStoreLock` at `src/store.ts:196`. | `create_dir_all` at `rust/src/cli.rs:74` and `create_private_dir_all` at `rust/src/store.rs:392`. | Process-default directory mode (0755 under harness umask 022), not forced 0700. `process.copy.empty` compares the directory mode. | +| Pending file | `src/capture.ts:44`/`:49`/`:52`. | `rust/src/cli.rs:75`/`:82` and `write_pending` at `:166`. | Name `pending--.json`; mode 0600 on creation; bytes are one JSON object plus `\n`; property order `selectedText`, `context`, `capturedAt`. Capture cases byte/mode-diff it. | +| Pending consumption | `src/editor.ts:34` reads/parses, then `fs.rmSync(...,{force:true})` at `:39`. | `pending_from_env` at `rust/src/editor.rs:320`, then `remove_pending_file` at `:337`. | Delete only after successful read and semantic parse; missing-at-delete is ignored; other deletion errors fail startup. `screen.editor.pending-file-save` compares the consumed tree and saved JSONL; Rust regression `pending_removal_is_forceful_like_typescript` pins the delete race. | +| Handoff take | `handoffPath`/`takeHandoff` at `src/handoff.ts:17`/`:24`. | `handoff_path`/`take_handoff` at `rust/src/handoff.rs:12`/`:32`. | `$XDG_RUNTIME_DIR` else temp + `herdr-annotate-/selection`; missing/stat failure means absent; regular files ≤15 s old are decoded as UTF-8 with replacement; stale and blank values are rejected; every found node is removed; non-NotFound removal failure propagates. Context-skipped handoff remains. Capture handoff cases diff pending bytes and the runtime tree. | +| Active append | `appendAnnotation` at `src/store.ts:42`. | `append_annotation` / `append_annotation_context_first` at `rust/src/store.rs:77`/`:82`, sharing `append_annotation_record` at `:97`. | Append one compact JSON object plus `\n`; mode 0600 on creation. Capture-file editor order is `selectedText,capturedAt,context,id,comment,createdAt`; direct invocation fallback preserves TypeScript's distinct `selectedText,context,capturedAt,id,comment,createdAt`. `screen.editor.pending-file-save.state` and `screen.editor.edit-save.state` byte-diff both orders; two Rust regressions pin them. | +| JSONL read | `loadJsonLines` at `src/store.ts:150` and parsers at `src/types.ts:92`/`:103`. | `load_json_lines` at `rust/src/store.rs:227` and parsers at `rust/src/types.rs:133`/`:145`. | Missing file = empty; empty lines skipped; any malformed/nonconforming non-empty line rejects the whole store; unknown fields tolerated by parsers. Invalid-store and paired unit cases cover both stores. | +| Active rewrite | Remove/merge at `src/store.ts:54`/`:70` → `replaceJsonLines` at `:175`. | `remove_annotations_by_id`/`merge_annotations` at `rust/src/store.rs:120`/`:136` → `replace_json_lines` at `:253`. | Retained append order; merge by id without duplicates; canonical saved field order; temporary `.--.tmp`, mode 0600, one newline per record (zero bytes when empty), then rename; temp removed on failure. Manager state byte diffs prove final files and absence of leftovers. | +| Archive rewrite | Append/remove at `src/store.ts:99`/`:111` → `replaceJsonLines`. | `append_archived_set`/`remove_archived_set` at `rust/src/store.rs:173`/`:182` → `replace_json_lines`. | Entire archives store is atomically replaced. Outer order is `version,id,archivedAt,annotations`; inner annotations are canonical; trailing newline and 0600 mode match. Copy/archive, restore, and permanent-delete scripts byte-diff the products. | +| Lock acquire | `withStoreLock`/`acquireStoreLock`/`createStoreLock` at `src/store.ts:196`/`:217`/`:241`. | `with_store_lock`/`acquire_store_lock`/`create_store_lock` at `rust/src/store.rs:285`/`:296`/`:333`. | Per-store `.annotations.lock` or `.archives.lock`; directory mode 0700; `owner` mode 0600 with `:\n`; exclusive directory creation. A fresh existing lock returns the exact busy error without mutation. | +| Lock steal | Staleness check/removal/retry at `src/store.ts:223`–`:238`, `isStaleLock` at `:261`. | Corresponding branch `rust/src/store.rs:313`–`:330`, `is_stale_lock` at `:345`. | Age ≥30 s is removed then acquired once; a competing recreation reports busy. `process.copy.busy-lock` and `stale-lock` compare exits, errors, and final lock trees. | +| Lock release | `releaseStoreLock` at `src/store.ts:269`, called in `finally` at `:210`. | `StoreLockLease::drop` at `rust/src/store.rs:52`. | Read current owner; remove recursively only if token still matches; ignore cleanup errors. Every state-compared completed store case asserts no owned lock remains; paired contention tests cover recovery. | + +## Process effects + +The editor pane argv is identical, including order: + +```text +herdr plugin pane open --cwd --plugin annotate --entrypoint editor \ + --placement popup --width 88 --height 24 \ + --env HERDR_ANNOTATE_PENDING= --focus +``` + +It is constructed at `src/capture.ts:54` and `rust/src/cli.rs:87` and compared by every successful +capture case. The manager argv is constructed at `src/open-manager.ts:13` and `rust/src/cli.rs:135`: + +```text +herdr plugin pane open --cwd --plugin annotate --entrypoint manager \ + --placement popup --width 100 --height 30 --focus +``` + +`runHerdr` (`src/herdr.ts:11`) and `run_herdr` (`rust/src/herdr.rs:22`) ignore stdin/stdout, capture +stderr, and use `HERDR_BIN_PATH` or `herdr`. Nonzero child status returns trimmed child stderr; if it +is empty or spawning fails, the exact fallback is `herdr failed`. Manage success, +stderr failure, and empty-stderr failure are differential cases. + +Notifications are best effort and never alter the primary exit. Both sides call: + +```text +herdr notification show [--body <body>] +``` + +The compared title/body pairs are `Nothing to annotate`, `Annotate failed`, `No annotations`, +`Annotations copied`, `Copy failed`, and `Unable to open annotations`; their bodies are listed in the +error/reporting table below or generated from the exact annotation count. + +Clipboard candidates and arguments are defined at `src/clipboard.ts:13`/`:30` and +`rust/src/clipboard.rs:12`/`:46`: + +| Platform | Read order | Write order | +|---|---|---| +| macOS | `pbpaste` | `pbcopy` | +| Windows | `powershell.exe -NoProfile -NonInteractive -Command "Get-Clipboard -Raw"` | `powershell.exe -NoProfile -NonInteractive -Command "$input | Set-Clipboard"` | +| Linux/other Unix | `wl-paste --no-newline`; `xclip -selection clipboard -out`; `xsel --clipboard --output` | `wl-copy`; `xclip -selection clipboard -in`; `xsel --clipboard --input` | + +Readers return the first exit-0 stdout, decoded with UTF-8 replacement. Writers pipe the exact UTF-8 +Markdown to stdin and accept the first exit-0 adapter. Child stdout/stderr is suppressed. The fake +adapters log every attempt and capture writer stdin; macOS and Ubuntu CI together prove both Unix +branches. Windows arguments are source-mapped and build-checked, not run by this harness. + +### Exit codes + +| Path | Exit | +|---|---| +| Successful capture, empty capture after notification, successful/empty copy, successful manage pane open | 0 | +| Missing required env, no clipboard adapter, store parse/lock/access failure, or pane-open failure | 1 after one stderr line; action commands also attempt their failure notification | +| Editor/manager initialization error | 1 with stderr, no action-level notification | +| Editor Esc/Ctrl+C, manager Esc/q/Ctrl+C, editor SIGTERM, manager SIGHUP | 0 after terminal restoration | +| Successful editor save | 0 after final `Saved.` frame and 250 ms delay | +| Manager copy/copy+archive success | 0 after clipboard/store completion; failure remains in the TUI until a later exit key | + +## Error and failure strings + +These are all Lite-authored error strings and templates. A `<CODE>` is the operating-system code +projected by `safeFileError` (`src/store.ts:285`, `rust/src/store.rs:396`); omitting the code leaves +the prefix exactly as shown. + +| Exact string or template | TypeScript emitter | Rust emitter | Proof | +|---|---|---|---| +| `HERDR_PLUGIN_STATE_DIR is not set` | `src/capture.ts:22`, `src/export.ts:9`, `src/manager.ts:32` | `rust/src/cli.rs:59`/`:115`, `rust/src/manager.rs:719` | Missing-state process cases. | +| `HERDR_PLUGIN_ROOT is not set` | `src/capture.ts:24`, `src/open-manager.ts:7` | `rust/src/cli.rs:60`/`:134` | Missing-root cases. | +| `No supported clipboard reader is available` | `src/clipboard.ts:59` | `rust/src/clipboard.rs:106` | `process.capture.no-clipboard`. | +| `Missing pending annotation` | `src/editor.ts:32` | `rust/src/editor.rs:326` | `process.editor.missing-pending`. | +| `Pending annotation is invalid` | `src/editor.ts:37` | `rust/src/editor.rs:332` | `process.editor.invalid-pending`. | +| `Write a comment before saving.` | `src/editor.ts:125` | `rust/src/editor.rs:256` | Editor empty-save frame. | +| `Plugin state directory is unavailable.` | `src/editor.ts:131` | `rust/src/editor.rs:260` | Editor missing-state frame. | +| `Unable to save annotation[ (<CODE>)]` | `src/store.ts:48` | `rust/src/store.rs:100` | Store error catalog and paired store/editor tests. | +| `Unable to read annotations (invalid data)` / `Unable to read archives (invalid data)` | `src/store.ts:163`/`:166` | `rust/src/store.rs:245`/`:247` | Invalid active process case and paired active/archive tests. | +| `Unable to read annotations (<CODE>)` / `Unable to read archives (<CODE>)` | `src/store.ts:171` | `rust/src/store.rs:235`/`:240` | Source/error catalog; OS-specific access cases remain unit-level. | +| `Unable to access annotations[ (<CODE>)]` / `Unable to access archives[ (<CODE>)]` | `src/store.ts:204` | `rust/src/store.rs:290` | Static catalog; normal missing-dir creation is differential. | +| `Unable to lock annotations[ (<CODE>)]` / `Unable to lock archives[ (<CODE>)]` | `src/store.ts:230`/`:257` | `rust/src/store.rs:306`/`:326` | Static catalog and contention tests. | +| `Annotations are busy; try again.` / `Archives are busy; try again.` | `src/store.ts:224`/`:235` | `rust/src/store.rs:314`/`:324` | Active busy-lock differential plus paired per-store tests. | +| `Unable to update annotations[ (<CODE>)]` / `Unable to update archives[ (<CODE>)]` | prefixes supplied at `src/store.ts:135`/`:147` | `rust/src/store.rs:202`/`:223` | Static catalog and rewrite failure unit paths. | +| `Nothing to copy.` | `src/manager-copy.ts:18` | `rust/src/manager_copy.rs:20` | `screen.manager.empty-actions`. | +| `Nothing to copy and archive.` | `src/archive-workflow.ts:35` | `rust/src/archive_workflow.rs:44` | `screen.manager.empty-actions`. | +| `No archive selected.` | `src/manager.ts:272`/`:349` | `rust/src/manager.rs:504`/`:608` | `screen.manager.empty-actions`. | +| `Copied and archived, but active annotations remain: <store error>` | `src/manager.ts:237` | `rust/src/manager.rs:569` | Paired workflow partial-failure tests plus static catalog. | +| `Annotations restored, but the archive remains: <store error>` | `src/manager.ts:287` | `rust/src/manager.rs:627` | Paired workflow partial-failure tests plus static catalog. | +| Child `herdr` stderr, or `herdr <argv> failed` | `src/herdr.ts:20` | `rust/src/herdr.rs:32`–`:39` | Manage child-stderr and empty-stderr cases; capture pane failure. | + +`Unable to save annotation.` (with a period) at `src/editor.ts:146` is specifically a Bun dynamic +module-loader failure. There is no corresponding runtime condition in the statically linked binary; +actual store-open/write failures take the mapped `Unable to save annotation[ (<CODE>)]` path on both +sides. JSON-parser and raw filesystem diagnostics emitted before a Lite-defined wrapper are supplied +by Bun or the Rust standard library, not authored stable strings; both processes fail and surface the +native diagnostic, but those platform/runtime wordings are not claimed as a portable Lite contract. + +## Test and harness coverage + +The existing behavior-spec mapping remains in `docs/rust-lite-parity.md`. The Rust unit modules mirror +all TypeScript spec files: `types`, `paths`, `handoff`, `format`, `width`, `layout`, `store`, +`archive-workflow`, and `manager-copy`. Rust additionally has `TestBackend` editor/manager frames and +subprocess command tests. The differential harness is independent of those expected-value tests: its +oracle is the current TypeScript implementation running on the same fixture. + +Notable differential groups: + +- `process.capture.*`: context > handoff > clipboard precedence; stale/blank/lossy-UTF-8 handoff; + invalid context; empty; missing adapter/env; pane success/failure and pending cleanup. +- `process.copy.*`: missing/empty/single/plural/invalid stores; writer failure; fresh/stale locks. +- `process.manage.*`, `process.editor.*`, `process.manager.*`: argv/error fallback and initialization. +- `screen.editor.*`: both pending sources, every requested edit key, save branches, cancel keys, and SIGTERM. +- `screen.manager.*`: both views, every view-valid key, ignored cross-view keys by handler mapping, + both confirmation flows, empty actions, copy failure, exit keys, and SIGHUP. +- `store.manager.*`: successful clipboard-only and copy/archive products; `store.cross-read` proves + each implementation parses and exports the other's editor-written record. + +The harness itself asserts the requested key-coverage set before it can print green. + +## Findings fixed while building the proof + +Each observed mismatch was fixed in a separate commit whose message cites this map section: + +- `b50f388`: missing store directories now use the process-default mode, matching Bun. +- `28e3131`: direct invocation fallback preserves its distinct TypeScript JSON property order. +- `1d75160`: handoff UTF-8 decoding uses replacement characters, matching Bun. +- `89e41d8`: SIGTERM/SIGHUP exits restore the terminal and return 0. +- `53ef811`: pending deletion ignores a missing-at-delete race like `{ force: true }`. +- `3cb49ec`: non-NotFound handoff deletion failures now propagate instead of silently falling back. + +No TypeScript source changed. + +## Deliberate divergences + +There is one. TypeScript delegates manager timestamps to `Date.prototype.toLocaleString()` and the +host's locale database (`src/manager.ts:132`, `:158`, `:169`). Rust parses into local time and emits +the en-US shape explicitly (`rust/src/manager.rs:690`). The persisted ISO timestamp, ordering, export, +and en-US display are identical. A non-en-US host can display localized punctuation/order in +TypeScript while Rust stays en-US. The harness pins UTC and en-US and compares the literal seeded +timestamp cells; it does not normalize them. + +Windows remains unexecuted by this Unix PTY harness, as required. It is a verification gap for the +separate promotion track, not an intentional product behavior difference. diff --git a/scripts/parity-lite.py b/scripts/parity-lite.py new file mode 100755 index 0000000..ead1aee --- /dev/null +++ b/scripts/parity-lite.py @@ -0,0 +1,1437 @@ +#!/usr/bin/env python3 +"""Deterministic differential harness for Herdr Annotate Lite.""" + +from __future__ import annotations + +import argparse +import codecs +import copy +import difflib +import fcntl +import json +import os +import pty +import re +import select +import shutil +import signal +import stat +import struct +import subprocess +import sys +import tempfile +import termios +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable, Mapping, Sequence + + +KNOWN_TIMESTAMPS = { + "2026-08-08T00:00:00.000Z", + "2026-08-08T00:00:01.000Z", + "2026-08-09T10:11:12.000Z", + "2026-08-09T10:11:13.000Z", + "2026-08-10T20:21:22.000Z", + "2026-08-10T20:21:23.000Z", + "2026-08-20T01:02:03.000Z", + "2026-08-21T01:02:03.000Z", +} +ISO_PATTERN = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z") +UUID_PATTERN = re.compile( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" +) +PENDING_PATTERN = re.compile(r"pending-\d+-\d+\.json") +TEMP_PATTERN = re.compile(r"\.(annotations|archives)-\d+-\d+\.tmp") +DELIBERATE_DIVERGENCES = ("manager timestamp locale outside en-US",) + +WIDE_RANGES = ( + (0x1100, 0x115F), + (0x2E80, 0x303E), + (0x3041, 0x33FF), + (0x3400, 0x4DBF), + (0x4E00, 0x9FFF), + (0xA000, 0xA4CF), + (0xA960, 0xA97F), + (0xAC00, 0xD7A3), + (0xF900, 0xFAFF), + (0xFE10, 0xFE19), + (0xFE30, 0xFE6F), + (0xFF00, 0xFF60), + (0xFFE0, 0xFFE6), + (0x1F300, 0x1F64F), + (0x1F900, 0x1F9FF), + (0x20000, 0x3FFFD), +) + + +def char_width(character: str) -> int: + code_point = ord(character) + if code_point < 0x20 or 0x7F <= code_point < 0xA0: + return 0 + if 0x0300 <= code_point <= 0x036F or 0x200B <= code_point <= 0x200F: + return 0 + for start, end in WIDE_RANGES: + if code_point < start: + return 1 + if code_point <= end: + return 2 + return 1 + + +@dataclass(frozen=True) +class CommandResult: + exit_code: int + stdout: bytes + stderr: bytes + + +@dataclass(frozen=True) +class Step: + label: str + data: bytes = b"" + coverage: tuple[str, ...] = () + process_signal: int | None = None + + +@dataclass +class PtyResult: + screens: list[tuple[str, tuple[tuple[str, ...], ...]]] + exit_code: int + + +class Proof: + def __init__(self, artifacts: Path) -> None: + self.artifacts = artifacts + self.observables = 0 + self.screens = 0 + self.failures: list[str] = [] + self.coverage: set[str] = set() + + @staticmethod + def _display(value: object) -> list[str]: + if isinstance(value, bytes): + return value.decode("utf-8", "backslashreplace").splitlines(keepends=True) + if isinstance(value, tuple) and value and isinstance(value[0], tuple): + return ["".join(cell or "·" for cell in row).rstrip() + "\n" for row in value] + return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").splitlines( + keepends=True + ) + + def compare(self, name: str, typescript: object, rust: object, *, screen: bool = False) -> None: + self.observables += 1 + if screen: + self.screens += 1 + if typescript == rust: + return + self.failures.append(name) + diff = "".join( + difflib.unified_diff( + self._display(typescript), + self._display(rust), + fromfile=f"{name}.typescript", + tofile=f"{name}.rust", + ) + ) + target = self.artifacts / "divergences" / f"{safe_name(name)}.diff" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(diff or f"TypeScript: {typescript!r}\nRust: {rust!r}\n", encoding="utf-8") + print(f"DIVERGENCE {name}\n{diff}", file=sys.stderr) + + def require_coverage(self, required: Iterable[str]) -> None: + missing = sorted(set(required) - self.coverage) + self.compare("screen.key-coverage", [], missing) + + +class TerminalGrid: + """Small ANSI terminal model for the sequences emitted by Bun and Ratatui.""" + + def __init__(self, rows: int, cols: int) -> None: + self.rows = rows + self.cols = cols + self.cells = [[" " for _ in range(cols)] for _ in range(rows)] + self.row = 0 + self.col = 0 + self.saved_primary: tuple[list[list[str]], int, int] | None = None + self.decoder = codecs.getincrementaldecoder("utf-8")("replace") + self.pending = "" + + def snapshot(self) -> tuple[tuple[str, ...], ...]: + return tuple(tuple(row) for row in self.cells) + + def text(self) -> str: + return "\n".join("".join(cell or " " for cell in row) for row in self.cells) + + def clear(self) -> None: + self.cells = [[" " for _ in range(self.cols)] for _ in range(self.rows)] + + def feed(self, data: bytes) -> None: + self.pending += self.decoder.decode(data) + index = 0 + while index < len(self.pending): + character = self.pending[index] + if character != "\x1b": + self._plain(character) + index += 1 + continue + if index + 1 >= len(self.pending): + break + marker = self.pending[index + 1] + if marker == "[": + end = index + 2 + while end < len(self.pending) and not 0x40 <= ord(self.pending[end]) <= 0x7E: + end += 1 + if end >= len(self.pending): + break + self._csi(self.pending[index + 2 : end], self.pending[end]) + index = end + 1 + continue + if marker == "]": + bell = self.pending.find("\x07", index + 2) + terminator = self.pending.find("\x1b\\", index + 2) + candidates = [value for value in (bell, terminator) if value >= 0] + if not candidates: + break + end = min(candidates) + index = end + (2 if self.pending.startswith("\x1b\\", end) else 1) + continue + if marker in "()" and index + 2 >= len(self.pending): + break + index += 3 if marker in "()" else 2 + self.pending = self.pending[index:] + + def _plain(self, character: str) -> None: + if character == "\r": + self.col = 0 + return + if character == "\n": + self.row = min(self.rows - 1, self.row + 1) + return + if character == "\b": + self.col = max(0, self.col - 1) + return + if character == "\t": + self.col = min(self.cols - 1, ((self.col // 8) + 1) * 8) + return + width = char_width(character) + if width == 0: + if self.col > 0: + target = self.col - 1 + if self.cells[self.row][target] == "" and target > 0: + target -= 1 + self.cells[self.row][target] += character + return + if self.col >= self.cols: + self.col = 0 + self.row = min(self.rows - 1, self.row + 1) + self._clear_glyph_at(self.row, self.col) + self.cells[self.row][self.col] = character + if width == 2 and self.col + 1 < self.cols: + self._clear_glyph_at(self.row, self.col + 1) + self.cells[self.row][self.col + 1] = "" + self.col += width + + def _clear_glyph_at(self, row: int, column: int) -> None: + if self.cells[row][column] == "" and column > 0: + self.cells[row][column - 1] = " " + if column + 1 < self.cols and self.cells[row][column + 1] == "": + self.cells[row][column + 1] = " " + self.cells[row][column] = " " + + @staticmethod + def _numbers(parameters: str) -> list[int]: + cleaned = parameters.lstrip("?<>") + values = cleaned.split(";") if cleaned else [""] + return [int(value) if value.isdigit() else 0 for value in values] + + def _csi(self, parameters: str, final: str) -> None: + values = self._numbers(parameters) + first = values[0] if values else 0 + if final in ("H", "f"): + self.row = min(self.rows - 1, max(0, (values[0] or 1) - 1)) + column = values[1] if len(values) > 1 else 1 + self.col = min(self.cols - 1, max(0, (column or 1) - 1)) + elif final == "A": + self.row = max(0, self.row - (first or 1)) + elif final == "B": + self.row = min(self.rows - 1, self.row + (first or 1)) + elif final == "C": + self.col = min(self.cols - 1, self.col + (first or 1)) + elif final == "D": + self.col = max(0, self.col - (first or 1)) + elif final == "G": + self.col = min(self.cols - 1, max(0, (first or 1) - 1)) + elif final == "d": + self.row = min(self.rows - 1, max(0, (first or 1) - 1)) + elif final == "J": + self._erase_display(first) + elif final == "K": + self._erase_line(first) + elif final in ("h", "l") and "1049" in parameters: + if final == "h": + self.saved_primary = (copy.deepcopy(self.cells), self.row, self.col) + self.clear() + self.row = 0 + self.col = 0 + elif self.saved_primary is not None: + self.cells, self.row, self.col = self.saved_primary + self.saved_primary = None + + def _erase_display(self, mode: int) -> None: + if mode in (2, 3): + self.clear() + elif mode == 0: + for column in range(self.col, self.cols): + self.cells[self.row][column] = " " + for row in range(self.row + 1, self.rows): + self.cells[row] = [" " for _ in range(self.cols)] + elif mode == 1: + for row in range(0, self.row): + self.cells[row] = [" " for _ in range(self.cols)] + for column in range(0, self.col + 1): + self.cells[self.row][column] = " " + + def _erase_line(self, mode: int) -> None: + start, end = (0, self.cols) if mode == 2 else ((0, self.col + 1) if mode == 1 else (self.col, self.cols)) + for column in range(start, end): + self.cells[self.row][column] = " " + + +class PtySession: + def __init__(self, command: Sequence[str], env: Mapping[str, str], cwd: Path, rows: int, cols: int) -> None: + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + self.process = subprocess.Popen( + list(command), + cwd=cwd, + env=dict(env), + stdin=slave, + stdout=slave, + stderr=slave, + close_fds=True, + preexec_fn=os.setsid, + ) + os.close(slave) + os.set_blocking(master, False) + self.master = master + self.grid = TerminalGrid(rows, cols) + + def drain(self, *, quiet: float = 0.08, maximum: float = 2.0) -> None: + deadline = time.monotonic() + maximum + quiet_deadline = time.monotonic() + quiet + while time.monotonic() < deadline: + timeout = max(0.0, min(quiet_deadline, deadline) - time.monotonic()) + readable, _, _ = select.select([self.master], [], [], timeout) + if not readable: + if time.monotonic() >= quiet_deadline: + return + continue + try: + data = os.read(self.master, 65536) + except (BlockingIOError, OSError): + data = b"" + if not data: + if self.process.poll() is not None: + return + continue + self.grid.feed(data) + quiet_deadline = time.monotonic() + quiet + + def wait_for(self, marker: str, timeout: float = 4.0) -> None: + deadline = time.monotonic() + timeout + while marker not in self.grid.text() and time.monotonic() < deadline: + self.drain(quiet=0.03, maximum=0.2) + if marker not in self.grid.text(): + raise RuntimeError(f"PTY did not render {marker!r}:\n{self.grid.text()}") + + def send(self, data: bytes) -> None: + os.write(self.master, data) + self.drain() + + def send_signal(self, process_signal: int) -> None: + os.killpg(self.process.pid, process_signal) + self.drain() + + def finish(self, timeout: float = 3.0) -> int: + try: + code = self.process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + os.killpg(self.process.pid, signal.SIGTERM) + try: + code = self.process.wait(timeout=1.0) + except subprocess.TimeoutExpired: + os.killpg(self.process.pid, signal.SIGKILL) + code = self.process.wait(timeout=1.0) + raise RuntimeError(f"PTY command did not exit (terminated with {code})") + self.drain(quiet=0.02, maximum=0.2) + os.close(self.master) + return code + + +def safe_name(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") + + +def annotation(identifier: str, selected: str, comment: str, captured: str, created: str) -> dict[str, object]: + return { + "selectedText": selected, + "capturedAt": captured, + "context": { + "workspace_id": "workspace-1", + "workspace_label": "api 한", + "tab_id": "tab-1", + "tab_label": "server", + "focused_pane_id": "pane-1", + "focused_pane_cwd": "/workspace", + "focused_pane_agent": "codex", + }, + "id": identifier, + "comment": comment, + "createdAt": created, + } + + +ANNOTATIONS = [ + annotation( + "ann-one", + "first selection with ``` ticks\nand a second line", + "first comment\nwith two lines", + "2026-08-08T00:00:00.000Z", + "2026-08-08T00:00:01.000Z", + ), + annotation( + "ann-two", + "wide 한글 selection and enough text to exercise clipping at the detail edge", + "second comment", + "2026-08-09T10:11:12.000Z", + "2026-08-09T10:11:13.000Z", + ), + annotation( + "ann-three", + "third selection", + "third comment", + "2026-08-10T20:21:22.000Z", + "2026-08-10T20:21:23.000Z", + ), +] +ARCHIVES = [ + { + "version": 1, + "id": "archive-old", + "archivedAt": "2026-08-20T01:02:03.000Z", + "annotations": [ANNOTATIONS[0]], + }, + { + "version": 1, + "id": "archive-new", + "archivedAt": "2026-08-21T01:02:03.000Z", + "annotations": [ANNOTATIONS[1], ANNOTATIONS[2]], + }, +] + + +def write_jsonl(path: Path, records: Sequence[object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + data = b"".join( + json.dumps(record, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n" + for record in records + ) + path.write_bytes(data) + path.chmod(0o600) + + +def seed_stores(state: Path, *, annotations: Sequence[object] = ANNOTATIONS, archives: Sequence[object] = ARCHIVES) -> None: + state.mkdir(parents=True, exist_ok=True, mode=0o700) + write_jsonl(state / "annotations.jsonl", annotations) + write_jsonl(state / "archives.jsonl", archives) + + +def normalize_text(value: str, roots: Iterable[Path]) -> str: + normalized = value + for root in sorted((str(path) for path in roots), key=len, reverse=True): + normalized = normalized.replace(root, "<ROOT>") + normalized = PENDING_PATTERN.sub("pending-<TIME>-<PID>.json", normalized) + normalized = TEMP_PATTERN.sub(r".\1-<PID>-<TIME>.tmp", normalized) + normalized = UUID_PATTERN.sub("<UUID>", normalized) + + def timestamp(match: re.Match[str]) -> str: + return match.group(0) if match.group(0) in KNOWN_TIMESTAMPS else "<TIMESTAMP>" + + return ISO_PATTERN.sub(timestamp, normalized) + + +def normalize_bytes(value: bytes, roots: Iterable[Path]) -> bytes: + return normalize_text(value.decode("utf-8", "replace"), roots).encode("utf-8") + + +def read_process_log(path: Path, roots: Iterable[Path]) -> bytes: + if not path.exists(): + return b"" + entries = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] + raw = "\n".join( + json.dumps(entry, ensure_ascii=False, separators=(",", ":"), sort_keys=True) for entry in entries + ) + return normalize_bytes((raw + ("\n" if raw else "")).encode("utf-8"), roots) + + +def state_snapshot(path: Path, roots: Iterable[Path]) -> bytes: + if not path.exists(): + return b"<missing>\n" + entries: list[dict[str, object]] = [] + paths = [path, *sorted(path.rglob("*"), key=lambda item: item.relative_to(path).as_posix())] + for item in paths: + relative = "." if item == path else item.relative_to(path).as_posix() + metadata = item.lstat() + entry: dict[str, object] = { + "path": normalize_text(relative, roots), + "mode": f"{stat.S_IMODE(metadata.st_mode):04o}", + "kind": "dir" if item.is_dir() else "file", + } + if item.is_file(): + entry["bytes"] = normalize_bytes(item.read_bytes(), roots).decode("utf-8", "replace") + entries.append(entry) + return (json.dumps(entries, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def create_fakes(directory: Path) -> Path: + directory.mkdir(parents=True) + fake = directory / "fake-process.py" + fake.write_text( + """#!/usr/bin/env python3 +import json, os, pathlib, sys +name = pathlib.Path(sys.argv[0]).name +log = pathlib.Path(os.environ["PARITY_PROCESS_LOG"]) +log.parent.mkdir(parents=True, exist_ok=True) +with log.open("a", encoding="utf-8") as output: + output.write(json.dumps({"command": name, "args": sys.argv[1:]}, ensure_ascii=False, separators=(",", ":")) + "\\n") +if name == "herdr-fake": + if os.environ.get("PARITY_HERDR_FAIL") == "1" and sys.argv[1:2] == ["plugin"]: + sys.stderr.write(os.environ.get("PARITY_HERDR_STDERR", "fake herdr failure") + "\\n") + raise SystemExit(7) + raise SystemExit(0) +reads = {"pbpaste", "wl-paste", "xclip-read", "xsel-read"} +writes = {"pbcopy", "wl-copy", "xclip-write", "xsel-write"} +mode = "read" if name in reads or (name == "xclip" and "-out" in sys.argv) or (name == "xsel" and "--output" in sys.argv) else "write" +data = sys.stdin.buffer.read() if mode == "write" else b"" +if mode == "write" and os.environ.get("PARITY_CLIPBOARD_OUTPUT"): + pathlib.Path(os.environ["PARITY_CLIPBOARD_OUTPUT"]).write_bytes(data) +if os.environ.get("PARITY_CLIPBOARD_FAIL") in (mode, "all"): + raise SystemExit(9) +if mode == "read": + source = os.environ.get("PARITY_CLIPBOARD_INPUT") + if source: + sys.stdout.buffer.write(pathlib.Path(source).read_bytes()) +raise SystemExit(0) +""", + encoding="utf-8", + ) + fake.chmod(0o755) + for name in ("herdr-fake", "pbpaste", "pbcopy", "wl-paste", "wl-copy", "xclip", "xsel"): + (directory / name).symlink_to(fake.name) + return directory + + +class Harness: + def __init__(self, root: Path, rust_binary: Path, workspace: Path, proof: Proof) -> None: + self.root = root + self.rust_binary = rust_binary + self.workspace = workspace + self.proof = proof + self.fake_bin = create_fakes(workspace / "fake-bin") + + def command(self, implementation: str, entrypoint: str) -> list[str]: + if implementation == "rust": + return [str(self.rust_binary), entrypoint] + scripts = { + "capture": "capture.ts", + "copy-context": "export.ts", + "manage": "open-manager.ts", + "editor": "editor.ts", + "manager": "manager.ts", + } + return ["bun", str(self.root / "src" / scripts[entrypoint])] + + def environment( + self, + state: Path, + runtime: Path, + process_log: Path, + clipboard_input: Path, + clipboard_output: Path, + extra: Mapping[str, str] | None = None, + ) -> dict[str, str]: + env = dict(os.environ) + env.update( + { + "PATH": f"{self.fake_bin}{os.pathsep}{env.get('PATH', '')}", + "HERDR_PLUGIN_STATE_DIR": str(state), + "HERDR_PLUGIN_ROOT": str(self.root), + "HERDR_BIN_PATH": str(self.fake_bin / "herdr-fake"), + "HERDR_PLUGIN_CONTEXT_JSON": "{}", + "XDG_RUNTIME_DIR": str(runtime), + "PARITY_PROCESS_LOG": str(process_log), + "PARITY_CLIPBOARD_INPUT": str(clipboard_input), + "PARITY_CLIPBOARD_OUTPUT": str(clipboard_output), + "TZ": "UTC", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + "TERM": "xterm-256color", + } + ) + for key in ("PARITY_CLIPBOARD_FAIL", "PARITY_HERDR_FAIL", "PARITY_HERDR_STDERR", "HERDR_ANNOTATE_PENDING"): + env.pop(key, None) + if extra: + env.update(extra) + return env + + def run(self, implementation: str, entrypoint: str, env: Mapping[str, str]) -> CommandResult: + result = subprocess.run( + self.command(implementation, entrypoint), + cwd=self.root, + env=dict(env), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + return CommandResult(result.returncode, result.stdout, result.stderr) + + def process_pair( + self, + name: str, + entrypoint: str, + setup: Callable[[str, Path, Path, Path, Path, Path], Mapping[str, str] | None], + inspect: Callable[[str, Path, Path, Path, Path, Path], object] | None = None, + ) -> tuple[Path, Path]: + case = self.workspace / name + results: dict[str, tuple[CommandResult, Path, Path, Path, Path, Path]] = {} + for implementation in ("typescript", "rust"): + base = case / implementation + state = base / "state" + runtime = base / "runtime" + log = base / "process.jsonl" + clipboard_input = base / "clipboard-input" + clipboard_output = base / "clipboard-output" + base.mkdir(parents=True) + runtime.mkdir() + clipboard_input.write_bytes(b"") + extra = setup(implementation, state, runtime, log, clipboard_input, clipboard_output) or {} + env = self.environment(state, runtime, log, clipboard_input, clipboard_output, extra) + results[implementation] = ( + self.run(implementation, entrypoint, env), + state, + runtime, + log, + clipboard_input, + clipboard_output, + ) + ts, rs = results["typescript"], results["rust"] + roots = [ts[1], ts[2], rs[1], rs[2], self.workspace] + self.proof.compare(f"{name}.exit", ts[0].exit_code, rs[0].exit_code) + self.proof.compare(f"{name}.stdout", normalize_bytes(ts[0].stdout, roots), normalize_bytes(rs[0].stdout, roots)) + self.proof.compare(f"{name}.stderr", normalize_bytes(ts[0].stderr, roots), normalize_bytes(rs[0].stderr, roots)) + self.proof.compare(f"{name}.processes", read_process_log(ts[3], roots), read_process_log(rs[3], roots)) + if inspect: + self.proof.compare( + f"{name}.artifact", + inspect("typescript", *ts[1:]), + inspect("rust", *rs[1:]), + ) + return ts[1], rs[1] + + def pty_pair( + self, + name: str, + entrypoint: str, + steps: Sequence[Step], + marker: str, + rows: int, + cols: int, + seed: Callable[[Path], None], + extra: Mapping[str, str] + | Callable[[str, Path], Mapping[str, str]] + | None = None, + compare_state: bool = False, + compare_clipboard: bool = False, + ) -> tuple[Path, Path]: + case = self.workspace / name + results: dict[str, tuple[PtyResult, Path, Path, Path, Path]] = {} + for implementation in ("typescript", "rust"): + base = case / implementation + state = base / "state" + runtime = base / "runtime" + log = base / "process.jsonl" + clipboard_input = base / "clipboard-input" + clipboard_output = base / "clipboard-output" + base.mkdir(parents=True) + runtime.mkdir() + clipboard_input.write_bytes(b"clipboard selection") + seed(state) + case_extra = extra(implementation, state) if callable(extra) else extra + env = self.environment( + state, runtime, log, clipboard_input, clipboard_output, case_extra + ) + session = PtySession(self.command(implementation, entrypoint), env, self.root, rows, cols) + session.wait_for(marker) + screens = [("initial", session.grid.snapshot())] + for step in steps: + self.proof.coverage.update(step.coverage) + if step.process_signal is None: + session.send(step.data) + else: + session.send_signal(step.process_signal) + screens.append((step.label, session.grid.snapshot())) + code = session.finish() + results[implementation] = (PtyResult(screens, code), state, runtime, log, clipboard_output) + ts, rs = results["typescript"], results["rust"] + roots = [ts[1], ts[2], rs[1], rs[2], self.workspace] + self.proof.compare(f"{name}.exit", ts[0].exit_code, rs[0].exit_code) + self.proof.compare(f"{name}.screen-count", len(ts[0].screens), len(rs[0].screens)) + for (ts_label, ts_screen), (rs_label, rs_screen) in zip(ts[0].screens, rs[0].screens): + self.proof.compare(f"{name}.screen.{ts_label}.label", ts_label, rs_label) + self.proof.compare(f"{name}.screen.{ts_label}", ts_screen, rs_screen, screen=True) + self.proof.compare(f"{name}.processes", read_process_log(ts[3], roots), read_process_log(rs[3], roots)) + if compare_clipboard: + self.proof.compare( + f"{name}.clipboard", + normalize_bytes(ts[4].read_bytes() if ts[4].exists() else b"", roots), + normalize_bytes(rs[4].read_bytes() if rs[4].exists() else b"", roots), + ) + if compare_state: + self.proof.compare(f"{name}.state", state_snapshot(ts[1], roots), state_snapshot(rs[1], roots)) + return ts[1], rs[1] + + +def pending_artifact(_implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object: + del runtime, log, source, sink + files = sorted(state.glob("pending-*.json")) + if len(files) != 1: + return {"pending_count": len(files)} + file = files[0] + return { + "name": PENDING_PATTERN.sub("pending-<TIME>-<PID>.json", file.name), + "mode": f"{stat.S_IMODE(file.stat().st_mode):04o}", + "bytes": normalize_bytes(file.read_bytes(), [state]).decode("utf-8"), + } + + +def pending_and_runtime_artifact( + implementation: str, + state: Path, + runtime: Path, + log: Path, + source: Path, + sink: Path, +) -> object: + return { + "pending": pending_artifact(implementation, state, runtime, log, source, sink), + "runtime": state_snapshot(runtime, [runtime]).decode("utf-8"), + } + + +def clipboard_artifact(_implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object: + del state, runtime, log, source + return sink.read_bytes() if sink.exists() else b"" + + +def no_pending_artifact(_implementation: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> object: + del runtime, log, source, sink + return sorted(path.name for path in state.glob("pending-*.json")) if state.exists() else [] + + +def run_process_layer(harness: Harness) -> None: + context = json.dumps( + { + "selected_text": "context selection 한글", + "workspace_id": "workspace-1", + "workspace_label": "api 한", + "tab_id": "tab-1", + "tab_label": "server", + "focused_pane_id": "pane-1", + "focused_pane_cwd": "/workspace", + "focused_pane_agent": "codex", + "ignored": "not persisted", + }, + ensure_ascii=False, + separators=(",", ":"), + ) + + def handoff_path(runtime: Path) -> Path: + return runtime / f"herdr-annotate-{os.getuid() if hasattr(os, 'getuid') else 'user'}" / "selection" + + def capture_context(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]: + del log, sink + state.mkdir() + handoff = handoff_path(runtime) + handoff.parent.mkdir() + handoff.write_text("lower-priority handoff", encoding="utf-8") + source.write_text("lower-priority clipboard", encoding="utf-8") + return {"HERDR_PLUGIN_CONTEXT_JSON": context} + + harness.process_pair( + "process.capture.context", "capture", capture_context, pending_and_runtime_artifact + ) + + def capture_handoff(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del log, sink + state.mkdir() + handoff = handoff_path(runtime) + handoff.parent.mkdir() + handoff.write_text("handoff selection\n", encoding="utf-8") + source.write_text("lower-priority clipboard", encoding="utf-8") + + harness.process_pair( + "process.capture.handoff", "capture", capture_handoff, pending_and_runtime_artifact + ) + + def capture_stale_handoff( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> None: + del log, sink + state.mkdir() + handoff = handoff_path(runtime) + handoff.parent.mkdir() + handoff.write_text("stale handoff", encoding="utf-8") + stale = time.time() - 16 + os.utime(handoff, (stale, stale)) + source.write_text("clipboard after stale handoff", encoding="utf-8") + + harness.process_pair( + "process.capture.stale-handoff", + "capture", + capture_stale_handoff, + pending_and_runtime_artifact, + ) + + def capture_blank_handoff( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> None: + del log, sink + state.mkdir() + handoff = handoff_path(runtime) + handoff.parent.mkdir() + handoff.write_text(" \n\t", encoding="utf-8") + source.write_text("clipboard after blank handoff", encoding="utf-8") + + harness.process_pair( + "process.capture.blank-handoff", + "capture", + capture_blank_handoff, + pending_and_runtime_artifact, + ) + + def capture_invalid_utf8_handoff( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> None: + del log, sink + state.mkdir() + handoff = handoff_path(runtime) + handoff.parent.mkdir() + handoff.write_bytes(b"invalid-\xff-handoff") + source.write_text("clipboard after invalid handoff", encoding="utf-8") + + harness.process_pair( + "process.capture.invalid-utf8-handoff", + "capture", + capture_invalid_utf8_handoff, + pending_and_runtime_artifact, + ) + + def capture_clipboard(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del runtime, log, sink + state.mkdir() + source.write_bytes("clipboard 한 selection".encode("utf-8")) + + harness.process_pair("process.capture.clipboard", "capture", capture_clipboard, pending_artifact) + + def capture_invalid_context( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> Mapping[str, str]: + del runtime, log, sink + state.mkdir() + source.write_text("clipboard after invalid context", encoding="utf-8") + return {"HERDR_PLUGIN_CONTEXT_JSON": "{broken"} + + harness.process_pair( + "process.capture.invalid-context", "capture", capture_invalid_context, pending_artifact + ) + + def capture_empty(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del runtime, log, sink + state.mkdir() + source.write_bytes(b" \n\t") + + harness.process_pair("process.capture.empty", "capture", capture_empty, no_pending_artifact) + + def capture_no_clipboard(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]: + del runtime, log, source, sink + state.mkdir() + return {"PARITY_CLIPBOARD_FAIL": "read"} + + harness.process_pair("process.capture.no-clipboard", "capture", capture_no_clipboard, no_pending_artifact) + + def capture_open_failure(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]: + del runtime, log, source, sink + state.mkdir() + return { + "HERDR_PLUGIN_CONTEXT_JSON": context, + "PARITY_HERDR_FAIL": "1", + "PARITY_HERDR_STDERR": "pane open failed", + } + + harness.process_pair("process.capture.open-failure", "capture", capture_open_failure, no_pending_artifact) + + def capture_missing_state( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> Mapping[str, str]: + del state, runtime, log, source, sink + return {"HERDR_PLUGIN_STATE_DIR": "", "HERDR_PLUGIN_CONTEXT_JSON": context} + + harness.process_pair("process.capture.missing-state", "capture", capture_missing_state) + + def capture_missing_root( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> Mapping[str, str]: + del runtime, log, source, sink + state.mkdir() + return {"HERDR_PLUGIN_ROOT": "", "HERDR_PLUGIN_CONTEXT_JSON": context} + + harness.process_pair("process.capture.missing-root", "capture", capture_missing_root) + + def copy_empty(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del state, runtime, log, source, sink + + harness.process_pair( + "process.copy.empty", + "copy-context", + copy_empty, + lambda impl, state, runtime, log, source, sink: state_snapshot(state, [state]), + ) + + def copy_populated(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del runtime, log, source, sink + seed_stores(state, archives=[]) + + harness.process_pair("process.copy.populated", "copy-context", copy_populated, clipboard_artifact) + + def copy_single(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del runtime, log, source, sink + seed_stores(state, annotations=ANNOTATIONS[:1], archives=[]) + + harness.process_pair("process.copy.single", "copy-context", copy_single, clipboard_artifact) + + def copy_no_clipboard(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]: + del runtime, log, source, sink + seed_stores(state, archives=[]) + return {"PARITY_CLIPBOARD_FAIL": "write"} + + harness.process_pair("process.copy.no-clipboard", "copy-context", copy_no_clipboard, clipboard_artifact) + + def copy_invalid(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del runtime, log, source, sink + state.mkdir() + (state / "annotations.jsonl").write_text("{broken\n", encoding="utf-8") + + harness.process_pair("process.copy.invalid-store", "copy-context", copy_invalid) + + def copy_missing_state( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> Mapping[str, str]: + del state, runtime, log, source, sink + return {"HERDR_PLUGIN_STATE_DIR": ""} + + harness.process_pair("process.copy.missing-state", "copy-context", copy_missing_state) + + def fresh_lock(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del runtime, log, source, sink + state.mkdir() + (state / ".annotations.lock").mkdir() + + harness.process_pair( + "process.copy.busy-lock", + "copy-context", + fresh_lock, + lambda impl, state, runtime, log, source, sink: state_snapshot(state, [state]), + ) + + def stale_lock(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + fresh_lock(_impl, state, runtime, log, source, sink) + stale = time.time() - 31 + os.utime(state / ".annotations.lock", (stale, stale)) + + harness.process_pair( + "process.copy.stale-lock", + "copy-context", + stale_lock, + lambda impl, state, runtime, log, source, sink: state_snapshot(state, [state]), + ) + + def manage_success(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del state, runtime, log, source, sink + + harness.process_pair("process.manage.success", "manage", manage_success) + + def manage_failure(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]: + del state, runtime, log, source, sink + return {"PARITY_HERDR_FAIL": "1", "PARITY_HERDR_STDERR": "manager open failed"} + + harness.process_pair("process.manage.failure", "manage", manage_failure) + + def manage_failure_without_stderr( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> Mapping[str, str]: + del state, runtime, log, source, sink + return {"PARITY_HERDR_FAIL": "1", "PARITY_HERDR_STDERR": ""} + + harness.process_pair( + "process.manage.failure-without-stderr", "manage", manage_failure_without_stderr + ) + + def manage_missing_root( + _impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path + ) -> Mapping[str, str]: + del state, runtime, log, source, sink + return {"HERDR_PLUGIN_ROOT": ""} + + harness.process_pair("process.manage.missing-root", "manage", manage_missing_root) + + def editor_missing(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> None: + del runtime, log, source, sink + state.mkdir() + + harness.process_pair("process.editor.missing-pending", "editor", editor_missing) + + def editor_invalid(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]: + del runtime, log, source, sink + state.mkdir() + pending = state / "invalid-pending.json" + pending.write_text('{"selectedText":"only"}\n', encoding="utf-8") + return {"HERDR_ANNOTATE_PENDING": str(pending)} + + harness.process_pair("process.editor.invalid-pending", "editor", editor_invalid) + + def manager_missing(_impl: str, state: Path, runtime: Path, log: Path, source: Path, sink: Path) -> Mapping[str, str]: + del state, runtime, log, source, sink + return {"HERDR_PLUGIN_STATE_DIR": ""} + + harness.process_pair("process.manager.missing-state", "manager", manager_missing) + + +EDITOR_REQUIRED = { + "editor:chars", + "editor:enter", + "editor:backspace", + "editor:delete", + "editor:left", + "editor:right", + "editor:up", + "editor:down", + "editor:home", + "editor:end", + "editor:ctrl-s", + "editor:esc", + "editor:ctrl-c", +} +MANAGER_REQUIRED = { + f"manager:{view}:{key}" + for view in ("active", "archives") + for key in ("j", "k", "y", "c", "C", "d", "D", "r", "u", "Tab", "Esc", "q", "Ctrl-C") +} + + +def editor_seed(state: Path) -> None: + state.mkdir(parents=True, mode=0o700) + + +def manager_seed(state: Path) -> None: + seed_stores(state) + + +def empty_manager_seed(state: Path) -> None: + state.mkdir(parents=True, mode=0o700) + + +def pending_file_extra(_implementation: str, state: Path) -> Mapping[str, str]: + pending = state / "pending-input.json" + pending.write_text( + json.dumps( + { + "selectedText": "selection from pending file", + "context": {"workspace_label": "pending workspace", "tab_label": "pending tab"}, + "capturedAt": "2026-08-08T00:00:00.000Z", + }, + separators=(",", ":"), + ) + + "\n", + encoding="utf-8", + ) + return {"HERDR_ANNOTATE_PENDING": str(pending)} + + +def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]: + editor_context = { + "HERDR_PLUGIN_CONTEXT_JSON": json.dumps( + { + "selected_text": "selected wide 한글 text\nwith a second line", + "workspace_label": "api 한", + "tab_label": "server", + }, + ensure_ascii=False, + separators=(",", ":"), + ) + } + editor_steps = [ + Step("chars", "alpha 한글 e\u0301".encode(), ("editor:chars",)), + Step("enter", b"\r", ("editor:enter",)), + Step("chars-second-line", b"beta", ("editor:chars",)), + Step("home", b"\x1b[H", ("editor:home",)), + Step("right", b"\x1b[C", ("editor:right",)), + Step("delete", b"\x1b[3~", ("editor:delete",)), + Step("end", b"\x1b[F", ("editor:end",)), + Step("backspace", b"\x7f", ("editor:backspace",)), + Step("up", b"\x1b[A", ("editor:up",)), + Step("left", b"\x1b[D", ("editor:left",)), + Step("down", b"\x1b[B", ("editor:down",)), + Step("save", b"\x13", ("editor:ctrl-s",)), + ] + editor_ts, editor_rs = harness.pty_pair( + "screen.editor.edit-save", + "editor", + editor_steps, + "Selected text", + 22, + 86, + editor_seed, + editor_context, + compare_state=True, + ) + harness.pty_pair( + "screen.editor.pending-file-save", + "editor", + [Step("chars", b"pending comment"), Step("save", b"\x13")], + "Selected text", + 22, + 86, + editor_seed, + pending_file_extra, + compare_state=True, + ) + harness.pty_pair( + "screen.editor.empty-save-escape", + "editor", + [ + Step("empty-save", b"\x13", ("editor:ctrl-s",)), + Step("escape", b"\x1b", ("editor:esc",)), + ], + "Selected text", + 22, + 86, + editor_seed, + editor_context, + ) + harness.pty_pair( + "screen.editor.control-c", + "editor", + [Step("control-c", b"\x03", ("editor:ctrl-c",))], + "Selected text", + 22, + 86, + editor_seed, + editor_context, + ) + harness.pty_pair( + "screen.editor.missing-state", + "editor", + [Step("char", b"x"), Step("save", b"\x13"), Step("escape", b"\x1b")], + "Selected text", + 22, + 86, + editor_seed, + {**editor_context, "HERDR_PLUGIN_STATE_DIR": ""}, + ) + harness.pty_pair( + "screen.editor.sigterm", + "editor", + [Step("sigterm", process_signal=signal.SIGTERM)], + "Selected text", + 22, + 86, + editor_seed, + editor_context, + ) + + manager_steps = [ + Step("active-j", b"j", ("manager:active:j",)), + Step("active-k", b"k", ("manager:active:k",)), + Step("active-arrow-down", b"\x1b[B"), + Step("active-arrow-up", b"\x1b[A"), + Step("active-y-failure", b"y", ("manager:active:y",)), + Step("active-c-failure", b"c", ("manager:active:c",)), + Step("active-C-failure", b"C", ("manager:active:C",)), + Step("active-delete", b"d", ("manager:active:d",)), + Step("active-clear-confirm", b"D", ("manager:active:D",)), + Step("active-clear-cancel", b"\x1b", ("manager:active:Esc",)), + Step("active-clear-confirm-again", b"D", ("manager:active:D",)), + Step("active-clear", b"D", ("manager:active:D",)), + Step("active-reload", b"r", ("manager:active:r",)), + Step("active-u-ignored", b"u", ("manager:active:u",)), + Step("active-to-archives", b"\t", ("manager:active:Tab",)), + Step("archives-j", b"j", ("manager:archives:j",)), + Step("archives-k", b"k", ("manager:archives:k",)), + Step("archives-arrow-down", b"\x1b[B"), + Step("archives-arrow-up", b"\x1b[A"), + Step("archives-y-failure", b"y", ("manager:archives:y",)), + Step("archives-reload", b"r", ("manager:archives:r",)), + Step("archives-c-ignored", b"c", ("manager:archives:c",)), + Step("archives-C-ignored", b"C", ("manager:archives:C",)), + Step("archives-D-ignored", b"D", ("manager:archives:D",)), + Step("archives-delete-confirm", b"d", ("manager:archives:d",)), + Step("archives-delete-cancel", b"\x1b", ("manager:archives:Esc",)), + Step("archives-restore", b"u", ("manager:archives:u",)), + Step("archives-delete-confirm-again", b"d", ("manager:archives:d",)), + Step("archives-delete", b"d", ("manager:archives:d",)), + Step("archives-to-active", b"\t", ("manager:archives:Tab",)), + Step("active-quit", b"q", ("manager:active:q",)), + ] + harness.pty_pair( + "screen.manager.all-views", + "manager", + manager_steps, + "Annotations (", + 28, + 98, + manager_seed, + {"PARITY_CLIPBOARD_FAIL": "write"}, + compare_state=True, + compare_clipboard=True, + ) + harness.pty_pair( + "screen.manager.escape-exit", + "manager", + [ + Step("to-archives", b"\t", ("manager:active:Tab",)), + Step("archives-escape", b"\x1b", ("manager:archives:Esc",)), + ], + "Annotations (", + 28, + 98, + manager_seed, + ) + harness.pty_pair( + "screen.manager.escape-active", + "manager", + [Step("active-escape", b"\x1b", ("manager:active:Esc",))], + "Annotations (", + 28, + 98, + manager_seed, + ) + harness.pty_pair( + "screen.manager.control-c-active", + "manager", + [Step("control-c", b"\x03", ("manager:active:Ctrl-C",))], + "Annotations (", + 28, + 98, + manager_seed, + ) + harness.pty_pair( + "screen.manager.control-c-archives", + "manager", + [ + Step("to-archives", b"\t", ("manager:active:Tab",)), + Step("control-c", b"\x03", ("manager:archives:Ctrl-C",)), + ], + "Annotations (", + 28, + 98, + manager_seed, + ) + harness.pty_pair( + "screen.manager.q-archives", + "manager", + [ + Step("to-archives", b"\t", ("manager:active:Tab",)), + Step("archives-q", b"q", ("manager:archives:q",)), + ], + "Annotations (", + 28, + 98, + manager_seed, + ) + harness.pty_pair( + "screen.manager.sighup", + "manager", + [Step("sighup", process_signal=signal.SIGHUP)], + "Annotations (", + 28, + 98, + manager_seed, + ) + harness.pty_pair( + "screen.manager.empty-actions", + "manager", + [ + Step("active-y", b"y"), + Step("active-c", b"c"), + Step("active-copy-archive", b"C"), + Step("to-archives", b"\t"), + Step("archives-y", b"y"), + Step("archives-u", b"u"), + Step("archives-d", b"d"), + Step("quit", b"q"), + ], + "Annotations (", + 28, + 98, + empty_manager_seed, + compare_state=True, + compare_clipboard=True, + ) + + for key, steps in ( + ("active-y-success", [Step("copy-one", b"y", ("manager:active:y",))]), + ("active-c-success", [Step("copy-all", b"c", ("manager:active:c",))]), + ( + "archives-y-success", + [ + Step("to-archives", b"\t", ("manager:active:Tab",)), + Step("copy-archive", b"y", ("manager:archives:y",)), + ], + ), + ): + harness.pty_pair( + f"store.manager.{key}", + "manager", + steps, + "Annotations (", + 28, + 98, + manager_seed, + compare_state=True, + compare_clipboard=True, + ) + + harness.pty_pair( + "store.manager.copy-archive", + "manager", + [Step("copy-archive", b"C", ("manager:active:C",))], + "Annotations (", + 28, + 98, + manager_seed, + compare_state=True, + compare_clipboard=True, + ) + + harness.proof.require_coverage(EDITOR_REQUIRED | MANAGER_REQUIRED) + return editor_ts, editor_rs + + +def cross_read(harness: Harness, typescript_state: Path, rust_state: Path) -> None: + case = harness.workspace / "store.cross-read" + outputs: dict[str, tuple[CommandResult, bytes, bytes]] = {} + for name, implementation, state in ( + ("typescript-reads-rust", "typescript", rust_state), + ("rust-reads-typescript", "rust", typescript_state), + ): + base = case / name + runtime = base / "runtime" + runtime.mkdir(parents=True) + log = base / "process.jsonl" + source = base / "clipboard-input" + source.write_bytes(b"") + sink = base / "clipboard-output" + env = harness.environment(state, runtime, log, source, sink) + result = harness.run(implementation, "copy-context", env) + outputs[name] = (result, sink.read_bytes() if sink.exists() else b"", read_process_log(log, [state, runtime])) + left = outputs["typescript-reads-rust"] + right = outputs["rust-reads-typescript"] + harness.proof.compare("store.cross-read.exit", left[0].exit_code, right[0].exit_code) + harness.proof.compare("store.cross-read.stderr", left[0].stderr, right[0].stderr) + harness.proof.compare("store.cross-read.markdown", left[1], right[1]) + harness.proof.compare("store.cross-read.processes", left[2], right[2]) + + +def verify_error_catalog(root: Path, proof: Proof) -> None: + pairs = { + "HERDR_PLUGIN_STATE_DIR is not set": ("src/capture.ts", "rust/src/cli.rs"), + "HERDR_PLUGIN_ROOT is not set": ("src/capture.ts", "rust/src/cli.rs"), + "No supported clipboard reader is available": ("src/clipboard.ts", "rust/src/clipboard.rs"), + "No supported clipboard writer is available": ("src/clipboard.ts", "rust/src/clipboard.rs"), + "Missing pending annotation": ("src/editor.ts", "rust/src/editor.rs"), + "Pending annotation is invalid": ("src/editor.ts", "rust/src/editor.rs"), + "Write a comment before saving.": ("src/editor.ts", "rust/src/editor.rs"), + "Plugin state directory is unavailable.": ("src/editor.ts", "rust/src/editor.rs"), + "Nothing to copy.": ("src/manager-copy.ts", "rust/src/manager_copy.rs"), + "Nothing to copy and archive.": ("src/archive-workflow.ts", "rust/src/archive_workflow.rs"), + "No archive selected.": ("src/manager.ts", "rust/src/manager.rs"), + "Unable to save annotation": ("src/store.ts", "rust/src/store.rs"), + "Unable to update annotations": ("src/store.ts", "rust/src/store.rs"), + "Unable to update archives": ("src/store.ts", "rust/src/store.rs"), + "Unable to read": ("src/store.ts", "rust/src/store.rs"), + "Unable to access": ("src/store.ts", "rust/src/store.rs"), + "Unable to lock": ("src/store.ts", "rust/src/store.rs"), + "are busy; try again.": ("src/store.ts", "rust/src/store.rs"), + "Copied and archived, but active annotations remain:": ( + "src/manager.ts", + "rust/src/manager.rs", + ), + "Annotations restored, but the archive remains:": ( + "src/manager.ts", + "rust/src/manager.rs", + ), + } + for message, (typescript_file, rust_file) in pairs.items(): + present = ( + message in (root / typescript_file).read_text(encoding="utf-8"), + message in (root / rust_file).read_text(encoding="utf-8"), + ) + proof.compare( + f"errors.catalog.{safe_name(message)}", + (True, True), + present, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--rust-binary", type=Path, required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + os.umask(0o022) + workspace = Path(tempfile.mkdtemp(prefix="herdr-annotate-parity-")) + proof = Proof(workspace / "artifacts") + try: + harness = Harness(args.root.resolve(), args.rust_binary.resolve(), workspace, proof) + print("== process layer") + run_process_layer(harness) + print("== screen and store layers") + typescript_state, rust_state = run_screen_and_store_layer(harness) + print("== cross-read and error catalog") + cross_read(harness, typescript_state, rust_state) + verify_error_catalog(args.root.resolve(), proof) + if proof.failures: + print( + f"Parity Lite: {proof.observables} observables compared, {proof.screens} screens diffed, " + f"{len(proof.failures)} divergences / " + f"{len(DELIBERATE_DIVERGENCES)} deliberate; artifacts: {workspace}", + file=sys.stderr, + ) + return 1 + print( + f"Parity Lite: {proof.observables} observables compared, {proof.screens} screens diffed, " + f"zero divergences / {len(DELIBERATE_DIVERGENCES)} deliberate" + ) + shutil.rmtree(workspace) + return 0 + except Exception as error: # noqa: BLE001 - harness must retain evidence on any failure. + print(f"parity-lite: {error}; artifacts: {workspace}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parity-lite.sh b/scripts/parity-lite.sh new file mode 100755 index 0000000..80edab1 --- /dev/null +++ b/scripts/parity-lite.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Differential proof for the TypeScript and native Rust Lite implementations. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" + +for command in bun cargo python3; do + command -v "$command" >/dev/null 2>&1 || { + echo "parity-lite: required command is unavailable: $command" >&2 + exit 2 + } +done + +echo "== stage native Lite" +bash "$root/lite-rs/scripts/stage-local.sh" >/dev/null + +exec python3 "$root/scripts/parity-lite.py" \ + --root "$root" \ + --rust-binary "$root/lite-rs/bin/herdr-annotate.exe" From 959474787d62f6e4bafe8414456f93e29268b9d2 Mon Sep 17 00:00:00 2001 From: Michael Ramos <mdramos8@gmail.com> Date: Sun, 30 Aug 2026 18:11:11 -0700 Subject: [PATCH 12/12] fix(rust-lite): keep Windows termination stub lint-clean --- rust/src/termination.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/src/termination.rs b/rust/src/termination.rs index 7481ca6..1f1bc58 100644 --- a/rust/src/termination.rs +++ b/rust/src/termination.rs @@ -37,6 +37,7 @@ impl Termination { } #[cfg(not(unix))] { + let _ = self; false } }