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..1eaaf04 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1300 @@ +# 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", + "same-file", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.18", + "unicode-normalization", + "unicode-segmentation", + "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 = "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" +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 = "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" +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-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" +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-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" +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..2565065 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[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" +same-file = "1" +tempfile = "3" +thiserror = "2" +unicode-segmentation = "1" +unicode-normalization = "0.1" +unicode-width = "0.2" + +[dev-dependencies] +assert_cmd = "2" +predicates = "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..cd1df76 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# 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. +`--comments` and `--output` must name different files. + +## 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..b7789ef --- /dev/null +++ b/docs/2026-07-10-data-model-review.md @@ -0,0 +1,49 @@ +--- +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 +[the output format reference](2026-07-10-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..659b235 --- /dev/null +++ b/docs/2026-07-10-output-formats.md @@ -0,0 +1,79 @@ +--- +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` 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 +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..85bcf69 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,495 @@ +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") + } +} + +#[derive(Debug)] +pub struct App { + pub source: SourceBuffer, + pub review: ReviewDocument, + 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, + pub follow_cursor: bool, + pub mouse_drag_anchor: Option, + 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, + active_comment_id: None, + scroll_row: 0, + horizontal_scroll: 0, + should_quit: false, + follow_cursor: true, + mouse_drag_anchor: None, + 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.active_comment_id = None; + 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; + self.active_comment_id = None; + } + + 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.cursor_line = end_line; + 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.active_comment_id = Some(comment.id); + 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.comment_id_at_cursor(); + 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.trim().is_empty() { + self.status = Some("Comment cannot be empty".into()); + return false; + } + 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, + end_line: editor.end_line, + body, + }); + self.editor = None; + self.selection = None; + self.status = Some("Comment saved".into()); + self.active_comment_id = Some(id); + 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.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()); + } + removed + } + + pub fn jump_comment(&mut self, forward: bool) { + 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() + .position(|comment| comment.start_line > self.cursor_line) + .unwrap_or(0) + } else { + self.review + .comments + .iter() + .rposition(|comment| comment.end_line < self.cursor_line) + .unwrap_or(self.review.comments.len() - 1) + }; + 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) + }) + } +} + +#[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(); + assert_eq!(app.cursor_line, 3); + 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 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(); + 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(); + 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")); + } + + #[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/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..b5fb5ef --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,330 @@ +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) -> Option { + let used = self + .comments + .iter() + .map(|comment| comment.id) + .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) { + 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(), Some(2)); + 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 { .. }) + )); + } + + #[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/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..a0b9763 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,164 @@ +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) + }) + .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.clone()) + .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) + ); + } + + #[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 new file mode 100644 index 0000000..32a66c7 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,439 @@ +use std::collections::BTreeMap; + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::{ + app::App, + input::{HitArea, HitTarget}, +}; + +const EDITOR_HEIGHT: usize = 5; +const MINIMUM_HEIGHT: u16 = 7; +const TAB_STOP: usize = 4; + +#[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 < MINIMUM_HEIGHT || area.width < 20 { + frame.render_widget( + Paragraph::new(format!( + "annotui needs at least 20 columns and {MINIMUM_HEIGHT} 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, + app.active_comment_id == Some(*id), + ); + 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); + 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)); + 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) { + 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, active: bool) { + let prefix = if first { " └─ " } else { " " }; + 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}{}", expand_tabs(text))).style(style), + 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 expanded = expand_tabs(text); + let mut skipped = 0; + expanded + .graphemes(true) + .skip_while(|grapheme| { + if skipped >= columns { + return false; + } + 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 " + } 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")); + + 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] + 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")); + + 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] + 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"); + 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 new file mode 100644 index 0000000..ea51e43 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,665 @@ +use std::{ + fs, + io::{self, IsTerminal, Read, Write}, + path::Path, +}; + +use anyhow::{bail, Context}; +use crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use ratatui_textarea::Scrolling; +use unicode_normalization::UnicodeNormalization; + +use crate::{ + app::App, + cli::Cli, + domain::ReviewDocument, + input::{hit_test, HitArea, HitTarget}, + output::format_review, + render::render_app, + source::SourceBuffer, + storage::{load_review, resolve_destination, 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<()> { + ensure_distinct_destinations(cli)?; + 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))?; + 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") + }; + + 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 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 { + 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_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) + { + return Ok(true); + } + Ok(false) +} + +fn paths_normalize_equally(left: &Path, right: &Path) -> bool { + left.to_str() + .zip(right.to_str()) + .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> { + 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() + .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 + .try_exists() + .with_context(|| format!("inspect comments file {}", path.display()))? + { + 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') + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + 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); + 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 app.mouse_drag_anchor.is_some() { + if let Some(HitTarget::SourceLine(line)) = target { + app.extend_selection(line); + } + } + } + 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); + } 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::Down(_) => app.mouse_drag_anchor = None, + MouseEventKind::Moved | MouseEventKind::Drag(_) => {} + } +} + +#[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}; + + 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 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(); + 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)); + } + + #[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 = + 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()); + } + + #[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()); + } + + #[test] + fn recognizes_case_and_normalization_equivalent_paths() { + assert!(paths_case_fold_equally( + Path::new("/tmp/review.json"), + Path::new("/TMP/REVIEW.JSON") + )); + assert!(!paths_case_fold_equally( + Path::new("/tmp/review.json"), + Path::new("/tmp/output.json") + )); + assert!(paths_normalize_equally( + Path::new("/tmp/é.json"), + Path::new("/tmp/e\u{301}.json") + )); + assert_eq!( + toggle_first_ascii_letter("review.json").as_deref(), + Some("Review.json") + ); + } + + #[cfg(unix)] + #[test] + fn sidecar_metadata_errors_are_reported_before_review() { + use std::os::unix::fs::symlink; + + let directory = tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + symlink(&second, &first).unwrap(); + symlink(&first, &second).unwrap(); + let cli = Cli::try_parse_from([ + "annotui".into(), + "--buffer".into(), + "hello".into(), + "--comments".into(), + first.into_os_string(), + ]) + .unwrap(); + let source = read_source(&cli).unwrap(); + + assert!(read_review(&cli, &source) + .unwrap_err() + .to_string() + .contains("inspect comments file")); + } +} 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..4e64b8c --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,230 @@ +use std::{ + collections::VecDeque, + ffi::OsString, + fs, + io::{self, Write}, + path::{Component, 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 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(&destination) { + Ok(metadata) => temporary + .as_file() + .set_permissions(metadata.permissions()) + .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 {}", destination.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(&destination) + .map_err(|error| error.error) + .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 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())); + } + } + } + } + } + Ok(resolved) +} + +#[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())) + } + 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)] +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_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + } + + #[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")); + } + + #[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 + ); + } + + #[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); + } + + #[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")); + } +} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..4654b1a --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,93 @@ +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()?; + let guard = Self; + enable_raw_mode()?; + RAW_ENABLED.store(true, Ordering::SeqCst); + 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((guard, 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}`" + ); + } +}