From 269147048629e3031d9d64199d654eaa4890def6 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:17:03 +0800 Subject: [PATCH 1/9] feat: add interactive terminal review comments --- .github/workflows/ci.yml | 57 ++ .gitignore | 3 + AGENTS.md | 11 + CONTRIBUTING.md | 15 + Cargo.lock | 1255 ++++++++++++++++++++++++++ Cargo.toml | 37 + LICENSE | 21 + README.md | 95 ++ docs/2026-07-10-architecture.md | 40 + docs/2026-07-10-data-model-review.md | 48 + docs/2026-07-10-output-formats.md | 78 ++ scripts/ci-local.sh | 27 + scripts/e2e-tmux.sh | 100 ++ slophammer.yml | 42 + src/app.rs | 369 ++++++++ src/cli.rs | 59 ++ src/domain.rs | 296 ++++++ src/input.rs | 53 ++ src/lib.rs | 12 + src/main.rs | 7 + src/output.rs | 150 +++ src/render.rs | 368 ++++++++ src/runner.rs | 337 +++++++ src/source.rs | 108 +++ src/storage.rs | 91 ++ src/terminal.rs | 91 ++ tests/architecture.rs | 60 ++ 27 files changed, 3830 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/2026-07-10-architecture.md create mode 100644 docs/2026-07-10-data-model-review.md create mode 100644 docs/2026-07-10-output-formats.md create mode 100755 scripts/ci-local.sh create mode 100755 scripts/e2e-tmux.sh create mode 100644 slophammer.yml create mode 100644 src/app.rs create mode 100644 src/cli.rs create mode 100644 src/domain.rs create mode 100644 src/input.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/output.rs create mode 100644 src/render.rs create mode 100644 src/runner.rs create mode 100644 src/source.rs create mode 100644 src/storage.rs create mode 100644 src/terminal.rs create mode 100644 tests/architecture.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ba0dfa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: ci + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + msrv: + name: rust 1.88 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install minimum supported Rust + run: rustup toolchain install 1.88.0 --profile minimal + + - name: Check minimum supported Rust + run: cargo +1.88.0 check --workspace + + verify: + name: verify + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal --component clippy,rustfmt,llvm-tools-preview + rustup default stable + + - name: Install Rust quality tools + run: | + cargo install cargo-llvm-cov --locked + cargo install cargo-audit --locked + cargo install cargo-mutants --locked + cargo install slophammer-rs --version 0.4.0 --locked + + - name: Install Node + uses: actions/setup-node@v5 + with: + node-version: 22 + + - name: Install tmux + run: sudo apt-get update && sudo apt-get install --yes tmux + + - name: Run local CI gate + run: scripts/ci-local.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fe8517 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +/mutants.out*/ +*.tmp-* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1a7b57f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS.md + +- Run `scripts/ci-local.sh` before finishing a change. +- Keep `#![forbid(unsafe_code)]` and the Slophammer unsafe policy enabled. +- Keep domain and source behavior independent of Ratatui, Crossterm, filesystem IO, + process state, and terminal coordinates. +- Validate external JSON before it enters app state. +- Add tests for every behavior change, including mouse hit testing when layout changes. +- Prefer the standard library or existing dependencies over adding a crate. +- Follow the Slophammer Rust guidance at + https://github.com/dutifuldev/slophammer/blob/main/docs/AGENT_ENTRYPOINT.md. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7326d47 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing + +annotui accepts focused changes that preserve the separation between review data, +terminal presentation, and IO. Add or update tests for every behavior change. + +Run the local gate before opening a pull request: + +```sh +scripts/ci-local.sh +``` + +The gate requires Rust formatting, compilation, tests, Clippy, 85% line coverage, +dependency audit, mutation discovery, Slophammer, documentation validation, and the +tmux mouse smoke test. See [docs/2026-07-10-architecture.md](docs/2026-07-10-architecture.md) before moving +code across module boundaries. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..edfa88d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1255 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "annotui" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "crossterm", + "predicates", + "ratatui", + "ratatui-textarea", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "unicode-width", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[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 = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "filedescriptor", + "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 = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[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", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[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 = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.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 = "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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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 = "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.18", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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 = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "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", + "compact_str", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "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-textarea" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c78d5ba0f26f97baed69a4c479f268a31c7b5b89d68ab939842152e383d6e73" +dependencies = [ + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags", + "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", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +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", + "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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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 = "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 = "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", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +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 = "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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4af7a5e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "annotui" +version = "0.1.0" +edition = "2021" +rust-version = "1.88" +description = "Mouse-first terminal UI for commenting on files and piped text." +license = "MIT" +repository = "https://github.com/dutifuldev/annotui" +homepage = "https://github.com/dutifuldev/annotui" +documentation = "https://docs.rs/annotui" +readme = "README.md" +keywords = ["terminal", "tui", "review", "comments", "ratatui"] +categories = ["command-line-utilities", "development-tools"] + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +crossterm = { version = "0.29", features = ["use-dev-tty"] } +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] } +ratatui-textarea = "0.9.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +unicode-width = "0.2" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +all = "warn" +pedantic = "warn" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8e98ac2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 dutifuldev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b76fd33 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# annotui + +annotui is a mouse-first terminal UI for commenting on files and piped text. +It gives an immutable buffer a GitHub-style review surface, then writes the review +as concise Markdown or JSON. + +```sh +annotui README.md +git diff | annotui +annotui --buffer "first line +second line" +``` + +Drag across source lines and release to open the inline comment editor. Enter saves +the comment. Existing comments remain visible below their ranges and can be clicked +to edit them. + +## Install + +annotui currently builds from source with Rust 1.88 or newer: + +```sh +git clone https://github.com/dutifuldev/annotui.git +cd annotui +cargo install --path . +``` + +## Output + +The default `comments` format writes only reviewed ranges and comments: + +```markdown +> quoted part line 1 +> quoted part line 2 + +human comment here ... +``` + +The TUI uses `/dev/tty`, leaving standard output clean for pipelines: + +```sh +git diff | annotui > review.md +annotui proposal.md --format full > reviewed-proposal.md +annotui src/main.rs --format json > review.json +``` + +`--format full` quotes the whole input and inserts comments after their selected +ranges. `--format json` writes the versioned line-range model. See +[the output format reference](docs/2026-07-10-output-formats.md) for exact behavior. + +Use `--comments review.json` to load and save a JSON sidecar while still choosing an +independent final output format: + +```sh +annotui proposal.md --comments proposal.review.json +annotui proposal.md --comments proposal.review.json --format full +``` + +The sidecar includes a source hash. annotui refuses to apply it to different content +instead of silently moving comments to the wrong lines. + +## Controls + +Mouse controls: + +- Drag source lines and release to comment on the range. +- Click a comment to edit it. +- Use the wheel to scroll the document or active editor. + +Keyboard controls: + +| Key | Action | +| --- | --- | +| `j` / `k`, arrows | Move between source lines | +| `v` | Start or cancel range selection | +| `Enter` | Comment on the cursor/range, or save an active comment | +| `Ctrl-O` | Insert a newline in a comment | +| `Ctrl-A` / `Ctrl-E` | Move to the beginning/end of the editor line | +| `Esc` | Cancel selection or editing | +| `e` / `d` | Edit or delete a comment on the cursor line | +| `[` / `]` | Jump to the previous/next comment | +| `h` / `l` | Scroll long source lines horizontally | +| `q` | Write output and quit | + +Pass `--no-mouse` when terminal mouse capture is undesirable. Native terminal text +selection is unavailable while mouse capture is active. + +## Requirements and limits + +annotui is a Unix utility and requires a controlling terminal at `/dev/tty`. Input +must currently be valid UTF-8. Source text is never modified. + +## License + +[MIT](LICENSE) diff --git a/docs/2026-07-10-architecture.md b/docs/2026-07-10-architecture.md new file mode 100644 index 0000000..5fdbd0c --- /dev/null +++ b/docs/2026-07-10-architecture.md @@ -0,0 +1,40 @@ +--- +title: "Architecture" +author: "Onur Solmaz <2453968+osolmaz@users.noreply.github.com>" +date: "2026-07-10" +--- + +# Architecture + +annotui keeps the reviewed text and comments independent of terminal behavior. The +source is immutable; only the review document changes. + +```text +file / stdin / --buffer + │ + ▼ + immutable source ───── review document + │ ▲ + ▼ │ + virtual document rows ── app actions + │ ▲ + ├── Ratatui rendering │ + └── semantic hit map ─┘ +``` + +`domain` owns the versioned review data and validation. `source` owns UTF-8 line +indexing and exact-byte hashing. Neither knows about Ratatui, Crossterm, the +filesystem, or process state. + +`app` owns cursor, selection, editor, and viewport state. `render` projects that state +into virtual source/comment/editor rows and returns explicit rectangular hit targets. +Mouse input resolves through those targets rather than converting screen rows directly +to source lines, which remains correct when inline comments change the layout. + +`runner` is the IO boundary. It reads the selected source, owns the event loop, loads +and saves sidecars, and writes the final format. `terminal` owns raw mode, alternate +screen, mouse capture, bracketed paste, and cleanup on both normal exit and panic. + +The test strategy mirrors these boundaries: domain and formatter unit tests, reducer +and hit-test event tests, Ratatui `TestBackend` layout tests, and a tmux smoke test that +injects actual SGR mouse sequences through a pseudoterminal. diff --git a/docs/2026-07-10-data-model-review.md b/docs/2026-07-10-data-model-review.md new file mode 100644 index 0000000..cc72ea4 --- /dev/null +++ b/docs/2026-07-10-data-model-review.md @@ -0,0 +1,48 @@ +--- +title: "Data model review" +author: "Onur Solmaz <2453968+osolmaz@users.noreply.github.com>" +date: "2026-07-10" +--- + +# Data model review + +The version 1 JSON model was reviewed with Schemator before the Rust types were +implemented. + +Context: annotui stores one immutable source identity and local line-range comments. +Terminal layout, cursor state, timestamps, users, and speculative diff anchors are not +stable data and were intentionally excluded. + +Commands: + +```sh +npx -y @dutifuldev/schemator run \ + --source annotui-schema.md \ + --context annotui-schema-context.md \ + --out annotui-schemator +npx -y @dutifuldev/schemator report \ + --run annotui-schemator \ + --out annotui-schemator/final-report.md +npx -y @dutifuldev/schemator diff \ + --run annotui-schemator \ + --out annotui-schemator/graph-diff.md +``` + +The review converged after one iteration with nine initial and final fields, zero +applied or skipped changes, zero manual structural proposals, and zero consistency +warnings. The initial and final field graphs were identical: + +| Field | Type | Required | +| --- | --- | --- | +| `version` | number | yes | +| `source` | object | yes | +| `source.name` | string | yes | +| `source.sha256` | string | yes | +| `comments` | array | yes | +| `comments[].id` | number | yes | +| `comments[].start_line` | number | yes | +| `comments[].end_line` | number | yes | +| `comments[].body` | string | yes | + +The accepted product model is documented in [output-formats.md](output-formats.md) and +implemented by the public Rust types in `src/domain.rs`. diff --git a/docs/2026-07-10-output-formats.md b/docs/2026-07-10-output-formats.md new file mode 100644 index 0000000..e65e016 --- /dev/null +++ b/docs/2026-07-10-output-formats.md @@ -0,0 +1,78 @@ +--- +title: "Output formats" +author: "Onur Solmaz <2453968+osolmaz@users.noreply.github.com>" +date: "2026-07-10" +--- + +# Output formats + +annotui separates its terminal interface from final output. The TUI reads and writes +the controlling terminal at `/dev/tty`; the selected output format goes to stdout or +the path passed to `--output`. + +## `comments` + +`comments` is the default. Each comment becomes one Markdown block containing only +its selected source lines, followed by a blank line and the comment body: + +```markdown +> first selected line +> second selected line + +This is the comment. +``` + +Comments are ordered by start line, end line, then ID. Multiple comment blocks are +separated by a blank line. An empty review produces no output. + +## `full` + +`full` emits every source line as a Markdown blockquote. A comment is inserted after +the final line in its selected range: + +```markdown +> first line +> second line + +This comment covers the first two lines. + +> third line +``` + +Source lines remain in their original order. The source is quoted so comments stay +visually distinct from the reviewed text. + +## `json` + +`json` emits the same versioned document used by `--comments` sidecars: + +```json +{ + "version": 1, + "source": { + "name": "src/main.rs", + "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }, + "comments": [ + { + "id": 1, + "start_line": 12, + "end_line": 16, + "body": "This branch can be simplified." + } + ] +} +``` + +Field rules: + +- `version` is currently `1`. +- `source.name` is the displayed source name. +- `source.sha256` is the lowercase SHA-256 of the exact input bytes. +- `id` is a positive integer unique within the document. +- `start_line` and `end_line` are one-based and inclusive. +- `body` is non-empty Markdown. +- Unknown fields are rejected when a sidecar is loaded. + +Sidecars are saved atomically. A loaded sidecar must have a supported version, valid +ranges, unique IDs, and a source hash matching the current input. diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh new file mode 100755 index 0000000..bd3f5ee --- /dev/null +++ b/scripts/ci-local.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$repo_root" + +cargo fmt --check +cargo check --workspace +cargo test --workspace --all-targets +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo llvm-cov --workspace --all-targets \ + --ignore-filename-regex 'src/(main|runner|terminal)\.rs' \ + --fail-under-lines 85 \ + --summary-only +cargo audit +cargo mutants --workspace --timeout 120 \ + -f src/app.rs \ + -f src/domain.rs \ + -f src/output.rs \ + -f src/source.rs +slophammer-rs dry . --format json +slophammer-rs boundaries . --format json +slophammer-rs unsafe . --format json +slophammer-rs check . --format json +npx -y @simpledoc/simpledoc check +scripts/e2e-tmux.sh +git diff --check diff --git a/scripts/e2e-tmux.sh b/scripts/e2e-tmux.sh new file mode 100755 index 0000000..2a9aade --- /dev/null +++ b/scripts/e2e-tmux.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +session="annotui-e2e-$$" +tmpdir=$(mktemp -d) + +cleanup() { + tmux kill-session -t "$session" 2>/dev/null || true + rm -rf "$tmpdir" +} +trap cleanup EXIT + +wait_for_pane() { + expected=$1 + for _ in $(seq 1 100); do + if tmux capture-pane -p -t "$session" 2>/dev/null | grep -Fq "$expected"; then + return 0 + fi + sleep 0.05 + done + tmux capture-pane -p -t "$session" 2>/dev/null || true + return 1 +} + +wait_for_file() { + file=$1 + for _ in $(seq 1 100); do + if [ -s "$file" ]; then + return 0 + fi + sleep 0.05 + done + return 1 +} + +start_tui() { + output=$1 + extra_args=$2 + tmux kill-session -t "$session" 2>/dev/null || true + rm -f "$tmpdir/status" "$tmpdir/error.log" + tmux new-session -d -s "$session" -x 100 -y 30 \ + "cd '$repo_root' && target/debug/annotui '$tmpdir/input.txt' $extra_args >'$output' 2>'$tmpdir/error.log'; printf '%s' \$? >'$tmpdir/status'" + tmux set-option -t "$session" remain-on-exit on + wait_for_pane "annotui ·" +} + +finish_tui() { + tmux send-keys -t "$session" q + wait_for_file "$tmpdir/status" + if [ "$(cat "$tmpdir/status")" != "0" ]; then + cat "$tmpdir/error.log" >&2 + exit 1 + fi +} + +printf 'quoted part line 1\nquoted part line 2\nunquoted line\n' >"$tmpdir/input.txt" +cargo build --quiet + +start_tui "$tmpdir/output.md" "--comments '$tmpdir/review.json'" + +# Header occupies row 1. Inject SGR mouse press/drag/release over source rows 2 and 3. +tmux send-keys -t "$session" Escape '[<0;10;2M' +tmux send-keys -t "$session" Escape '[<32;10;3M' +tmux send-keys -t "$session" Escape '[<0;10;3m' +wait_for_pane "Comment on lines 1" + +tmux send-keys -t "$session" -l "human comment here ..." +tmux send-keys -t "$session" Enter +wait_for_pane "Comment saved" +finish_tui + +printf '> quoted part line 1\n> quoted part line 2\n\nhuman comment here ...\n' >"$tmpdir/expected.md" +diff -u "$tmpdir/expected.md" "$tmpdir/output.md" + +start_tui "$tmpdir/full.md" "--comments '$tmpdir/review.json' --format full" +finish_tui +printf '> quoted part line 1\n> quoted part line 2\n\nhuman comment here ...\n\n> unquoted line\n' >"$tmpdir/expected-full.md" +diff -u "$tmpdir/expected-full.md" "$tmpdir/full.md" + +start_tui "$tmpdir/review-output.json" "--comments '$tmpdir/review.json' --format json" +finish_tui +python3 - "$tmpdir/review-output.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + review = json.load(handle) + +assert review["version"] == 1 +assert len(review["source"]["sha256"]) == 64 +assert review["comments"] == [ + { + "id": 1, + "start_line": 1, + "end_line": 2, + "body": "human comment here ...", + } +] +PY diff --git a/slophammer.yml b/slophammer.yml new file mode 100644 index 0000000..4ae42e4 --- /dev/null +++ b/slophammer.yml @@ -0,0 +1,42 @@ +rust: + coverage: + threshold: 85 + paths: + - src + exclude: + - "target/**" + - "tests/**" + - pattern: "src/main.rs" + reason: process entrypoint is exercised through the tmux end-to-end test + - pattern: "src/runner.rs" + reason: controlling-terminal event loop is exercised through the tmux end-to-end test + - pattern: "src/terminal.rs" + reason: raw /dev/tty lifecycle is exercised through the tmux end-to-end test + complexity: + cognitive_max: 8 + targets: + - src + exclude: + - "target/**" + - "tests/**" + dry: + max_findings: 0 + paths: + - src + exclude: + - "target/**" + - "tests/**" + copied_blocks: + enabled: true + min_tokens: 100 + unsafe: + policy: forbid + mutation: + targets: + - src/app.rs + - src/domain.rs + - src/output.rs + - src/source.rs + dependency_boundaries: + - from: . + allow: [] diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..9d2579a --- /dev/null +++ b/src/app.rs @@ -0,0 +1,369 @@ +use ratatui_textarea::{CursorMove, TextArea}; + +use crate::{ + domain::{Comment, ReviewDocument}, + source::SourceBuffer, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Selection { + pub anchor: usize, + pub current: usize, +} + +impl Selection { + #[must_use] + pub fn normalized(self) -> (usize, usize) { + if self.anchor <= self.current { + (self.anchor, self.current) + } else { + (self.current, self.anchor) + } + } + + #[must_use] + pub fn contains(self, line: usize) -> bool { + let (start, end) = self.normalized(); + start <= line && line <= end + } +} + +#[derive(Debug)] +pub struct CommentEditor { + pub comment_id: Option, + pub start_line: usize, + pub end_line: usize, + pub textarea: TextArea<'static>, +} + +impl CommentEditor { + fn new(comment_id: Option, start_line: usize, end_line: usize, body: &str) -> Self { + let mut textarea = if body.is_empty() { + TextArea::default() + } else { + TextArea::from(body.split('\n')) + }; + textarea.set_placeholder_text("Write a comment…"); + textarea.set_cursor_line_style(ratatui::style::Style::default()); + textarea.move_cursor(CursorMove::Bottom); + textarea.move_cursor(CursorMove::End); + Self { + comment_id, + start_line, + end_line, + textarea, + } + } + + pub fn body(&self) -> String { + self.textarea.lines().join("\n").trim().to_owned() + } +} + +#[derive(Debug)] +pub struct App { + pub source: SourceBuffer, + pub review: ReviewDocument, + pub cursor_line: usize, + pub selection: Option, + pub editor: Option, + pub scroll_row: usize, + pub horizontal_scroll: usize, + pub should_quit: bool, + pub follow_cursor: bool, + pub status: Option, + pub dirty: bool, +} + +impl App { + #[must_use] + pub fn new(source: SourceBuffer, review: ReviewDocument) -> Self { + Self { + source, + review, + cursor_line: 1, + selection: None, + editor: None, + scroll_row: 0, + horizontal_scroll: 0, + should_quit: false, + follow_cursor: true, + status: None, + dirty: false, + } + } + + pub fn move_cursor(&mut self, delta: isize) { + let maximum = self.source.line_count(); + self.cursor_line = self + .cursor_line + .saturating_add_signed(delta) + .clamp(1, maximum); + if let Some(selection) = &mut self.selection { + selection.current = self.cursor_line; + } + self.follow_cursor = true; + self.status = None; + } + + pub fn move_to_line(&mut self, line: usize) { + self.cursor_line = line.clamp(1, self.source.line_count()); + if let Some(selection) = &mut self.selection { + selection.current = self.cursor_line; + } + self.follow_cursor = true; + } + + pub fn begin_selection(&mut self, line: usize) { + self.move_to_line(line); + self.selection = Some(Selection { + anchor: self.cursor_line, + current: self.cursor_line, + }); + self.status = None; + } + + pub fn extend_selection(&mut self, line: usize) { + if self.selection.is_none() { + self.begin_selection(line); + return; + } + self.cursor_line = line.clamp(1, self.source.line_count()); + if let Some(selection) = &mut self.selection { + selection.current = self.cursor_line; + } + } + + pub fn cancel_selection(&mut self) { + self.selection = None; + self.status = None; + } + + pub fn open_selected_editor(&mut self) { + let selection = self.selection.unwrap_or(Selection { + anchor: self.cursor_line, + current: self.cursor_line, + }); + let (start_line, end_line) = selection.normalized(); + self.editor = Some(CommentEditor::new(None, start_line, end_line, "")); + self.follow_cursor = true; + self.status = None; + } + + pub fn begin_edit(&mut self, comment_id: u64) -> bool { + let Some(comment) = self + .review + .comments + .iter() + .find(|comment| comment.id == comment_id) + .cloned() + else { + return false; + }; + self.cursor_line = comment.end_line; + self.selection = Some(Selection { + anchor: comment.start_line, + current: comment.end_line, + }); + self.editor = Some(CommentEditor::new( + Some(comment.id), + comment.start_line, + comment.end_line, + &comment.body, + )); + self.follow_cursor = true; + self.status = None; + true + } + + pub fn edit_comment_at_cursor(&mut self) -> bool { + let id = self + .review + .comments + .iter() + .find(|comment| comment.contains_line(self.cursor_line)) + .map(|comment| comment.id); + id.is_some_and(|id| self.begin_edit(id)) + } + + pub fn submit_editor(&mut self) -> bool { + let Some(editor) = &self.editor else { + return false; + }; + let body = editor.body(); + if body.is_empty() { + self.status = Some("Comment cannot be empty".into()); + return false; + } + let id = editor + .comment_id + .unwrap_or_else(|| self.review.next_comment_id()); + self.review.upsert_comment(Comment { + id, + start_line: editor.start_line, + end_line: editor.end_line, + body, + }); + self.editor = None; + self.selection = None; + self.status = Some("Comment saved".into()); + self.dirty = true; + true + } + + pub fn cancel_editor(&mut self) { + self.editor = None; + self.selection = None; + self.status = Some("Edit cancelled".into()); + } + + pub fn delete_comment_at_cursor(&mut self) -> bool { + let id = self + .review + .comments + .iter() + .find(|comment| comment.contains_line(self.cursor_line)) + .map(|comment| comment.id); + let removed = id.is_some_and(|id| self.review.remove_comment(id)); + if removed { + self.status = Some("Comment deleted".into()); + self.dirty = true; + } else { + self.status = Some("No comment on this line".into()); + } + removed + } + + pub fn jump_comment(&mut self, forward: bool) { + let target = if forward { + self.review + .comments + .iter() + .find(|comment| comment.start_line > self.cursor_line) + .or_else(|| self.review.comments.first()) + } else { + self.review + .comments + .iter() + .rev() + .find(|comment| comment.end_line < self.cursor_line) + .or_else(|| self.review.comments.last()) + }; + if let Some(comment) = target { + self.cursor_line = comment.start_line; + self.follow_cursor = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> App { + let source = SourceBuffer::from_bytes("sample", b"one\ntwo\nthree\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + App::new(source, review) + } + + #[test] + fn reverse_selection_normalizes_before_editing() { + let mut app = app(); + app.begin_selection(3); + app.extend_selection(1); + let selection = app.selection.unwrap(); + assert!(selection.contains(1)); + assert!(selection.contains(2)); + assert!(selection.contains(3)); + assert!(!selection.contains(4)); + app.open_selected_editor(); + let editor = app.editor.unwrap(); + assert_eq!((editor.start_line, editor.end_line), (1, 3)); + } + + #[test] + fn comment_can_be_added_reopened_and_deleted() { + let mut app = app(); + app.begin_selection(2); + app.open_selected_editor(); + app.editor.as_mut().unwrap().textarea.insert_str("hello"); + assert!(app.submit_editor()); + assert_eq!(app.review.comments[0].body, "hello"); + + app.cursor_line = 2; + assert!(app.edit_comment_at_cursor()); + app.editor.as_mut().unwrap().textarea.insert_str(" world"); + assert!(app.submit_editor()); + assert_eq!(app.review.comments[0].body, "hello world"); + + app.cursor_line = 2; + assert!(app.delete_comment_at_cursor()); + assert!(app.review.comments.is_empty()); + } + + #[test] + fn empty_comment_stays_open() { + let mut app = app(); + app.open_selected_editor(); + assert!(!app.submit_editor()); + assert!(app.editor.is_some()); + assert_eq!(app.status.as_deref(), Some("Comment cannot be empty")); + } + + #[test] + fn cursor_selection_and_cancellation_stay_within_source() { + let mut app = app(); + app.move_cursor(-10); + assert_eq!(app.cursor_line, 1); + app.begin_selection(2); + app.move_cursor(10); + assert_eq!(app.cursor_line, 3); + assert_eq!(app.selection.unwrap().normalized(), (2, 3)); + app.cancel_selection(); + assert!(app.selection.is_none()); + assert!(app.status.is_none()); + } + + #[test] + fn missing_comment_actions_report_without_mutating() { + let mut app = app(); + assert!(!app.begin_edit(42)); + assert!(!app.edit_comment_at_cursor()); + assert!(!app.delete_comment_at_cursor()); + assert_eq!(app.status.as_deref(), Some("No comment on this line")); + assert!(!app.dirty); + } + + #[test] + fn comment_jumps_wrap_in_both_directions() { + let mut app = app(); + for (id, line) in [(1, 1), (2, 3)] { + app.review.upsert_comment(Comment { + id, + start_line: line, + end_line: line, + body: format!("comment {id}"), + }); + } + app.cursor_line = 1; + app.jump_comment(true); + assert_eq!(app.cursor_line, 3); + app.jump_comment(true); + assert_eq!(app.cursor_line, 1); + app.jump_comment(false); + assert_eq!(app.cursor_line, 3); + app.jump_comment(false); + assert_eq!(app.cursor_line, 1); + } + + #[test] + fn cancelled_editor_discards_draft() { + let mut app = app(); + app.open_selected_editor(); + app.editor.as_mut().unwrap().textarea.insert_str("draft"); + app.cancel_editor(); + assert!(app.editor.is_none()); + assert!(app.review.comments.is_empty()); + assert_eq!(app.status.as_deref(), Some("Edit cancelled")); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..ca00ef1 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,59 @@ +use std::path::PathBuf; + +use clap::Parser; + +use crate::output::OutputFormat; + +#[derive(Debug, Clone, Parser)] +#[command( + name = "annotui", + version, + about = "Comment on files and piped text in a mouse-first terminal UI" +)] +pub struct Cli { + /// File to review, or - to read standard input + #[arg(value_name = "FILE", conflicts_with = "buffer")] + pub input: Option, + + /// Review this literal text instead of a file or standard input + #[arg(long, value_name = "TEXT", conflicts_with = "input")] + pub buffer: Option, + + /// Display name used for standard input or --buffer + #[arg(long, value_name = "NAME")] + pub source_name: Option, + + /// Load and save comments in this JSON sidecar + #[arg(long, value_name = "PATH")] + pub comments: Option, + + /// Final output format written after the TUI closes + #[arg(long, value_enum, default_value_t)] + pub format: OutputFormat, + + /// Write final output to a file instead of standard output + #[arg(short, long, value_name = "PATH")] + pub output: Option, + + /// Disable mouse capture and use keyboard selection only + #[arg(long)] + pub no_mouse: bool, +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[test] + fn comments_is_the_default_format() { + let cli = Cli::try_parse_from(["annotui", "input.txt"]).unwrap(); + assert_eq!(cli.format, OutputFormat::Comments); + } + + #[test] + fn buffer_conflicts_with_a_file() { + assert!(Cli::try_parse_from(["annotui", "input.txt", "--buffer", "hello"]).is_err()); + } +} diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..ddb1005 --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,296 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const REVIEW_FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceRef { + pub name: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Comment { + pub id: u64, + pub start_line: usize, + pub end_line: usize, + pub body: String, +} + +impl Comment { + #[must_use] + pub fn contains_line(&self, line: usize) -> bool { + self.start_line <= line && line <= self.end_line + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReviewDocument { + pub version: u32, + pub source: SourceRef, + pub comments: Vec, +} + +impl ReviewDocument { + #[must_use] + pub fn empty(source: SourceRef) -> Self { + Self { + version: REVIEW_FORMAT_VERSION, + source, + comments: Vec::new(), + } + } + + #[must_use] + pub fn next_comment_id(&self) -> u64 { + self.comments + .iter() + .map(|comment| comment.id) + .max() + .unwrap_or(0) + .saturating_add(1) + } + + pub fn upsert_comment(&mut self, comment: Comment) { + if let Some(existing) = self + .comments + .iter_mut() + .find(|existing| existing.id == comment.id) + { + *existing = comment; + } else { + self.comments.push(comment); + } + self.sort_comments(); + } + + pub fn remove_comment(&mut self, id: u64) -> bool { + let before = self.comments.len(); + self.comments.retain(|comment| comment.id != id); + self.comments.len() != before + } + + pub fn sort_comments(&mut self) { + self.comments + .sort_by_key(|comment| (comment.start_line, comment.end_line, comment.id)); + } + + /// Validates version, source metadata, IDs, line ranges, and comment bodies. + /// + /// # Errors + /// + /// Returns a [`ReviewValidationError`] when any document invariant is broken. + pub fn validate(&self, line_count: usize) -> Result<(), ReviewValidationError> { + if self.version != REVIEW_FORMAT_VERSION { + return Err(ReviewValidationError::UnsupportedVersion(self.version)); + } + if self.source.name.trim().is_empty() { + return Err(ReviewValidationError::EmptySourceName); + } + if self.source.sha256.len() != 64 + || !self + .source + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(ReviewValidationError::InvalidSourceHash); + } + + let mut ids = std::collections::BTreeSet::new(); + for comment in &self.comments { + if comment.id == 0 || !ids.insert(comment.id) { + return Err(ReviewValidationError::InvalidCommentId(comment.id)); + } + if comment.start_line == 0 + || comment.end_line < comment.start_line + || comment.end_line > line_count + { + return Err(ReviewValidationError::InvalidRange { + id: comment.id, + start: comment.start_line, + end: comment.end_line, + line_count, + }); + } + if comment.body.trim().is_empty() { + return Err(ReviewValidationError::EmptyBody(comment.id)); + } + } + Ok(()) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ReviewValidationError { + #[error("unsupported review format version {0}")] + UnsupportedVersion(u32), + #[error("source name must not be empty")] + EmptySourceName, + #[error("source sha256 must contain 64 lowercase hexadecimal characters")] + InvalidSourceHash, + #[error("comment ID {0} must be positive and unique")] + InvalidCommentId(u64), + #[error("comment {id} has invalid range {start}-{end}; source has {line_count} lines")] + InvalidRange { + id: u64, + start: usize, + end: usize, + line_count: usize, + }, + #[error("comment {0} has an empty body")] + EmptyBody(u64), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source() -> SourceRef { + SourceRef { + name: "input.txt".into(), + sha256: "a".repeat(64), + } + } + + #[test] + fn next_id_and_upsert_are_deterministic() { + let mut review = ReviewDocument::empty(source()); + review.upsert_comment(Comment { + id: 4, + start_line: 2, + end_line: 2, + body: "later".into(), + }); + review.upsert_comment(Comment { + id: 1, + start_line: 1, + end_line: 1, + body: "first".into(), + }); + review.upsert_comment(Comment { + id: 4, + start_line: 2, + end_line: 3, + body: "updated".into(), + }); + + assert_eq!(review.next_comment_id(), 5); + assert_eq!( + review.comments.iter().map(|c| c.id).collect::>(), + [1, 4] + ); + assert_eq!(review.comments[1].body, "updated"); + } + + #[test] + fn validation_rejects_bad_ranges_and_bodies() { + let mut review = ReviewDocument::empty(source()); + review.comments.push(Comment { + id: 1, + start_line: 2, + end_line: 4, + body: "comment".into(), + }); + assert!(matches!( + review.validate(3), + Err(ReviewValidationError::InvalidRange { .. }) + )); + + review.comments[0].end_line = 3; + review.comments[0].body = " ".into(); + assert_eq!(review.validate(3), Err(ReviewValidationError::EmptyBody(1))); + } + + #[test] + fn validation_checks_document_metadata_and_unique_ids() { + let mut review = ReviewDocument::empty(source()); + assert!(review.validate(1).is_ok()); + review.version = 2; + assert_eq!( + review.validate(1), + Err(ReviewValidationError::UnsupportedVersion(2)) + ); + review.version = REVIEW_FORMAT_VERSION; + review.source.name = " ".into(); + assert_eq!( + review.validate(1), + Err(ReviewValidationError::EmptySourceName) + ); + review.source.name = "input".into(); + review.source.sha256 = "ABC".into(); + assert_eq!( + review.validate(1), + Err(ReviewValidationError::InvalidSourceHash) + ); + review.source.sha256 = "A".repeat(64); + assert_eq!( + review.validate(1), + Err(ReviewValidationError::InvalidSourceHash) + ); + review.source.sha256 = "b".repeat(63); + assert_eq!( + review.validate(1), + Err(ReviewValidationError::InvalidSourceHash) + ); + + review.source.sha256 = "b".repeat(64); + review.comments = vec![ + Comment { + id: 1, + start_line: 1, + end_line: 1, + body: "one".into(), + }, + Comment { + id: 1, + start_line: 1, + end_line: 1, + body: "two".into(), + }, + ]; + assert_eq!( + review.validate(1), + Err(ReviewValidationError::InvalidCommentId(1)) + ); + assert!(review.remove_comment(1)); + assert!(!review.remove_comment(1)); + } + + #[test] + fn line_containment_and_each_range_boundary_are_validated() { + let comment = Comment { + id: 1, + start_line: 2, + end_line: 3, + body: "body".into(), + }; + assert!(!comment.contains_line(1)); + assert!(comment.contains_line(2)); + assert!(comment.contains_line(3)); + assert!(!comment.contains_line(4)); + + let mut review = ReviewDocument::empty(source()); + review.comments.push(comment); + review.comments[0].start_line = 0; + assert!(matches!( + review.validate(3), + Err(ReviewValidationError::InvalidRange { .. }) + )); + review.comments[0].start_line = 3; + review.comments[0].end_line = 2; + assert!(matches!( + review.validate(3), + Err(ReviewValidationError::InvalidRange { .. }) + )); + review.comments[0].start_line = 2; + review.comments[0].end_line = 4; + assert!(matches!( + review.validate(3), + Err(ReviewValidationError::InvalidRange { .. }) + )); + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..c8b0296 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,53 @@ +use ratatui::layout::Rect; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HitTarget { + SourceLine(usize), + Comment(u64), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HitArea { + pub rect: Rect, + pub target: HitTarget, +} + +impl HitArea { + #[must_use] + pub fn new(rect: Rect, target: HitTarget) -> Self { + Self { rect, target } + } + + #[must_use] + pub fn contains(self, column: u16, row: u16) -> bool { + column >= self.rect.x + && column < self.rect.x.saturating_add(self.rect.width) + && row >= self.rect.y + && row < self.rect.y.saturating_add(self.rect.height) + } +} + +#[must_use] +pub fn hit_test(areas: &[HitArea], column: u16, row: u16) -> Option { + areas + .iter() + .rev() + .find(|area| area.contains(column, row)) + .map(|area| area.target) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn later_hit_areas_win_like_render_z_order() { + let areas = [ + HitArea::new(Rect::new(0, 0, 10, 2), HitTarget::SourceLine(1)), + HitArea::new(Rect::new(2, 0, 4, 1), HitTarget::Comment(7)), + ]; + assert_eq!(hit_test(&areas, 3, 0), Some(HitTarget::Comment(7))); + assert_eq!(hit_test(&areas, 9, 1), Some(HitTarget::SourceLine(1))); + assert_eq!(hit_test(&areas, 10, 1), None); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d0b2337 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +#![forbid(unsafe_code)] + +pub mod app; +pub mod cli; +pub mod domain; +pub mod input; +pub mod output; +pub mod render; +pub mod runner; +pub mod source; +pub mod storage; +pub mod terminal; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f7fd4f7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,7 @@ +use clap::Parser; + +use annotui::{cli::Cli, runner}; + +fn main() -> anyhow::Result<()> { + runner::run(&Cli::parse()) +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..fce57e4 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,150 @@ +use std::fmt::Write as _; + +use anyhow::Context; +use clap::ValueEnum; + +use crate::{domain::ReviewDocument, source::SourceBuffer}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum OutputFormat { + #[default] + Comments, + Full, + Json, +} + +/// Formats a completed review for stdout or a file. +/// +/// # Errors +/// +/// Returns an error when JSON serialization fails. +pub fn format_review( + format: OutputFormat, + source: &SourceBuffer, + review: &ReviewDocument, +) -> anyhow::Result { + match format { + OutputFormat::Comments => Ok(format_comments(source, review)), + OutputFormat::Full => Ok(format_full(source, review)), + OutputFormat::Json => serde_json::to_string_pretty(review).context("serialize review JSON"), + } +} + +#[must_use] +pub fn format_comments(source: &SourceBuffer, review: &ReviewDocument) -> String { + review + .comments + .iter() + .map(|comment| { + let quote = (comment.start_line..=comment.end_line) + .filter_map(|line| source.line(line)) + .map(quote_line) + .collect::>() + .join("\n"); + format!("{quote}\n\n{}", comment.body.trim()) + }) + .collect::>() + .join("\n\n") +} + +#[must_use] +pub fn format_full(source: &SourceBuffer, review: &ReviewDocument) -> String { + let mut blocks = Vec::new(); + let mut quote = String::new(); + + for line_number in 1..=source.line_count() { + if !quote.is_empty() { + quote.push('\n'); + } + let _ = write!( + quote, + "{}", + quote_line(source.line(line_number).unwrap_or_default()) + ); + + let comments = review + .comments + .iter() + .filter(|comment| comment.end_line == line_number) + .map(|comment| comment.body.trim().to_owned()) + .collect::>(); + if !comments.is_empty() { + blocks.push(std::mem::take(&mut quote)); + blocks.extend(comments); + } + } + + if !quote.is_empty() { + blocks.push(quote); + } + blocks.join("\n\n") +} + +fn quote_line(line: &str) -> String { + format!("> {line}") +} + +#[cfg(test)] +mod tests { + use crate::domain::{Comment, ReviewDocument}; + + use super::*; + + fn fixture() -> (SourceBuffer, ReviewDocument) { + let source = SourceBuffer::from_bytes("sample", b"alpha\nbeta\ngamma\ndelta\n").unwrap(); + let mut review = ReviewDocument::empty(source.source_ref()); + review.upsert_comment(Comment { + id: 1, + start_line: 2, + end_line: 3, + body: "human comment here ...".into(), + }); + (source, review) + } + + #[test] + fn comments_mode_only_quotes_the_anchored_range() { + let (source, review) = fixture(); + assert_eq!( + format_comments(&source, &review), + "> beta\n> gamma\n\nhuman comment here ..." + ); + } + + #[test] + fn full_mode_quotes_every_line_and_inserts_comments_inline() { + let (source, review) = fixture(); + assert_eq!( + format_full(&source, &review), + "> alpha\n> beta\n> gamma\n\nhuman comment here ...\n\n> delta" + ); + } + + #[test] + fn comments_mode_is_empty_without_comments() { + let source = SourceBuffer::from_bytes("sample", b"alpha\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + assert!(format_comments(&source, &review).is_empty()); + } + + #[test] + fn dispatcher_serializes_json_and_multiple_comment_blocks() { + let (source, mut review) = fixture(); + review.upsert_comment(Comment { + id: 2, + start_line: 4, + end_line: 4, + body: "second".into(), + }); + let comments = format_review(OutputFormat::Comments, &source, &review).unwrap(); + assert!(comments.contains("human comment here ...\n\n> delta\n\nsecond")); + + let json = format_review(OutputFormat::Json, &source, &review).unwrap(); + let decoded: ReviewDocument = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, review); + assert_eq!( + format_review(OutputFormat::Full, &source, &review).unwrap(), + format_full(&source, &review) + ); + } +} diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..92f51e9 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,368 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::{ + app::App, + input::{HitArea, HitTarget}, +}; + +const EDITOR_HEIGHT: usize = 5; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DocumentRow { + Source(usize), + Comment { id: u64, text: String, first: bool }, + Editor, +} + +pub fn render_app(frame: &mut Frame<'_>, app: &mut App) -> Vec { + let area = frame.area(); + if area.height < 3 || area.width < 20 { + frame.render_widget( + Paragraph::new("annotui needs at least 20 columns and 3 rows") + .style(Style::default().fg(Color::Red)), + area, + ); + return Vec::new(); + } + + let header = Rect::new(area.x, area.y, area.width, 1); + let content = Rect::new( + area.x, + area.y.saturating_add(1), + area.width, + area.height.saturating_sub(2), + ); + let footer = Rect::new( + area.x, + area.y.saturating_add(area.height.saturating_sub(1)), + area.width, + 1, + ); + + render_header(frame, app, header); + let hits = render_document(frame, app, content); + render_footer(frame, app, footer); + hits +} + +fn render_header(frame: &mut Frame<'_>, app: &App, area: Rect) { + let title = format!( + " annotui · {} · {} lines · {} comments ", + app.source.name(), + app.source.line_count(), + app.review.comments.len() + ); + frame.render_widget( + Paragraph::new(title).style( + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + area, + ); +} + +fn render_document(frame: &mut Frame<'_>, app: &mut App, area: Rect) -> Vec { + let rows = document_rows(app); + update_scroll(app, &rows, usize::from(area.height)); + let visible = rows + .iter() + .enumerate() + .skip(app.scroll_row) + .take(usize::from(area.height)); + let line_digits = app.source.line_count().to_string().len(); + let mut hits = Vec::new(); + let mut editor_rect: Option = None; + + for (screen_offset, (_, row)) in visible.enumerate() { + let rect = Rect::new( + area.x, + area.y + .saturating_add(u16::try_from(screen_offset).unwrap_or(u16::MAX)), + area.width, + 1, + ); + match row { + DocumentRow::Source(line_number) => { + render_source_row(frame, app, rect, *line_number, line_digits); + hits.push(HitArea::new(rect, HitTarget::SourceLine(*line_number))); + } + DocumentRow::Comment { id, text, first } => { + render_comment_row(frame, rect, text, *first); + hits.push(HitArea::new(rect, HitTarget::Comment(*id))); + } + DocumentRow::Editor => extend_editor_rect(&mut editor_rect, rect), + } + } + + if let (Some(rect), Some(editor)) = (editor_rect, app.editor.as_mut()) { + let range = if editor.start_line == editor.end_line { + format!(" Comment on line {} ", editor.start_line) + } else { + format!( + " Comment on lines {}–{} ", + editor.start_line, editor.end_line + ) + }; + editor.textarea.set_block( + Block::default() + .title(range) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)), + ); + frame.render_widget(&editor.textarea, rect); + } + hits +} + +fn document_rows(app: &App) -> Vec { + let mut rows = Vec::new(); + let editing_id = app.editor.as_ref().and_then(|editor| editor.comment_id); + let editor_end = app.editor.as_ref().map(|editor| editor.end_line); + + for line_number in 1..=app.source.line_count() { + rows.push(DocumentRow::Source(line_number)); + for comment in app + .review + .comments + .iter() + .filter(|comment| comment.end_line == line_number && Some(comment.id) != editing_id) + { + for (index, body_line) in comment.body.lines().enumerate() { + rows.push(DocumentRow::Comment { + id: comment.id, + text: body_line.to_owned(), + first: index == 0, + }); + } + } + if editor_end == Some(line_number) { + rows.extend(std::iter::repeat_n(DocumentRow::Editor, EDITOR_HEIGHT)); + } + } + rows +} + +fn update_scroll(app: &mut App, rows: &[DocumentRow], viewport_height: usize) { + let maximum = rows.len().saturating_sub(viewport_height); + app.scroll_row = app.scroll_row.min(maximum); + if !app.follow_cursor || viewport_height == 0 { + return; + } + + let source_row = rows + .iter() + .position(|row| matches!(row, DocumentRow::Source(line) if *line == app.cursor_line)) + .unwrap_or(0); + let target_row = if app.editor.is_some() { + rows.iter() + .enumerate() + .skip(source_row) + .take_while( + |(_, row)| !matches!(row, DocumentRow::Source(line) if *line > app.cursor_line), + ) + .map(|(index, _)| index) + .last() + .unwrap_or(source_row) + } else { + source_row + }; + + if source_row < app.scroll_row { + app.scroll_row = source_row; + } else if target_row >= app.scroll_row.saturating_add(viewport_height) { + app.scroll_row = target_row.saturating_add(1).saturating_sub(viewport_height); + } + app.scroll_row = app.scroll_row.min(maximum); + app.follow_cursor = false; +} + +fn render_source_row( + frame: &mut Frame<'_>, + app: &App, + area: Rect, + line_number: usize, + line_digits: usize, +) { + let selected = app + .selection + .is_some_and(|selection| selection.contains(line_number)); + let cursor = app.cursor_line == line_number && app.editor.is_none(); + let marker = if cursor { "▶" } else { " " }; + let number = format!("{marker}{line_number:>line_digits$} │ "); + let text = horizontally_scrolled( + app.source.line(line_number).unwrap_or_default(), + app.horizontal_scroll, + ); + let style = if selected { + Style::default().fg(Color::White).bg(Color::DarkGray) + } else { + Style::default() + }; + let number_style = if selected { + Style::default().fg(Color::Gray).bg(Color::DarkGray) + } else { + Style::default().fg(Color::DarkGray) + }; + let line = Line::from(vec![ + Span::styled(number, number_style), + Span::styled(text, style), + ]); + frame.render_widget(Paragraph::new(line).style(style), area); +} + +fn render_comment_row(frame: &mut Frame<'_>, area: Rect, text: &str, first: bool) { + let prefix = if first { " └─ " } else { " " }; + frame.render_widget( + Paragraph::new(format!("{prefix}{text}")) + .style(Style::default().fg(Color::Green).bg(Color::Rgb(20, 35, 25))), + area, + ); +} + +fn extend_editor_rect(editor_rect: &mut Option, row: Rect) { + if let Some(rect) = editor_rect { + rect.height = rect.height.saturating_add(1); + } else { + *editor_rect = Some(row); + } +} + +fn horizontally_scrolled(text: &str, columns: usize) -> String { + let mut skipped = 0; + text.chars() + .skip_while(|character| { + if skipped >= columns { + return false; + } + skipped += unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0); + true + }) + .collect() +} + +fn render_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { + let help = if app.editor.is_some() { + " Enter save · Ctrl-O newline · Esc cancel · Ctrl-A/E line start/end " + } else if let Some(selection) = app.selection { + let (start, end) = selection.normalized(); + return render_footer_text( + frame, + area, + &format!(" Lines {start}–{end} selected · Enter comment · Esc cancel "), + ); + } else { + " drag select · Enter comment · e edit · d delete · [/] comments · q output & quit " + }; + let text = app.status.as_deref().unwrap_or(help); + render_footer_text(frame, area, text); +} + +fn render_footer_text(frame: &mut Frame<'_>, area: Rect, text: &str) { + frame.render_widget( + Paragraph::new(text).style(Style::default().fg(Color::Black).bg(Color::Gray)), + area, + ); +} + +#[cfg(test)] +mod tests { + use ratatui::{backend::TestBackend, Terminal}; + + use crate::{domain::ReviewDocument, source::SourceBuffer}; + + use super::*; + + #[test] + fn renders_source_selection_and_mouse_targets() { + let source = SourceBuffer::from_bytes("sample", b"one\ntwo\nthree\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + let mut app = App::new(source, review); + app.begin_selection(2); + app.extend_selection(3); + let backend = TestBackend::new(80, 12); + let mut terminal = Terminal::new(backend).unwrap(); + let mut hits = Vec::new(); + + terminal + .draw(|frame| hits = render_app(frame, &mut app)) + .unwrap(); + + let rendered = terminal.backend().to_string(); + assert!(rendered.contains("annotui · sample · 3 lines")); + assert!(rendered.contains("two")); + assert!(hits + .iter() + .any(|hit| hit.target == HitTarget::SourceLine(3))); + } + + #[test] + fn cramped_terminal_shows_a_clear_requirement() { + let source = SourceBuffer::from_bytes("sample", b"one\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + let mut app = App::new(source, review); + let backend = TestBackend::new(15, 2); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| drop(render_app(frame, &mut app))) + .unwrap(); + assert!(terminal.backend().to_string().contains("annotui needs")); + } + + #[test] + fn renders_existing_comments_and_inline_editor() { + let source = SourceBuffer::from_bytes("sample", b"one\ntwo\nthree\n").unwrap(); + let mut review = ReviewDocument::empty(source.source_ref()); + review.upsert_comment(crate::domain::Comment { + id: 1, + start_line: 1, + end_line: 2, + body: "first line\nsecond line".into(), + }); + let mut app = App::new(source, review); + let backend = TestBackend::new(80, 15); + let mut terminal = Terminal::new(backend).unwrap(); + let mut hits = Vec::new(); + terminal + .draw(|frame| hits = render_app(frame, &mut app)) + .unwrap(); + assert!(terminal.backend().to_string().contains("└─ first line")); + assert!(hits.iter().any(|hit| hit.target == HitTarget::Comment(1))); + + app.begin_edit(1); + terminal + .draw(|frame| hits = render_app(frame, &mut app)) + .unwrap(); + let rendered = terminal.backend().to_string(); + assert!(rendered.contains("Comment on lines 1–2")); + assert!(rendered.contains("Ctrl-O newline")); + } + + #[test] + fn following_cursor_scrolls_long_documents_and_horizontal_text() { + let text = (1..=30) + .map(|line| format!("line {line}")) + .collect::>() + .join("\n"); + let source = SourceBuffer::from_bytes("long", text.as_bytes()).unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + let mut app = App::new(source, review); + app.move_to_line(30); + app.horizontal_scroll = 5; + let backend = TestBackend::new(40, 8); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| drop(render_app(frame, &mut app))) + .unwrap(); + assert!(app.scroll_row > 0); + assert_eq!(horizontally_scrolled("abcdef", 3), "def"); + assert_eq!(horizontally_scrolled("界abc", 2), "abc"); + } +} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..2150a8f --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,337 @@ +use std::{ + fs, + io::{self, IsTerminal, Read, Write}, + path::Path, + time::Duration, +}; + +use anyhow::{bail, Context}; +use crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use ratatui_textarea::Scrolling; + +use crate::{ + app::App, + cli::Cli, + domain::ReviewDocument, + input::{hit_test, HitArea, HitTarget}, + output::format_review, + render::render_app, + source::SourceBuffer, + storage::{load_review, save_review}, + terminal::TerminalGuard, +}; + +/// Runs one interactive review and writes its selected output format. +/// +/// # Errors +/// +/// Returns an error for invalid input, terminal failures, invalid sidecars, or output failures. +pub fn run(cli: &Cli) -> anyhow::Result<()> { + let source = read_source(cli)?; + let review = read_review(cli, &source)?; + let mut app = App::new(source, review); + + { + let (_guard, mut terminal) = TerminalGuard::enter(!cli.no_mouse)?; + let mut hit_areas = Vec::new(); + while !app.should_quit { + terminal.draw(|frame| hit_areas = render_app(frame, &mut app))?; + if event::poll(Duration::from_millis(250))? { + handle_event(event::read()?, &mut app, &hit_areas); + } + } + } + + app.review.validate(app.source.line_count())?; + if let Some(path) = &cli.comments { + save_review(path, &app.review)?; + } + let output = format_review(cli.format, &app.source, &app.review)?; + write_output(cli.output.as_deref(), &output) +} + +fn read_source(cli: &Cli) -> anyhow::Result { + let (name, bytes) = if let Some(buffer) = &cli.buffer { + ( + cli.source_name.as_deref().unwrap_or("(buffer)").to_owned(), + buffer.as_bytes().to_vec(), + ) + } else if let Some(path) = &cli.input { + if path == Path::new("-") { + ( + cli.source_name.as_deref().unwrap_or("(stdin)").to_owned(), + read_stdin()?, + ) + } else { + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + ( + cli.source_name + .clone() + .unwrap_or_else(|| path.display().to_string()), + bytes, + ) + } + } else if !io::stdin().is_terminal() { + ( + cli.source_name.as_deref().unwrap_or("(stdin)").to_owned(), + read_stdin()?, + ) + } else { + bail!("provide a file, pipe standard input, or pass --buffer") + }; + + SourceBuffer::from_bytes(name, &bytes).context("input must be valid UTF-8") +} + +fn read_stdin() -> anyhow::Result> { + let mut bytes = Vec::new(); + io::stdin() + .read_to_end(&mut bytes) + .context("read standard input")?; + Ok(bytes) +} + +fn read_review(cli: &Cli, source: &SourceBuffer) -> anyhow::Result { + let Some(path) = &cli.comments else { + return Ok(ReviewDocument::empty(source.source_ref())); + }; + if !path.exists() { + return Ok(ReviewDocument::empty(source.source_ref())); + } + let mut review = load_review(path)?; + review.validate(source.line_count())?; + if review.source.sha256 != source.sha256() { + bail!( + "comments in {} belong to different source content", + path.display() + ); + } + review.source = source.source_ref(); + review.sort_comments(); + Ok(review) +} + +fn write_output(path: Option<&Path>, output: &str) -> anyhow::Result<()> { + if output.is_empty() { + if let Some(path) = path { + fs::write(path, []).with_context(|| format!("clear output {}", path.display()))?; + } + return Ok(()); + } + if let Some(path) = path { + let mut file = + fs::File::create(path).with_context(|| format!("create output {}", path.display()))?; + writeln!(file, "{output}").with_context(|| format!("write output {}", path.display()))?; + } else { + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{output}").context("write standard output")?; + } + Ok(()) +} + +pub fn handle_event(event: Event, app: &mut App, hit_areas: &[HitArea]) { + match event { + Event::Key(key) if key.kind != KeyEventKind::Release => handle_key(key, app), + Event::Mouse(mouse) => handle_mouse(mouse, app, hit_areas), + Event::Paste(text) => { + if let Some(editor) = app.editor.as_mut() { + editor.textarea.insert_str(text); + } + } + Event::FocusGained | Event::FocusLost | Event::Resize(_, _) | Event::Key(_) => {} + } +} + +pub fn handle_key(key: KeyEvent, app: &mut App) { + if app.editor.is_some() { + handle_editor_key(key, app); + } else { + handle_browse_key(key, app); + } +} + +fn handle_editor_key(key: KeyEvent, app: &mut App) { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => app.cancel_editor(), + (KeyCode::Enter, modifiers) + if modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) => + { + if let Some(editor) = app.editor.as_mut() { + editor.textarea.insert_newline(); + } + } + (KeyCode::Char('o'), KeyModifiers::CONTROL) => { + if let Some(editor) = app.editor.as_mut() { + editor.textarea.insert_newline(); + } + } + (KeyCode::Enter, _) => { + app.submit_editor(); + } + _ => { + if let Some(editor) = app.editor.as_mut() { + editor.textarea.input(key); + } + } + } +} + +fn handle_browse_key(key: KeyEvent, app: &mut App) { + match key.code { + KeyCode::Char('q') => app.should_quit = true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true; + } + KeyCode::Down | KeyCode::Char('j') => app.move_cursor(1), + KeyCode::Up | KeyCode::Char('k') => app.move_cursor(-1), + KeyCode::PageDown => app.move_cursor(10), + KeyCode::PageUp => app.move_cursor(-10), + KeyCode::Char('g') => app.move_to_line(1), + KeyCode::Char('G') => app.move_to_line(app.source.line_count()), + KeyCode::Char('v') => { + if app.selection.is_some() { + app.cancel_selection(); + } else { + app.begin_selection(app.cursor_line); + } + } + KeyCode::Enter => app.open_selected_editor(), + KeyCode::Esc => app.cancel_selection(), + KeyCode::Char('e') => { + app.edit_comment_at_cursor(); + } + KeyCode::Char('d') => { + app.delete_comment_at_cursor(); + } + KeyCode::Char(']') => app.jump_comment(true), + KeyCode::Char('[') => app.jump_comment(false), + KeyCode::Char('h') | KeyCode::Left => { + app.horizontal_scroll = app.horizontal_scroll.saturating_sub(4); + } + KeyCode::Char('l') | KeyCode::Right => { + app.horizontal_scroll = app.horizontal_scroll.saturating_add(4); + } + _ => {} + } +} + +fn handle_mouse(mouse: MouseEvent, app: &mut App, hit_areas: &[HitArea]) { + let target = hit_test(hit_areas, mouse.column, mouse.row); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => match target { + Some(HitTarget::SourceLine(line)) if app.editor.is_none() => app.begin_selection(line), + Some(HitTarget::Comment(id)) if app.editor.is_none() => { + app.begin_edit(id); + } + _ => {} + }, + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(HitTarget::SourceLine(line)) = target { + app.extend_selection(line); + } + } + MouseEventKind::Up(_) => { + if app.editor.is_none() && app.selection.is_some() { + if let Some(HitTarget::SourceLine(line)) = target { + app.extend_selection(line); + } + app.open_selected_editor(); + } + } + MouseEventKind::ScrollDown => { + if let Some(editor) = app.editor.as_mut() { + editor.textarea.scroll(Scrolling::PageDown); + } else { + app.scroll_row = app.scroll_row.saturating_add(3); + app.follow_cursor = false; + } + } + MouseEventKind::ScrollUp => { + if let Some(editor) = app.editor.as_mut() { + editor.textarea.scroll(Scrolling::PageUp); + } else { + app.scroll_row = app.scroll_row.saturating_sub(3); + app.follow_cursor = false; + } + } + MouseEventKind::ScrollLeft => { + app.horizontal_scroll = app.horizontal_scroll.saturating_sub(4); + } + MouseEventKind::ScrollRight => { + app.horizontal_scroll = app.horizontal_scroll.saturating_add(4); + } + MouseEventKind::Moved | MouseEventKind::Down(_) | MouseEventKind::Drag(_) => {} + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyEvent, MouseEvent}; + use ratatui::layout::Rect; + + use crate::{domain::ReviewDocument, source::SourceBuffer}; + + use super::*; + + fn app() -> App { + let source = SourceBuffer::from_bytes("sample", b"one\ntwo\nthree\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + App::new(source, review) + } + + #[test] + fn keyboard_selection_opens_and_submits_editor() { + let mut app = app(); + handle_key( + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), + &mut app, + ); + handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &mut app); + handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut app); + handle_key( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + &mut app, + ); + handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut app); + + assert_eq!(app.review.comments[0].start_line, 1); + assert_eq!(app.review.comments[0].end_line, 2); + assert_eq!(app.review.comments[0].body, "x"); + } + + #[test] + fn mouse_drag_release_opens_editor_for_the_range() { + let mut app = app(); + let hits = [ + HitArea::new(Rect::new(0, 1, 80, 1), HitTarget::SourceLine(1)), + HitArea::new(Rect::new(0, 2, 80, 1), HitTarget::SourceLine(2)), + ]; + for mouse in [ + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 1, + modifiers: KeyModifiers::NONE, + }, + MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: 5, + row: 2, + modifiers: KeyModifiers::NONE, + }, + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 5, + row: 2, + modifiers: KeyModifiers::NONE, + }, + ] { + handle_mouse(mouse, &mut app, &hits); + } + let editor = app.editor.unwrap(); + assert_eq!((editor.start_line, editor.end_line), (1, 2)); + } +} diff --git a/src/source.rs b/src/source.rs new file mode 100644 index 0000000..b0c2f62 --- /dev/null +++ b/src/source.rs @@ -0,0 +1,108 @@ +use std::path::Path; + +use sha2::{Digest, Sha256}; + +use crate::domain::SourceRef; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceBuffer { + name: String, + sha256: String, + lines: Vec, +} + +impl SourceBuffer { + /// Builds a source buffer from exact UTF-8 input bytes. + /// + /// # Errors + /// + /// Returns an error when the input is not valid UTF-8. + pub fn from_bytes(name: impl Into, bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes)?; + let mut lines = text + .split_terminator('\n') + .map(|line| line.strip_suffix('\r').unwrap_or(line).to_owned()) + .collect::>(); + if lines.is_empty() { + lines.push(String::new()); + } + + Ok(Self { + name: name.into(), + sha256: format!("{:x}", Sha256::digest(bytes)), + lines, + }) + } + + /// Builds a source buffer whose display name is the given path. + /// + /// # Errors + /// + /// Returns an error when the input is not valid UTF-8. + pub fn from_path(path: &Path, bytes: &[u8]) -> Result { + Self::from_bytes(path.display().to_string(), bytes) + } + + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + #[must_use] + pub fn sha256(&self) -> &str { + &self.sha256 + } + + #[must_use] + pub fn lines(&self) -> &[String] { + &self.lines + } + + #[must_use] + pub fn line_count(&self) -> usize { + self.lines.len() + } + + pub fn line(&self, one_based: usize) -> Option<&str> { + one_based + .checked_sub(1) + .and_then(|index| self.lines.get(index)) + .map(String::as_str) + } + + #[must_use] + pub fn source_ref(&self) -> SourceRef { + SourceRef { + name: self.name.clone(), + sha256: self.sha256.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_unix_and_windows_lines_without_an_artificial_trailing_line() { + let source = SourceBuffer::from_bytes("input", b"one\r\ntwo\n").unwrap(); + assert_eq!(source.lines(), ["one", "two"]); + assert_eq!(source.line(2), Some("two")); + assert_eq!(source.line(0), None); + } + + #[test] + fn empty_input_has_one_selectable_line() { + let source = SourceBuffer::from_bytes("empty", b"").unwrap(); + assert_eq!(source.lines(), [""]); + assert_eq!(source.sha256().len(), 64); + } + + #[test] + fn path_names_and_invalid_utf8_are_handled_explicitly() { + let source = SourceBuffer::from_path(Path::new("folder/file.txt"), b"hello").unwrap(); + assert_eq!(source.name(), "folder/file.txt"); + assert_eq!(source.line_count(), 1); + assert!(SourceBuffer::from_bytes("bad", &[0xff]).is_err()); + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..563474b --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,91 @@ +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; + +use crate::domain::ReviewDocument; + +/// Loads a review sidecar from JSON. +/// +/// # Errors +/// +/// Returns an error when the file cannot be read or parsed. +pub fn load_review(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("read comments from {}", path.display()))?; + serde_json::from_slice(&bytes) + .with_context(|| format!("parse comments from {}", path.display())) +} + +/// Atomically saves a review sidecar next to its destination. +/// +/// # Errors +/// +/// Returns an error when serialization or any filesystem operation fails. +pub fn save_review(path: &Path, review: &ReviewDocument) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .with_context(|| format!("create comments directory {}", parent.display()))?; + let temporary = temporary_path(path); + let bytes = serde_json::to_vec_pretty(review).context("serialize comments")?; + + let mut file = fs::File::create(&temporary) + .with_context(|| format!("create temporary comments file {}", temporary.display()))?; + file.write_all(&bytes) + .with_context(|| format!("write temporary comments file {}", temporary.display()))?; + file.write_all(b"\n") + .with_context(|| format!("finish temporary comments file {}", temporary.display()))?; + file.sync_all() + .with_context(|| format!("sync temporary comments file {}", temporary.display()))?; + fs::rename(&temporary, path).with_context(|| { + format!( + "replace comments file {} with {}", + path.display(), + temporary.display() + ) + })?; + Ok(()) +} + +fn temporary_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".tmp-{}", std::process::id())); + path.with_file_name(name) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use crate::{domain::ReviewDocument, source::SourceBuffer}; + + use super::*; + + #[test] + fn review_round_trips_through_an_atomic_sidecar() { + let directory = tempdir().unwrap(); + let path = directory.path().join("review.json"); + let source = SourceBuffer::from_bytes("sample", b"hello\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + + save_review(&path, &review).unwrap(); + assert_eq!(load_review(&path).unwrap(), review); + assert!(!temporary_path(&path).exists()); + } + + #[test] + fn save_creates_parent_directories_and_load_reports_invalid_json() { + let directory = tempdir().unwrap(); + let path = directory.path().join("nested/review.json"); + let source = SourceBuffer::from_bytes("sample", b"hello\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + save_review(&path, &review).unwrap(); + assert_eq!(load_review(&path).unwrap(), review); + + std::fs::write(&path, b"not json").unwrap(); + let error = load_review(&path).unwrap_err().to_string(); + assert!(error.contains("parse comments")); + } +} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..4b6499b --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,91 @@ +use std::{ + fs::{File, OpenOptions}, + panic, + sync::{ + atomic::{AtomicBool, Ordering}, + Once, + }, +}; + +use crossterm::{ + event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +pub type AppTerminal = Terminal>; + +static PANIC_HOOK: Once = Once::new(); +static RAW_ENABLED: AtomicBool = AtomicBool::new(false); +static SCREEN_ENABLED: AtomicBool = AtomicBool::new(false); +static MOUSE_ENABLED: AtomicBool = AtomicBool::new(false); +static PASTE_ENABLED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug)] +pub struct TerminalGuard; + +impl TerminalGuard { + /// Enters raw alternate-screen mode on `/dev/tty`. + /// + /// # Errors + /// + /// Returns an error when the controlling terminal cannot be opened or configured. + pub fn enter(mouse_enabled: bool) -> anyhow::Result<(Self, AppTerminal)> { + install_panic_hook(); + let mut tty = open_tty()?; + enable_raw_mode()?; + RAW_ENABLED.store(true, Ordering::SeqCst); + execute!(tty, EnterAlternateScreen, EnableBracketedPaste)?; + SCREEN_ENABLED.store(true, Ordering::SeqCst); + PASTE_ENABLED.store(true, Ordering::SeqCst); + if mouse_enabled { + execute!(tty, EnableMouseCapture)?; + MOUSE_ENABLED.store(true, Ordering::SeqCst); + } + let terminal = Terminal::new(CrosstermBackend::new(tty))?; + Ok((Self, terminal)) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + restore_terminal(); + } +} + +fn open_tty() -> std::io::Result { + OpenOptions::new().read(true).write(true).open("/dev/tty") +} + +fn install_panic_hook() { + PANIC_HOOK.call_once(|| { + let default_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + restore_terminal(); + default_hook(info); + })); + }); +} + +fn restore_terminal() { + let mut tty = open_tty().ok(); + if MOUSE_ENABLED.swap(false, Ordering::SeqCst) { + if let Some(tty) = &mut tty { + let _ = execute!(tty, DisableMouseCapture); + } + } + if PASTE_ENABLED.swap(false, Ordering::SeqCst) { + if let Some(tty) = &mut tty { + let _ = execute!(tty, DisableBracketedPaste); + } + } + if SCREEN_ENABLED.swap(false, Ordering::SeqCst) { + if let Some(tty) = &mut tty { + let _ = execute!(tty, LeaveAlternateScreen); + } + } + if RAW_ENABLED.swap(false, Ordering::SeqCst) { + let _ = disable_raw_mode(); + } +} diff --git a/tests/architecture.rs b/tests/architecture.rs new file mode 100644 index 0000000..9037328 --- /dev/null +++ b/tests/architecture.rs @@ -0,0 +1,60 @@ +use std::{fs, path::Path}; + +#[test] +fn domain_and_source_are_terminal_and_io_independent() { + assert_forbidden( + &["src/domain.rs", "src/source.rs"], + &[ + "crate::app", + "crate::render", + "crate::runner", + "crate::terminal", + "crossterm", + "ratatui", + "std::fs", + "std::process", + ], + ); +} + +#[test] +fn output_does_not_depend_on_terminal_layers() { + assert_forbidden( + &["src/output.rs"], + &[ + "crate::app", + "crate::render", + "crate::terminal", + "crossterm", + "ratatui", + ], + ); +} + +#[test] +fn input_hit_testing_stays_adapter_free() { + assert_forbidden( + &["src/input.rs"], + &["crate::runner", "crate::terminal", "crossterm", "std::fs"], + ); +} + +fn assert_forbidden(files: &[&str], forbidden: &[&str]) { + let source = files + .iter() + .map(|file| { + assert!( + Path::new(file).exists(), + "missing architecture input {file}" + ); + fs::read_to_string(file).expect("read architecture input") + }) + .collect::>() + .join("\n"); + for needle in forbidden { + assert!( + !source.contains(needle), + "architecture contains forbidden dependency `{needle}`" + ); + } +} From 928fe10a69860ddc1c1958dc3477b779bd319f47 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:28:56 +0800 Subject: [PATCH 2/9] fix: harden comment navigation and terminal setup --- docs/2026-07-10-data-model-review.md | 5 +- src/app.rs | 150 ++++++++++++++++++++++----- src/domain.rs | 46 ++++++-- src/render.rs | 21 ++-- src/terminal.rs | 6 +- 5 files changed, 186 insertions(+), 42 deletions(-) diff --git a/docs/2026-07-10-data-model-review.md b/docs/2026-07-10-data-model-review.md index cc72ea4..b7789ef 100644 --- a/docs/2026-07-10-data-model-review.md +++ b/docs/2026-07-10-data-model-review.md @@ -44,5 +44,6 @@ warnings. The initial and final field graphs were identical: | `comments[].end_line` | number | yes | | `comments[].body` | string | yes | -The accepted product model is documented in [output-formats.md](output-formats.md) and -implemented by the public Rust types in `src/domain.rs`. +The accepted product model is documented in +[the output format reference](2026-07-10-output-formats.md) and implemented by the +public Rust types in `src/domain.rs`. diff --git a/src/app.rs b/src/app.rs index 9d2579a..69809a7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,6 +67,7 @@ pub struct App { pub cursor_line: usize, pub selection: Option, pub editor: Option, + pub active_comment_id: Option, pub scroll_row: usize, pub horizontal_scroll: usize, pub should_quit: bool, @@ -84,6 +85,7 @@ impl App { cursor_line: 1, selection: None, editor: None, + active_comment_id: None, scroll_row: 0, horizontal_scroll: 0, should_quit: false, @@ -103,6 +105,7 @@ impl App { selection.current = self.cursor_line; } self.follow_cursor = true; + self.active_comment_id = None; self.status = None; } @@ -112,6 +115,7 @@ impl App { selection.current = self.cursor_line; } self.follow_cursor = true; + self.active_comment_id = None; } pub fn begin_selection(&mut self, line: usize) { @@ -145,6 +149,7 @@ impl App { current: self.cursor_line, }); let (start_line, end_line) = selection.normalized(); + self.cursor_line = end_line; self.editor = Some(CommentEditor::new(None, start_line, end_line, "")); self.follow_cursor = true; self.status = None; @@ -161,6 +166,7 @@ impl App { return false; }; self.cursor_line = comment.end_line; + self.active_comment_id = Some(comment.id); self.selection = Some(Selection { anchor: comment.start_line, current: comment.end_line, @@ -177,12 +183,7 @@ impl App { } pub fn edit_comment_at_cursor(&mut self) -> bool { - let id = self - .review - .comments - .iter() - .find(|comment| comment.contains_line(self.cursor_line)) - .map(|comment| comment.id); + let id = self.comment_id_at_cursor(); id.is_some_and(|id| self.begin_edit(id)) } @@ -195,9 +196,10 @@ impl App { self.status = Some("Comment cannot be empty".into()); return false; } - let id = editor - .comment_id - .unwrap_or_else(|| self.review.next_comment_id()); + let Some(id) = editor.comment_id.or_else(|| self.review.next_comment_id()) else { + self.status = Some("No comment IDs available".into()); + return false; + }; self.review.upsert_comment(Comment { id, start_line: editor.start_line, @@ -207,6 +209,7 @@ impl App { self.editor = None; self.selection = None; self.status = Some("Comment saved".into()); + self.active_comment_id = Some(id); self.dirty = true; true } @@ -218,15 +221,11 @@ impl App { } pub fn delete_comment_at_cursor(&mut self) -> bool { - let id = self - .review - .comments - .iter() - .find(|comment| comment.contains_line(self.cursor_line)) - .map(|comment| comment.id); + let id = self.comment_id_at_cursor(); let removed = id.is_some_and(|id| self.review.remove_comment(id)); if removed { self.status = Some("Comment deleted".into()); + self.active_comment_id = None; self.dirty = true; } else { self.status = Some("No comment on this line".into()); @@ -235,24 +234,63 @@ impl App { } pub fn jump_comment(&mut self, forward: bool) { - let target = if forward { + if self.review.comments.is_empty() { + self.active_comment_id = None; + return; + } + let active_index = self.active_comment_id.and_then(|id| { + self.review + .comments + .iter() + .position(|comment| comment.id == id) + }); + let target_index = if let Some(index) = active_index { + if forward { + (index + 1) % self.review.comments.len() + } else { + index + .checked_sub(1) + .unwrap_or(self.review.comments.len() - 1) + } + } else if forward { self.review .comments .iter() - .find(|comment| comment.start_line > self.cursor_line) - .or_else(|| self.review.comments.first()) + .position(|comment| comment.start_line > self.cursor_line) + .unwrap_or(0) } else { self.review .comments .iter() - .rev() - .find(|comment| comment.end_line < self.cursor_line) - .or_else(|| self.review.comments.last()) + .rposition(|comment| comment.end_line < self.cursor_line) + .unwrap_or(self.review.comments.len() - 1) }; - if let Some(comment) = target { - self.cursor_line = comment.start_line; - self.follow_cursor = true; - } + let comment = &self.review.comments[target_index]; + self.cursor_line = comment.start_line; + self.active_comment_id = Some(comment.id); + self.follow_cursor = true; + self.status = Some(format!( + "Comment {}/{} · e edit · d delete", + target_index + 1, + self.review.comments.len() + )); + } + + fn comment_id_at_cursor(&self) -> Option { + self.active_comment_id + .filter(|id| { + self.review + .comments + .iter() + .any(|comment| comment.id == *id && comment.contains_line(self.cursor_line)) + }) + .or_else(|| { + self.review + .comments + .iter() + .find(|comment| comment.contains_line(self.cursor_line)) + .map(|comment| comment.id) + }) } } @@ -277,6 +315,7 @@ mod tests { assert!(selection.contains(3)); assert!(!selection.contains(4)); app.open_selected_editor(); + assert_eq!(app.cursor_line, 3); let editor = app.editor.unwrap(); assert_eq!((editor.start_line, editor.end_line), (1, 3)); } @@ -366,4 +405,65 @@ mod tests { assert!(app.review.comments.is_empty()); assert_eq!(app.status.as_deref(), Some("Edit cancelled")); } + + #[test] + fn co_located_comments_can_be_selected_edited_and_deleted_individually() { + let mut app = app(); + for id in [1, 2] { + app.review.upsert_comment(Comment { + id, + start_line: 2, + end_line: 2, + body: format!("comment {id}"), + }); + } + app.cursor_line = 1; + app.jump_comment(true); + assert_eq!(app.active_comment_id, Some(1)); + app.jump_comment(true); + assert_eq!(app.active_comment_id, Some(2)); + assert!(app.edit_comment_at_cursor()); + assert_eq!(app.editor.as_ref().unwrap().comment_id, Some(2)); + app.cancel_editor(); + assert!(app.delete_comment_at_cursor()); + assert_eq!(app.review.comments.len(), 1); + assert_eq!(app.review.comments[0].id, 1); + } + + #[test] + fn inactive_backward_jumps_choose_previous_or_wrap() { + let mut app = app(); + for (id, line) in [(1, 1), (2, 3)] { + app.review.upsert_comment(Comment { + id, + start_line: line, + end_line: line, + body: format!("comment {id}"), + }); + } + app.cursor_line = 3; + app.jump_comment(false); + assert_eq!(app.active_comment_id, Some(1)); + app.active_comment_id = None; + app.cursor_line = 1; + app.jump_comment(false); + assert_eq!(app.active_comment_id, Some(2)); + } + + #[test] + fn stale_active_comment_falls_back_to_a_comment_on_the_cursor() { + let mut app = app(); + for (id, line) in [(1, 1), (2, 2)] { + app.review.upsert_comment(Comment { + id, + start_line: line, + end_line: line, + body: format!("comment {id}"), + }); + } + app.active_comment_id = Some(2); + app.cursor_line = 1; + assert!(app.edit_comment_at_cursor()); + assert_eq!(app.editor.as_ref().unwrap().comment_id, Some(1)); + } } diff --git a/src/domain.rs b/src/domain.rs index ddb1005..b5fb5ef 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -45,13 +45,20 @@ impl ReviewDocument { } #[must_use] - pub fn next_comment_id(&self) -> u64 { - self.comments + pub fn next_comment_id(&self) -> Option { + let used = self + .comments .iter() .map(|comment| comment.id) - .max() - .unwrap_or(0) - .saturating_add(1) + .collect::>(); + let mut candidate = 1_u64; + for id in used { + if id > candidate { + return Some(candidate); + } + candidate = candidate.checked_add(1)?; + } + Some(candidate) } pub fn upsert_comment(&mut self, comment: Comment) { @@ -178,7 +185,7 @@ mod tests { body: "updated".into(), }); - assert_eq!(review.next_comment_id(), 5); + assert_eq!(review.next_comment_id(), Some(2)); assert_eq!( review.comments.iter().map(|c| c.id).collect::>(), [1, 4] @@ -293,4 +300,31 @@ mod tests { Err(ReviewValidationError::InvalidRange { .. }) )); } + + #[test] + fn id_allocation_fills_gaps_including_below_the_maximum() { + let mut review = ReviewDocument::empty(source()); + review.comments = vec![ + Comment { + id: 2, + start_line: 1, + end_line: 1, + body: "two".into(), + }, + Comment { + id: u64::MAX, + start_line: 1, + end_line: 1, + body: "maximum".into(), + }, + ]; + assert_eq!(review.next_comment_id(), Some(1)); + review.comments.push(Comment { + id: 1, + start_line: 1, + end_line: 1, + body: "one".into(), + }); + assert_eq!(review.next_comment_id(), Some(3)); + } } diff --git a/src/render.rs b/src/render.rs index 92f51e9..4b6b5b0 100644 --- a/src/render.rs +++ b/src/render.rs @@ -95,7 +95,13 @@ fn render_document(frame: &mut Frame<'_>, app: &mut App, area: Rect) -> Vec { - render_comment_row(frame, rect, text, *first); + render_comment_row( + frame, + rect, + text, + *first, + app.active_comment_id == Some(*id), + ); hits.push(HitArea::new(rect, HitTarget::Comment(*id))); } DocumentRow::Editor => extend_editor_rect(&mut editor_rect, rect), @@ -218,13 +224,14 @@ fn render_source_row( frame.render_widget(Paragraph::new(line).style(style), area); } -fn render_comment_row(frame: &mut Frame<'_>, area: Rect, text: &str, first: bool) { +fn render_comment_row(frame: &mut Frame<'_>, area: Rect, text: &str, first: bool, active: bool) { let prefix = if first { " └─ " } else { " " }; - frame.render_widget( - Paragraph::new(format!("{prefix}{text}")) - .style(Style::default().fg(Color::Green).bg(Color::Rgb(20, 35, 25))), - area, - ); + let style = if active { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + Style::default().fg(Color::Green).bg(Color::Rgb(20, 35, 25)) + }; + frame.render_widget(Paragraph::new(format!("{prefix}{text}")).style(style), area); } fn extend_editor_rect(editor_rect: &mut Option, row: Rect) { diff --git a/src/terminal.rs b/src/terminal.rs index 4b6499b..4654b1a 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -34,17 +34,19 @@ impl TerminalGuard { pub fn enter(mouse_enabled: bool) -> anyhow::Result<(Self, AppTerminal)> { install_panic_hook(); let mut tty = open_tty()?; + let guard = Self; enable_raw_mode()?; RAW_ENABLED.store(true, Ordering::SeqCst); - execute!(tty, EnterAlternateScreen, EnableBracketedPaste)?; + execute!(tty, EnterAlternateScreen)?; SCREEN_ENABLED.store(true, Ordering::SeqCst); + execute!(tty, EnableBracketedPaste)?; PASTE_ENABLED.store(true, Ordering::SeqCst); if mouse_enabled { execute!(tty, EnableMouseCapture)?; MOUSE_ENABLED.store(true, Ordering::SeqCst); } let terminal = Terminal::new(CrosstermBackend::new(tty))?; - Ok((Self, terminal)) + Ok((guard, terminal)) } } From c6ced384900891ff0c3f892ca0739a45ae699c60 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:40:11 +0800 Subject: [PATCH 3/9] fix: validate output paths and scale rendering --- README.md | 1 + docs/2026-07-10-output-formats.md | 3 +- src/app.rs | 15 ++++- src/output.rs | 18 +++++- src/render.rs | 54 +++++++++++----- src/runner.rs | 103 ++++++++++++++++++++++++++++-- 6 files changed, 170 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b76fd33..cd1df76 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ annotui proposal.md --comments proposal.review.json --format full The sidecar includes a source hash. annotui refuses to apply it to different content instead of silently moving comments to the wrong lines. +`--comments` and `--output` must name different files. ## Controls diff --git a/docs/2026-07-10-output-formats.md b/docs/2026-07-10-output-formats.md index e65e016..659b235 100644 --- a/docs/2026-07-10-output-formats.md +++ b/docs/2026-07-10-output-formats.md @@ -71,7 +71,8 @@ Field rules: - `source.sha256` is the lowercase SHA-256 of the exact input bytes. - `id` is a positive integer unique within the document. - `start_line` and `end_line` are one-based and inclusive. -- `body` is non-empty Markdown. +- `body` must contain a non-whitespace Markdown character. Its original indentation + and surrounding whitespace are preserved. - Unknown fields are rejected when a sidecar is loaded. Sidecars are saved atomically. A loaded sidecar must have a supported version, valid diff --git a/src/app.rs b/src/app.rs index 69809a7..6407fbd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -56,7 +56,7 @@ impl CommentEditor { } pub fn body(&self) -> String { - self.textarea.lines().join("\n").trim().to_owned() + self.textarea.lines().join("\n") } } @@ -349,6 +349,19 @@ mod tests { assert_eq!(app.status.as_deref(), Some("Comment cannot be empty")); } + #[test] + fn comment_markdown_whitespace_is_preserved() { + let mut app = app(); + app.open_selected_editor(); + app.editor + .as_mut() + .unwrap() + .textarea + .insert_str(" indented code "); + assert!(app.submit_editor()); + assert_eq!(app.review.comments[0].body, " indented code "); + } + #[test] fn cursor_selection_and_cancellation_stay_within_source() { let mut app = app(); diff --git a/src/output.rs b/src/output.rs index fce57e4..a0b9763 100644 --- a/src/output.rs +++ b/src/output.rs @@ -41,7 +41,7 @@ pub fn format_comments(source: &SourceBuffer, review: &ReviewDocument) -> String .map(quote_line) .collect::>() .join("\n"); - format!("{quote}\n\n{}", comment.body.trim()) + format!("{quote}\n\n{}", comment.body) }) .collect::>() .join("\n\n") @@ -66,7 +66,7 @@ pub fn format_full(source: &SourceBuffer, review: &ReviewDocument) -> String { .comments .iter() .filter(|comment| comment.end_line == line_number) - .map(|comment| comment.body.trim().to_owned()) + .map(|comment| comment.body.clone()) .collect::>(); if !comments.is_empty() { blocks.push(std::mem::take(&mut quote)); @@ -147,4 +147,18 @@ mod tests { format_full(&source, &review) ); } + + #[test] + fn markdown_output_preserves_comment_indentation() { + let source = SourceBuffer::from_bytes("sample", b"alpha\n").unwrap(); + let mut review = ReviewDocument::empty(source.source_ref()); + review.upsert_comment(Comment { + id: 1, + start_line: 1, + end_line: 1, + body: " code() ".into(), + }); + assert_eq!(format_comments(&source, &review), "> alpha\n\n code() "); + assert_eq!(format_full(&source, &review), "> alpha\n\n code() "); + } } diff --git a/src/render.rs b/src/render.rs index 4b6b5b0..70c3182 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, @@ -12,6 +14,7 @@ use crate::{ }; const EDITOR_HEIGHT: usize = 5; +const MINIMUM_HEIGHT: u16 = 7; #[derive(Debug, Clone, PartialEq, Eq)] enum DocumentRow { @@ -22,10 +25,12 @@ enum DocumentRow { pub fn render_app(frame: &mut Frame<'_>, app: &mut App) -> Vec { let area = frame.area(); - if area.height < 3 || area.width < 20 { + if area.height < MINIMUM_HEIGHT || area.width < 20 { frame.render_widget( - Paragraph::new("annotui needs at least 20 columns and 3 rows") - .style(Style::default().fg(Color::Red)), + Paragraph::new(format!( + "annotui needs at least 20 columns and {MINIMUM_HEIGHT} rows" + )) + .style(Style::default().fg(Color::Red)), area, ); return Vec::new(); @@ -132,21 +137,30 @@ fn document_rows(app: &App) -> Vec { let mut rows = Vec::new(); let editing_id = app.editor.as_ref().and_then(|editor| editor.comment_id); let editor_end = app.editor.as_ref().map(|editor| editor.end_line); + let mut comments_by_end = BTreeMap::>::new(); + for comment in app + .review + .comments + .iter() + .filter(|comment| Some(comment.id) != editing_id) + { + comments_by_end + .entry(comment.end_line) + .or_default() + .push(comment); + } for line_number in 1..=app.source.line_count() { rows.push(DocumentRow::Source(line_number)); - for comment in app - .review - .comments - .iter() - .filter(|comment| comment.end_line == line_number && Some(comment.id) != editing_id) - { - for (index, body_line) in comment.body.lines().enumerate() { - rows.push(DocumentRow::Comment { - id: comment.id, - text: body_line.to_owned(), - first: index == 0, - }); + if let Some(comments) = comments_by_end.get(&line_number) { + for comment in comments { + for (index, body_line) in comment.body.lines().enumerate() { + rows.push(DocumentRow::Comment { + id: comment.id, + text: body_line.to_owned(), + first: index == 0, + }); + } } } if editor_end == Some(line_number) { @@ -321,6 +335,16 @@ mod tests { .draw(|frame| drop(render_app(frame, &mut app))) .unwrap(); assert!(terminal.backend().to_string().contains("annotui needs")); + + let source = SourceBuffer::from_bytes("sample", b"one\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + let mut app = App::new(source, review); + let backend = TestBackend::new(40, MINIMUM_HEIGHT - 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| drop(render_app(frame, &mut app))) + .unwrap(); + assert!(terminal.backend().to_string().contains("annotui needs")); } #[test] diff --git a/src/runner.rs b/src/runner.rs index 2150a8f..13b47fa 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,8 +1,7 @@ use std::{ fs, io::{self, IsTerminal, Read, Write}, - path::Path, - time::Duration, + path::{Component, Path, PathBuf}, }; use anyhow::{bail, Context}; @@ -30,6 +29,7 @@ use crate::{ /// /// Returns an error for invalid input, terminal failures, invalid sidecars, or output failures. pub fn run(cli: &Cli) -> anyhow::Result<()> { + ensure_distinct_destinations(cli)?; let source = read_source(cli)?; let review = read_review(cli, &source)?; let mut app = App::new(source, review); @@ -39,9 +39,7 @@ pub fn run(cli: &Cli) -> anyhow::Result<()> { let mut hit_areas = Vec::new(); while !app.should_quit { terminal.draw(|frame| hit_areas = render_app(frame, &mut app))?; - if event::poll(Duration::from_millis(250))? { - handle_event(event::read()?, &mut app, &hit_areas); - } + handle_event(event::read()?, &mut app, &hit_areas); } } @@ -83,9 +81,59 @@ fn read_source(cli: &Cli) -> anyhow::Result { bail!("provide a file, pipe standard input, or pass --buffer") }; + if name.trim().is_empty() { + bail!("source name must not be empty") + } SourceBuffer::from_bytes(name, &bytes).context("input must be valid UTF-8") } +fn ensure_distinct_destinations(cli: &Cli) -> anyhow::Result<()> { + let (Some(comments), Some(output)) = (&cli.comments, &cli.output) else { + return Ok(()); + }; + if comparable_destination(comments)? == comparable_destination(output)? { + bail!("--comments and --output must refer to different files") + } + Ok(()) +} + +fn comparable_destination(path: &Path) -> anyhow::Result { + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir() + .context("resolve current directory")? + .join(path) + }; + let normalized = normalize_lexically(&absolute); + if normalized.exists() { + return fs::canonicalize(&normalized) + .with_context(|| format!("resolve destination {}", path.display())); + } + let parent = normalized.parent().unwrap_or_else(|| Path::new("/")); + let file_name = normalized + .file_name() + .context("destination must name a file")?; + let resolved_parent = fs::canonicalize(parent).unwrap_or_else(|_| parent.to_owned()); + Ok(resolved_parent.join(file_name)) +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized +} + fn read_stdin() -> anyhow::Result> { let mut bytes = Vec::new(); io::stdin() @@ -269,8 +317,10 @@ fn handle_mouse(mouse: MouseEvent, app: &mut App, hit_areas: &[HitArea]) { #[cfg(test)] mod tests { + use clap::Parser; use crossterm::event::{KeyEvent, MouseEvent}; use ratatui::layout::Rect; + use tempfile::tempdir; use crate::{domain::ReviewDocument, source::SourceBuffer}; @@ -334,4 +384,47 @@ mod tests { let editor = app.editor.unwrap(); assert_eq!((editor.start_line, editor.end_line), (1, 2)); } + + #[test] + fn rejects_empty_source_names_before_entering_the_tui() { + let cli = + Cli::try_parse_from(["annotui", "--buffer", "hello", "--source-name", " "]).unwrap(); + assert!(read_source(&cli) + .unwrap_err() + .to_string() + .contains("source name must not be empty")); + } + + #[test] + fn rejects_lexically_equivalent_sidecar_and_output_paths() { + let directory = tempdir().unwrap(); + let comments = directory.path().join("review.json"); + let output = directory.path().join("nested/../review.json"); + let cli = Cli::try_parse_from([ + "annotui".into(), + "--buffer".into(), + "hello".into(), + "--comments".into(), + comments.into_os_string(), + "--output".into(), + output.into_os_string(), + ]) + .unwrap(); + assert!(ensure_distinct_destinations(&cli) + .unwrap_err() + .to_string() + .contains("different files")); + + let distinct = Cli::try_parse_from([ + "annotui", + "--buffer", + "hello", + "--comments", + "comments.json", + "--output", + "output.md", + ]) + .unwrap(); + assert!(ensure_distinct_destinations(&distinct).is_ok()); + } } From 6da244c05bb9fd17f71d79a66f89f31d1ceb693f Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:22 +0800 Subject: [PATCH 4/9] fix: prevent destructive file aliases --- Cargo.lock | 19 +++++++ Cargo.toml | 3 +- src/app.rs | 13 ++++- src/runner.rs | 142 +++++++++++++++++++++++++++++++++++++++++++------ src/storage.rs | 74 ++++++++++++++++---------- 5 files changed, 205 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index edfa88d..77a1de4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,7 @@ dependencies = [ "predicates", "ratatui", "ratatui-textarea", + "same-file", "serde", "serde_json", "sha2", @@ -922,6 +923,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1227,6 +1237,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 4af7a5e..2ca41cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,13 +21,14 @@ ratatui-textarea = "0.9.2" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +same-file = "1" +tempfile = "3" thiserror = "2" unicode-width = "0.2" [dev-dependencies] assert_cmd = "2" predicates = "3" -tempfile = "3" [lints.rust] unsafe_code = "forbid" diff --git a/src/app.rs b/src/app.rs index 6407fbd..d3bdbc3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -192,7 +192,7 @@ impl App { return false; }; let body = editor.body(); - if body.is_empty() { + if body.trim().is_empty() { self.status = Some("Comment cannot be empty".into()); return false; } @@ -349,6 +349,17 @@ mod tests { assert_eq!(app.status.as_deref(), Some("Comment cannot be empty")); } + #[test] + fn whitespace_only_comment_stays_open() { + let mut app = app(); + app.open_selected_editor(); + app.editor.as_mut().unwrap().textarea.insert_str(" \n\t"); + assert!(!app.submit_editor()); + assert!(app.editor.is_some()); + assert!(app.review.comments.is_empty()); + assert_eq!(app.status.as_deref(), Some("Comment cannot be empty")); + } + #[test] fn comment_markdown_whitespace_is_preserved() { let mut app = app(); diff --git a/src/runner.rs b/src/runner.rs index 13b47fa..8fd636d 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -88,15 +88,38 @@ fn read_source(cli: &Cli) -> anyhow::Result { } fn ensure_distinct_destinations(cli: &Cli) -> anyhow::Result<()> { - let (Some(comments), Some(output)) = (&cli.comments, &cli.output) else { - return Ok(()); - }; - if comparable_destination(comments)? == comparable_destination(output)? { - bail!("--comments and --output must refer to different files") + let mut paths = Vec::with_capacity(3); + if let Some(input) = cli.input.as_deref().filter(|path| *path != Path::new("-")) { + paths.push(("input", input)); + } + if let Some(comments) = cli.comments.as_deref() { + paths.push(("--comments", comments)); + } + if let Some(output) = cli.output.as_deref() { + paths.push(("--output", output)); + } + + for (index, (left_name, left_path)) in paths.iter().enumerate() { + for (right_name, right_path) in &paths[index + 1..] { + if paths_alias(left_path, right_path)? { + bail!("{left_name} and {right_name} must refer to different files") + } + } } Ok(()) } +fn paths_alias(left: &Path, right: &Path) -> anyhow::Result { + if comparable_destination(left)? == comparable_destination(right)? { + return Ok(true); + } + if left.exists() && right.exists() { + return same_file::is_same_file(left, right) + .with_context(|| format!("compare {} and {}", left.display(), right.display())); + } + Ok(false) +} + fn comparable_destination(path: &Path) -> anyhow::Result { let absolute = if path.is_absolute() { path.to_owned() @@ -105,17 +128,51 @@ fn comparable_destination(path: &Path) -> anyhow::Result { .context("resolve current directory")? .join(path) }; - let normalized = normalize_lexically(&absolute); - if normalized.exists() { - return fs::canonicalize(&normalized) - .with_context(|| format!("resolve destination {}", path.display())); + resolve_symlinks(&normalize_lexically(&absolute), path) +} + +fn resolve_symlinks(absolute: &Path, original: &Path) -> anyhow::Result { + let mut unresolved = absolute.to_owned(); + for _ in 0..40 { + let mut resolved = PathBuf::new(); + let components = unresolved.components().collect::>(); + let mut followed = false; + + for (index, component) in components.iter().enumerate() { + resolved.push(component.as_os_str()); + match fs::symlink_metadata(&resolved) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let target = fs::read_link(&resolved) + .with_context(|| format!("resolve destination {}", original.display()))?; + let parent = resolved.parent().unwrap_or_else(|| Path::new("/")); + let mut next = if target.is_absolute() { + target + } else { + parent.join(target) + }; + for remaining in &components[index + 1..] { + next.push(remaining.as_os_str()); + } + unresolved = normalize_lexically(&next); + followed = true; + break; + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("resolve destination {}", original.display())); + } + } + } + if !followed { + return Ok(normalize_lexically(&resolved)); + } } - let parent = normalized.parent().unwrap_or_else(|| Path::new("/")); - let file_name = normalized - .file_name() - .context("destination must name a file")?; - let resolved_parent = fs::canonicalize(parent).unwrap_or_else(|_| parent.to_owned()); - Ok(resolved_parent.join(file_name)) + bail!( + "too many symbolic links while resolving {}", + original.display() + ) } fn normalize_lexically(path: &Path) -> PathBuf { @@ -427,4 +484,59 @@ mod tests { .unwrap(); assert!(ensure_distinct_destinations(&distinct).is_ok()); } + + #[test] + fn rejects_output_that_aliases_the_input() { + let directory = tempdir().unwrap(); + let input = directory.path().join("input.txt"); + std::fs::write(&input, "hello").unwrap(); + let cli = Cli::try_parse_from([ + "annotui".into(), + input.clone().into_os_string(), + "--output".into(), + input.into_os_string(), + ]) + .unwrap(); + let error = ensure_distinct_destinations(&cli).unwrap_err().to_string(); + assert!(error.contains("input and --output")); + } + + #[cfg(unix)] + #[test] + fn rejects_dangling_symlink_that_would_alias_another_destination() { + use std::os::unix::fs::symlink; + + let directory = tempdir().unwrap(); + let comments = directory.path().join("review.json"); + let output = directory.path().join("output-link"); + symlink(&comments, &output).unwrap(); + let cli = Cli::try_parse_from([ + "annotui".into(), + "--buffer".into(), + "hello".into(), + "--comments".into(), + comments.into_os_string(), + "--output".into(), + output.into_os_string(), + ]) + .unwrap(); + assert!(ensure_distinct_destinations(&cli).is_err()); + } + + #[test] + fn rejects_hard_linked_input_and_output() { + let directory = tempdir().unwrap(); + let input = directory.path().join("input.txt"); + let output = directory.path().join("output.txt"); + std::fs::write(&input, "hello").unwrap(); + std::fs::hard_link(&input, &output).unwrap(); + let cli = Cli::try_parse_from([ + "annotui".into(), + input.into_os_string(), + "--output".into(), + output.into_os_string(), + ]) + .unwrap(); + assert!(ensure_distinct_destinations(&cli).is_err()); + } } diff --git a/src/storage.rs b/src/storage.rs index 563474b..31d8cf9 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,8 +1,4 @@ -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; +use std::{fs, io::Write, path::Path}; use anyhow::{Context, Result}; @@ -28,33 +24,36 @@ pub fn save_review(path: &Path, review: &ReviewDocument) -> Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); fs::create_dir_all(parent) .with_context(|| format!("create comments directory {}", parent.display()))?; - let temporary = temporary_path(path); let bytes = serde_json::to_vec_pretty(review).context("serialize comments")?; - - let mut file = fs::File::create(&temporary) - .with_context(|| format!("create temporary comments file {}", temporary.display()))?; - file.write_all(&bytes) - .with_context(|| format!("write temporary comments file {}", temporary.display()))?; - file.write_all(b"\n") - .with_context(|| format!("finish temporary comments file {}", temporary.display()))?; - file.sync_all() - .with_context(|| format!("sync temporary comments file {}", temporary.display()))?; - fs::rename(&temporary, path).with_context(|| { - format!( - "replace comments file {} with {}", - path.display(), - temporary.display() - ) - })?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("create temporary comments file in {}", parent.display()))?; + match fs::metadata(path) { + Ok(metadata) => temporary + .as_file() + .set_permissions(metadata.permissions()) + .with_context(|| format!("preserve permissions from {}", path.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("read permissions from {}", path.display())); + } + } + temporary + .write_all(&bytes) + .with_context(|| format!("write temporary comments file for {}", path.display()))?; + temporary + .write_all(b"\n") + .with_context(|| format!("finish temporary comments file for {}", path.display()))?; + temporary + .as_file() + .sync_all() + .with_context(|| format!("sync temporary comments file for {}", path.display()))?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("replace comments file {}", path.display()))?; Ok(()) } -fn temporary_path(path: &Path) -> PathBuf { - let mut name = path.file_name().unwrap_or_default().to_os_string(); - name.push(format!(".tmp-{}", std::process::id())); - path.with_file_name(name) -} - #[cfg(test)] mod tests { use tempfile::tempdir; @@ -72,7 +71,7 @@ mod tests { save_review(&path, &review).unwrap(); assert_eq!(load_review(&path).unwrap(), review); - assert!(!temporary_path(&path).exists()); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); } #[test] @@ -88,4 +87,21 @@ mod tests { let error = load_review(&path).unwrap_err().to_string(); assert!(error.contains("parse comments")); } + + #[cfg(unix)] + #[test] + fn save_preserves_existing_sidecar_permissions() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempdir().unwrap(); + let path = directory.path().join("review.json"); + std::fs::write(&path, "{}").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let source = SourceBuffer::from_bytes("sample", b"hello\n").unwrap(); + save_review(&path, &ReviewDocument::empty(source.source_ref())).unwrap(); + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } } From 59a413177f0eddd7b88a696067bcac42a5b61e6b Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:57:31 +0800 Subject: [PATCH 5/9] fix: preserve sidecar symlink targets --- src/runner.rs | 77 ++--------------------------------- src/storage.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 81 deletions(-) diff --git a/src/runner.rs b/src/runner.rs index 8fd636d..d301b20 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,7 +1,7 @@ use std::{ fs, io::{self, IsTerminal, Read, Write}, - path::{Component, Path, PathBuf}, + path::Path, }; use anyhow::{bail, Context}; @@ -19,7 +19,7 @@ use crate::{ output::format_review, render::render_app, source::SourceBuffer, - storage::{load_review, save_review}, + storage::{load_review, resolve_destination, save_review}, terminal::TerminalGuard, }; @@ -110,7 +110,7 @@ fn ensure_distinct_destinations(cli: &Cli) -> anyhow::Result<()> { } fn paths_alias(left: &Path, right: &Path) -> anyhow::Result { - if comparable_destination(left)? == comparable_destination(right)? { + if resolve_destination(left)? == resolve_destination(right)? { return Ok(true); } if left.exists() && right.exists() { @@ -120,77 +120,6 @@ fn paths_alias(left: &Path, right: &Path) -> anyhow::Result { Ok(false) } -fn comparable_destination(path: &Path) -> anyhow::Result { - let absolute = if path.is_absolute() { - path.to_owned() - } else { - std::env::current_dir() - .context("resolve current directory")? - .join(path) - }; - resolve_symlinks(&normalize_lexically(&absolute), path) -} - -fn resolve_symlinks(absolute: &Path, original: &Path) -> anyhow::Result { - let mut unresolved = absolute.to_owned(); - for _ in 0..40 { - let mut resolved = PathBuf::new(); - let components = unresolved.components().collect::>(); - let mut followed = false; - - for (index, component) in components.iter().enumerate() { - resolved.push(component.as_os_str()); - match fs::symlink_metadata(&resolved) { - Ok(metadata) if metadata.file_type().is_symlink() => { - let target = fs::read_link(&resolved) - .with_context(|| format!("resolve destination {}", original.display()))?; - let parent = resolved.parent().unwrap_or_else(|| Path::new("/")); - let mut next = if target.is_absolute() { - target - } else { - parent.join(target) - }; - for remaining in &components[index + 1..] { - next.push(remaining.as_os_str()); - } - unresolved = normalize_lexically(&next); - followed = true; - break; - } - Ok(_) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("resolve destination {}", original.display())); - } - } - } - if !followed { - return Ok(normalize_lexically(&resolved)); - } - } - bail!( - "too many symbolic links while resolving {}", - original.display() - ) -} - -fn normalize_lexically(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - normalized.pop(); - } - Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { - normalized.push(component.as_os_str()); - } - } - } - normalized -} - fn read_stdin() -> anyhow::Result> { let mut bytes = Vec::new(); io::stdin() diff --git a/src/storage.rs b/src/storage.rs index 31d8cf9..729b7db 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,4 +1,8 @@ -use std::{fs, io::Write, path::Path}; +use std::{ + fs, + io::{self, Write}, + path::{Component, Path, PathBuf}, +}; use anyhow::{Context, Result}; @@ -21,20 +25,22 @@ pub fn load_review(path: &Path) -> Result { /// /// Returns an error when serialization or any filesystem operation fails. pub fn save_review(path: &Path, review: &ReviewDocument) -> Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let destination = resolve_destination(path)?; + let parent = destination.parent().unwrap_or_else(|| Path::new("/")); fs::create_dir_all(parent) .with_context(|| format!("create comments directory {}", parent.display()))?; let bytes = serde_json::to_vec_pretty(review).context("serialize comments")?; let mut temporary = tempfile::NamedTempFile::new_in(parent) .with_context(|| format!("create temporary comments file in {}", parent.display()))?; - match fs::metadata(path) { + match fs::metadata(&destination) { Ok(metadata) => temporary .as_file() .set_permissions(metadata.permissions()) - .with_context(|| format!("preserve permissions from {}", path.display()))?, + .with_context(|| format!("preserve permissions from {}", destination.display()))?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { - return Err(error).with_context(|| format!("read permissions from {}", path.display())); + return Err(error) + .with_context(|| format!("read permissions from {}", destination.display())); } } temporary @@ -48,12 +54,76 @@ pub fn save_review(path: &Path, review: &ReviewDocument) -> Result<()> { .sync_all() .with_context(|| format!("sync temporary comments file for {}", path.display()))?; temporary - .persist(path) + .persist(&destination) .map_err(|error| error.error) - .with_context(|| format!("replace comments file {}", path.display()))?; + .with_context(|| format!("replace comments file {}", destination.display()))?; Ok(()) } +pub(crate) fn resolve_destination(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir() + .context("resolve current directory")? + .join(path) + }; + let mut unresolved = normalize_lexically(&absolute); + for _ in 0..40 { + let mut resolved = PathBuf::new(); + let components = unresolved.components().collect::>(); + let mut followed = false; + + for (index, component) in components.iter().enumerate() { + resolved.push(component.as_os_str()); + match fs::symlink_metadata(&resolved) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let target = fs::read_link(&resolved) + .with_context(|| format!("resolve destination {}", path.display()))?; + let parent = resolved.parent().unwrap_or_else(|| Path::new("/")); + let mut next = if target.is_absolute() { + target + } else { + parent.join(target) + }; + for remaining in &components[index + 1..] { + next.push(remaining.as_os_str()); + } + unresolved = normalize_lexically(&next); + followed = true; + break; + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("resolve destination {}", path.display())); + } + } + } + if !followed { + return Ok(normalize_lexically(&resolved)); + } + } + anyhow::bail!("too many symbolic links while resolving {}", path.display()) +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized +} + #[cfg(test)] mod tests { use tempfile::tempdir; @@ -104,4 +174,26 @@ mod tests { 0o600 ); } + + #[cfg(unix)] + #[test] + fn save_through_a_symlink_updates_its_target_and_preserves_the_link() { + use std::os::unix::fs::symlink; + + let directory = tempdir().unwrap(); + let target = directory.path().join("review.json"); + let link = directory.path().join("review-link.json"); + std::fs::write(&target, "{}").unwrap(); + symlink(&target, &link).unwrap(); + let source = SourceBuffer::from_bytes("sample", b"hello\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + + save_review(&link, &review).unwrap(); + + assert!(std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(load_review(&target).unwrap(), review); + } } From a5998f76a7c8be89fb7ea8c2af6b33a59da1d84c Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:58:57 +0800 Subject: [PATCH 6/9] fix: render tabs and scope mouse gestures --- Cargo.lock | 1 + Cargo.toml | 1 + src/app.rs | 2 ++ src/render.rs | 36 +++++++++++++++++++++++--- src/runner.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77a1de4..70e5c83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.18", + "unicode-segmentation", "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index 2ca41cf..32e642a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ sha2 = "0.10" same-file = "1" tempfile = "3" thiserror = "2" +unicode-segmentation = "1" unicode-width = "0.2" [dev-dependencies] diff --git a/src/app.rs b/src/app.rs index d3bdbc3..85bcf69 100644 --- a/src/app.rs +++ b/src/app.rs @@ -72,6 +72,7 @@ pub struct App { pub horizontal_scroll: usize, pub should_quit: bool, pub follow_cursor: bool, + pub mouse_drag_anchor: Option, pub status: Option, pub dirty: bool, } @@ -90,6 +91,7 @@ impl App { horizontal_scroll: 0, should_quit: false, follow_cursor: true, + mouse_drag_anchor: None, status: None, dirty: false, } diff --git a/src/render.rs b/src/render.rs index 70c3182..c224bdb 100644 --- a/src/render.rs +++ b/src/render.rs @@ -7,6 +7,8 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, Frame, }; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use crate::{ app::App, @@ -15,6 +17,7 @@ use crate::{ const EDITOR_HEIGHT: usize = 5; const MINIMUM_HEIGHT: u16 = 7; +const TAB_STOP: usize = 4; #[derive(Debug, Clone, PartialEq, Eq)] enum DocumentRow { @@ -257,18 +260,36 @@ fn extend_editor_rect(editor_rect: &mut Option, row: Rect) { } fn horizontally_scrolled(text: &str, columns: usize) -> String { + let expanded = expand_tabs(text); let mut skipped = 0; - text.chars() - .skip_while(|character| { + expanded + .graphemes(true) + .skip_while(|grapheme| { if skipped >= columns { return false; } - skipped += unicode_width::UnicodeWidthChar::width(*character).unwrap_or(0); + skipped += grapheme.width(); true }) .collect() } +fn expand_tabs(text: &str) -> String { + let mut expanded = String::with_capacity(text.len()); + let mut column = 0; + for grapheme in text.graphemes(true) { + if grapheme == "\t" { + let spaces = TAB_STOP - column % TAB_STOP; + expanded.extend(std::iter::repeat_n(' ', spaces)); + column += spaces; + } else { + expanded.push_str(grapheme); + column += grapheme.width(); + } + } + expanded +} + fn render_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { let help = if app.editor.is_some() { " Enter save · Ctrl-O newline · Esc cancel · Ctrl-A/E line start/end " @@ -395,5 +416,14 @@ mod tests { assert!(app.scroll_row > 0); assert_eq!(horizontally_scrolled("abcdef", 3), "def"); assert_eq!(horizontally_scrolled("界abc", 2), "abc"); + assert_eq!(horizontally_scrolled("e\u{301}abc", 1), "abc"); + } + + #[test] + fn tabs_expand_to_four_column_stops_before_scrolling() { + assert_eq!(expand_tabs("\talpha"), " alpha"); + assert_eq!(expand_tabs("a\tb"), "a b"); + assert_eq!(expand_tabs("界\tb"), "界 b"); + assert_eq!(horizontally_scrolled("a\tb", 4), "b"); } } diff --git a/src/runner.rs b/src/runner.rs index d301b20..5ff176a 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -256,25 +256,35 @@ fn handle_mouse(mouse: MouseEvent, app: &mut App, hit_areas: &[HitArea]) { let target = hit_test(hit_areas, mouse.column, mouse.row); match mouse.kind { MouseEventKind::Down(MouseButton::Left) => match target { - Some(HitTarget::SourceLine(line)) if app.editor.is_none() => app.begin_selection(line), + Some(HitTarget::SourceLine(line)) if app.editor.is_none() => { + app.begin_selection(line); + app.mouse_drag_anchor = Some(line); + } Some(HitTarget::Comment(id)) if app.editor.is_none() => { + app.mouse_drag_anchor = None; app.begin_edit(id); } - _ => {} + _ => app.mouse_drag_anchor = None, }, MouseEventKind::Drag(MouseButton::Left) => { - if let Some(HitTarget::SourceLine(line)) = target { - app.extend_selection(line); + if app.mouse_drag_anchor.is_some() { + if let Some(HitTarget::SourceLine(line)) = target { + app.extend_selection(line); + } } } - MouseEventKind::Up(_) => { - if app.editor.is_none() && app.selection.is_some() { + MouseEventKind::Up(MouseButton::Left) => { + let active = app.mouse_drag_anchor.take().is_some(); + if active && app.editor.is_none() && app.selection.is_some() { if let Some(HitTarget::SourceLine(line)) = target { app.extend_selection(line); } app.open_selected_editor(); } } + MouseEventKind::Up(_) => { + app.mouse_drag_anchor = None; + } MouseEventKind::ScrollDown => { if let Some(editor) = app.editor.as_mut() { editor.textarea.scroll(Scrolling::PageDown); @@ -297,7 +307,8 @@ fn handle_mouse(mouse: MouseEvent, app: &mut App, hit_areas: &[HitArea]) { MouseEventKind::ScrollRight => { app.horizontal_scroll = app.horizontal_scroll.saturating_add(4); } - MouseEventKind::Moved | MouseEventKind::Down(_) | MouseEventKind::Drag(_) => {} + MouseEventKind::Down(_) => app.mouse_drag_anchor = None, + MouseEventKind::Moved | MouseEventKind::Drag(_) => {} } } @@ -371,6 +382,51 @@ mod tests { assert_eq!((editor.start_line, editor.end_line), (1, 2)); } + #[test] + fn unrelated_mouse_releases_do_not_open_a_keyboard_selection() { + let mut app = app(); + app.begin_selection(1); + let hits = [HitArea::new( + Rect::new(0, 1, 80, 1), + HitTarget::SourceLine(1), + )]; + + handle_mouse( + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + column: 5, + row: 1, + modifiers: KeyModifiers::NONE, + }, + &mut app, + &hits, + ); + assert!(app.editor.is_none()); + + handle_mouse( + MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 0, + modifiers: KeyModifiers::NONE, + }, + &mut app, + &hits, + ); + handle_mouse( + MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: 5, + row: 1, + modifiers: KeyModifiers::NONE, + }, + &mut app, + &hits, + ); + assert!(app.editor.is_none()); + assert!(app.selection.is_some()); + } + #[test] fn rejects_empty_source_names_before_entering_the_tui() { let cli = From 963edb2d575f6690e21cf7af5f746caa79853efe Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:10:21 +0800 Subject: [PATCH 7/9] fix: resolve filesystem path identity --- src/runner.rs | 67 ++++++++++++++++++++++++++- src/storage.rs | 123 +++++++++++++++++++++++++++++++------------------ 2 files changed, 143 insertions(+), 47 deletions(-) diff --git a/src/runner.rs b/src/runner.rs index 5ff176a..034c400 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -110,16 +110,65 @@ fn ensure_distinct_destinations(cli: &Cli) -> anyhow::Result<()> { } fn paths_alias(left: &Path, right: &Path) -> anyhow::Result { - if resolve_destination(left)? == resolve_destination(right)? { + let resolved_left = resolve_destination(left)?; + let resolved_right = resolve_destination(right)?; + if resolved_left == resolved_right { return Ok(true); } if left.exists() && right.exists() { return same_file::is_same_file(left, right) .with_context(|| format!("compare {} and {}", left.display(), right.display())); } + if paths_differ_only_by_case(&resolved_left, &resolved_right) + && nearest_existing_ancestor(&resolved_left) + .or_else(|| nearest_existing_ancestor(&resolved_right)) + .is_some_and(filesystem_is_case_insensitive) + { + return Ok(true); + } Ok(false) } +fn paths_differ_only_by_case(left: &Path, right: &Path) -> bool { + left.to_str() + .zip(right.to_str()) + .is_some_and(|(left, right)| left.to_lowercase() == right.to_lowercase()) +} + +fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { + path.ancestors().find(|ancestor| ancestor.exists()) +} + +fn filesystem_is_case_insensitive(path: &Path) -> bool { + path.ancestors().any(|ancestor| { + let Some(file_name) = ancestor.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let Some(toggled_name) = toggle_first_ascii_letter(file_name) else { + return false; + }; + let toggled = ancestor.with_file_name(toggled_name); + toggled.exists() && same_file::is_same_file(ancestor, toggled).unwrap_or(false) + }) +} + +fn toggle_first_ascii_letter(value: &str) -> Option { + let mut toggled = value.to_owned(); + let (index, character) = value + .char_indices() + .find(|(_, character)| character.is_ascii_alphabetic())?; + let replacement = if character.is_ascii_lowercase() { + character.to_ascii_uppercase() + } else { + character.to_ascii_lowercase() + }; + toggled.replace_range( + index..index + character.len_utf8(), + &replacement.to_string(), + ); + Some(toggled) +} + fn read_stdin() -> anyhow::Result> { let mut bytes = Vec::new(); io::stdin() @@ -524,4 +573,20 @@ mod tests { .unwrap(); assert!(ensure_distinct_destinations(&cli).is_err()); } + + #[test] + fn recognizes_case_only_path_differences() { + assert!(paths_differ_only_by_case( + Path::new("/tmp/review.json"), + Path::new("/TMP/REVIEW.JSON") + )); + assert!(!paths_differ_only_by_case( + Path::new("/tmp/review.json"), + Path::new("/tmp/output.json") + )); + assert_eq!( + toggle_first_ascii_letter("review.json").as_deref(), + Some("Review.json") + ); + } } diff --git a/src/storage.rs b/src/storage.rs index 729b7db..4e64b8c 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,4 +1,6 @@ use std::{ + collections::VecDeque, + ffi::OsString, fs, io::{self, Write}, path::{Component, Path, PathBuf}, @@ -68,60 +70,72 @@ pub(crate) fn resolve_destination(path: &Path) -> Result { .context("resolve current directory")? .join(path) }; - let mut unresolved = normalize_lexically(&absolute); - for _ in 0..40 { - let mut resolved = PathBuf::new(); - let components = unresolved.components().collect::>(); - let mut followed = false; - - for (index, component) in components.iter().enumerate() { - resolved.push(component.as_os_str()); - match fs::symlink_metadata(&resolved) { - Ok(metadata) if metadata.file_type().is_symlink() => { - let target = fs::read_link(&resolved) - .with_context(|| format!("resolve destination {}", path.display()))?; - let parent = resolved.parent().unwrap_or_else(|| Path::new("/")); - let mut next = if target.is_absolute() { - target - } else { - parent.join(target) - }; - for remaining in &components[index + 1..] { - next.push(remaining.as_os_str()); + let mut pending = owned_components(&absolute); + let mut resolved = PathBuf::new(); + let mut followed_links = 0; + while let Some(component) = pending.pop_front() { + match component { + OwnedComponent::Root => { + resolved.clear(); + resolved.push(Path::new("/")); + } + OwnedComponent::Prefix(prefix) => { + resolved.clear(); + resolved.push(prefix); + } + OwnedComponent::Parent => { + resolved.pop(); + } + OwnedComponent::Normal(name) => { + let candidate = resolved.join(&name); + match fs::symlink_metadata(&candidate) { + Ok(metadata) if metadata.file_type().is_symlink() => { + followed_links += 1; + if followed_links > 40 { + anyhow::bail!( + "too many symbolic links while resolving {}", + path.display() + ); + } + let target = fs::read_link(&candidate) + .with_context(|| format!("resolve destination {}", path.display()))?; + for target_component in owned_components(&target).into_iter().rev() { + pending.push_front(target_component); + } + } + Ok(_) => resolved.push(name), + Err(error) if error.kind() == io::ErrorKind::NotFound => resolved.push(name), + Err(error) => { + return Err(error) + .with_context(|| format!("resolve destination {}", path.display())); } - unresolved = normalize_lexically(&next); - followed = true; - break; - } - Ok(_) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("resolve destination {}", path.display())); } } } - if !followed { - return Ok(normalize_lexically(&resolved)); - } } - anyhow::bail!("too many symbolic links while resolving {}", path.display()) + Ok(resolved) } -fn normalize_lexically(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - normalized.pop(); - } - Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { - normalized.push(component.as_os_str()); +#[derive(Debug)] +enum OwnedComponent { + Prefix(OsString), + Root, + Parent, + Normal(OsString), +} + +fn owned_components(path: &Path) -> VecDeque { + path.components() + .filter_map(|component| match component { + Component::Prefix(prefix) => { + Some(OwnedComponent::Prefix(prefix.as_os_str().to_owned())) } - } - } - normalized + Component::RootDir => Some(OwnedComponent::Root), + Component::CurDir => None, + Component::ParentDir => Some(OwnedComponent::Parent), + Component::Normal(name) => Some(OwnedComponent::Normal(name.to_owned())), + }) + .collect() } #[cfg(test)] @@ -196,4 +210,21 @@ mod tests { .is_symlink()); assert_eq!(load_review(&target).unwrap(), review); } + + #[cfg(unix)] + #[test] + fn symlinks_are_resolved_before_parent_components() { + use std::os::unix::fs::symlink; + + let directory = tempdir().unwrap(); + let root = directory.path().join("root"); + let other = directory.path().join("other"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(other.join("child")).unwrap(); + symlink(other.join("child"), root.join("link")).unwrap(); + + let resolved = resolve_destination(&root.join("link/../review.json")).unwrap(); + + assert_eq!(resolved, other.join("review.json")); + } } From 0f430f9c802c7b68d2628308b6db66e97d8742a0 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:19:40 +0800 Subject: [PATCH 8/9] fix: constrain destructive keys and render tabs --- src/render.rs | 12 +++++++++++- src/runner.rs | 24 +++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/render.rs b/src/render.rs index c224bdb..32a66c7 100644 --- a/src/render.rs +++ b/src/render.rs @@ -248,7 +248,10 @@ fn render_comment_row(frame: &mut Frame<'_>, area: Rect, text: &str, first: bool } else { Style::default().fg(Color::Green).bg(Color::Rgb(20, 35, 25)) }; - frame.render_widget(Paragraph::new(format!("{prefix}{text}")).style(style), area); + frame.render_widget( + Paragraph::new(format!("{prefix}{}", expand_tabs(text))).style(style), + area, + ); } fn extend_editor_rect(editor_rect: &mut Option, row: Rect) { @@ -395,6 +398,13 @@ mod tests { let rendered = terminal.backend().to_string(); assert!(rendered.contains("Comment on lines 1–2")); assert!(rendered.contains("Ctrl-O newline")); + + app.cancel_editor(); + app.review.comments[0].body = "\tcode".into(); + terminal + .draw(|frame| drop(render_app(frame, &mut app))) + .unwrap(); + assert!(terminal.backend().to_string().contains("└─ code")); } #[test] diff --git a/src/runner.rs b/src/runner.rs index 034c400..ec8b440 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -286,7 +286,11 @@ fn handle_browse_key(key: KeyEvent, app: &mut App) { KeyCode::Char('e') => { app.edit_comment_at_cursor(); } - KeyCode::Char('d') => { + KeyCode::Char('d') + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { app.delete_comment_at_cursor(); } KeyCode::Char(']') => app.jump_comment(true), @@ -398,6 +402,24 @@ mod tests { assert_eq!(app.review.comments[0].body, "x"); } + #[test] + fn modified_d_does_not_delete_a_comment() { + let mut app = app(); + app.review.upsert_comment(crate::domain::Comment { + id: 1, + start_line: 1, + end_line: 1, + body: "keep me".into(), + }); + + handle_key( + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + &mut app, + ); + + assert_eq!(app.review.comments.len(), 1); + } + #[test] fn mouse_drag_release_opens_editor_for_the_range() { let mut app = app(); From 18921120129446f7ce6ab0d71d527856cb6562ab Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:29:15 +0800 Subject: [PATCH 9/9] fix: fail closed on sidecar path errors --- Cargo.lock | 25 ++++++++++++++++++++ Cargo.toml | 1 + src/runner.rs | 65 +++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70e5c83..1eaaf04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "sha2", "tempfile", "thiserror 2.0.18", + "unicode-normalization", "unicode-segmentation", "unicode-width", ] @@ -1160,6 +1161,21 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "typenum" version = "1.20.1" @@ -1172,6 +1188,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/Cargo.toml b/Cargo.toml index 32e642a..2565065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ same-file = "1" tempfile = "3" thiserror = "2" unicode-segmentation = "1" +unicode-normalization = "0.1" unicode-width = "0.2" [dev-dependencies] diff --git a/src/runner.rs b/src/runner.rs index ec8b440..ea51e43 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -10,6 +10,7 @@ use crossterm::event::{ MouseEventKind, }; use ratatui_textarea::Scrolling; +use unicode_normalization::UnicodeNormalization; use crate::{ app::App, @@ -119,7 +120,10 @@ fn paths_alias(left: &Path, right: &Path) -> anyhow::Result { return same_file::is_same_file(left, right) .with_context(|| format!("compare {} and {}", left.display(), right.display())); } - if paths_differ_only_by_case(&resolved_left, &resolved_right) + if paths_normalize_equally(&resolved_left, &resolved_right) { + return Ok(true); + } + if paths_case_fold_equally(&resolved_left, &resolved_right) && nearest_existing_ancestor(&resolved_left) .or_else(|| nearest_existing_ancestor(&resolved_right)) .is_some_and(filesystem_is_case_insensitive) @@ -129,10 +133,24 @@ fn paths_alias(left: &Path, right: &Path) -> anyhow::Result { Ok(false) } -fn paths_differ_only_by_case(left: &Path, right: &Path) -> bool { +fn paths_normalize_equally(left: &Path, right: &Path) -> bool { left.to_str() .zip(right.to_str()) - .is_some_and(|(left, right)| left.to_lowercase() == right.to_lowercase()) + .is_some_and(|(left, right)| { + left != right && normalize_path_text(left) == normalize_path_text(right) + }) +} + +fn paths_case_fold_equally(left: &Path, right: &Path) -> bool { + left.to_str() + .zip(right.to_str()) + .is_some_and(|(left, right)| { + normalize_path_text(left).to_lowercase() == normalize_path_text(right).to_lowercase() + }) +} + +fn normalize_path_text(path: &str) -> String { + path.nfc().collect() } fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { @@ -181,7 +199,10 @@ fn read_review(cli: &Cli, source: &SourceBuffer) -> anyhow::Result