diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c1debf5..c2428f9 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -23,14 +23,17 @@ jobs: rustup component add rustfmt rustup component add clippy + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2 + - name: Check formatting run: cargo fmt --check - name: Build - run: cargo build --verbose + run: cargo build --locked --all-targets --verbose - name: Run tests - run: cargo test --verbose + run: cargo test --locked --all-targets --verbose - name: Run clippy - run: cargo clippy -- -D warnings + run: cargo clippy --locked --all-targets -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d84a6ec..062be3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,8 +41,13 @@ jobs: toolchain: stable targets: ${{ matrix.target }} + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - name: Build for target - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --release --locked --target ${{ matrix.target }} - name: Rename binary (Linux/macOS) if: runner.os != 'Windows' diff --git a/CHANGELOG.md b/CHANGELOG.md index c160324..ddf6634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.1] - 2026-04-24 + +### Added + +- TUI `:help` command — opens a scrollable, structured key-reference overlay with all navigation, editing, and command-mode shortcuts. + +### Changed + +- TUI visual refresh: cleaner title bar with branding and inline row/column stats, single-line status bar, and a consistent dark theme across panels. +- `check` scans are more efficient and report findings with A1-style cell ranges. + ## [1.3.0] - 2026-04-22 ### Added @@ -201,10 +212,11 @@ This is the initial release of excel-cli, a lightweight terminal-based Excel vie - Copy, cut, and paste functionality with `y`, `d`, and `p` keys - Support for pipe operator when exporting to JSON -[Unreleased]: https://github.com/fuhan666/excel-cli/compare/v1.3.0...HEAD -[1.3.0]: https://github.com/fuhan666/excel-cli/compare/v1.2.0...v1.3.0 -[1.2.0]: https://github.com/fuhan666/excel-cli/compare/v1.1.0...v1.2.0 -[1.1.0]: https://github.com/fuhan666/excel-cli/compare/v1.0.0...v1.1.0 +[Unreleased]: https://github.com/fuhan666/excel-cli/compare/v1.3.1...HEAD +[1.3.1]: https://github.com/fuhan666/excel-cli/releases/tag/v1.3.1 +[1.3.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.3.0 +[1.2.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.2.0 +[1.1.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.1.0 [1.0.0]: https://github.com/fuhan666/excel-cli/releases/tag/v1.0.0 [0.5.2]: https://github.com/fuhan666/excel-cli/releases/tag/v0.5.2 [0.5.1]: https://github.com/fuhan666/excel-cli/releases/tag/v0.5.1 diff --git a/Cargo.lock b/Cargo.lock index 25dcd1e..8d06196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,20 +4,9 @@ version = 4 [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" @@ -34,12 +23,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -51,9 +34,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -66,86 +49,71 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", - "once_cell", - "windows-sys 0.59.0", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "arbitrary" -version = "1.4.1" +name = "atoi_simd" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "8ad17c7c205c2c28b527b9845eeb91cf1b4d008b438f98ce0e628227a822758e" dependencies = [ - "derive_arbitrary", + "debug_unsafe", ] -[[package]] -name = "atoi_simd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4790f9e8961209112beb783d85449b508673cf4a6a419c8449b210743ac4dbe9" - [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" - -[[package]] -name = "block-buffer" -version = "0.10.4" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -153,30 +121,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "calamine" -version = "0.27.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d80f81ba5c68206b9027e62346d49dc26fb32ffc4fe6ef7022a8ae21d348ccb" +checksum = "20ae05a4e39297eecf9a994210d27501318c37a9318201f8e11050add82bb6f0" dependencies = [ "atoi_simd", "byteorder", @@ -195,30 +144,37 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" -version = "1.2.19" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ - "jobserver", - "libc", + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -226,21 +182,11 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" -version = "4.5.36" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -248,9 +194,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.36" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -260,11 +206,11 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn", @@ -272,9 +218,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codepage" @@ -287,15 +233,23 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "constant_time_eq" -version = "0.3.1" +name = "compact_str" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] [[package]] name = "core-foundation-sys" @@ -303,56 +257,26 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - [[package]] name = "crossterm" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "libc", "mio", "parking_lot", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -368,51 +292,44 @@ dependencies = [ ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "generic-array", - "typenum", + "darling_core", + "darling_macro", ] [[package]] -name = "deflate64" -version = "0.1.12" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "powerfmt", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] -name = "derive_arbitrary" -version = "1.4.1" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "proc-macro2", + "darling_core", "quote", "syn", ] [[package]] -name = "digest" -version = "0.10.7" +name = "debug_unsafe" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" [[package]] name = "either" @@ -435,9 +352,19 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "excel-cli" -version = "1.3.0" +version = "1.3.1" dependencies = [ "anyhow", "calamine", @@ -461,14 +388,20 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -477,35 +410,11 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", @@ -513,10 +422,10 @@ dependencies = [ ] [[package]] -name = "heck" -version = "0.4.1" +name = "hashbrown" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -524,20 +433,11 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -557,68 +457,72 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" -version = "2.9.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", "serde", + "serde_core", ] [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] -name = "inout" -version = "0.1.4" +name = "instability" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "generic-array", + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom", - "libc", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "once_cell", "wasm-bindgen", @@ -626,25 +530,30 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.172" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" @@ -652,63 +561,37 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", -] - -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", + "hashbrown 0.15.5", ] [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "0.8.11" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-traits" version = "0.2.19" @@ -720,15 +603,21 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +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 = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -736,15 +625,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -753,42 +642,20 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "encoding_rs", "memchr", @@ -796,42 +663,39 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "ratatui" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ebc917cfb527a566c37ecb94c7e3fd098353516fb4eb6bea17015ade0182425" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", + "compact_str", "crossterm", "indoc", + "instability", "itertools", "lru", "paste", "strum", "unicode-segmentation", - "unicode-width", + "unicode-truncate", + "unicode-width 0.2.0", ] [[package]] name = "redox_syscall" -version = "0.5.11" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -867,24 +731,37 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rust_xlsxwriter" -version = "0.86.0" +version = "0.94.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce466a17c071a45249477993a1f1b6ceb447af42cbe9cdc65e30f38bab850688" +checksum = "efc4a0f1f7b425669996977016152b2939be9be44d40df252a5051c9c6b3b859" dependencies = [ "zip", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "scopeguard" @@ -894,18 +771,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +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.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -914,25 +801,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "serde_core", + "zmij", ] [[package]] @@ -943,9 +820,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", "signal-hook-registry", @@ -953,9 +830,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", @@ -964,24 +841,31 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "strsim" @@ -991,90 +875,76 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.25.0" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.25.3" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.4.1", + "heck", "proc-macro2", "quote", "rustversion", "syn", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" -version = "2.0.100" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - [[package]] name = "tui-textarea" -version = "0.4.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ "crossterm", "ratatui", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] -name = "typenum" -version = "1.19.0" +name = "typed-path" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] [[package]] name = "unicode-width" @@ -1083,63 +953,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "utf8parse" -version = "0.2.2" +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] -name = "version_check" -version = "0.9.5" +name = "utf8parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1147,22 +995,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -1191,9 +1039,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.61.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", @@ -1204,9 +1052,9 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -1215,9 +1063,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -1226,59 +1074,44 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] -name = "windows-targets" -version = "0.48.5" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-link", ] [[package]] @@ -1287,46 +1120,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1339,153 +1154,64 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" - -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zip" -version = "2.5.0" +version = "7.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ - "aes", - "arbitrary", - "bzip2", - "constant_time_eq", "crc32fast", - "crossbeam-utils", - "deflate64", "flate2", - "getrandom", - "hmac", "indexmap", - "lzma-rs", "memchr", - "pbkdf2", - "sha1", - "time", - "xz2", - "zeroize", + "typed-path", "zopfli", - "zstd", ] [[package]] -name = "zopfli" -version = "0.8.2" +name = "zlib-rs" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] -name = "zstd" -version = "0.13.3" +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +name = "zopfli" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ - "cc", - "pkg-config", + "bumpalo", + "crc32fast", + "log", + "simd-adler32", ] diff --git a/Cargo.toml b/Cargo.toml index b60ad72..a313e50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "excel-cli" -version = "1.3.0" +version = "1.3.1" edition = "2021" description = "Excel CLI for AI, scripting, and terminal users. Headless JSON API for automation, plus a Vim-like TUI for interactive browsing and editing." license = "MIT" @@ -10,19 +10,19 @@ categories = ["command-line-interface", "command-line-utilities", "development-t exclude = ["/.tests", "/.github", "AGENTS.md", "CHANGELOG.md", ".gitignore"] [dependencies] -ratatui = "0.24.0" -crossterm = "0.27.0" -calamine = "0.27.0" +ratatui = "0.29.0" +crossterm = "0.28.1" +calamine = "0.34.0" anyhow = "1.0.79" clap = { version = "4.5.0", features = ["derive"] } -rust_xlsxwriter = "0.86.0" +rust_xlsxwriter = "0.94.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = "0.4" indexmap = { version = "2.0", features = ["serde"] } -tui-textarea = "0.4.0" -quick-xml = "0.37.5" -zip = "2.5.0" +tui-textarea = "0.7.0" +quick-xml = "0.39.2" +zip = { version = "7.2.0", default-features = false, features = ["deflate"] } regex = "1" [profile.release] diff --git a/README.md b/README.md index 45b637b..5e89f14 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,6 @@ An Excel CLI for AI, scripting, and terminal users. Inspect and read headlessly - Run workbook and sheet quality checks with stable JSON findings - Delete rows and columns - Search functionality with highlighting -- Read-only query preview in the TUI -- Review quality-check findings inside the TUI - Command mode for advanced operations ## Installation & Uninstallation @@ -404,8 +402,7 @@ The JSON files are saved in the same directory as the original Excel file. ### Other Commands - `:nohlsearch` or `:noh` - Disable search highlighting -- `:help` - Show available commands -- `:preview` or `:pv` - Show a read-only preview of the current sheet target and sample rows +- `:help` - Show all keyboard-shortcut reference ## File Saving Logic diff --git a/README_zh.md b/README_zh.md index b9f2c64..b74e2fc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -12,8 +12,6 @@ - 对工作簿或单个工作表做质量检查,并以稳定的 JSON 格式输出结果 - 删除行和列 - 支持搜索并高亮匹配项 -- 在 TUI 内以只读方式预览查询结果 -- 在 TUI 内查看质量检查结果 - 命令模式支持高级操作 ## 安装与卸载 @@ -405,8 +403,7 @@ JSON 文件保存在原始 Excel 文件所在目录。 ### 其他命令 - `:nohlsearch` 或 `:noh` — 关闭搜索高亮 -- `:help` — 显示可用命令 -- `:preview` 或 `:pv` — 显示当前工作表目标和样本行的只读预览 +- `:help` — 显示所有快捷键 ## 文件保存逻辑 diff --git a/src/actions/cell.rs b/src/actions/cell.rs index a0eeea1..ba1e66f 100644 --- a/src/actions/cell.rs +++ b/src/actions/cell.rs @@ -1,6 +1,5 @@ use super::{ActionType, Command}; use crate::excel::Cell; -use anyhow::Result; #[derive(Clone)] pub struct CellAction { @@ -37,14 +36,6 @@ impl CellAction { } impl Command for CellAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { self.action_type.clone() } diff --git a/src/actions/column.rs b/src/actions/column.rs index 106aa65..6a4a3ab 100644 --- a/src/actions/column.rs +++ b/src/actions/column.rs @@ -1,6 +1,5 @@ use super::{ActionType, Command}; use crate::excel::Cell; -use anyhow::Result; #[derive(Clone)] pub struct ColumnAction { @@ -12,14 +11,6 @@ pub struct ColumnAction { } impl Command for ColumnAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { ActionType::DeleteColumn } @@ -36,14 +27,6 @@ pub struct MultiColumnAction { } impl Command for MultiColumnAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { ActionType::DeleteMultiColumns } diff --git a/src/actions/row.rs b/src/actions/row.rs index c9f19b1..3bc34b7 100644 --- a/src/actions/row.rs +++ b/src/actions/row.rs @@ -1,6 +1,5 @@ use super::{ActionType, Command}; use crate::excel::Cell; -use anyhow::Result; #[derive(Clone)] pub struct RowAction { @@ -11,14 +10,6 @@ pub struct RowAction { } impl Command for RowAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { ActionType::DeleteRow } @@ -34,14 +25,6 @@ pub struct MultiRowAction { } impl Command for MultiRowAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { ActionType::DeleteMultiRows } diff --git a/src/actions/sheet.rs b/src/actions/sheet.rs index d1c12a2..a1269c2 100644 --- a/src/actions/sheet.rs +++ b/src/actions/sheet.rs @@ -1,6 +1,5 @@ use super::{ActionType, Command}; use crate::excel::Sheet; -use anyhow::Result; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SheetOperation { @@ -18,14 +17,6 @@ pub struct SheetAction { } impl Command for SheetAction { - fn execute(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - - fn undo(&self) -> Result<()> { - unimplemented!("Requires an ActionExecutor implementation") - } - fn action_type(&self) -> ActionType { match self.operation { SheetOperation::Create => ActionType::CreateSheet, diff --git a/src/actions/types.rs b/src/actions/types.rs index 61ab7fa..b81b4d3 100644 --- a/src/actions/types.rs +++ b/src/actions/types.rs @@ -42,8 +42,14 @@ pub trait ActionExecutor { // Command interface for actions that can be executed and undone pub trait Command { - fn execute(&self) -> anyhow::Result<()>; - fn undo(&self) -> anyhow::Result<()>; + fn execute(&self) -> anyhow::Result<()> { + unimplemented!("Requires an ActionExecutor implementation") + } + + fn undo(&self) -> anyhow::Result<()> { + unimplemented!("Requires an ActionExecutor implementation") + } + fn action_type(&self) -> ActionType; } diff --git a/src/app/findings.rs b/src/app/findings.rs deleted file mode 100644 index 576e02c..0000000 --- a/src/app/findings.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::app::{AppState, InputMode}; -use crate::cli::args::SeverityThreshold; -use crate::cli::check::{run_check_report, CheckFinding}; -use crate::utils::{cell_reference, parse_range}; - -#[derive(Clone, Debug, Default)] -pub(crate) struct FindingsState { - pub(crate) items: Vec, - pub(crate) selected: usize, - pub(crate) last_refresh_error: Option, -} - -impl FindingsState { - fn clamp_selected(&mut self) { - if self.items.is_empty() { - self.selected = 0; - } else { - self.selected = self.selected.min(self.items.len() - 1); - } - } -} - -impl AppState<'_> { - pub fn show_findings(&mut self) { - self.input_mode = InputMode::Findings; - self.refresh_findings(); - } - - pub fn close_findings(&mut self) { - self.input_mode = InputMode::Normal; - } - - pub fn refresh_findings(&mut self) { - let was_modified = self.workbook.is_modified(); - let result = self.ensure_findings_workbook_ready().and_then(|_| { - run_check_report(&mut self.workbook, None, None, SeverityThreshold::Info) - }); - self.workbook.set_modified(was_modified); - - match result { - Ok(report) => { - let finding_count = report.findings.len(); - self.findings.items = report.findings; - self.findings.last_refresh_error = None; - self.findings.clamp_selected(); - - if finding_count == 0 { - self.add_notification("No findings in current workbook".to_string()); - } else { - self.add_notification(format!("Loaded {finding_count} findings")); - } - } - Err(err) => { - self.findings.items.clear(); - self.findings.selected = 0; - self.findings.last_refresh_error = Some(err.to_string()); - self.add_notification(format!("Findings refresh failed: {err}")); - } - } - } - - pub fn select_next_finding(&mut self) { - if self.findings.selected + 1 < self.findings.items.len() { - self.findings.selected += 1; - } - } - - pub fn select_prev_finding(&mut self) { - self.findings.selected = self.findings.selected.saturating_sub(1); - } - - pub fn activate_selected_finding(&mut self) { - let Some(finding) = self.findings.items.get(self.findings.selected).cloned() else { - self.add_notification("No finding selected".to_string()); - return; - }; - - let target_index = match self.workbook.resolve_sheet_by_name(&finding.sheet) { - Ok(index) => index, - Err(err) => { - self.add_notification(format!( - "Finding sheet '{}' not found: {err}", - finding.sheet - )); - return; - } - }; - - if self.workbook.get_current_sheet_index() != target_index { - if let Err(err) = self.switch_sheet_by_index(target_index) { - self.add_notification(format!("Failed to switch to finding sheet: {err}")); - return; - } - } - - let Some(target_cell) = finding_target_cell(&finding) else { - self.add_notification(format!("Jumped to finding on sheet '{}'", finding.sheet)); - return; - }; - - let sheet = self.workbook.get_current_sheet(); - let max_row = sheet.max_rows.max(1); - let max_col = sheet.max_cols.max(1); - let clamped = (target_cell.0.min(max_row), target_cell.1.min(max_col)); - - self.selected_cell = clamped; - self.handle_scrolling(); - - if clamped == target_cell { - self.add_notification(format!("Jumped to finding at {}", cell_reference(clamped))); - } else { - self.add_notification(format!( - "Finding target {} was out of range; jumped to {}", - cell_reference(target_cell), - cell_reference(clamped) - )); - } - } - - fn ensure_findings_workbook_ready(&mut self) -> Result<(), crate::cli::error::AppError> { - let sheet_names = self.workbook.get_sheet_names(); - for (index, sheet_name) in sheet_names.iter().enumerate() { - self.workbook - .ensure_sheet_loaded(index, sheet_name) - .map_err(crate::cli::error::anyhow_to_app_error)?; - } - Ok(()) - } -} - -fn finding_target_cell(finding: &CheckFinding) -> Option<(usize, usize)> { - match (finding.row, finding.column) { - (Some(row), Some(col)) => Some((row, col)), - _ => finding - .range - .as_deref() - .and_then(parse_range) - .map(|(start, _)| start) - .or_else(|| finding.row.map(|row| (row, 1))) - .or_else(|| finding.column.map(|col| (1, col))), - } -} diff --git a/src/app/help.rs b/src/app/help.rs new file mode 100644 index 0000000..2da0fbf --- /dev/null +++ b/src/app/help.rs @@ -0,0 +1,294 @@ +pub struct HelpEntry { + pub keys: &'static str, + pub description: &'static str, +} + +pub struct HelpSection { + pub title: &'static str, + pub entries: &'static [HelpEntry], +} + +pub const LEFT_HELP_SECTIONS: &[HelpSection] = &[ + HelpSection { + title: "NAVIGATION", + entries: &[ + HelpEntry { + keys: "h j k l / arrows", + description: "Move cell", + }, + HelpEntry { + keys: "[ / ]", + description: "Switch sheet", + }, + HelpEntry { + keys: "gg / G", + description: "Start/end of data", + }, + HelpEntry { + keys: "0 / ^ / $", + description: "Row start / first non-empty / end", + }, + HelpEntry { + keys: "Ctrl+arrows", + description: "Jump to next non-empty cell", + }, + ], + }, + HelpSection { + title: "SEARCH", + entries: &[ + HelpEntry { + keys: "/", + description: "Search forward", + }, + HelpEntry { + keys: "?", + description: "Search backward", + }, + HelpEntry { + keys: "n / N", + description: "Next/previous search result", + }, + HelpEntry { + keys: ":noh / :nohlsearch", + description: "Disable search highlighting", + }, + ], + }, + HelpSection { + title: "JUMP & SHEETS", + entries: &[ + HelpEntry { + keys: ":", + description: "Jump to cell, e.g. :B10", + }, + HelpEntry { + keys: ":sheet ", + description: "Switch sheet", + }, + HelpEntry { + keys: ":addsheet ", + description: "Add sheet after current", + }, + HelpEntry { + keys: ":delsheet", + description: "Delete current sheet", + }, + ], + }, + HelpSection { + title: "ROWS & COLUMNS", + entries: &[ + HelpEntry { + keys: ":cw fit", + description: "Fit current column", + }, + HelpEntry { + keys: ":cw fit all", + description: "Fit all columns", + }, + HelpEntry { + keys: ":cw min", + description: "Minimize current column", + }, + HelpEntry { + keys: ":cw min all", + description: "Minimize all columns", + }, + HelpEntry { + keys: ":cw ", + description: "Set current column width", + }, + HelpEntry { + keys: ":dr / :dr ", + description: "Delete current/specific row", + }, + HelpEntry { + keys: ":dr ", + description: "Delete row range", + }, + HelpEntry { + keys: ":dc / :dc ", + description: "Delete current/specific column", + }, + HelpEntry { + keys: ":dc ", + description: "Delete column range", + }, + ], + }, +]; + +pub const RIGHT_HELP_SECTIONS: &[HelpSection] = &[ + HelpSection { + title: "ACTIONS", + entries: &[ + HelpEntry { + keys: "Enter", + description: "Edit cell", + }, + HelpEntry { + keys: "y / :y", + description: "Copy current cell", + }, + HelpEntry { + keys: "d / :d", + description: "Cut current cell", + }, + HelpEntry { + keys: "p / :put / :pu", + description: "Paste to current cell", + }, + HelpEntry { + keys: "u", + description: "Undo", + }, + HelpEntry { + keys: "Ctrl+r", + description: "Redo", + }, + HelpEntry { + keys: "+ / = / -", + description: "Resize info panel", + }, + ], + }, + HelpSection { + title: "FILE & APP", + entries: &[ + HelpEntry { + keys: ":w", + description: "Save file", + }, + HelpEntry { + keys: ":wq / :x", + description: "Save and quit", + }, + HelpEntry { + keys: ":q", + description: "Quit, warn if unsaved", + }, + HelpEntry { + keys: ":q!", + description: "Force quit without saving", + }, + HelpEntry { + keys: ":help", + description: "Show this overlay", + }, + ], + }, + HelpSection { + title: "EXPORT", + entries: &[ + HelpEntry { + keys: ":ej", + description: "Export current sheet JSON", + }, + HelpEntry { + keys: ":ej ", + description: "Export with header direction/count", + }, + HelpEntry { + keys: ":eja", + description: "Export all sheets JSON", + }, + HelpEntry { + keys: ":eja ", + description: "Export all with header settings", + }, + ], + }, + HelpSection { + title: "EDIT MODE", + entries: &[ + HelpEntry { + keys: "Esc", + description: "Save edits and return", + }, + HelpEntry { + keys: "i / v", + description: "Insert / visual mode", + }, + HelpEntry { + keys: "h j k l", + description: "Move cursor", + }, + HelpEntry { + keys: "w / b / e", + description: "Word navigation", + }, + HelpEntry { + keys: "^ / $", + description: "Line start / end", + }, + HelpEntry { + keys: "gg / G", + description: "First / last line", + }, + HelpEntry { + keys: "x / D / C", + description: "Delete/change text", + }, + HelpEntry { + keys: "y / d / c", + description: "Operator commands", + }, + HelpEntry { + keys: "p / u / Ctrl+r", + description: "Paste / undo / redo", + }, + HelpEntry { + keys: "o / O / A / I", + description: "Open or insert at line edges", + }, + ], + }, + HelpSection { + title: "HELP CONTROLS", + entries: &[ + HelpEntry { + keys: "Esc / q / Enter", + description: "Close overlay", + }, + HelpEntry { + keys: "j / k / arrows", + description: "Scroll one line", + }, + HelpEntry { + keys: "PgUp / PgDn", + description: "Scroll one page", + }, + HelpEntry { + keys: "Home / End", + description: "Jump to top/bottom", + }, + ], + }, +]; + +pub fn help_reference_line_count() -> usize { + column_line_count(LEFT_HELP_SECTIONS) + column_line_count(RIGHT_HELP_SECTIONS) + 1 +} + +pub fn help_reference_text() -> String { + let mut lines = Vec::new(); + + for section in LEFT_HELP_SECTIONS.iter().chain(RIGHT_HELP_SECTIONS.iter()) { + lines.push(section.title.to_string()); + for entry in section.entries { + lines.push(format!("{} - {}", entry.keys, entry.description)); + } + lines.push(String::new()); + } + + lines.join("\n") +} + +fn column_line_count(sections: &[HelpSection]) -> usize { + sections + .iter() + .map(|section| section.entries.len() + 2) + .sum::() + .saturating_sub(1) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 9079b85..a4b5067 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,6 @@ mod edit; -mod findings; +mod help; mod navigation; -mod query_preview; mod search; mod sheet; mod state; @@ -10,6 +9,6 @@ mod undo_manager; mod vim; mod word; -pub use query_preview::*; +pub use help::*; pub use state::*; pub use vim::*; diff --git a/src/app/query_preview.rs b/src/app/query_preview.rs deleted file mode 100644 index 692691e..0000000 --- a/src/app/query_preview.rs +++ /dev/null @@ -1,195 +0,0 @@ -use crate::app::{AppState, InputMode}; -use crate::utils::{cell_reference, index_to_col_name}; - -const SAMPLE_ROWS: usize = 6; -const SAMPLE_COLS: usize = 6; -const MAX_CELL_DISPLAY_CHARS: usize = 24; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct QueryPreview { - pub file_path: String, - pub sheet_name: String, - pub sheet_index: usize, - pub selected_cell: String, - pub used_range: String, - pub selects: String, - pub filters: String, - pub columns: Vec, - pub rows: Vec, - pub truncated: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct QueryPreviewRow { - pub row_number: usize, - pub values: Vec, -} - -impl QueryPreview { - fn from_app(app: &AppState) -> Self { - let sheet = app.workbook.get_current_sheet(); - let sheet_index = app.workbook.get_current_sheet_index(); - let selected_cell = cell_reference(app.selected_cell); - let used_range = if sheet.max_rows == 0 || sheet.max_cols == 0 { - "empty".to_string() - } else { - format!("A1:{}{}", index_to_col_name(sheet.max_cols), sheet.max_rows) - }; - - let sample_start_row = if sheet.max_rows == 0 { - 0 - } else { - app.selected_cell.0.clamp(1, sheet.max_rows) - }; - let sample_start_col = if sheet.max_cols == 0 { 0 } else { 1 }; - let sample_end_row = (sample_start_row + SAMPLE_ROWS.saturating_sub(1)).min(sheet.max_rows); - let sample_end_col = (sample_start_col + SAMPLE_COLS.saturating_sub(1)).min(sheet.max_cols); - - let columns = if sample_start_col == 0 { - Vec::new() - } else { - (sample_start_col..=sample_end_col) - .map(index_to_col_name) - .collect() - }; - - let rows = if sample_start_row == 0 || sample_start_col == 0 { - Vec::new() - } else { - (sample_start_row..=sample_end_row) - .map(|row| QueryPreviewRow { - row_number: row, - values: (sample_start_col..=sample_end_col) - .map(|col| { - sheet - .data - .get(row) - .and_then(|cells| cells.get(col)) - .map(|cell| truncate_cell(&cell.value)) - .unwrap_or_default() - }) - .collect(), - }) - .collect() - }; - - let truncated = sample_start_row > 1 - || sample_start_col > 1 - || sample_end_row < sheet.max_rows - || sample_end_col < sheet.max_cols; - - Self { - file_path: app.file_path.to_string_lossy().to_string(), - sheet_name: sheet.name.clone(), - sheet_index: sheet_index + 1, - selected_cell, - used_range, - selects: "all columns".to_string(), - filters: "none".to_string(), - columns, - rows, - truncated, - } - } -} - -impl AppState<'_> { - pub fn show_query_preview(&mut self) { - let sheet_index = self.workbook.get_current_sheet_index(); - let sheet_name = self.workbook.get_current_sheet_name(); - - if self.workbook.is_lazy_loading() && !self.workbook.is_sheet_loaded(sheet_index) { - if let Err(e) = self.workbook.ensure_sheet_loaded(sheet_index, &sheet_name) { - self.add_notification(format!("Preview failed: {e}")); - return; - } - } - - self.query_preview = Some(QueryPreview::from_app(self)); - self.input_mode = InputMode::Preview; - } - - pub fn close_query_preview(&mut self) { - self.query_preview = None; - self.input_mode = InputMode::Normal; - } -} - -fn truncate_cell(value: &str) -> String { - let mut result = String::new(); - for (idx, ch) in value.chars().enumerate() { - if idx >= MAX_CELL_DISPLAY_CHARS { - result.push_str("..."); - return result; - } - result.push(ch); - } - result -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::app::AppState; - use crate::excel::{Cell, Sheet, Workbook}; - - fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet { - let max_rows = values.len(); - let max_cols = values.iter().map(|row| row.len()).max().unwrap_or(0); - let mut data = vec![vec![Cell::empty(); max_cols + 1]; max_rows + 1]; - - for (row_idx, row) in values.iter().enumerate() { - for (col_idx, value) in row.iter().enumerate() { - data[row_idx + 1][col_idx + 1] = Cell::new((*value).to_string(), false); - } - } - - Sheet { - name: name.to_string(), - data, - max_rows, - max_cols, - is_loaded: true, - } - } - - #[test] - fn preview_snapshots_current_target_and_capped_sample() { - let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( - "Data", - &[ - &[ - "Name", "Region", "Sales", "Owner", "Quarter", "Status", "Notes", - ], - &["Ada", "West", "10", "Mina", "Q1", "Open", "A"], - &["Ben", "East", "12", "Noor", "Q1", "Won", "B"], - &["Cid", "North", "9", "Ira", "Q2", "Open", "C"], - &["Dee", "South", "7", "Ola", "Q2", "Lost", "D"], - &["Eli", "West", "8", "Paz", "Q3", "Open", "E"], - &["Fay", "East", "11", "Uma", "Q3", "Won", "F"], - ], - )]); - let mut app = AppState::new(workbook, PathBuf::from("/tmp/report.xlsx")).unwrap(); - app.selected_cell = (2, 2); - - app.show_query_preview(); - - let preview = app.query_preview.as_ref().expect("preview should be set"); - assert_eq!(preview.file_path, "/tmp/report.xlsx"); - assert_eq!(preview.sheet_name, "Data"); - assert_eq!(preview.sheet_index, 1); - assert_eq!(preview.selected_cell, "B2"); - assert_eq!(preview.used_range, "A1:G7"); - assert_eq!(preview.selects, "all columns"); - assert_eq!(preview.filters, "none"); - assert_eq!(preview.columns, vec!["A", "B", "C", "D", "E", "F"]); - assert_eq!(preview.rows.len(), 6); - assert_eq!(preview.rows[0].row_number, 2); - assert_eq!( - preview.rows[0].values, - vec!["Ada", "West", "10", "Mina", "Q1", "Open"] - ); - assert!(preview.truncated); - } -} diff --git a/src/app/state.rs b/src/app/state.rs index 07f69ff..0467570 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -4,8 +4,6 @@ use std::path::PathBuf; use tui_textarea::TextArea; use crate::actions::UndoHistory; -use crate::app::findings::FindingsState; -use crate::app::QueryPreview; use crate::app::VimState; use crate::excel::Workbook; @@ -25,8 +23,6 @@ pub enum InputMode { SearchForward, SearchBackward, Help, - Preview, - Findings, LazyLoading, CommandInLazyLoading, } @@ -60,8 +56,7 @@ pub struct AppState<'a> { pub help_text: String, pub help_scroll: usize, pub help_visible_lines: usize, - pub query_preview: Option, - pub(crate) findings: FindingsState, + pub help_total_lines: usize, pub undo_history: UndoHistory, pub vim_state: Option, } @@ -159,8 +154,7 @@ impl AppState<'_> { help_text: String::new(), help_scroll: 0, help_visible_lines: 20, - query_preview: None, - findings: FindingsState::default(), + help_total_lines: 0, undo_history: UndoHistory::new(), vim_state: None, }) @@ -228,14 +222,6 @@ impl AppState<'_> { pub fn cancel_input(&mut self) { match self.input_mode { - InputMode::Preview => { - self.close_query_preview(); - return; - } - InputMode::Findings => { - self.close_findings(); - return; - } InputMode::Help => { self.input_mode = InputMode::Normal; return; @@ -273,91 +259,3 @@ impl AppState<'_> { self.input_buffer = String::new(); } } - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::{AppState, InputMode}; - use crate::cli::check::CheckRuleId; - use crate::excel::{Cell, Sheet, Workbook}; - - fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet { - let max_rows = values.len(); - let max_cols = values.iter().map(|row| row.len()).max().unwrap_or(0); - let mut data = vec![vec![Cell::empty(); max_cols + 1]; max_rows + 1]; - - for (row_idx, row) in values.iter().enumerate() { - for (col_idx, value) in row.iter().enumerate() { - data[row_idx + 1][col_idx + 1] = Cell::new((*value).to_string(), false); - } - } - - Sheet { - name: name.to_string(), - data, - max_rows, - max_cols, - is_loaded: true, - } - } - - #[test] - fn show_findings_refreshes_report_without_marking_workbook_modified() { - let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( - "Data", - &[&["Name", "Name"], &["Ada", ""], &["", ""]], - )]); - let mut app = AppState::new(workbook, PathBuf::from("quality.xlsx")).unwrap(); - - app.show_findings(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(!app.workbook.is_modified()); - assert!(!app.findings.items.is_empty()); - assert_eq!(app.findings.selected, 0); - } - - #[test] - fn activate_selected_finding_switches_sheet_and_uses_range_fallback() { - let workbook = Workbook::from_sheets_for_test(vec![ - sheet_with_values("Summary", &[&["Status"], &["ok"]]), - sheet_with_values("报告", &[&["Name", ""], &["Ada", ""]]), - ]); - let mut app = AppState::new(workbook, PathBuf::from("quality.xlsx")).unwrap(); - - app.show_findings(); - app.findings.selected = app - .findings - .items - .iter() - .position(|finding| finding.rule_id == CheckRuleId::BlankColumns) - .expect("blank column finding should exist"); - app.activate_selected_finding(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert_eq!(app.workbook.get_current_sheet_name(), "报告"); - assert_eq!(app.selected_cell, (1, 2)); - } - - #[test] - fn activate_selected_finding_prefers_exact_row_and_column() { - let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( - "Data", - &[&["Name", "Score"], &["Ada", "10"], &["Ada", "11"]], - )]); - let mut app = AppState::new(workbook, PathBuf::from("quality.xlsx")).unwrap(); - - app.show_findings(); - app.findings.selected = app - .findings - .items - .iter() - .position(|finding| finding.rule_id == CheckRuleId::DuplicateValues) - .expect("duplicate values finding should exist"); - app.activate_selected_finding(); - - assert_eq!(app.workbook.get_current_sheet_name(), "Data"); - assert_eq!(app.selected_cell, (2, 1)); - } -} diff --git a/src/app/ui.rs b/src/app/ui.rs index afca0a3..3cecd3c 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -4,96 +4,8 @@ use crate::app::InputMode; impl AppState<'_> { pub fn show_help(&mut self) { self.help_scroll = 0; - - self.help_text = "FILE OPERATIONS:\n\ - :w - Save file\n\ - :wq, :x - Save and quit\n\ - :q - Quit (will warn if unsaved changes)\n\ - :q! - Force quit without saving\n\n\ - NAVIGATION:\n\ - :[cell] - Jump to cell (e.g., :B10)\n\ - :preview, :pv - Show read-only preview of current sheet data\n\ - :findings, :issues - Open workbook findings panel\n\ - hjkl - Move cursor (left, down, up, right)\n\ - f - Open or refresh findings panel\n\ - 0 - Jump to first column\n\ - ^ - Jump to first non-empty column\n\ - $ - Jump to last column\n\ - gg - Jump to first row\n\ - G - Jump to last row\n\ - Ctrl+arrows - Jump to next non-empty cell\n\ - [ - Switch to previous sheet\n\ - ] - Switch to next sheet\n\ - :sheet [name/number] - Switch to sheet by name or index\n\n\ - EDITING:\n\ - Enter - Edit current cell\n\ - :y - Copy current cell\n\ - :d - Cut current cell\n\ - :put, :pu - Paste to current cell\n\ - u - Undo last operation\n\ - Ctrl+r - Redo last undone operation\n\n\ - SEARCH:\n\ - / - Search forward\n\ - ? - Search backward\n\ - n - Jump to next search result\n\ - N - Jump to previous search result\n\ - :nohlsearch, :noh - Disable search highlighting\n\n\ - FINDINGS PANEL:\n\ - j/k, ↑/↓ - Move findings selection\n\ - Enter - Jump to selected finding location\n\ - r or f - Refresh findings\n\ - Esc or q - Close findings panel\n\n\ -\ - COLUMN OPERATIONS:\n\ - :cw fit - Adjust width of current column to fit its content\n\ - :cw fit all - Adjust width of all columns to fit their content\n\ - :cw min - Set current column width to minimum (5 characters)\n\ - :cw min all - Set all columns width to minimum\n\ - :cw [number] - Set current column width to specific number of characters\n\ - :dc - Delete current column\n\ - :dc [col] - Delete specific column (e.g., :dc A or :dc 1)\n\ - :dc [start] [end] - Delete columns from start to end (e.g., :dc A C)\n\n\ - ROW OPERATIONS:\n\ - :dr - Delete current row\n\ - :dr [row] - Delete specific row\n\ - :dr [start] [end] - Delete rows from start to end\n\n\ - EXPORT:\n\ - :ej [h|v] [rows] - Export current sheet to JSON\n\ - :eja [h|v] [rows] - Export all sheets to a single JSON file\n\ - h=horizontal (default), v=vertical\n\ - [rows]=number of header rows (default: 1)\n\n\ - SHEET OPERATIONS:\n\ - :addsheet [name] - Add a new sheet after the current sheet\n\ - :delsheet - Delete the current sheet\n\n\ - UI ADJUSTMENTS:\n\ - +/= - Increase info panel height\n\ - - - Decrease info panel height\n\n\ - EDITING MODE:\n\ - Esc - Exit Vim mode and save changes\n\ - i - Enter Insert mode\n\ - v - Enter Visual mode\n\ - y - Yank (copy) text in Visual mode or with operator\n\ - d - Delete text in Visual mode or with operator\n\ - c - Change text in Visual mode or with operator\n\ - p - Paste yanked or deleted text\n\ - u - Undo last change\n\ - Ctrl+r - Redo last undone change\n\ - h,j,k,l - Move cursor left, down, up, right\n\ - w - Move to next word\n\ - b - Move to beginning of word\n\ - e - Move to end of word\n\ - $ - Move to end of line\n\ - ^ - Move to first non-blank character of line\n\ - gg - Move to first line\n\ - G - Move to last line\n\ - x - Delete character under cursor\n\ - D - Delete to end of line\n\ - C - Change to end of line\n\ - o - Open new line below and enter Insert mode\n\ - O - Open new line above and enter Insert mode\n\ - A - Append at end of line\n\ - I - Insert at beginning of line" - .to_string(); + self.help_text = crate::app::help_reference_text(); + self.help_total_lines = crate::app::help_reference_line_count(); self.input_mode = InputMode::Help; } diff --git a/src/cli/check.rs b/src/cli/check.rs index cffa1b1..da4b06f 100644 --- a/src/cli/check.rs +++ b/src/cli/check.rs @@ -2,11 +2,13 @@ use serde::Serialize; use serde_json::{json, Value}; use std::cmp::Ordering; use std::collections::{BTreeMap, HashMap}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use crate::cli::args::SeverityThreshold; +use crate::cli::common::{file_format, format_range}; use crate::cli::envelope; use crate::cli::error::{AppError, EXIT_CHECK_FINDINGS, EXIT_SUCCESS}; +use crate::cli::sheet_query::{cell_at, cell_has_formula, cell_is_present, header_value}; use crate::excel::{open_workbook, Cell, CellType, Sheet, Workbook}; use crate::utils::{cell_reference, index_to_col_name}; @@ -113,13 +115,6 @@ pub(crate) fn run_check_report( }) } -fn file_format(path: &Path) -> String { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_else(|| "unknown".to_string()) -} - fn parse_rules(value: Option<&str>) -> Result, AppError> { let Some(value) = value else { return Ok(RULES.to_vec()); @@ -208,6 +203,91 @@ struct SheetCheckContext<'a> { used_range: String, data_start_row: usize, data_row_count: usize, + facts: SheetFacts, +} + +struct SheetFacts { + row_has_present: Vec, + column_has_present: Vec, + data_column_has_present: Vec, + data_column_null_rows: Vec>, + data_column_type_counts: Vec>, + data_column_cells_by_type: Vec>>, + formula_cells: Vec, + formula_bounds: Option<(usize, usize, usize, usize)>, +} + +struct FormulaFact { + cell: String, + formula: String, +} + +impl SheetFacts { + fn new(sheet: &Sheet, data_start_row: usize, data_row_count: usize) -> Self { + let mut facts = Self { + row_has_present: vec![false; sheet.max_rows + 1], + column_has_present: vec![false; sheet.max_cols + 1], + data_column_has_present: vec![false; sheet.max_cols + 1], + data_column_null_rows: vec![Vec::new(); sheet.max_cols + 1], + data_column_type_counts: vec![BTreeMap::new(); sheet.max_cols + 1], + data_column_cells_by_type: vec![BTreeMap::new(); sheet.max_cols + 1], + formula_cells: Vec::new(), + formula_bounds: None, + }; + + for row in 1..=sheet.max_rows { + for col in 1..=sheet.max_cols { + let cell = cell_at(sheet, row, col); + let present = cell_is_present(cell); + + facts.row_has_present[row] |= present; + facts.column_has_present[col] |= present; + + if data_row_count == 0 || row < data_start_row { + continue; + } + + facts.data_column_has_present[col] |= present; + if !present { + facts.data_column_null_rows[col].push(row); + } + + let Some(cell) = cell else { + continue; + }; + + if let Some(kind) = cell_kind(cell) { + *facts.data_column_type_counts[col].entry(kind).or_default() += 1; + facts.data_column_cells_by_type[col] + .entry(kind) + .or_default() + .push(cell_reference((row, col))); + } + + if cell_has_formula(cell) { + facts.add_formula(row, col, cell); + } + } + } + + facts + } + + fn add_formula(&mut self, row: usize, col: usize, cell: &Cell) { + self.formula_bounds = Some(match self.formula_bounds { + Some((min_row, min_col, max_row, max_col)) => ( + min_row.min(row), + min_col.min(col), + max_row.max(row), + max_col.max(col), + ), + None => (row, col, row, col), + }); + self.formula_cells.push(FormulaFact { + cell: cell_reference((row, col)), + formula: cell.formula.clone().unwrap_or_else(|| cell.value.clone()), + }); + } } impl<'a> SheetCheckContext<'a> { @@ -230,6 +310,7 @@ impl<'a> SheetCheckContext<'a> { } else { 0 }; + let facts = SheetFacts::new(sheet, data_start_row, data_row_count); Ok(Self { sheet, @@ -237,6 +318,7 @@ impl<'a> SheetCheckContext<'a> { used_range, data_start_row, data_row_count, + facts, }) } @@ -253,12 +335,11 @@ impl<'a> SheetCheckContext<'a> { if self.data_row_count == 0 { None } else { - Some(format!( - "{}{}:{}{}", - index_to_col_name(col), + Some(format_range( self.data_start_row, - index_to_col_name(col), - self.sheet.max_rows + col, + self.sheet.max_rows, + col, )) } } @@ -358,12 +439,9 @@ fn find_blank_rows(context: &SheetCheckContext<'_>) -> Vec { } (1..=context.sheet.max_rows) - .filter(|row| { - (1..=context.sheet.max_cols).all(|col| is_blank_cell(cell_at(context.sheet, *row, col))) - }) + .filter(|row| !context.facts.row_has_present[*row]) .map(|row| { - let end_col = index_to_col_name(context.sheet.max_cols); - let range = format!("A{row}:{end_col}{row}"); + let range = format_range(row, 1, row, context.sheet.max_cols); CheckFinding { rule_id: CheckRuleId::BlankRows, severity: Severity::Warning, @@ -388,12 +466,10 @@ fn find_blank_columns(context: &SheetCheckContext<'_>) -> Vec { } (1..=context.sheet.max_cols) - .filter(|col| { - (1..=context.sheet.max_rows).all(|row| is_blank_cell(cell_at(context.sheet, row, *col))) - }) + .filter(|col| !context.facts.column_has_present[*col]) .map(|col| { let column_label = index_to_col_name(col); - let range = format!("{column_label}1:{column_label}{}", context.sheet.max_rows); + let range = format_range(1, col, context.sheet.max_rows, col); CheckFinding { rule_id: CheckRuleId::BlankColumns, severity: Severity::Warning, @@ -423,10 +499,7 @@ fn check_null_ratio(context: &SheetCheckContext<'_>) -> Vec { let mut findings = Vec::new(); for col in 1..=context.sheet.max_cols { - let null_rows: Vec = (context.data_start_row..=context.sheet.max_rows) - .filter(|row| !cell_is_present(cell_at(context.sheet, *row, col))) - .collect(); - + let null_rows = &context.facts.data_column_null_rows[col]; if null_rows.is_empty() { continue; } @@ -537,14 +610,14 @@ fn default_duplicate_candidate(context: &SheetCheckContext<'_>) -> Option<(usize let has_header = cell_at(context.sheet, header_row, col) .map(|cell| !cell.value.trim().is_empty()) .unwrap_or(false); - if has_header && data_column_has_value(context, col) { + if has_header && context.facts.data_column_has_present[col] { return Some((col, "first non-empty header data column")); } } } (1..=context.sheet.max_cols) - .find(|col| data_column_has_value(context, *col)) + .find(|col| context.facts.data_column_has_present[*col]) .map(|col| (col, "first data column with values")) } @@ -555,30 +628,14 @@ fn check_type_drift(context: &SheetCheckContext<'_>) -> Vec { let mut findings = Vec::new(); for col in 1..=context.sheet.max_cols { - let mut type_counts: BTreeMap<&'static str, usize> = BTreeMap::new(); - let mut cells_by_type: BTreeMap<&'static str, Vec> = BTreeMap::new(); - - for row in context.data_start_row..=context.sheet.max_rows { - let Some(cell) = cell_at(context.sheet, row, col) else { - continue; - }; - let Some(kind) = cell_kind(cell) else { - continue; - }; - - *type_counts.entry(kind).or_default() += 1; - cells_by_type - .entry(kind) - .or_default() - .push(cell_reference((row, col))); - } - + let type_counts = &context.facts.data_column_type_counts[col]; + let cells_by_type = &context.facts.data_column_cells_by_type[col]; if type_counts.len() < 2 { continue; } - let dominant_type = dominant_type(&type_counts); - let Some((drift_type, drift_count)) = first_drift_type(&type_counts, dominant_type) else { + let dominant_type = dominant_type(type_counts); + let Some((drift_type, drift_count)) = first_drift_type(type_counts, dominant_type) else { continue; }; let Some(first_drift_cell) = cells_by_type @@ -588,7 +645,8 @@ fn check_type_drift(context: &SheetCheckContext<'_>) -> Vec { else { continue; }; - let Some((first_drift_row, _)) = parse_cell_for_row(&first_drift_cell) else { + let Some((first_drift_row, _)) = crate::utils::parse_cell_reference(&first_drift_cell) + else { continue; }; let column_name = context.column_name(col); @@ -628,39 +686,27 @@ fn check_formula_presence(context: &SheetCheckContext<'_>) -> Vec return Vec::new(); } - let mut formulas = Vec::new(); - let mut min_row = usize::MAX; - let mut min_col = usize::MAX; - let mut max_row = 0; - let mut max_col = 0; - - for row in context.data_start_row..=context.sheet.max_rows { - for col in 1..=context.sheet.max_cols { - let Some(cell) = cell_at(context.sheet, row, col) else { - continue; - }; - if !cell_has_formula(cell) { - continue; - } - - min_row = min_row.min(row); - min_col = min_col.min(col); - max_row = max_row.max(row); - max_col = max_col.max(col); - formulas.push(json!({ - "cell": cell_reference((row, col)), - "formula": cell.formula.clone().unwrap_or_else(|| cell.value.clone()) - })); - } - } - - if formulas.is_empty() { + if context.facts.formula_cells.is_empty() { return Vec::new(); } - let formula_count = formulas.len(); + let formula_count = context.facts.formula_cells.len(); let formula_ratio = rounded_ratio(formula_count, context.data_row_count); - formulas.truncate(5); + let formulas: Vec = context + .facts + .formula_cells + .iter() + .take(5) + .map(|formula| { + json!({ + "cell": formula.cell.clone(), + "formula": formula.formula.clone(), + }) + }) + .collect(); + let Some((min_row, min_col, max_row, max_col)) = context.facts.formula_bounds else { + return Vec::new(); + }; vec![CheckFinding { rule_id: CheckRuleId::FormulaPresence, @@ -668,13 +714,7 @@ fn check_formula_presence(context: &SheetCheckContext<'_>) -> Vec sheet: context.sheet.name.clone(), row: Some(min_row), column: Some(min_col), - range: Some(format!( - "{}{}:{}{}", - index_to_col_name(min_col), - min_row, - index_to_col_name(max_col), - max_row - )), + range: Some(format_range(min_row, min_col, max_row, max_col)), message: format!( "Sheet '{}' contains {} formula cells.", context.sheet.name, formula_count @@ -688,36 +728,11 @@ fn check_formula_presence(context: &SheetCheckContext<'_>) -> Vec }] } -fn cell_at(sheet: &Sheet, row: usize, col: usize) -> Option<&Cell> { - sheet.data.get(row).and_then(|row_data| row_data.get(col)) -} - -fn header_value(sheet: &Sheet, row: usize, col: usize) -> String { - cell_at(sheet, row, col) - .filter(|cell| !cell_has_formula(cell)) - .map(|cell| cell.value.trim().to_string()) - .unwrap_or_default() -} - fn is_blank_cell(cell: Option<&Cell>) -> bool { cell.map(|cell| !cell_has_formula(cell) && cell.value.trim().is_empty()) .unwrap_or(true) } -fn cell_has_formula(cell: &Cell) -> bool { - cell.is_formula || cell.formula.is_some() -} - -fn cell_is_present(cell: Option<&Cell>) -> bool { - cell.map(|cell| !cell.value.trim().is_empty() || cell_has_formula(cell)) - .unwrap_or(false) -} - -fn data_column_has_value(context: &SheetCheckContext<'_>, col: usize) -> bool { - (context.data_start_row..=context.sheet.max_rows) - .any(|row| cell_is_present(cell_at(context.sheet, row, col))) -} - fn cell_kind(cell: &Cell) -> Option<&'static str> { if !cell_is_present(Some(cell)) { return None; @@ -759,10 +774,6 @@ fn first_drift_type( .map(|(kind, count)| (*kind, *count)) } -fn parse_cell_for_row(cell: &str) -> Option<(usize, usize)> { - crate::utils::parse_cell_reference(cell) -} - fn rounded_ratio(numerator: usize, denominator: usize) -> f64 { if denominator == 0 { 0.0 @@ -944,14 +955,6 @@ impl Severity { SeverityThreshold::Error => Severity::Error, } } - - pub(crate) fn as_str(&self) -> &'static str { - match self { - Severity::Info => "info", - Severity::Warning => "warning", - Severity::Error => "error", - } - } } #[derive(Clone, Debug, Serialize)] diff --git a/src/cli/common.rs b/src/cli/common.rs new file mode 100644 index 0000000..052ee20 --- /dev/null +++ b/src/cli/common.rs @@ -0,0 +1,62 @@ +use serde_json::Value; +use std::path::Path; + +use crate::cli::error::AppError; +use crate::cli::sheet_query::SheetBounds; +use crate::excel::{Sheet, Workbook}; +use crate::utils::index_to_col_name; + +pub(crate) fn file_format(path: &Path) -> String { + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_lowercase) + .unwrap_or_else(|| "unknown".to_string()) +} + +pub(crate) fn format_range( + start_row: usize, + start_col: usize, + end_row: usize, + end_col: usize, +) -> String { + format!( + "{}{}:{}{}", + index_to_col_name(start_col), + start_row, + index_to_col_name(end_col), + end_row + ) +} + +pub(crate) fn format_bounds(bounds: SheetBounds) -> String { + format_range( + bounds.start_row, + bounds.start_col, + bounds.end_row, + bounds.end_col, + ) +} + +pub(crate) fn value_text(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(value) => value.clone(), + other => other.to_string(), + } +} + +pub(crate) fn tab_separated_values(values: &[Value]) -> String { + values.iter().map(value_text).collect::>().join("\t") +} + +pub(crate) fn sheet_by_index<'a>( + workbook: &'a Workbook, + sheet_index: usize, + sheet_name: &str, +) -> Result<&'a Sheet, AppError> { + workbook + .get_sheet_by_index(sheet_index) + .ok_or_else(|| AppError::TargetNotFound { + message: format!("Sheet '{}' not found", sheet_name), + }) +} diff --git a/src/cli/error.rs b/src/cli/error.rs index 12d59a3..6517b66 100644 --- a/src/cli/error.rs +++ b/src/cli/error.rs @@ -87,7 +87,12 @@ pub fn anyhow_to_app_error(err: anyhow::Error) -> AppError { let msg = err.to_string(); let lower = msg.to_lowercase(); - if lower.contains("unable to parse excel file") + if err + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + AppError::FileError { message: msg } + } else if lower.contains("unable to parse excel file") || lower.contains("parser panic: malformed workbook data") || lower.contains("no worksheets found") { diff --git a/src/cli/inspect.rs b/src/cli/inspect.rs index 6f9f1ab..a8f7b17 100644 --- a/src/cli/inspect.rs +++ b/src/cli/inspect.rs @@ -1,12 +1,15 @@ -use anyhow::Context; use serde_json::{json, Value}; use std::collections::HashMap; -use crate::cli::args::{resolve_sheet_target, InspectCommands}; +use crate::cli::args::InspectCommands; +use crate::cli::common::{file_format, format_range, sheet_by_index}; use crate::cli::envelope; use crate::cli::error::AppError; +use crate::cli::sheet_query::{ + cell_at, load_target_sheet, resolve_bounds, resolve_header_row, resolve_optional_header_row, +}; use crate::excel::{open_workbook, Cell, CellType, Sheet}; -use crate::utils::{index_to_col_name, parse_range}; +use crate::utils::index_to_col_name; pub fn handle(cmd: InspectCommands) -> Result { match cmd { @@ -40,13 +43,6 @@ pub fn handle(cmd: InspectCommands) -> Result { } } -fn file_format(path: &std::path::Path) -> String { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_else(|| "unknown".to_string()) -} - fn inspect_workbook(file: std::path::PathBuf) -> Result { let format_str = file_format(&file); let path_str = file.to_string_lossy().to_string(); @@ -99,36 +95,32 @@ fn inspect_sheet( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let used_range = workbook - .get_used_range(index) + .get_used_range(resolved_sheet.index) .map_err(crate::cli::error::anyhow_to_app_error)?; let non_empty_rows = workbook - .count_non_empty_rows(index) + .count_non_empty_rows(resolved_sheet.index) .map_err(crate::cli::error::anyhow_to_app_error)?; let non_empty_cols = workbook - .count_non_empty_cols(index) + .count_non_empty_cols(resolved_sheet.index) .map_err(crate::cli::error::anyhow_to_app_error)?; let (header_candidates, recommended_header_row) = workbook - .find_header_candidates(index) + .find_header_candidates(resolved_sheet.index) .map_err(crate::cli::error::anyhow_to_app_error)?; let data = json!({ "name": sheet_obj.name, - "index": index, + "index": resolved_sheet.index, "used_range": used_range, "max_rows": sheet_obj.max_rows, "max_cols": sheet_obj.max_cols, @@ -142,7 +134,7 @@ fn inspect_sheet( "inspect.sheet", &path_str, &format_str, - envelope::target_sheet(&sheet_name, index), + envelope::target_sheet(&resolved_sheet.name, resolved_sheet.index), json!({}), data, vec![], @@ -163,61 +155,21 @@ fn inspect_sample( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; - - let used_range = workbook.get_used_range(index).unwrap_or_default(); - - // Determine the sample range - let ((mut start_row, mut start_col), (mut end_row, mut end_col)) = if let Some(ref r) = range { - parse_range(r).ok_or_else(|| AppError::InvalidQuery { - message: format!("Invalid range format: {}", r), - })? - } else if !used_range.is_empty() { - parse_range(&used_range).unwrap_or(((1, 1), (1, 1))) - } else { - ((1, 1), (1, 1)) - }; - - // Clamp to actual bounds - let max_row = sheet_obj.max_rows.max(1); - let max_col = sheet_obj.max_cols.max(1); - start_row = start_row.min(max_row); - start_col = start_col.min(max_col); - end_row = end_row.min(max_row); - end_col = end_col.min(max_col); - if start_row > end_row { - std::mem::swap(&mut start_row, &mut end_row); - } - if start_col > end_col { - std::mem::swap(&mut start_col, &mut end_col); - } + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; + let bounds = resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, range.as_deref())?; // Apply row limit let row_limit = rows.unwrap_or(10); - let sample_end_row = (start_row + row_limit.saturating_sub(1)).min(end_row); - - // Resolve header row - let resolved_header = if header_row == "auto" { - let (_, recommended) = workbook - .find_header_candidates(index) - .map_err(crate::cli::error::anyhow_to_app_error)?; - recommended - } else { - header_row - .parse::() - .ok() - .filter(|&r| r >= 1 && r <= sheet_obj.max_rows) - }; + let sample_end_row = (bounds.start_row + row_limit.saturating_sub(1)).min(bounds.end_row); + + let resolved_header = + resolve_optional_header_row(&workbook, sheet_obj, resolved_sheet.index, &header_row)?; let sample_mode = if resolved_header.is_some() { "records" @@ -225,19 +177,18 @@ fn inspect_sample( "rows" }; - let range_str = format!( - "{}{}:{}{}", - index_to_col_name(start_col), - start_row, - index_to_col_name(end_col), - sample_end_row + let range_str = format_range( + bounds.start_row, + bounds.start_col, + sample_end_row, + bounds.end_col, ); let data = if let Some(header_row_idx) = resolved_header { // Build records with headers let mut headers = Vec::new(); if header_row_idx < sheet_obj.data.len() { - for col in start_col..=end_col { + for col in bounds.start_col..=bounds.end_col { let val = if col < sheet_obj.data[header_row_idx].len() { sheet_obj.data[header_row_idx][col].value.clone() } else { @@ -248,7 +199,7 @@ fn inspect_sample( } let mut records = Vec::new(); - for row in start_row..=sample_end_row { + for row in bounds.start_row..=sample_end_row { if row == header_row_idx { continue; } @@ -256,7 +207,7 @@ fn inspect_sample( break; } let mut record = serde_json::Map::new(); - for (col_idx, col) in (start_col..=end_col).enumerate() { + for (col_idx, col) in (bounds.start_col..=bounds.end_col).enumerate() { let key = headers.get(col_idx).cloned().unwrap_or_default(); let key = if key.is_empty() { format!("col_{}", col_idx + 1) @@ -281,12 +232,12 @@ fn inspect_sample( } else { // Raw rows let mut row_values = Vec::new(); - for row in start_row..=sample_end_row { + for row in bounds.start_row..=sample_end_row { if row >= sheet_obj.data.len() { break; } let mut cols = Vec::new(); - for col in start_col..=end_col { + for col in bounds.start_col..=bounds.end_col { let value = if col < sheet_obj.data[row].len() { crate::json_export::process_cell_value(&sheet_obj.data[row][col]) } else { @@ -308,7 +259,7 @@ fn inspect_sample( "inspect.sample", &path_str, &format_str, - envelope::target_range(&sheet_name, index, &range_str), + envelope::target_range(&resolved_sheet.name, resolved_sheet.index, &range_str), json!({}), data, vec![], @@ -326,22 +277,16 @@ fn inspect_columns( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = workbook - .resolve_sheet_by_name(&sheet) - .map_err(|e| AppError::TargetNotFound { - message: e.to_string(), - })?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &Some(sheet), &None)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let resolved_header = resolve_columns_header_row(&workbook, index, &header_row)?; - let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; + let resolved_header = + resolve_header_row(&workbook, sheet_obj, resolved_sheet.index, &header_row)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let header_names = column_header_names(sheet_obj, resolved_header); let duplicate_flags = duplicate_header_flags(&header_names); @@ -383,7 +328,7 @@ fn inspect_columns( "inspect.columns", &path_str, &format_str, - envelope::target_sheet(&sheet_name, index), + envelope::target_sheet(&resolved_sheet.name, resolved_sheet.index), json!({ "header_row_mode": header_row, "resolved_header_row": resolved_header, @@ -404,18 +349,13 @@ fn inspect_tables(file: std::path::PathBuf, sheet: String) -> Result Result Result Result, AppError> { - if header_row == "auto" { - let (_, recommended) = workbook - .find_header_candidates(sheet_index) - .map_err(crate::cli::error::anyhow_to_app_error)?; - return Ok(recommended); - } - - let row = header_row - .parse::() - .map_err(|_| AppError::InvalidQuery { - message: format!("Invalid header row: {}", header_row), - })?; - - let sheet = - workbook - .get_sheet_by_index(sheet_index) - .ok_or_else(|| AppError::TargetNotFound { - message: "Sheet index out of range".to_string(), - })?; - - if row < 1 || row > sheet.max_rows { - return Err(AppError::InvalidQuery { - message: format!( - "Header row {} is outside the used row range 1..={}", - row, sheet.max_rows - ), - }); - } - - Ok(Some(row)) -} - fn column_header_names(sheet: &Sheet, resolved_header: Option) -> Vec { (1..=sheet.max_cols) .map(|col| { @@ -605,10 +508,6 @@ fn analyze_column( } } -fn cell_at(sheet: &Sheet, row: usize, col: usize) -> Option<&Cell> { - sheet.data.get(row).and_then(|row_data| row_data.get(col)) -} - fn is_non_null(cell: &Cell) -> bool { !cell.value.is_empty() } @@ -688,12 +587,11 @@ fn detect_table_candidates(sheet: &Sheet) -> Vec { candidates .into_iter() .map(|candidate| { - let range = format!( - "{}{}:{}{}", - index_to_col_name(candidate.start_col), + let range = format_range( candidate.start_row, - index_to_col_name(candidate.end_col), - candidate.end_row + candidate.start_col, + candidate.end_row, + candidate.end_col, ); json!({ "range": range, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3f9864d..8b9c1f1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,8 +1,10 @@ pub mod args; pub mod check; +pub mod common; pub mod dispatch; pub mod envelope; pub mod error; pub mod inspect; pub mod output; pub mod read; +pub mod sheet_query; diff --git a/src/cli/output.rs b/src/cli/output.rs index 6a37417..e6285f4 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -1,6 +1,7 @@ use serde_json::Value; use crate::cli::args::OutputFormat; +use crate::cli::common::tab_separated_values; use crate::cli::error::AppError; /// Write a success value to stdout. @@ -109,26 +110,9 @@ fn write_text_sheet(value: &Value) -> Result<(), AppError> { fn write_text_sample(value: &Value) -> Result<(), AppError> { let data = &value["data"]; if let Some(rows) = data["rows"].as_array() { - for row in rows { - if let Some(cells) = row.as_array() { - let line: Vec = cells - .iter() - .map(|c| match c { - Value::Null => String::new(), - Value::String(s) => s.clone(), - _ => c.to_string(), - }) - .collect(); - println!("{}", line.join("\t")); - } - } + write_row_arrays(rows); } else if let Some(records) = data["records"].as_array() { - for record in records { - if let Some(obj) = record.as_object() { - let parts: Vec = obj.iter().map(|(k, v)| format!("{}={}", k, v)).collect(); - println!("{}", parts.join("\t")); - } - } + write_record_objects(records); } Ok(()) } @@ -199,19 +183,7 @@ fn write_text_cell(value: &Value) -> Result<(), AppError> { fn write_text_range(value: &Value) -> Result<(), AppError> { let data = &value["data"]; if let Some(rows) = data["rows"].as_array() { - for row in rows { - if let Some(cells) = row.as_array() { - let line: Vec = cells - .iter() - .map(|c| match c { - Value::Null => String::new(), - Value::String(s) => s.clone(), - _ => c.to_string(), - }) - .collect(); - println!("{}", line.join("\t")); - } - } + write_row_arrays(rows); } Ok(()) } @@ -219,26 +191,26 @@ fn write_text_range(value: &Value) -> Result<(), AppError> { fn write_text_rows(value: &Value) -> Result<(), AppError> { let data = &value["data"]; if let Some(rows) = data["rows"].as_array() { - for row in rows { - if let Some(cells) = row.as_array() { - let line: Vec = cells - .iter() - .map(|c| match c { - Value::Null => String::new(), - Value::String(s) => s.clone(), - _ => c.to_string(), - }) - .collect(); - println!("{}", line.join("\t")); - } - } + write_row_arrays(rows); } else if let Some(records) = data["records"].as_array() { - for record in records { - if let Some(obj) = record.as_object() { - let parts: Vec = obj.iter().map(|(k, v)| format!("{}={}", k, v)).collect(); - println!("{}", parts.join("\t")); - } - } + write_record_objects(records); } Ok(()) } + +fn write_row_arrays(rows: &[Value]) { + for row in rows { + if let Some(cells) = row.as_array() { + println!("{}", tab_separated_values(cells)); + } + } +} + +fn write_record_objects(records: &[Value]) { + for record in records { + if let Some(obj) = record.as_object() { + let parts: Vec = obj.iter().map(|(k, v)| format!("{}={}", k, v)).collect(); + println!("{}", parts.join("\t")); + } + } +} diff --git a/src/cli/read.rs b/src/cli/read.rs index 0ea42f3..f1a0e9c 100644 --- a/src/cli/read.rs +++ b/src/cli/read.rs @@ -1,18 +1,17 @@ -use anyhow::Context; -use quick_xml::events::Event; use regex::Regex; use serde_json::{json, Value}; -use std::collections::HashMap; -use std::fs::File; -use std::io::{Read, Seek}; -use std::path::{Path, PathBuf}; -use zip::ZipArchive; +use std::path::PathBuf; -use crate::cli::args::{resolve_sheet_target, OutputFormat, OutputShape, ReadCommands}; +use crate::cli::args::{OutputFormat, OutputShape, ReadCommands}; +use crate::cli::common::{file_format, format_bounds, sheet_by_index}; use crate::cli::envelope; use crate::cli::error::AppError; -use crate::excel::{open_workbook, CellType}; -use crate::utils::{index_to_col_name, parse_cell_reference, parse_range}; +use crate::cli::sheet_query::{ + load_target_sheet, read_header_values, resolve_bounds, resolve_optional_header_row, + stable_record_keys, +}; +use crate::excel::{open_workbook, CellType, Sheet}; +use crate::utils::{index_to_col_name, parse_cell_reference}; pub fn handle(cmd: ReadCommands) -> Result { match cmd { @@ -95,13 +94,6 @@ pub fn handle(cmd: ReadCommands) -> Result { } } -fn file_format(path: &std::path::Path) -> String { - path.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_else(|| "unknown".to_string()) -} - fn read_cell( file: std::path::PathBuf, sheet: Option, @@ -118,26 +110,20 @@ fn read_cell( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; let cell_ref = cell.to_ascii_uppercase(); let in_bounds = row < sheet_obj.data.len() && col < sheet_obj.data[row].len(); let (value, cell_type, formula) = if in_bounds { let c = &sheet_obj.data[row][col]; - let formula = c - .formula - .clone() - .or_else(|| lookup_formula_in_xlsx(&file, &sheet_name, &cell_ref)); + let formula = + workbook.formula_for_cell(resolved_sheet.index, &resolved_sheet.name, &cell_ref); let type_str = if c.is_formula || formula.is_some() { "formula" } else { @@ -166,37 +152,13 @@ fn read_cell( "read.cell", &path_str, &format_str, - envelope::target_cell(&sheet_name, index, &cell_ref), + envelope::target_cell(&resolved_sheet.name, resolved_sheet.index, &cell_ref), json!({}), Value::Object(data), vec![], )) } -fn stable_record_keys(headers: &[String], start_col: usize) -> Vec { - let mut counts = HashMap::new(); - - headers - .iter() - .enumerate() - .map(|(offset, header)| { - let base = if header.trim().is_empty() { - format!("col_{}", index_to_col_name(start_col + offset)) - } else { - header.trim().to_string() - }; - - let count = counts.entry(base.clone()).or_insert(0usize); - *count += 1; - if *count == 1 { - base - } else { - format!("{base}_{count}") - } - }) - .collect() -} - #[derive(Clone, Copy)] enum FilterOp { Eq, @@ -235,12 +197,123 @@ struct RowReadRequest { format: OutputFormat, } +#[derive(Clone, Copy)] +struct RowBounds { + start_row: usize, + end_row: usize, + start_col: usize, + end_col: usize, +} + +struct RowOutput { + values: Vec, + row_count: usize, + truncated: bool, +} + +#[derive(Clone, Copy)] +struct RowOutputFormat<'a> { + selected_indices: &'a [usize], + columns: &'a [String], + output_shape: OutputShape, +} + +struct RowCollectRequest<'a> { + sheet: &'a Sheet, + bounds: RowBounds, + output_format: RowOutputFormat<'a>, + filters: &'a [FilterSpec], + non_empty: bool, + offset: usize, + limit: Option, +} + fn invalid_query(message: impl Into) -> AppError { AppError::InvalidQuery { message: message.into(), } } +fn sheet_row_values(sheet: &Sheet, row: usize, bounds: RowBounds) -> Option> { + if row >= sheet.data.len() { + return None; + } + + let values = (bounds.start_col..=bounds.end_col) + .map(|col| { + if col < sheet.data[row].len() { + crate::json_export::process_cell_value(&sheet.data[row][col]) + } else { + Value::Null + } + }) + .collect(); + + Some(values) +} + +fn row_passes_filters(row: &[Value], filters: &[FilterSpec], non_empty: bool) -> bool { + if non_empty && row.iter().all(is_empty_cell) { + return false; + } + + filters.iter().all(|filter| filter_matches(row, filter)) +} + +fn output_row(row: &[Value], output_format: RowOutputFormat<'_>) -> Value { + if matches!( + output_format.output_shape, + OutputShape::Records | OutputShape::Jsonl + ) { + let mut record = serde_json::Map::new(); + for idx in output_format.selected_indices { + let value = row.get(*idx).cloned().unwrap_or(Value::Null); + record.insert(output_format.columns[*idx].clone(), value); + } + return Value::Object(record); + } + + Value::Array( + output_format + .selected_indices + .iter() + .map(|idx| row.get(*idx).cloned().unwrap_or(Value::Null)) + .collect(), + ) +} + +fn collect_row_output(request: RowCollectRequest<'_>) -> RowOutput { + let mut values = Vec::new(); + let mut skipped = 0usize; + let mut truncated = false; + + for row_idx in request.bounds.start_row..=request.bounds.end_row { + let Some(row) = sheet_row_values(request.sheet, row_idx, request.bounds) else { + break; + }; + if !row_passes_filters(&row, request.filters, request.non_empty) { + continue; + } + if skipped < request.offset { + skipped += 1; + continue; + } + if request.limit.is_some_and(|size| values.len() >= size) { + truncated = true; + break; + } + + values.push(output_row(&row, request.output_format)); + } + + let row_count = values.len(); + RowOutput { + values, + row_count, + truncated, + } +} + fn parse_selected_columns( select: Option, columns: &[String], @@ -420,148 +493,6 @@ fn filter_matches(row: &[Value], filter: &FilterSpec) -> bool { } } -fn read_zip_entry(archive: &mut ZipArchive, entry_name: &str) -> Option { - let mut entry = archive.by_name(entry_name).ok()?; - let mut contents = String::new(); - entry.read_to_string(&mut contents).ok()?; - Some(contents) -} - -fn attr_value( - reader: &quick_xml::Reader<&[u8]>, - event: &quick_xml::events::BytesStart<'_>, - key: &[u8], -) -> Option { - for attr in event.attributes().flatten() { - if attr.key.as_ref() == key { - return attr - .decode_and_unescape_value(reader.decoder()) - .ok() - .map(|value| value.into_owned()); - } - } - None -} - -fn resolve_xlsx_sheet_path( - archive: &mut ZipArchive, - sheet_name: &str, -) -> Option { - let workbook_xml = read_zip_entry(archive, "xl/workbook.xml")?; - let mut workbook_reader = quick_xml::Reader::from_str(&workbook_xml); - workbook_reader.config_mut().trim_text(true); - let mut workbook_buf = Vec::new(); - let mut relationship_id = None; - - loop { - match workbook_reader.read_event_into(&mut workbook_buf).ok()? { - Event::Start(event) | Event::Empty(event) if event.name().as_ref() == b"sheet" => { - let name = attr_value(&workbook_reader, &event, b"name"); - if name.as_deref() == Some(sheet_name) { - relationship_id = attr_value(&workbook_reader, &event, b"r:id"); - break; - } - } - Event::Eof => break, - _ => {} - } - workbook_buf.clear(); - } - - let relationship_id = relationship_id?; - let rels_xml = read_zip_entry(archive, "xl/_rels/workbook.xml.rels")?; - let mut rels_reader = quick_xml::Reader::from_str(&rels_xml); - rels_reader.config_mut().trim_text(true); - let mut rels_buf = Vec::new(); - - loop { - match rels_reader.read_event_into(&mut rels_buf).ok()? { - Event::Start(event) | Event::Empty(event) - if event.name().as_ref() == b"Relationship" => - { - let id = attr_value(&rels_reader, &event, b"Id"); - if id.as_deref() == Some(relationship_id.as_str()) { - let target = attr_value(&rels_reader, &event, b"Target")?; - return Some(if target.starts_with('/') { - target.trim_start_matches('/').to_string() - } else { - format!("xl/{target}") - }); - } - } - Event::Eof => break, - _ => {} - } - rels_buf.clear(); - } - - None -} - -fn lookup_formula_in_xlsx(file: &Path, sheet_name: &str, cell_ref: &str) -> Option { - let extension = file - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase())?; - if extension != "xlsx" && extension != "xlsm" { - return None; - } - - let archive_file = File::open(file).ok()?; - let mut archive = ZipArchive::new(archive_file).ok()?; - let sheet_path = resolve_xlsx_sheet_path(&mut archive, sheet_name)?; - let sheet_xml = read_zip_entry(&mut archive, &sheet_path)?; - let target_ref = cell_ref.to_ascii_uppercase(); - - let mut reader = quick_xml::Reader::from_str(&sheet_xml); - reader.config_mut().trim_text(false); - let mut buf = Vec::new(); - let mut current_cell = None; - - loop { - match reader.read_event_into(&mut buf).ok()? { - Event::Start(event) if event.name().as_ref() == b"c" => { - current_cell = attr_value(&reader, &event, b"r") - .map(|reference| reference.to_ascii_uppercase()); - } - Event::End(event) if event.name().as_ref() == b"c" => { - current_cell = None; - } - Event::Start(event) if event.name().as_ref() == b"f" => { - let mut formula = String::new(); - let end_tag = event.name().as_ref().to_vec(); - let mut inner_buf = Vec::new(); - loop { - match reader.read_event_into(&mut inner_buf).ok()? { - Event::Text(text) => formula.push_str(&text.unescape().ok()?), - Event::End(end_event) - if end_event.name().as_ref() == end_tag.as_slice() => - { - break; - } - Event::Eof => return None, - _ => {} - } - inner_buf.clear(); - } - - if current_cell.as_deref() == Some(target_ref.as_str()) && !formula.is_empty() { - return Some(if formula.starts_with('=') { - formula - } else { - format!("={formula}") - }); - } - } - Event::Eof => break, - _ => {} - } - buf.clear(); - } - - None -} - fn read_range( file: std::path::PathBuf, sheet: Option, @@ -571,44 +502,22 @@ fn read_range( let format_str = file_format(&file); let path_str = file.to_string_lossy().to_string(); - let ((mut start_row, mut start_col), (mut end_row, mut end_col)) = parse_range(&range) - .ok_or_else(|| AppError::InvalidQuery { - message: format!("Invalid range format: {}", range), - })?; - let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) - .map_err(crate::cli::error::anyhow_to_app_error)?; - - // Clamp to actual bounds - let max_row = sheet_obj.max_rows.max(1); - let max_col = sheet_obj.max_cols.max(1); - start_row = start_row.min(max_row); - start_col = start_col.min(max_col); - end_row = end_row.min(max_row); - end_col = end_col.min(max_col); - if start_row > end_row { - std::mem::swap(&mut start_row, &mut end_row); - } - if start_col > end_col { - std::mem::swap(&mut start_col, &mut end_col); - } + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; + let bounds = resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, Some(&range))?; let mut rows = Vec::new(); - for row in start_row..=end_row { + for row in bounds.start_row..=bounds.end_row { let mut cols = Vec::new(); - for col in start_col..=end_col { + for col in bounds.start_col..=bounds.end_col { let value = if row < sheet_obj.data.len() && col < sheet_obj.data[row].len() { crate::json_export::process_cell_value(&sheet_obj.data[row][col]) } else { @@ -619,13 +528,7 @@ fn read_range( rows.push(Value::Array(cols)); } - let range_str = format!( - "{}{}:{}{}", - index_to_col_name(start_col), - start_row, - index_to_col_name(end_col), - end_row - ); + let range_str = format_bounds(bounds); let data = json!({ "range": range_str, @@ -636,7 +539,7 @@ fn read_range( "read.range", &path_str, &format_str, - envelope::target_range(&sheet_name, index, &range_str), + envelope::target_range(&resolved_sheet.name, resolved_sheet.index, &range_str), json!({}), data, vec![], @@ -675,66 +578,19 @@ fn read_rows( let mut workbook = open_workbook(&file, false).map_err(crate::cli::error::anyhow_to_app_error)?; - let index = resolve_sheet_target(&workbook, &sheet, &sheet_index)?; - let sheet_name = workbook.get_sheet_names()[index].clone(); + let resolved_sheet = load_target_sheet(&workbook, &sheet, &sheet_index)?; workbook - .ensure_sheet_loaded(index, &sheet_name) - .map_err(crate::cli::error::anyhow_to_app_error)?; - - let sheet_obj = workbook - .get_sheet_by_index(index) - .with_context(|| format!("Sheet '{}' not found", sheet_name)) + .ensure_sheet_loaded(resolved_sheet.index, &resolved_sheet.name) .map_err(crate::cli::error::anyhow_to_app_error)?; - // Determine the range - let ((mut start_row, mut start_col), (mut end_row, mut end_col)) = if let Some(ref r) = range { - parse_range(r).ok_or_else(|| AppError::InvalidQuery { - message: format!("Invalid range format: {}", r), - })? - } else { - let used = workbook.get_used_range(index).unwrap_or_default(); - if used.is_empty() { - ((1, 1), (1, 1)) - } else { - parse_range(&used).unwrap_or(((1, 1), (1, 1))) - } - }; - - // Clamp to actual bounds - let max_row = sheet_obj.max_rows.max(1); - let max_col = sheet_obj.max_cols.max(1); - start_row = start_row.min(max_row); - start_col = start_col.min(max_col); - end_row = end_row.min(max_row); - end_col = end_col.min(max_col); - if start_row > end_row { - std::mem::swap(&mut start_row, &mut end_row); - } - if start_col > end_col { - std::mem::swap(&mut start_col, &mut end_col); - } - - // Resolve header row - let resolved_header = if header_row == "auto" { - let (_, recommended) = workbook - .find_header_candidates(index) - .map_err(crate::cli::error::anyhow_to_app_error)?; - recommended - } else { - header_row - .parse::() - .ok() - .filter(|&r| r >= 1 && r <= sheet_obj.max_rows) - }; + let sheet_obj = sheet_by_index(&workbook, resolved_sheet.index, &resolved_sheet.name)?; + let requested_bounds = + resolve_bounds(&workbook, sheet_obj, resolved_sheet.index, range.as_deref())?; + let resolved_header = + resolve_optional_header_row(&workbook, sheet_obj, resolved_sheet.index, &header_row)?; - let range_str = format!( - "{}{}:{}{}", - index_to_col_name(start_col), - start_row, - index_to_col_name(end_col), - end_row - ); + let range_str = format_bounds(requested_bounds); if resolved_header.is_none() && (command_requires_header @@ -745,61 +601,18 @@ fn read_rows( )); } - let (has_header, columns, row_values) = if let Some(header_row_idx) = resolved_header { - let mut headers = Vec::new(); - for col in start_col..=end_col { - let val = if header_row_idx < sheet_obj.data.len() - && col < sheet_obj.data[header_row_idx].len() - { - sheet_obj.data[header_row_idx][col].value.clone() - } else { - String::new() - }; - headers.push(val); - } - let columns = stable_record_keys(&headers, start_col); - - let mut row_values = Vec::new(); - let data_start_row = start_row.max(header_row_idx.saturating_add(1)); - for row in data_start_row..=end_row { - if row >= sheet_obj.data.len() { - break; - } - let mut values = Vec::new(); - for col in start_col..=end_col { - let value = if col < sheet_obj.data[row].len() { - crate::json_export::process_cell_value(&sheet_obj.data[row][col]) - } else { - Value::Null - }; - values.push(value); - } - row_values.push(values); - } - - (true, columns, row_values) + let (has_header, columns, data_start_row) = if let Some(header_row_idx) = resolved_header { + let headers = read_header_values(sheet_obj, header_row_idx, requested_bounds); + let columns = stable_record_keys(&headers, requested_bounds.start_col); + let data_start_row = requested_bounds + .start_row + .max(header_row_idx.saturating_add(1)); + (true, columns, data_start_row) } else { - let columns: Vec = (start_col..=end_col) + let columns: Vec = (requested_bounds.start_col..=requested_bounds.end_col) .map(|col| format!("col_{}", index_to_col_name(col))) .collect(); - let mut row_values = Vec::new(); - for row in start_row..=end_row { - if row >= sheet_obj.data.len() { - break; - } - let mut values = Vec::new(); - for col in start_col..=end_col { - let value = if col < sheet_obj.data[row].len() { - crate::json_export::process_cell_value(&sheet_obj.data[row][col]) - } else { - Value::Null - }; - values.push(value); - } - row_values.push(values); - } - - (false, columns, row_values) + (false, columns, requested_bounds.start_row) }; let selected_indices = parse_selected_columns(select, &columns)?; @@ -813,56 +626,33 @@ fn read_rows( .map(|idx| columns[*idx].clone()) .collect(); - let mut filtered_rows: Vec> = row_values; - if non_empty { - filtered_rows.retain(|row| row.iter().any(|cell| !is_empty_cell(cell))); - } - filtered_rows.retain(|row| { - parsed_filters - .iter() - .all(|filter| filter_matches(row, filter)) + let row_output = collect_row_output(RowCollectRequest { + sheet: sheet_obj, + bounds: RowBounds { + start_row: data_start_row, + end_row: requested_bounds.end_row, + start_col: requested_bounds.start_col, + end_col: requested_bounds.end_col, + }, + output_format: RowOutputFormat { + selected_indices: &selected_indices, + columns: &columns, + output_shape, + }, + filters: &parsed_filters, + non_empty, + offset: offset.unwrap_or(0), + limit, }); - let offset = offset.unwrap_or(0); - let rows_after_offset: Vec> = filtered_rows.into_iter().skip(offset).collect(); - let truncated = limit.is_some_and(|size| rows_after_offset.len() > size); - let returned_rows: Vec> = if let Some(size) = limit { - rows_after_offset.into_iter().take(size).collect() - } else { - rows_after_offset - }; - - let row_count = returned_rows.len(); - - let records: Vec = returned_rows - .iter() - .map(|row| { - let mut record = serde_json::Map::new(); - for idx in &selected_indices { - let value = row.get(*idx).cloned().unwrap_or(Value::Null); - record.insert(columns[*idx].clone(), value); - } - Value::Object(record) - }) - .collect(); - - let rows: Vec = returned_rows - .into_iter() - .map(|row| { - Value::Array( - selected_indices - .iter() - .map(|idx| row.get(*idx).cloned().unwrap_or(Value::Null)) - .collect(), - ) - }) - .collect(); + let row_count = row_output.row_count; + let truncated = row_output.truncated; let data = if matches!(output_shape, OutputShape::Records | OutputShape::Jsonl) { json!({ "resolved_header_row": resolved_header.unwrap(), "mode": output_shape.as_str(), - "records": records, + "records": row_output.values, }) } else { json!({ @@ -872,7 +662,7 @@ fn read_rows( Value::Null }, "mode": "rows", - "rows": rows, + "rows": row_output.values, }) }; @@ -888,7 +678,7 @@ fn read_rows( command, &path_str, &format_str, - envelope::target_range(&sheet_name, index, &range_str), + envelope::target_range(&resolved_sheet.name, resolved_sheet.index, &range_str), meta, data, vec![], diff --git a/src/cli/sheet_query.rs b/src/cli/sheet_query.rs new file mode 100644 index 0000000..94cc5ac --- /dev/null +++ b/src/cli/sheet_query.rs @@ -0,0 +1,329 @@ +use std::collections::HashMap; + +use crate::cli::args::resolve_sheet_target; +use crate::cli::error::{anyhow_to_app_error, AppError}; +use crate::excel::{Cell, Sheet, Workbook}; +use crate::utils::{index_to_col_name, parse_range}; + +pub(crate) struct ResolvedSheet { + pub(crate) index: usize, + pub(crate) name: String, +} + +#[derive(Clone, Copy)] +pub(crate) struct SheetBounds { + pub(crate) start_row: usize, + pub(crate) end_row: usize, + pub(crate) start_col: usize, + pub(crate) end_col: usize, +} + +pub(crate) fn load_target_sheet( + workbook: &Workbook, + sheet: &Option, + sheet_index: &Option, +) -> Result { + let index = resolve_sheet_target(workbook, sheet, sheet_index)?; + let name = workbook + .get_sheet_names() + .get(index) + .cloned() + .ok_or_else(|| AppError::TargetNotFound { + message: format!("Sheet index {} not found", index), + })?; + + Ok(ResolvedSheet { index, name }) +} + +pub(crate) fn resolve_bounds( + workbook: &Workbook, + sheet: &Sheet, + sheet_index: usize, + range: Option<&str>, +) -> Result { + let ((mut start_row, mut start_col), (mut end_row, mut end_col)) = if let Some(range) = range { + parse_range(range).ok_or_else(|| AppError::InvalidQuery { + message: format!("Invalid range format: {}", range), + })? + } else { + let used_range = workbook.get_used_range(sheet_index).unwrap_or_default(); + if used_range.is_empty() { + ((1, 1), (1, 1)) + } else { + parse_range(&used_range).unwrap_or(((1, 1), (1, 1))) + } + }; + + let max_row = sheet.max_rows.max(1); + let max_col = sheet.max_cols.max(1); + start_row = start_row.min(max_row); + start_col = start_col.min(max_col); + end_row = end_row.min(max_row); + end_col = end_col.min(max_col); + + if start_row > end_row { + std::mem::swap(&mut start_row, &mut end_row); + } + if start_col > end_col { + std::mem::swap(&mut start_col, &mut end_col); + } + + Ok(SheetBounds { + start_row, + end_row, + start_col, + end_col, + }) +} + +pub(crate) fn resolve_header_row( + workbook: &Workbook, + sheet: &Sheet, + sheet_index: usize, + header_row: &str, +) -> Result, AppError> { + if header_row == "auto" { + let (_, recommended) = workbook + .find_header_candidates(sheet_index) + .map_err(anyhow_to_app_error)?; + return Ok(recommended); + } + + let row = header_row + .parse::() + .map_err(|_| AppError::InvalidQuery { + message: format!("Invalid header row: {}", header_row), + })?; + + if row < 1 || row > sheet.max_rows { + return Err(AppError::InvalidQuery { + message: format!( + "Header row {} is outside the used row range 1..={}", + row, sheet.max_rows + ), + }); + } + + Ok(Some(row)) +} + +pub(crate) fn resolve_optional_header_row( + workbook: &Workbook, + sheet: &Sheet, + sheet_index: usize, + header_row: &str, +) -> Result, AppError> { + if header_row == "auto" { + let (_, recommended) = workbook + .find_header_candidates(sheet_index) + .map_err(anyhow_to_app_error)?; + return Ok(recommended); + } + + Ok(header_row + .parse::() + .ok() + .filter(|row| *row >= 1 && *row <= sheet.max_rows)) +} + +pub(crate) fn cell_at(sheet: &Sheet, row: usize, col: usize) -> Option<&Cell> { + sheet.data.get(row).and_then(|row_data| row_data.get(col)) +} + +pub(crate) fn cell_has_formula(cell: &Cell) -> bool { + cell.is_formula || cell.formula.is_some() +} + +pub(crate) fn cell_is_present(cell: Option<&Cell>) -> bool { + cell.map(|cell| !cell.value.trim().is_empty() || cell_has_formula(cell)) + .unwrap_or(false) +} + +pub(crate) fn header_value(sheet: &Sheet, row: usize, col: usize) -> String { + cell_at(sheet, row, col) + .filter(|cell| !cell_has_formula(cell)) + .map(|cell| cell.value.trim().to_string()) + .unwrap_or_default() +} + +pub(crate) fn stable_record_keys(headers: &[String], start_col: usize) -> Vec { + let mut counts = HashMap::new(); + + headers + .iter() + .enumerate() + .map(|(offset, header)| { + let base = if header.trim().is_empty() { + format!("col_{}", index_to_col_name(start_col + offset)) + } else { + header.trim().to_string() + }; + + let count = counts.entry(base.clone()).or_insert(0usize); + *count += 1; + if *count == 1 { + base + } else { + format!("{base}_{count}") + } + }) + .collect() +} + +pub(crate) fn read_header_values( + sheet: &Sheet, + header_row: usize, + bounds: SheetBounds, +) -> Vec { + (bounds.start_col..=bounds.end_col) + .map(|col| { + if header_row < sheet.data.len() && col < sheet.data[header_row].len() { + sheet.data[header_row][col].value.clone() + } else { + String::new() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::excel::{CellType, Sheet}; + + fn sheet_with_values(name: &str, values: &[&[&str]]) -> Sheet { + let max_rows = values.len(); + let max_cols = values.iter().map(|row| row.len()).max().unwrap_or(0); + let mut data = vec![vec![Cell::empty(); max_cols + 1]; max_rows + 1]; + + for (row_idx, row) in values.iter().enumerate() { + for (col_idx, value) in row.iter().enumerate() { + data[row_idx + 1][col_idx + 1] = Cell::new((*value).to_string(), false); + } + } + + Sheet { + name: name.to_string(), + data, + max_rows, + max_cols, + is_loaded: true, + } + } + + #[test] + fn resolve_bounds_clamps_and_normalizes_explicit_ranges() { + let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( + "Orders", + &[&["order_id", "customer"], &["1001", "Alice"]], + )]); + let sheet = workbook.get_sheet_by_index(0).unwrap(); + + let bounds = resolve_bounds(&workbook, sheet, 0, Some("D5:B2")).unwrap(); + + assert_eq!(bounds.start_row, 2); + assert_eq!(bounds.end_row, 2); + assert_eq!(bounds.start_col, 2); + assert_eq!(bounds.end_col, 2); + } + + #[test] + fn resolve_bounds_falls_back_to_a1_for_empty_used_ranges() { + let mut sheet = Sheet::blank("Empty".to_string()); + sheet.max_rows = 0; + sheet.max_cols = 0; + let workbook = Workbook::from_sheets_for_test(vec![sheet]); + let sheet = workbook.get_sheet_by_index(0).unwrap(); + + let bounds = resolve_bounds(&workbook, sheet, 0, None).unwrap(); + + assert_eq!(bounds.start_row, 1); + assert_eq!(bounds.end_row, 1); + assert_eq!(bounds.start_col, 1); + assert_eq!(bounds.end_col, 1); + } + + #[test] + fn resolve_header_row_rejects_non_numeric_and_out_of_range_values() { + let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( + "Orders", + &[&["order_id", "customer"], &["1001", "Alice"]], + )]); + let sheet = workbook.get_sheet_by_index(0).unwrap(); + + let invalid = resolve_header_row(&workbook, sheet, 0, "header").unwrap_err(); + assert_eq!(invalid.code(), "invalid_query"); + + let out_of_range = resolve_header_row(&workbook, sheet, 0, "9").unwrap_err(); + assert_eq!(out_of_range.code(), "invalid_query"); + } + + #[test] + fn resolve_optional_header_row_preserves_lenient_record_resolution() { + let workbook = Workbook::from_sheets_for_test(vec![sheet_with_values( + "Orders", + &[&["order_id", "customer"], &["1001", "Alice"]], + )]); + let sheet = workbook.get_sheet_by_index(0).unwrap(); + + assert_eq!( + resolve_optional_header_row(&workbook, sheet, 0, "header").unwrap(), + None + ); + assert_eq!( + resolve_optional_header_row(&workbook, sheet, 0, "9").unwrap(), + None + ); + assert_eq!( + resolve_optional_header_row(&workbook, sheet, 0, "1").unwrap(), + Some(1) + ); + } + + #[test] + fn header_and_cell_helpers_preserve_existing_formula_semantics() { + let mut sheet = sheet_with_values("Orders", &[&["order_id", ""], &["1001", "Alice"]]); + sheet.data[1][2] = Cell { + value: "total".to_string(), + formula: Some("=UPPER(\"total\")".to_string()), + is_formula: false, + cell_type: CellType::Text, + original_type: None, + }; + sheet.data[2][2] = Cell { + value: String::new(), + formula: Some("=A2".to_string()), + is_formula: false, + cell_type: CellType::Text, + original_type: None, + }; + + assert_eq!(header_value(&sheet, 1, 1), "order_id"); + assert_eq!(header_value(&sheet, 1, 2), ""); + assert!(cell_has_formula(cell_at(&sheet, 1, 2).unwrap())); + assert!(cell_is_present(cell_at(&sheet, 2, 2))); + } + + #[test] + fn record_helpers_generate_stable_column_names_and_header_values() { + let sheet = sheet_with_values( + "Orders", + &[ + &["order_id", "customer", "customer", ""], + &["1001", "Alice", "VIP", "true"], + ], + ); + let bounds = SheetBounds { + start_row: 1, + end_row: 2, + start_col: 1, + end_col: 4, + }; + + let headers = read_header_values(&sheet, 1, bounds); + let columns = stable_record_keys(&headers, bounds.start_col); + + assert_eq!(headers, vec!["order_id", "customer", "customer", ""]); + assert_eq!(columns, vec!["order_id", "customer", "customer_2", "col_D"]); + } +} diff --git a/src/commands/executor.rs b/src/commands/executor.rs index 96a95ca..f49ae8d 100644 --- a/src/commands/executor.rs +++ b/src/commands/executor.rs @@ -52,8 +52,6 @@ impl AppState<'_> { } "nohlsearch" | "noh" => self.disable_search_highlight(), "help" => self.show_help(), - "preview" | "pv" => self.show_query_preview(), - "findings" | "issues" => self.show_findings(), "delsheet" => self.delete_current_sheet(), "addsheet" => self.add_notification("Usage: :addsheet ".to_string()), _ => { @@ -395,11 +393,7 @@ fn parse_cell_reference(input: &str) -> Option<(usize, usize)> { #[cfg(test)] mod tests { - use std::path::PathBuf; - use super::parse_cell_reference; - use crate::app::{AppState, InputMode}; - use crate::excel::{Cell, Sheet, Workbook}; #[test] fn parses_valid_cell_references() { @@ -412,75 +406,4 @@ mod tests { assert_eq!(parse_cell_reference("addsheet 测试1"), None); assert_eq!(parse_cell_reference("测试1"), None); } - - fn app_with_sheet() -> AppState<'static> { - let mut data = vec![vec![Cell::empty(); 3]; 3]; - data[1][1] = Cell::new("Name".to_string(), false); - data[1][2] = Cell::new("Name".to_string(), false); - data[2][1] = Cell::new("Ada".to_string(), false); - data[2][2] = Cell::new("10".to_string(), false); - - let sheet = Sheet { - name: "Data".to_string(), - data, - max_rows: 2, - max_cols: 2, - is_loaded: true, - }; - - AppState::new( - Workbook::from_sheets_for_test(vec![sheet]), - PathBuf::from("scores.xlsx"), - ) - .unwrap() - } - - #[test] - fn preview_command_populates_preview_state() { - let mut app = app_with_sheet(); - app.input_buffer = "preview".to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Preview)); - assert_eq!( - app.query_preview - .as_ref() - .map(|preview| preview.sheet_name.as_str()), - Some("Data") - ); - } - - #[test] - fn preview_alias_populates_preview_state() { - let mut app = app_with_sheet(); - app.input_buffer = "pv".to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Preview)); - assert!(app.query_preview.is_some()); - } - - #[test] - fn findings_command_opens_findings_panel() { - let mut app = app_with_sheet(); - app.input_buffer = "findings".to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(!app.findings.items.is_empty()); - } - - #[test] - fn issues_alias_opens_findings_panel() { - let mut app = app_with_sheet(); - app.input_buffer = "issues".to_string(); - - app.execute_command(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(!app.findings.items.is_empty()); - } } diff --git a/src/excel/workbook.rs b/src/excel/workbook.rs index 22c5caf..06c341a 100644 --- a/src/excel/workbook.rs +++ b/src/excel/workbook.rs @@ -1,19 +1,24 @@ use anyhow::{Context, Result}; -use calamine::{open_workbook_auto, Data, Reader, Xls, Xlsx}; -use chrono::Local; -use rust_xlsxwriter::{Format, Workbook as XlsxWorkbook}; +use calamine::{open_workbook_auto, Reader, Xls, Xlsx}; use std::collections::HashSet; use std::fs::File; use std::io::BufReader; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::Path; -use crate::excel::{Cell, CellType, DataTypeInfo, Sheet}; -use crate::utils::index_to_col_name; +use crate::excel::{Cell, CellType, Sheet}; +use crate::utils::{index_to_col_name, parse_cell_reference}; + +mod formula_lookup; +mod save; +mod sheet_parse; + +use formula_lookup::lookup_formula_in_xlsx; +use sheet_parse::create_sheet_from_range; pub enum CalamineWorkbook { Xlsx(Box>>), - Xls(Xls>), + Xls(Box>>), None, } @@ -120,7 +125,7 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res if let Ok(file) = File::open(&path) { let reader = BufReader::new(file); if let Ok(xls_workbook) = Xls::new(reader) { - calamine_workbook = CalamineWorkbook::Xls(xls_workbook); + calamine_workbook = CalamineWorkbook::Xls(Box::new(xls_workbook)); } } } @@ -128,9 +133,12 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res } else { // For formats that don't support lazy loading or if lazy loading is disabled, for name in &sheet_names { - let range = workbook - .worksheet_range(name) - .with_context(|| format!("Unable to read worksheet: {}", name))?; + let range = workbook.worksheet_range(name).with_context(|| { + format!( + "Unable to parse Excel file: {} (unable to read worksheet: {})", + path_str, name + ) + })?; let formula_range = workbook.worksheet_formula(name).ok(); let mut sheet = create_sheet_from_range(name, range, formula_range); @@ -162,119 +170,6 @@ fn open_workbook_impl>(path: P, enable_lazy_loading: bool) -> Res }) } -fn create_sheet_from_range( - name: &str, - range: calamine::Range, - formula_range: Option>, -) -> Sheet { - let (height, width) = range.get_size(); - - // Create a data grid with empty cells, adding 1 to dimensions for 1-based indexing - let mut data = vec![vec![Cell::empty(); width + 1]; height + 1]; - - // Process only non-empty cells - for (row_idx, col_idx, cell) in range.used_cells() { - // Extract value, cell_type, and original_type from the Data - let (value, cell_type, original_type) = match cell { - Data::Empty => (String::new(), CellType::Empty, Some(DataTypeInfo::Empty)), - - Data::String(s) => { - let value = s.clone(); - (value, CellType::Text, Some(DataTypeInfo::String)) - } - - Data::Float(f) => { - let value = if *f == (*f as i64) as f64 && f.abs() < 1e10 { - (*f as i64).to_string() - } else { - f.to_string() - }; - (value, CellType::Number, Some(DataTypeInfo::Float(*f))) - } - - Data::Int(i) => (i.to_string(), CellType::Number, Some(DataTypeInfo::Int(*i))), - - Data::Bool(b) => ( - if *b { - "TRUE".to_string() - } else { - "FALSE".to_string() - }, - CellType::Boolean, - Some(DataTypeInfo::Bool(*b)), - ), - - Data::Error(e) => { - let mut value = String::with_capacity(15); - value.push_str("Error: "); - value.push_str(&format!("{:?}", e)); - (value, CellType::Text, Some(DataTypeInfo::Error)) - } - - Data::DateTime(dt) => ( - dt.to_string(), - CellType::Date, - Some(DataTypeInfo::DateTime(dt.as_f64())), - ), - - Data::DateTimeIso(s) => { - let value = s.clone(); - ( - value.clone(), - CellType::Date, - Some(DataTypeInfo::DateTimeIso(value)), - ) - } - - Data::DurationIso(s) => { - let value = s.clone(); - ( - value.clone(), - CellType::Text, - Some(DataTypeInfo::DurationIso(value)), - ) - } - }; - - let is_formula = !value.is_empty() && value.starts_with('='); - - // Store the cell in data grid (using 1-based indexing) - data[row_idx + 1][col_idx + 1] = - Cell::new_with_type(value, is_formula, cell_type, original_type); - } - - if let Some(formulas) = formula_range { - let (start_row, start_col) = formulas.start().unwrap_or((0, 0)); - for (row_idx, col_idx, formula) in formulas.used_cells() { - if formula.is_empty() { - continue; - } - - let normalized = if formula.starts_with('=') { - formula.to_string() - } else { - format!("={formula}") - }; - - let row = start_row as usize + row_idx + 1; - let col = start_col as usize + col_idx + 1; - if row < data.len() && col < data[row].len() { - let cell = &mut data[row][col]; - cell.is_formula = true; - cell.formula = Some(normalized); - } - } - } - - Sheet { - name: name.to_string(), - data, - max_rows: height, - max_cols: width, - is_loaded: true, - } -} - impl Workbook { pub fn get_current_sheet(&self) -> &Sheet { &self.sheets[self.current_sheet_index] @@ -358,6 +253,24 @@ impl Workbook { self.sheets.iter().find(|s| s.name == name) } + pub(crate) fn formula_for_cell( + &self, + sheet_index: usize, + sheet_name: &str, + cell_ref: &str, + ) -> Option { + let (row, col) = parse_cell_reference(cell_ref)?; + let loaded_formula = self + .sheets + .get(sheet_index) + .and_then(|sheet| sheet.data.get(row)) + .and_then(|cells| cells.get(col)) + .and_then(|cell| cell.formula.clone()); + + loaded_formula + .or_else(|| lookup_formula_in_xlsx(Path::new(&self.file_path), sheet_name, cell_ref)) + } + /// Resolve a sheet specifier (name or 0-based index) to a sheet index. pub fn resolve_sheet(&self, spec: &str) -> Result { // Try parsing as 0-based index first @@ -791,111 +704,6 @@ impl Workbook { self.sheets[sheet_index].is_loaded } - pub fn save(&mut self) -> Result<()> { - if !self.is_modified { - println!("No changes to save."); - return Ok(()); - } - - self.ensure_all_sheets_loaded()?; - - // Create a new workbook with rust_xlsxwriter - let mut workbook = XlsxWorkbook::new(); - - let now = Local::now(); - let timestamp = now.format("%Y%m%d_%H%M%S").to_string(); - let path = Path::new(&self.file_path); - let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet"); - let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("xlsx"); - let parent_dir = path.parent().unwrap_or_else(|| Path::new("")); - let new_filename = format!("{}_{}.{}", file_stem, timestamp, extension); - let new_filepath = parent_dir.join(new_filename); - - // Create formats - let number_format = Format::new().set_num_format("General"); - let date_format = Format::new().set_num_format("yyyy-mm-dd"); - - // Process each sheet - for sheet in &self.sheets { - let worksheet = workbook.add_worksheet().set_name(&sheet.name)?; - - // Set column widths - for col in 0..sheet.max_cols { - worksheet.set_column_width(col as u16, 15)?; - } - - // Write cell data - for row in 1..sheet.data.len() { - if row <= sheet.max_rows { - for col in 1..sheet.data[0].len() { - if col <= sheet.max_cols { - let cell = &sheet.data[row][col]; - - // Skip empty cells - if cell.value.is_empty() { - continue; - } - - let row_idx = (row - 1) as u32; - let col_idx = (col - 1) as u16; - - if cell.is_formula { - let formula_text = - cell.formula.as_deref().unwrap_or(cell.value.as_str()); - let formula = rust_xlsxwriter::Formula::new(formula_text); - worksheet.write_formula(row_idx, col_idx, formula)?; - if !cell.value.is_empty() && cell.value != formula_text { - worksheet.set_formula_result(row_idx, col_idx, &cell.value); - } - continue; - } - - // Write cell based on its type - match cell.cell_type { - CellType::Number => { - if let Ok(num) = cell.value.parse::() { - worksheet.write_number_with_format( - row_idx, - col_idx, - num, - &number_format, - )?; - } else { - worksheet.write_string(row_idx, col_idx, &cell.value)?; - } - } - CellType::Date => { - worksheet.write_string_with_format( - row_idx, - col_idx, - &cell.value, - &date_format, - )?; - } - CellType::Boolean => { - if let Ok(b) = cell.value.parse::() { - worksheet.write_boolean(row_idx, col_idx, b)?; - } else { - worksheet.write_string(row_idx, col_idx, &cell.value)?; - } - } - CellType::Text => { - worksheet.write_string(row_idx, col_idx, &cell.value)?; - } - CellType::Empty => {} - } - } - } - } - } - } - - workbook.save(&new_filepath)?; - self.is_modified = false; - - Ok(()) - } - pub fn insert_sheet_at_index(&mut self, sheet: Sheet, index: usize) -> Result<()> { if index > self.sheets.len() { anyhow::bail!( @@ -1020,102 +828,4 @@ impl Workbook { } #[cfg(test)] -mod tests { - use super::Workbook; - use crate::excel::Sheet; - - fn blank_sheet(name: &str) -> Sheet { - Sheet::blank(name.to_string()) - } - - #[test] - fn adds_blank_sheet_after_current_sheet() { - let mut workbook = - Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1"), blank_sheet("Sheet2")]); - - let sheet_name = workbook.add_sheet("Added", 1).unwrap(); - - assert_eq!(sheet_name, "Added"); - assert_eq!( - workbook.get_sheet_names(), - vec!["Sheet1", "Added", "Sheet2"] - ); - - let added_sheet = workbook.get_sheet_by_index(1).unwrap(); - assert_eq!(added_sheet.name, "Added"); - assert_eq!(added_sheet.max_rows, 1); - assert_eq!(added_sheet.max_cols, 1); - assert!(added_sheet.is_loaded); - assert_eq!(added_sheet.data.len(), 2); - assert_eq!(added_sheet.data[1].len(), 2); - } - - #[test] - fn rejects_duplicate_sheet_names_case_insensitively() { - let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Summary")]); - - let error = workbook.add_sheet("summary", 1).unwrap_err().to_string(); - - assert!(error.contains("already exists")); - } - - #[test] - fn rejects_invalid_sheet_names() { - let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); - - assert!(workbook.add_sheet("", 1).is_err()); - assert!(workbook.add_sheet("Bad/Name", 1).is_err()); - assert!(workbook.add_sheet("'quoted", 1).is_err()); - assert!(workbook - .add_sheet("this-sheet-name-is-definitely-too-long", 1) - .is_err()); - } - - #[test] - fn counts_sheet_name_length_by_characters() { - let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); - let valid_name = "表".repeat(31); - let invalid_name = "表".repeat(32); - - assert!(workbook.add_sheet(&valid_name, 1).is_ok()); - assert!(workbook.add_sheet(&invalid_name, 2).is_err()); - } - - #[test] - fn resolves_sheet_by_index_and_name() { - let workbook = Workbook::from_sheets_for_test(vec![ - blank_sheet("Sheet1"), - blank_sheet("Orders"), - blank_sheet("客户"), - ]); - - assert_eq!(workbook.resolve_sheet("0").unwrap(), 0); - assert_eq!(workbook.resolve_sheet("2").unwrap(), 2); - assert_eq!(workbook.resolve_sheet("Sheet1").unwrap(), 0); - assert_eq!(workbook.resolve_sheet("Orders").unwrap(), 1); - assert_eq!(workbook.resolve_sheet("客户").unwrap(), 2); - - assert!(workbook.resolve_sheet("99").is_err()); - assert!(workbook.resolve_sheet("Missing").is_err()); - } - - #[test] - fn computes_used_range_for_sheet() { - let mut sheet = Sheet::blank("Test".to_string()); - sheet.max_rows = 10; - sheet.max_cols = 5; - let workbook = Workbook::from_sheets_for_test(vec![sheet]); - - assert_eq!(workbook.get_used_range(0).unwrap(), "A1:E10"); - assert!(workbook.get_used_range(99).is_err()); - } - - #[test] - fn empty_sheet_has_no_used_range() { - let mut sheet = Sheet::blank("Empty".to_string()); - sheet.max_rows = 0; - sheet.max_cols = 0; - let workbook = Workbook::from_sheets_for_test(vec![sheet]); - assert_eq!(workbook.get_used_range(0).unwrap(), ""); - } -} +mod tests; diff --git a/src/excel/workbook/formula_lookup.rs b/src/excel/workbook/formula_lookup.rs new file mode 100644 index 0000000..5ce8f58 --- /dev/null +++ b/src/excel/workbook/formula_lookup.rs @@ -0,0 +1,155 @@ +use quick_xml::events::Event; +use std::fs::File; +use std::io::{Read, Seek}; +use std::path::Path; +use zip::ZipArchive; + +pub(super) fn lookup_formula_in_xlsx( + file: &Path, + sheet_name: &str, + cell_ref: &str, +) -> Option { + let extension = file + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase())?; + if extension != "xlsx" && extension != "xlsm" { + return None; + } + + let archive_file = File::open(file).ok()?; + let mut archive = ZipArchive::new(archive_file).ok()?; + let sheet_path = resolve_xlsx_sheet_path(&mut archive, sheet_name)?; + let sheet_xml = read_zip_entry(&mut archive, &sheet_path)?; + let target_ref = cell_ref.to_ascii_uppercase(); + + let mut reader = quick_xml::Reader::from_str(&sheet_xml); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + let mut current_cell = None; + + loop { + match reader.read_event_into(&mut buf).ok()? { + Event::Start(event) if event.name().as_ref() == b"c" => { + current_cell = attr_value(&reader, &event, b"r") + .map(|reference| reference.to_ascii_uppercase()); + } + Event::End(event) if event.name().as_ref() == b"c" => { + current_cell = None; + } + Event::Start(event) if event.name().as_ref() == b"f" => { + let mut formula = String::new(); + let end_tag = event.name().as_ref().to_vec(); + let mut inner_buf = Vec::new(); + loop { + match reader.read_event_into(&mut inner_buf).ok()? { + Event::Text(text) => { + let decoded = text.decode().ok()?; + let unescaped = quick_xml::escape::unescape(decoded.as_ref()).ok()?; + formula.push_str(&unescaped); + } + Event::End(end_event) + if end_event.name().as_ref() == end_tag.as_slice() => + { + break; + } + Event::Eof => return None, + _ => {} + } + inner_buf.clear(); + } + + if current_cell.as_deref() == Some(target_ref.as_str()) && !formula.is_empty() { + return Some(if formula.starts_with('=') { + formula + } else { + format!("={formula}") + }); + } + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + + None +} + +fn read_zip_entry(archive: &mut ZipArchive, entry_name: &str) -> Option { + let mut entry = archive.by_name(entry_name).ok()?; + let mut contents = String::new(); + entry.read_to_string(&mut contents).ok()?; + Some(contents) +} + +fn attr_value( + reader: &quick_xml::Reader<&[u8]>, + event: &quick_xml::events::BytesStart<'_>, + key: &[u8], +) -> Option { + for attr in event.attributes().flatten() { + if attr.key.as_ref() == key { + return attr + .decode_and_unescape_value(reader.decoder()) + .ok() + .map(|value| value.into_owned()); + } + } + None +} + +fn resolve_xlsx_sheet_path( + archive: &mut ZipArchive, + sheet_name: &str, +) -> Option { + let workbook_xml = read_zip_entry(archive, "xl/workbook.xml")?; + let mut workbook_reader = quick_xml::Reader::from_str(&workbook_xml); + workbook_reader.config_mut().trim_text(true); + let mut workbook_buf = Vec::new(); + let mut relationship_id = None; + + loop { + match workbook_reader.read_event_into(&mut workbook_buf).ok()? { + Event::Start(event) | Event::Empty(event) if event.name().as_ref() == b"sheet" => { + let name = attr_value(&workbook_reader, &event, b"name"); + if name.as_deref() == Some(sheet_name) { + relationship_id = attr_value(&workbook_reader, &event, b"r:id"); + break; + } + } + Event::Eof => break, + _ => {} + } + workbook_buf.clear(); + } + + let relationship_id = relationship_id?; + let rels_xml = read_zip_entry(archive, "xl/_rels/workbook.xml.rels")?; + let mut rels_reader = quick_xml::Reader::from_str(&rels_xml); + rels_reader.config_mut().trim_text(true); + let mut rels_buf = Vec::new(); + + loop { + match rels_reader.read_event_into(&mut rels_buf).ok()? { + Event::Start(event) | Event::Empty(event) + if event.name().as_ref() == b"Relationship" => + { + let id = attr_value(&rels_reader, &event, b"Id"); + if id.as_deref() == Some(relationship_id.as_str()) { + let target = attr_value(&rels_reader, &event, b"Target")?; + return Some(if target.starts_with('/') { + target.trim_start_matches('/').to_string() + } else { + format!("xl/{target}") + }); + } + } + Event::Eof => break, + _ => {} + } + rels_buf.clear(); + } + + None +} diff --git a/src/excel/workbook/save.rs b/src/excel/workbook/save.rs new file mode 100644 index 0000000..b135046 --- /dev/null +++ b/src/excel/workbook/save.rs @@ -0,0 +1,129 @@ +use anyhow::Result; +use chrono::Local; +use rust_xlsxwriter::{Format, Workbook as XlsxWorkbook, Worksheet}; +use std::path::{Path, PathBuf}; + +use super::Workbook; +use crate::excel::{Cell, CellType, Sheet}; + +impl Workbook { + pub fn save(&mut self) -> Result<()> { + if !self.is_modified { + println!("No changes to save."); + return Ok(()); + } + + self.ensure_all_sheets_loaded()?; + + let mut workbook = XlsxWorkbook::new(); + let new_filepath = timestamped_save_path(&self.file_path); + let number_format = Format::new().set_num_format("General"); + let date_format = Format::new().set_num_format("yyyy-mm-dd"); + + for sheet in &self.sheets { + write_sheet(&mut workbook, sheet, &number_format, &date_format)?; + } + + workbook.save(&new_filepath)?; + self.is_modified = false; + + Ok(()) + } +} + +fn timestamped_save_path(file_path: &str) -> PathBuf { + let timestamp = Local::now().format("%Y%m%d_%H%M%S").to_string(); + let path = Path::new(file_path); + let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet"); + let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("xlsx"); + let parent_dir = path.parent().unwrap_or_else(|| Path::new("")); + parent_dir.join(format!("{file_stem}_{timestamp}.{extension}")) +} + +fn write_sheet( + workbook: &mut XlsxWorkbook, + sheet: &Sheet, + number_format: &Format, + date_format: &Format, +) -> Result<()> { + let worksheet = workbook.add_worksheet().set_name(&sheet.name)?; + + for col in 0..sheet.max_cols { + worksheet.set_column_width(col as u16, 15)?; + } + + for row in 1..sheet.data.len() { + if row > sheet.max_rows { + continue; + } + + for col in 1..sheet.data[0].len() { + if col > sheet.max_cols { + continue; + } + + let cell = &sheet.data[row][col]; + if cell.value.is_empty() { + continue; + } + + let row_idx = (row - 1) as u32; + let col_idx = (col - 1) as u16; + write_cell( + worksheet, + cell, + row_idx, + col_idx, + number_format, + date_format, + )?; + } + } + + Ok(()) +} + +fn write_cell( + worksheet: &mut Worksheet, + cell: &Cell, + row_idx: u32, + col_idx: u16, + number_format: &Format, + date_format: &Format, +) -> Result<()> { + if cell.is_formula { + let formula_text = cell.formula.as_deref().unwrap_or(cell.value.as_str()); + let formula = rust_xlsxwriter::Formula::new(formula_text); + worksheet.write_formula(row_idx, col_idx, formula)?; + if !cell.value.is_empty() && cell.value != formula_text { + worksheet.set_formula_result(row_idx, col_idx, &cell.value); + } + return Ok(()); + } + + match cell.cell_type { + CellType::Number => { + if let Ok(num) = cell.value.parse::() { + worksheet.write_number_with_format(row_idx, col_idx, num, number_format)?; + } else { + worksheet.write_string(row_idx, col_idx, &cell.value)?; + } + } + CellType::Date => { + worksheet.write_string_with_format(row_idx, col_idx, &cell.value, date_format)?; + } + CellType::Boolean => { + if let Ok(b) = cell.value.parse::() { + worksheet.write_boolean(row_idx, col_idx, b)?; + } else { + worksheet.write_string(row_idx, col_idx, &cell.value)?; + } + } + CellType::Text => { + worksheet.write_string(row_idx, col_idx, &cell.value)?; + } + CellType::Empty => {} + } + + Ok(()) +} diff --git a/src/excel/workbook/sheet_parse.rs b/src/excel/workbook/sheet_parse.rs new file mode 100644 index 0000000..2d1268b --- /dev/null +++ b/src/excel/workbook/sheet_parse.rs @@ -0,0 +1,109 @@ +use calamine::{Data, Range}; + +use crate::excel::{Cell, CellType, DataTypeInfo, Sheet}; + +pub(super) fn create_sheet_from_range( + name: &str, + range: Range, + formula_range: Option>, +) -> Sheet { + let (height, width) = range.get_size(); + let mut data = vec![vec![Cell::empty(); width + 1]; height + 1]; + + for (row_idx, col_idx, cell) in range.used_cells() { + let (value, cell_type, original_type) = cell_value_parts(cell); + let is_formula = !value.is_empty() && value.starts_with('='); + + data[row_idx + 1][col_idx + 1] = + Cell::new_with_type(value, is_formula, cell_type, original_type); + } + + apply_formula_metadata(&mut data, formula_range); + + Sheet { + name: name.to_string(), + data, + max_rows: height, + max_cols: width, + is_loaded: true, + } +} + +fn cell_value_parts(cell: &Data) -> (String, CellType, Option) { + match cell { + Data::Empty => (String::new(), CellType::Empty, Some(DataTypeInfo::Empty)), + Data::String(s) => (s.clone(), CellType::Text, Some(DataTypeInfo::String)), + Data::Float(f) => { + let value = if *f == (*f as i64) as f64 && f.abs() < 1e10 { + (*f as i64).to_string() + } else { + f.to_string() + }; + (value, CellType::Number, Some(DataTypeInfo::Float(*f))) + } + Data::Int(i) => (i.to_string(), CellType::Number, Some(DataTypeInfo::Int(*i))), + Data::Bool(b) => ( + if *b { + "TRUE".to_string() + } else { + "FALSE".to_string() + }, + CellType::Boolean, + Some(DataTypeInfo::Bool(*b)), + ), + Data::Error(e) => { + let mut value = String::with_capacity(15); + value.push_str("Error: "); + value.push_str(&format!("{:?}", e)); + (value, CellType::Text, Some(DataTypeInfo::Error)) + } + Data::DateTime(dt) => ( + dt.to_string(), + CellType::Date, + Some(DataTypeInfo::DateTime(dt.as_f64())), + ), + Data::DateTimeIso(s) => { + let value = s.clone(); + ( + value.clone(), + CellType::Date, + Some(DataTypeInfo::DateTimeIso(value)), + ) + } + Data::DurationIso(s) => { + let value = s.clone(); + ( + value.clone(), + CellType::Text, + Some(DataTypeInfo::DurationIso(value)), + ) + } + } +} + +fn apply_formula_metadata(data: &mut [Vec], formula_range: Option>) { + let Some(formulas) = formula_range else { + return; + }; + + let (start_row, start_col) = formulas.start().unwrap_or((0, 0)); + for (row_idx, col_idx, formula) in formulas.used_cells() { + if formula.is_empty() { + continue; + } + + let normalized = if formula.starts_with('=') { + formula.to_string() + } else { + format!("={formula}") + }; + + let row = start_row as usize + row_idx + 1; + let col = start_col as usize + col_idx + 1; + if row < data.len() && col < data[row].len() { + let cell = &mut data[row][col]; + cell.is_formula = true; + cell.formula = Some(normalized); + } + } +} diff --git a/src/excel/workbook/tests.rs b/src/excel/workbook/tests.rs new file mode 100644 index 0000000..3692933 --- /dev/null +++ b/src/excel/workbook/tests.rs @@ -0,0 +1,138 @@ +use std::path::{Path, PathBuf}; + +use super::{open_workbook, Workbook}; +use crate::excel::Sheet; + +fn blank_sheet(name: &str) -> Sheet { + Sheet::blank(name.to_string()) +} + +fn temp_path(name: &str) -> PathBuf { + std::env::temp_dir().join(name) +} + +fn create_formula_workbook(path: &Path) { + use rust_xlsxwriter::Workbook as XlsxWorkbook; + + let mut workbook = XlsxWorkbook::new(); + let sheet = workbook.add_worksheet(); + sheet.set_name("TypedCells").unwrap(); + sheet.write_string(0, 0, "text_value").unwrap(); + sheet.write_string(0, 1, "number_value").unwrap(); + sheet.write_string(0, 2, "date_value").unwrap(); + sheet.write_string(0, 3, "boolean_value").unwrap(); + sheet.write_string(0, 4, "formula_value").unwrap(); + sheet.write_string(1, 0, "hello").unwrap(); + sheet.write_number(1, 1, 42.5).unwrap(); + sheet.write_boolean(1, 3, true).unwrap(); + sheet.write_formula(1, 4, "=B2*2").unwrap(); + sheet.set_formula_result(1, 4, "85"); + workbook.save(path).unwrap(); +} + +#[test] +fn adds_blank_sheet_after_current_sheet() { + let mut workbook = + Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1"), blank_sheet("Sheet2")]); + + let sheet_name = workbook.add_sheet("Added", 1).unwrap(); + + assert_eq!(sheet_name, "Added"); + assert_eq!( + workbook.get_sheet_names(), + vec!["Sheet1", "Added", "Sheet2"] + ); + + let added_sheet = workbook.get_sheet_by_index(1).unwrap(); + assert_eq!(added_sheet.name, "Added"); + assert_eq!(added_sheet.max_rows, 1); + assert_eq!(added_sheet.max_cols, 1); + assert!(added_sheet.is_loaded); + assert_eq!(added_sheet.data.len(), 2); + assert_eq!(added_sheet.data[1].len(), 2); +} + +#[test] +fn rejects_duplicate_sheet_names_case_insensitively() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Summary")]); + + let error = workbook.add_sheet("summary", 1).unwrap_err().to_string(); + + assert!(error.contains("already exists")); +} + +#[test] +fn rejects_invalid_sheet_names() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); + + assert!(workbook.add_sheet("", 1).is_err()); + assert!(workbook.add_sheet("Bad/Name", 1).is_err()); + assert!(workbook.add_sheet("'quoted", 1).is_err()); + assert!(workbook + .add_sheet("this-sheet-name-is-definitely-too-long", 1) + .is_err()); +} + +#[test] +fn counts_sheet_name_length_by_characters() { + let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]); + let valid_name = "表".repeat(31); + let invalid_name = "表".repeat(32); + + assert!(workbook.add_sheet(&valid_name, 1).is_ok()); + assert!(workbook.add_sheet(&invalid_name, 2).is_err()); +} + +#[test] +fn resolves_sheet_by_index_and_name() { + let workbook = Workbook::from_sheets_for_test(vec![ + blank_sheet("Sheet1"), + blank_sheet("Orders"), + blank_sheet("客户"), + ]); + + assert_eq!(workbook.resolve_sheet("0").unwrap(), 0); + assert_eq!(workbook.resolve_sheet("2").unwrap(), 2); + assert_eq!(workbook.resolve_sheet("Sheet1").unwrap(), 0); + assert_eq!(workbook.resolve_sheet("Orders").unwrap(), 1); + assert_eq!(workbook.resolve_sheet("客户").unwrap(), 2); + + assert!(workbook.resolve_sheet("99").is_err()); + assert!(workbook.resolve_sheet("Missing").is_err()); +} + +#[test] +fn computes_used_range_for_sheet() { + let mut sheet = Sheet::blank("Test".to_string()); + sheet.max_rows = 10; + sheet.max_cols = 5; + let workbook = Workbook::from_sheets_for_test(vec![sheet]); + + assert_eq!(workbook.get_used_range(0).unwrap(), "A1:E10"); + assert!(workbook.get_used_range(99).is_err()); +} + +#[test] +fn empty_sheet_has_no_used_range() { + let mut sheet = Sheet::blank("Empty".to_string()); + sheet.max_rows = 0; + sheet.max_cols = 0; + let workbook = Workbook::from_sheets_for_test(vec![sheet]); + assert_eq!(workbook.get_used_range(0).unwrap(), ""); +} + +#[test] +fn formula_for_cell_falls_back_to_xlsx_archive_metadata() { + let path = temp_path("excel_cli_workbook_formula_lookup.xlsx"); + create_formula_workbook(&path); + + let workbook = open_workbook(&path, true).unwrap(); + let sheet_index = workbook.resolve_sheet_by_name("TypedCells").unwrap(); + + let sheet = workbook.get_sheet_by_index(sheet_index).unwrap(); + assert!(!sheet.is_loaded); + assert_eq!( + workbook.formula_for_cell(sheet_index, "TypedCells", "E2"), + Some("=B2*2".to_string()) + ); +} diff --git a/src/ui/handlers.rs b/src/ui/handlers.rs index 97a64c5..11058d7 100644 --- a/src/ui/handlers.rs +++ b/src/ui/handlers.rs @@ -1,7 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tui_textarea::{Input, Key, TextArea}; -use crate::app::{AppState, InputMode}; +use crate::app::{help_reference_line_count, AppState, InputMode}; pub fn handle_key_event(app_state: &mut AppState, key: KeyEvent) { match app_state.input_mode { @@ -20,8 +20,6 @@ pub fn handle_key_event(app_state: &mut AppState, key: KeyEvent) { InputMode::SearchForward => handle_search_mode(app_state, key.code), InputMode::SearchBackward => handle_search_mode(app_state, key.code), InputMode::Help => handle_help_mode(app_state, key.code), - InputMode::Preview => handle_preview_mode(app_state, key.code), - InputMode::Findings => handle_findings_mode(app_state, key.code), InputMode::LazyLoading => handle_lazy_loading_mode(app_state, key.code), } } @@ -91,13 +89,6 @@ fn handle_command_in_lazy_loading_mode(app_state: &mut AppState, key_code: KeyCo } } -fn handle_preview_mode(app_state: &mut AppState, key_code: KeyCode) { - match key_code { - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => app_state.close_query_preview(), - _ => {} - } -} - fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { match key_code { KeyCode::Enter => { @@ -204,10 +195,6 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { app_state.g_pressed = false; app_state.start_command_mode(); } - KeyCode::Char('f') => { - app_state.g_pressed = false; - app_state.show_findings(); - } KeyCode::Char('/') => { app_state.g_pressed = false; app_state.start_search_forward(); @@ -264,17 +251,6 @@ fn handle_normal_mode(app_state: &mut AppState, key_code: KeyCode) { } } -fn handle_findings_mode(app_state: &mut AppState, key_code: KeyCode) { - match key_code { - KeyCode::Char('j') | KeyCode::Down => app_state.select_next_finding(), - KeyCode::Char('k') | KeyCode::Up => app_state.select_prev_finding(), - KeyCode::Enter => app_state.activate_selected_finding(), - KeyCode::Char('r') | KeyCode::Char('f') => app_state.refresh_findings(), - KeyCode::Esc | KeyCode::Char('q') => app_state.close_findings(), - _ => {} - } -} - fn handle_editing_mode(app_state: &mut AppState, key: KeyEvent) { // Convert KeyEvent to Input for tui-textarea let input = Input { @@ -386,30 +362,30 @@ fn handle_lazy_loading_mode(app_state: &mut AppState, key_code: KeyCode) { } fn handle_help_mode(app_state: &mut AppState, key_code: KeyCode) { - let line_count = app_state.help_text.lines().count(); - - let visible_lines = app_state.help_visible_lines; - + let line_count = app_state.help_total_lines.max(help_reference_line_count()); + let visible_lines = app_state.help_visible_lines.max(1); let max_scroll = line_count.saturating_sub(visible_lines); match key_code { - KeyCode::Enter | KeyCode::Esc => { + KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q') => { app_state.input_mode = InputMode::Normal; } KeyCode::Char('j') | KeyCode::Down => { - // Scroll down, but not beyond the last line app_state.help_scroll = (app_state.help_scroll + 1).min(max_scroll); } KeyCode::Char('k') | KeyCode::Up => { - // Scroll up app_state.help_scroll = app_state.help_scroll.saturating_sub(1); } + KeyCode::PageDown => { + app_state.help_scroll = (app_state.help_scroll + visible_lines).min(max_scroll); + } + KeyCode::PageUp => { + app_state.help_scroll = app_state.help_scroll.saturating_sub(visible_lines); + } KeyCode::Home => { - // Scroll to the top app_state.help_scroll = 0; } KeyCode::End => { - // Scroll to the bottom app_state.help_scroll = max_scroll; } _ => {} @@ -425,7 +401,7 @@ mod tests { use crate::app::{AppState, InputMode}; use crate::excel::{Cell, Sheet, Workbook}; - fn app_with_preview() -> AppState<'static> { + fn app_with_sheet() -> AppState<'static> { let mut data = vec![vec![Cell::empty(); 3]; 3]; data[1][1] = Cell::new("Name".to_string(), false); data[1][2] = Cell::new("Name".to_string(), false); @@ -438,29 +414,42 @@ mod tests { max_cols: 2, is_loaded: true, }; - let mut app = AppState::new( + let app = AppState::new( Workbook::from_sheets_for_test(vec![sheet]), PathBuf::from("test.xlsx"), ) .unwrap(); - app.show_query_preview(); app } #[test] - fn escape_closes_preview_without_quitting() { - let mut app = app_with_preview(); + fn question_mark_starts_backward_search_from_normal_mode() { + let mut app = app_with_sheet(); + + handle_key_event( + &mut app, + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::empty()), + ); + + assert!(matches!(app.input_mode, InputMode::SearchBackward)); + } + + #[test] + fn chinese_question_mark_does_not_open_help_overlay_from_normal_mode() { + let mut app = app_with_sheet(); - handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + handle_key_event( + &mut app, + KeyEvent::new(KeyCode::Char('?'), KeyModifiers::empty()), + ); assert!(matches!(app.input_mode, InputMode::Normal)); - assert!(app.query_preview.is_none()); - assert!(!app.should_quit); } #[test] - fn q_closes_preview_without_quitting() { - let mut app = app_with_preview(); + fn q_closes_help_overlay_without_quitting() { + let mut app = app_with_sheet(); + app.show_help(); handle_key_event( &mut app, @@ -468,37 +457,54 @@ mod tests { ); assert!(matches!(app.input_mode, InputMode::Normal)); - assert!(app.query_preview.is_none()); assert!(!app.should_quit); } #[test] - fn f_opens_findings_panel_from_normal_mode() { - let mut app = app_with_preview(); - app.close_query_preview(); + fn page_keys_scroll_help_overlay_by_visible_page() { + let mut app = app_with_sheet(); + app.show_help(); + app.help_visible_lines = 8; handle_key_event( &mut app, - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), + KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()), ); - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(!app.findings.items.is_empty()); + assert_eq!(app.help_scroll, 8); + + handle_key_event( + &mut app, + KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()), + ); + + assert_eq!(app.help_scroll, 0); } #[test] - fn j_moves_selected_finding_in_findings_mode() { - let mut app = app_with_preview(); - app.close_query_preview(); - app.show_findings(); + fn end_key_scrolls_help_overlay_to_full_reference_bottom() { + let mut app = app_with_sheet(); + app.show_help(); + app.help_visible_lines = 8; + + handle_key_event(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::empty())); + + let full_reference_bottom = crate::app::help_reference_text() + .lines() + .count() + .saturating_sub(app.help_visible_lines); + assert_eq!(app.help_scroll, full_reference_bottom); + } - let initial = app.findings.selected; + #[test] + fn end_key_uses_rendered_help_total_lines_when_available() { + let mut app = app_with_sheet(); + app.show_help(); + app.help_visible_lines = 8; + app.help_total_lines = 120; - handle_key_event( - &mut app, - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()), - ); + handle_key_event(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::empty())); - assert!(app.findings.selected >= initial); + assert_eq!(app.help_scroll, 112); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c566118..0483466 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ mod handlers; mod render; +mod theme; pub use crate::ui::render::run_app; diff --git a/src/ui/render.rs b/src/ui/render.rs deleted file mode 100644 index daa5281..0000000 --- a/src/ui/render.rs +++ /dev/null @@ -1,1020 +0,0 @@ -use anyhow::Result; -use crossterm::{ - event::{self, Event, KeyEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, -}; -use ratatui::{ - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}, - Frame, Terminal, -}; -use std::{io, time::Duration}; - -use crate::app::AppState; -use crate::app::InputMode; -use crate::ui::handlers::handle_key_event; -use crate::utils::cell_reference; -use crate::utils::index_to_col_name; - -pub fn run_app(mut app_state: AppState) -> Result<()> { - // Setup terminal - let mut terminal = setup_terminal()?; - - // Main event loop - while !app_state.should_quit { - terminal.draw(|f| ui(f, &mut app_state))?; - - if event::poll(Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - handle_key_event(&mut app_state, key); - } - } - } - } - - // Restore terminal - restore_terminal(&mut terminal)?; - - Ok(()) -} - -/// Setup the terminal for the application -fn setup_terminal() -> Result>> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - stdout.execute(EnterAlternateScreen)?; - - let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; - - Ok(terminal) -} - -/// Restore the terminal to its original state -fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { - disable_raw_mode()?; - terminal.backend_mut().execute(LeaveAlternateScreen)?; - terminal.show_cursor()?; - - Ok(()) -} - -/// Update the visible area of the spreadsheet based on the available space -fn update_visible_area(app_state: &mut AppState, area: Rect) { - // Calculate visible rows based on available height (subtract header and borders) - app_state.visible_rows = (area.height as usize).saturating_sub(3); - - // Ensure the selected column is visible - app_state.ensure_column_visible(app_state.selected_cell.1); - - // Update row number width based on the maximum row number - app_state.update_row_number_width(); - - // Calculate available width for columns (subtract row numbers and borders) - let available_width = (area.width as usize).saturating_sub(app_state.row_number_width + 2); // row_number_width + 2 for borders - - // Calculate how many columns can fit in the available width - let mut visible_cols = 0; - let mut width_used = 0; - - // Iterate through columns starting from the leftmost visible column - for col_idx in app_state.start_col.. { - let col_width = app_state.get_column_width(col_idx); - - if col_idx == app_state.start_col { - // Always include the first column even if it's wider than available space - width_used += col_width; - visible_cols += 1; - - if width_used >= available_width { - break; - } - } else if width_used + col_width <= available_width { - // Add columns that fit completely - width_used += col_width; - visible_cols += 1; - } else if width_used < available_width { - // Excel-like behavior: include one partially visible column - visible_cols += 1; - break; - } else { - // No more space available - break; - } - } - - // Ensure at least one column is visible - app_state.visible_cols = visible_cols.max(1); -} - -fn ui(f: &mut Frame, app_state: &mut AppState) { - // Create the main layout - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // Combined title bar and sheet tabs - Constraint::Min(1), // Spreadsheet - Constraint::Length(app_state.info_panel_height as u16), // Info panel - Constraint::Length(1), // Status bar - ]) - .split(f.size()); - - draw_title_with_tabs(f, app_state, chunks[0]); - - update_visible_area(app_state, chunks[1]); - draw_spreadsheet(f, app_state, chunks[1]); - - draw_info_panel(f, app_state, chunks[2]); - draw_status_bar(f, app_state, chunks[3]); - - // If in help mode, draw the help popup over everything else - if let InputMode::Help = app_state.input_mode { - draw_help_popup(f, app_state, f.size()); - } - - // If in lazy loading mode or CommandInLazyLoading mode and the current sheet is not loaded, draw the lazy loading overlay - match app_state.input_mode { - InputMode::LazyLoading | InputMode::CommandInLazyLoading => { - let current_index = app_state.workbook.get_current_sheet_index(); - if !app_state.workbook.is_sheet_loaded(current_index) { - draw_lazy_loading_overlay(f, app_state, chunks[1]); - } else if matches!(app_state.input_mode, InputMode::LazyLoading) { - // If the sheet is loaded, switch back to Normal mode - app_state.input_mode = crate::app::InputMode::Normal; - } - } - _ => {} - } -} - -fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { - // Calculate visible row and column ranges - let start_row = app_state.start_row; - let end_row = start_row + app_state.visible_rows - 1; - let start_col = app_state.start_col; - let end_col = start_col + app_state.visible_cols - 1; - - let mut constraints = Vec::with_capacity(app_state.visible_cols + 1); - constraints.push(Constraint::Length(app_state.row_number_width as u16)); // Dynamic row header width - - for col in start_col..=end_col { - constraints.push(Constraint::Length(app_state.get_column_width(col) as u16)); - } - - // Set table style based on current mode - let (table_block, header_style, cell_style) = - if matches!(app_state.input_mode, InputMode::Normal) { - // In Normal mode, add color to the border of the data display area to indicate current focus - ( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)), - Style::default().bg(Color::DarkGray).fg(Color::Gray), - Style::default(), - ) - } else { - // In editing mode, dim the data display area - ( - Block::default().borders(Borders::ALL), - Style::default().fg(Color::DarkGray), - Style::default().fg(Color::DarkGray), // Dimmed cell content - ) - }; - - // Create header row - let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1); - header_cells.push(Cell::from("").style(header_style)); - - // Add column headers - for col in start_col..=end_col { - let col_name = index_to_col_name(col); - header_cells.push(Cell::from(col_name).style(header_style)); - } - - let header = Row::new(header_cells).height(1); - - // Create data rows - let rows = (start_row..=end_row).map(|row| { - let mut cells = Vec::with_capacity(app_state.visible_cols + 1); - - // Add row header - cells.push(Cell::from(row.to_string()).style(header_style)); - - // Add cells for this row - for col in start_col..=end_col { - let content = if app_state.selected_cell == (row, col) - && matches!(app_state.input_mode, InputMode::Editing) - { - // Handle editing mode content - let current_content = app_state.text_area.lines().join("\n"); - let col_width = app_state.get_column_width(col); - - // Calculate display width - let display_width = current_content - .chars() - .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }); - - if display_width > col_width.saturating_sub(2) { - // Truncate content if it's too wide - let mut result = String::with_capacity(col_width); - let mut cumulative_width = 0; - - // Process characters from the end to show the most recent input - for c in current_content.chars().rev().take(col_width * 2) { - let char_width = if c.is_ascii() { 1 } else { 2 }; - if cumulative_width + char_width <= col_width.saturating_sub(2) { - cumulative_width += char_width; - result.push(c); - } else { - break; - } - } - - // Reverse the characters to get the correct order - result.chars().rev().collect::() - } else { - current_content - } - } else { - // Handle normal cell content - let content = app_state.get_cell_content(row, col); - let col_width = app_state.get_column_width(col); - - // Calculate display width - let display_width = content - .chars() - .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }); - - if display_width > col_width { - // Truncate content if it's too wide - let mut result = String::with_capacity(col_width); - let mut current_width = 0; - - for c in content.chars() { - let char_width = if c.is_ascii() { 1 } else { 2 }; - if current_width + char_width < col_width { - result.push(c); - current_width += char_width; - } else { - break; - } - } - - if !content.is_empty() && result.len() < content.len() { - result.push('…'); - } - - result - } else { - content - } - }; - - // Determine cell style - let style = if app_state.selected_cell == (row, col) { - Style::default().bg(Color::White).fg(Color::Black) - } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col)) - { - Style::default().bg(Color::Yellow).fg(Color::Black) - } else { - Style::default() - }; - - cells.push(Cell::from(content).style(style)); - } - - Row::new(cells) - }); - - // Create table with header and rows - let table = Table::new( - // Combine header and data rows - std::iter::once(header).chain(rows), - ) - .block(table_block) - .style(cell_style) - .widths(&constraints); - - f.render_widget(table, area); -} - -// Parse command input and identify keywords and parameters for highlighting -fn parse_command(input: &str) -> Vec> { - if input.is_empty() { - return vec![Span::raw("")]; - } - - let known_commands = [ - "w", - "wq", - "q", - "q!", - "x", - "y", - "d", - "put", - "pu", - "nohlsearch", - "noh", - "help", - "preview", - "pv", - "findings", - "issues", - "addsheet", - "delsheet", - ]; - - let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"]; - - let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"]; - - // Check if input is a simple command without parameters - if known_commands.contains(&input) { - return vec![Span::styled(input, Style::default().fg(Color::Yellow))]; - } - - // Extract command and parameters - let parts: Vec<&str> = input.split_whitespace().collect(); - if parts.is_empty() { - return vec![Span::raw(input)]; - } - - let cmd = parts[0]; - - // Check if it's a known command with parameters - if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) { - let mut spans = Vec::new(); - - // Add the command part with yellow color - spans.push(Span::styled(cmd, Style::default().fg(Color::Yellow))); - - // Add parameters if they exist - if parts.len() > 1 { - spans.push(Span::raw(" ")); - - for i in 1..parts.len() { - // Determine style based on whether it's a special keyword - let style = if special_keywords.contains(&parts[i]) { - Style::default().fg(Color::Yellow) // Keywords are yellow - } else { - Style::default().fg(Color::LightCyan) // Parameters are cyan - }; - - spans.push(Span::styled(parts[i], style)); - - // Add space between parameters - if i < parts.len() - 1 { - spans.push(Span::raw(" ")); - } - } - } - - return spans; - } - - // For cell references or unknown commands, return as is - vec![Span::raw(input)] -} - -fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { - let constraints = if matches!(app_state.input_mode, InputMode::Preview) { - [Constraint::Percentage(75), Constraint::Percentage(25)] - } else { - [Constraint::Percentage(50), Constraint::Percentage(50)] - }; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(area); - - // Get the cell reference - let (row, col) = app_state.selected_cell; - let cell_ref = cell_reference(app_state.selected_cell); - - // Handle the top panel based on the input mode - if let InputMode::Preview = app_state.input_mode { - let preview_text = app_state - .query_preview - .as_ref() - .map(format_query_preview) - .unwrap_or_else(|| "No preview available".to_string()); - let preview_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .title(" Query Preview "); - let preview_paragraph = Paragraph::new(preview_text) - .block(preview_block) - .wrap(ratatui::widgets::Wrap { trim: false }); - - f.render_widget(preview_paragraph, chunks[0]); - } else if let InputMode::Editing = app_state.input_mode { - let (vim_mode_str, mode_color) = if let Some(vim_state) = &app_state.vim_state { - match vim_state.mode { - crate::app::VimMode::Normal => ("NORMAL", Color::Green), - crate::app::VimMode::Insert => ("INSERT", Color::LightBlue), - crate::app::VimMode::Visual => ("VISUAL", Color::Yellow), - crate::app::VimMode::Operator(op) => { - let op_str = match op { - 'y' => "YANK", - 'd' => "DELETE", - 'c' => "CHANGE", - _ => "OPERATOR", - }; - (op_str, Color::LightRed) - } - } - } else { - ("VIM", Color::White) - }; - - let title = Line::from(vec![ - Span::raw(" Editing Cell "), - Span::raw(cell_ref.clone()), - Span::raw(" - "), - Span::styled( - vim_mode_str, - Style::default().fg(mode_color).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - ]); - - let edit_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .title(title); - - // Calculate inner area with padding - let inner_area = edit_block.inner(chunks[0]); - let padded_area = Rect { - x: inner_area.x + 1, // Add 1 character padding on the left - y: inner_area.y, - width: inner_area.width.saturating_sub(2), // Subtract 2 for left and right padding - height: inner_area.height, - }; - - f.render_widget(edit_block, chunks[0]); - f.render_widget(app_state.text_area.widget(), padded_area); - } else if let InputMode::Findings = app_state.input_mode { - let title = format!(" Findings ({}) ", app_state.findings.items.len()); - let findings_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .title(title); - let findings_paragraph = Paragraph::new(format_findings_lines(app_state)) - .block(findings_block) - .wrap(ratatui::widgets::Wrap { trim: false }); - - f.render_widget(findings_paragraph, chunks[0]); - } else { - // Get cell content - let content = app_state.get_cell_content(row, col); - - let title = format!(" Cell {cell_ref} Content "); - let cell_block = Block::default().borders(Borders::ALL).title(title); - - // Create paragraph with cell content - let cell_paragraph = Paragraph::new(content) - .block(cell_block) - .wrap(ratatui::widgets::Wrap { trim: false }); - - f.render_widget(cell_paragraph, chunks[0]); - } - - // Create notification block - let notification_block = if matches!(app_state.input_mode, InputMode::Editing) { - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) - .title(Span::styled( - " Notifications ", - Style::default().fg(Color::DarkGray), - )) - } else { - Block::default() - .borders(Borders::ALL) - .title(" Notifications ") - }; - - // Calculate how many notifications can be shown - let notification_height = notification_block.inner(chunks[1]).height as usize; - - // Prepare notifications text - let notifications_text = if app_state.notification_messages.is_empty() { - String::new() - } else if app_state.notification_messages.len() <= notification_height { - app_state.notification_messages.join("\n") - } else { - // Show only the most recent notifications that fit - let start_idx = app_state.notification_messages.len() - notification_height; - app_state.notification_messages[start_idx..].join("\n") - }; - - let notification_paragraph = Paragraph::new(notifications_text) - .block(notification_block) - .wrap(ratatui::widgets::Wrap { trim: false }) - .style(if matches!(app_state.input_mode, InputMode::Editing) { - Style::default().fg(Color::DarkGray) - } else { - Style::default() - }); - - f.render_widget(notification_paragraph, chunks[1]); -} - -fn format_query_preview(preview: &crate::app::QueryPreview) -> String { - let mut lines = vec![ - format!("File: {}", preview.file_path), - format!( - "Sheet: {} ({}) | Selected: {} | Range: {}", - preview.sheet_name, preview.sheet_index, preview.selected_cell, preview.used_range - ), - format!("Select: {} | Filters: {}", preview.selects, preview.filters), - ]; - - if preview.rows.is_empty() { - lines.push("Sample: no rows".to_string()); - return lines.join("\n"); - } - - lines.push(format!("Sample: row | {}", preview.columns.join(" | "))); - for row in &preview.rows { - lines.push(format!("{} | {}", row.row_number, row.values.join(" | "))); - } - - if preview.truncated { - lines.push("Sample truncated".to_string()); - } - - lines.join("\n") -} - -fn format_findings_lines(app_state: &AppState) -> Vec> { - if let Some(error) = &app_state.findings.last_refresh_error { - return vec![Line::from(format!("Refresh failed: {error}"))]; - } - - if app_state.findings.items.is_empty() { - return vec![Line::from("No findings. Press r to refresh.")]; - } - - app_state - .findings - .items - .iter() - .enumerate() - .map(|(index, finding)| { - let location = finding - .range - .clone() - .or_else(|| match (finding.row, finding.column) { - (Some(row), Some(col)) => Some(cell_reference((row, col))), - (Some(row), None) => Some(format!("row {row}")), - (None, Some(col)) => Some(index_to_col_name(col)), - (None, None) => None, - }) - .unwrap_or_else(|| "sheet".to_string()); - - let marker = if index == app_state.findings.selected { - ">" - } else { - " " - }; - let summary = format!( - "{marker} {} {} {} {}", - finding.severity.as_str(), - finding.rule_id.as_str(), - finding.sheet, - location - ); - let style = if index == app_state.findings.selected { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - }; - - Line::from(vec![ - Span::styled(summary, style), - Span::raw(format!(" {}", finding.message)), - ]) - }) - .collect() -} - -fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { - match app_state.input_mode { - InputMode::Normal => { - let status = "Input :help for operating instructions | hjkl=move f=findings [ ]=prev/next-sheet Enter=edit y=copy d=cut p=paste /=search N/n=prev/next-search-result :=command "; - - let status_widget = Paragraph::new(status) - .style(Style::default()) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); - } - - InputMode::Editing => { - let status_widget = Paragraph::new("Press Esc to exit editing mode") - .style(Style::default().fg(Color::DarkGray)) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); - } - - InputMode::Command | InputMode::CommandInLazyLoading => { - // Create a styled text with different colors for command and parameters - let mut spans = vec![Span::styled(":", Style::default())]; - let command_spans = parse_command(&app_state.input_buffer); - spans.extend(command_spans); - - let text = Line::from(spans); - let status_widget = Paragraph::new(text) - .style(Style::default()) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); - } - - InputMode::SearchForward | InputMode::SearchBackward => { - // Get search prefix based on mode - let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) { - "/" - } else { - "?" - }; - - // Split the area for search prefix and search input - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(1), // Search prefix - Constraint::Min(1), // Search input - ]) - .split(area); - - // Render search prefix - let prefix_widget = Paragraph::new(prefix) - .style(Style::default()) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(prefix_widget, chunks[0]); - - // Render search input with cursor visible - let mut text_area = app_state.text_area.clone(); - text_area.set_cursor_line_style(Style::default()); - text_area.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); - - f.render_widget(text_area.widget(), chunks[1]); - } - - InputMode::Help => { - // No status bar in help mode - } - - InputMode::Preview => { - let status_widget = Paragraph::new("Query preview: Esc/Enter/q closes") - .style(Style::default().fg(Color::LightCyan)) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); - } - - InputMode::Findings => { - let status_widget = - Paragraph::new("Findings: j/k or arrows=move Enter=jump r/f=refresh Esc/q=close") - .style(Style::default().fg(Color::LightCyan)) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); - } - - InputMode::LazyLoading => { - // Show a status message for lazy loading mode - let status_widget = Paragraph::new( - "Sheet data not loaded... Press Enter to load, [ and ] to switch sheets, :addsheet to add a sheet, :delsheet to delete current sheet, :q to quit, :q! to quit without saving", - ) - .style(Style::default().fg(Color::LightYellow)) - .alignment(ratatui::layout::Alignment::Left); - - f.render_widget(status_widget, area); - } - } -} - -fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) { - // Create a semi-transparent overlay - let overlay = Block::default() - .style(Style::default().bg(Color::Black).fg(Color::White)) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)); - - f.render_widget(Clear, area); - f.render_widget(overlay, area); - - // Calculate center position for the message - let message = "Press Enter to load the sheet, [ and ] to switch sheets"; - let width = message.len() as u16; - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + area.height / 2; - - if x < area.width && y < area.height { - let message_area = Rect { - x, - y, - width: width.min(area.width), - height: 1, - }; - - let message_widget = Paragraph::new(message).style( - Style::default() - .fg(Color::LightYellow) - .add_modifier(Modifier::BOLD), - ); - - f.render_widget(message_widget, message_area); - } -} - -fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) { - // Clear the background - f.render_widget(Clear, area); - - // Calculate popup dimensions - let line_count = app_state.help_text.lines().count() as u16; - let content_height = line_count + 2; // +2 for borders - - let max_line_width = app_state - .help_text - .lines() - .map(|line| line.len() as u16) - .max() - .unwrap_or(40); - - let content_width = max_line_width + 4; // +4 for borders and padding - - // Ensure popup fits within screen - let popup_width = content_width.min(area.width.saturating_sub(4)); - let popup_height = content_height.min(area.height.saturating_sub(4)); - - // Center the popup on screen - let popup_x = (area.width.saturating_sub(popup_width)) / 2; - let popup_y = (area.height.saturating_sub(popup_height)) / 2; - - let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); - - // Calculate scrolling parameters - let visible_lines = popup_height.saturating_sub(2) as usize; // Subtract 2 for top and bottom borders - app_state.help_visible_lines = visible_lines; - - let line_count = app_state.help_text.lines().count(); - let max_scroll = line_count.saturating_sub(visible_lines); - - app_state.help_scroll = app_state.help_scroll.min(max_scroll); - - let mut title = " [ESC/Enter to close] ".to_string(); - - if max_scroll > 0 { - let scroll_indicator = if app_state.help_scroll == 0 { - " [↓ or j to scroll] " - } else if app_state.help_scroll >= max_scroll { - " [↑ or k to scroll] " - } else { - " [↑↓ or j/k to scroll] " - }; - title.push_str(scroll_indicator); - } - - let help_block = Block::default() - .title(title) - .title_style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightCyan)) - .style(Style::default().bg(Color::Blue).fg(Color::White)); - - // Create paragraph with help text - let help_paragraph = Paragraph::new(app_state.help_text.clone()) - .block(help_block) - .wrap(ratatui::widgets::Wrap { trim: false }) - .scroll((app_state.help_scroll as u16, 0)); - - f.render_widget(help_paragraph, popup_area); -} - -fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { - let is_editing = matches!(app_state.input_mode, InputMode::Editing); - let sheet_names = app_state.workbook.get_sheet_names(); - let current_index = app_state.workbook.get_current_sheet_index(); - - let file_name = app_state - .file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("Untitled"); - - let title_content = format!(" {file_name} "); - - let title_width = title_content - .chars() - .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }) as u16; - - let available_width = area.width.saturating_sub(title_width) as usize; - - let mut tab_widths = Vec::new(); - let mut total_width = 0; - let mut visible_tabs = Vec::new(); - - for (i, name) in sheet_names.iter().enumerate() { - let tab_width = name.len(); - - if total_width + tab_width <= available_width { - tab_widths.push(tab_width as u16); - total_width += tab_width; - visible_tabs.push(i); - } else { - // If current tab isn't visible, make room for it - if !visible_tabs.contains(¤t_index) { - // Remove tabs from the beginning until there's enough space - while !visible_tabs.is_empty() && total_width + tab_width > available_width { - let removed_width = tab_widths.remove(0) as usize; - visible_tabs.remove(0); - total_width -= removed_width; - } - - // Add current tab if there's now enough space - if total_width + tab_width <= available_width { - tab_widths.push(tab_width as u16); - visible_tabs.push(current_index); - } - } - break; - } - } - - // Limit title width to at most 2/3 of the area - let max_title_width = (area.width * 2 / 3).min(title_width); - - // Create a two-column layout: title column and tab column - let horizontal_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(max_title_width), Constraint::Min(0)]) - .split(area); - - let title_style = if is_editing { - Style::default().bg(Color::DarkGray).fg(Color::Gray) - } else { - Style::default().bg(Color::DarkGray).fg(Color::White) - }; - - let title_widget = Paragraph::new(title_content).style(title_style); - - f.render_widget(title_widget, horizontal_layout[0]); - - // Create constraints for tab layout - let mut tab_constraints = Vec::new(); - for &width in &tab_widths { - tab_constraints.push(Constraint::Length(width)); - } - tab_constraints.push(Constraint::Min(0)); // Filler space - - let tab_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints(tab_constraints) - .split(horizontal_layout[1]); - - // Render each visible tab - for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() { - if layout_idx >= tab_layout.len() - 1 { - break; - } - - let name = &sheet_names[sheet_idx]; - let is_current = sheet_idx == current_index; - - let style = if is_editing { - if is_current { - Style::default().bg(Color::DarkGray).fg(Color::Gray) - } else { - Style::default().fg(Color::DarkGray) - } - } else if is_current { - Style::default().bg(Color::DarkGray).fg(Color::White) - } else { - Style::default() - }; - - let tab_widget = Paragraph::new(name.to_string()) - .style(style) - .alignment(ratatui::layout::Alignment::Center); - - f.render_widget(tab_widget, tab_layout[layout_idx]); - } - - // Show indicator if not all tabs are visible - if visible_tabs.len() < sheet_names.len() { - let more_indicator = "..."; - let indicator_style = Style::default().bg(Color::DarkGray).fg(Color::White); - let indicator_width = more_indicator.len() as u16; - - // Position indicator at the right edge - let indicator_rect = Rect { - x: area.x + area.width - indicator_width, - y: area.y, - width: indicator_width, - height: 1, - }; - - let indicator_widget = Paragraph::new(more_indicator).style(indicator_style); - f.render_widget(indicator_widget, indicator_rect); - } -} - -#[cfg(test)] -mod tests { - use ratatui::{backend::TestBackend, Terminal}; - use std::path::PathBuf; - - use super::ui; - use crate::app::{AppState, InputMode}; - use crate::excel::{Cell, Sheet, Workbook}; - - fn app_with_preview() -> AppState<'static> { - let mut data = vec![vec![Cell::empty(); 3]; 3]; - data[1][1] = Cell::new("Name".to_string(), false); - data[1][2] = Cell::new("Name".to_string(), false); - data[2][1] = Cell::new("Ada".to_string(), false); - data[2][2] = Cell::new("10".to_string(), false); - - let sheet = Sheet { - name: "Data".to_string(), - data, - max_rows: 2, - max_cols: 2, - is_loaded: true, - }; - let mut app = AppState::new( - Workbook::from_sheets_for_test(vec![sheet]), - PathBuf::from("scores.xlsx"), - ) - .unwrap(); - app.show_query_preview(); - app - } - - #[test] - fn renders_query_preview_pane_with_target_and_sample() { - let backend = TestBackend::new(100, 30); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_preview(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let rendered = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol.as_str()) - .collect::(); - - assert!(matches!(app.input_mode, InputMode::Preview)); - assert!(rendered.contains("Query Preview")); - assert!(rendered.contains("Sheet: Data")); - assert!(rendered.contains("Range: A1:B2")); - assert!(rendered.contains("Select: all columns")); - assert!(rendered.contains("Filters: none")); - assert!(rendered.contains("Ada")); - } - - #[test] - fn renders_findings_panel_with_selected_entry() { - let backend = TestBackend::new(100, 30); - let mut terminal = Terminal::new(backend).unwrap(); - let mut app = app_with_preview(); - app.close_query_preview(); - app.show_findings(); - - terminal.draw(|frame| ui(frame, &mut app)).unwrap(); - - let rendered = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol.as_str()) - .collect::(); - - assert!(matches!(app.input_mode, InputMode::Findings)); - assert!(rendered.contains("Findings")); - assert!(rendered.contains("duplicate_headers")); - assert!(rendered.contains("Data")); - } -} diff --git a/src/ui/render/help_overlay.rs b/src/ui/render/help_overlay.rs new file mode 100644 index 0000000..bd4536d --- /dev/null +++ b/src/ui/render/help_overlay.rs @@ -0,0 +1,369 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::app::{AppState, HelpEntry, HelpSection, LEFT_HELP_SECTIONS, RIGHT_HELP_SECTIONS}; +use crate::ui::theme; + +use super::{display_width, line_display_width}; + +const HELP_ENTRY_INDENT: u16 = 2; +const HELP_ENTRY_GAP: u16 = 1; + +pub(super) fn draw_help_popup(f: &mut Frame, app_state: &mut AppState, area: Rect) { + let popup_area = help_popup_area(area); + let block = Block::default() + .title(" COMMAND HELP ") + .title_alignment(Alignment::Center) + .title_style( + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::TEXT_SECONDARY)) + .style(theme::surface()); + let inner = block.inner(popup_area); + + f.render_widget(Clear, area); + f.render_widget(Block::default().style(theme::base()), area); + f.render_widget(Clear, popup_area); + f.render_widget(block, popup_area); + + let Some((content_area, divider_area, footer_area)) = help_popup_inner_areas(inner) else { + return; + }; + + let lines = help_overlay_lines(content_area.width); + let visible_lines = content_area.height.max(1) as usize; + app_state.help_visible_lines = visible_lines; + app_state.help_total_lines = lines.len(); + let max_scroll = lines.len().saturating_sub(visible_lines); + app_state.help_scroll = app_state.help_scroll.min(max_scroll); + + let help_paragraph = Paragraph::new(lines) + .style(theme::surface()) + .scroll((app_state.help_scroll as u16, 0)); + f.render_widget(help_paragraph, content_area); + + let divider = Paragraph::new("-".repeat(inner.width as usize)).style(theme::surface()); + f.render_widget(divider, divider_area); + render_help_footer( + f, + footer_area, + app_state.help_scroll, + visible_lines, + max_scroll, + ); +} + +fn help_popup_area(area: Rect) -> Rect { + let popup_width = area.width.saturating_sub(4).clamp(48, 112); + let popup_height = area.height.saturating_sub(2).clamp(12, 32); + let popup_x = area.x + area.width.saturating_sub(popup_width) / 2; + let popup_y = area.y + area.height.saturating_sub(popup_height) / 2; + + Rect::new(popup_x, popup_y, popup_width, popup_height) +} + +fn help_popup_inner_areas(inner: Rect) -> Option<(Rect, Rect, Rect)> { + if inner.height < 4 || inner.width < 24 { + return None; + } + + let footer_height = 2; + let content_area = Rect { + x: inner.x.saturating_add(1), + y: inner.y, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(footer_height), + }; + let divider_area = Rect::new( + inner.x, + content_area.y + content_area.height, + inner.width, + 1, + ); + let footer_area = Rect::new(inner.x, divider_area.y + 1, inner.width, 1); + + Some((content_area, divider_area, footer_area)) +} + +fn render_help_footer( + f: &mut Frame, + area: Rect, + scroll: usize, + visible_lines: usize, + max_scroll: usize, +) { + let footer = help_footer_line(scroll, visible_lines, max_scroll); + let footer_widget = Paragraph::new(footer) + .style(theme::surface()) + .alignment(Alignment::Center); + f.render_widget(footer_widget, area); +} + +pub(super) fn help_overlay_lines(width: u16) -> Vec> { + if width >= 82 { + two_column_help_lines(width) + } else { + one_column_help_lines(width) + } +} + +fn two_column_help_lines(width: u16) -> Vec> { + let gap = 4; + let column_width = width.saturating_sub(gap) / 2; + let left = help_column_lines(LEFT_HELP_SECTIONS, column_width); + let right = help_column_lines(RIGHT_HELP_SECTIONS, column_width); + let row_count = left.len().max(right.len()); + let mut rows = Vec::with_capacity(row_count); + + for index in 0..row_count { + let mut line = left.get(index).cloned().unwrap_or_else(Line::default); + pad_line(&mut line, column_width); + line.spans.push(Span::raw(" ".repeat(gap as usize))); + if let Some(right_line) = right.get(index) { + line.spans.extend(right_line.spans.clone()); + } + rows.push(line); + } + + rows +} + +fn one_column_help_lines(width: u16) -> Vec> { + let mut lines = help_column_lines(LEFT_HELP_SECTIONS, width); + lines.push(Line::default()); + lines.extend(help_column_lines(RIGHT_HELP_SECTIONS, width)); + lines +} + +fn help_column_lines(sections: &[HelpSection], width: u16) -> Vec> { + let mut lines = Vec::new(); + + for (index, section) in sections.iter().enumerate() { + if index > 0 { + lines.push(Line::default()); + } + lines.push(section_title_line(section.title)); + for entry in section.entries { + lines.extend(help_entry_lines(entry, width)); + } + } + + lines +} + +fn section_title_line(title: &'static str) -> Line<'static> { + Line::from(Span::styled( + title, + Style::default() + .fg(theme::WARNING) + .add_modifier(Modifier::BOLD), + )) +} + +pub(super) fn help_entry_lines(entry: &HelpEntry, width: u16) -> Vec> { + let prefix = help_entry_prefix(entry.keys); + let prefix_width = spans_display_width(&prefix); + let description_width = width.saturating_sub(prefix_width + HELP_ENTRY_GAP).max(1); + let mut chunks = wrap_text(entry.description, description_width); + + if chunks.is_empty() { + return vec![Line::from(prefix)]; + } + + let first = chunks.remove(0); + let mut lines = vec![line_with_right_aligned_description(prefix, first, width)]; + for chunk in chunks { + lines.push(right_aligned_description_line(chunk, width)); + } + + lines +} + +fn help_entry_prefix(keys: &str) -> Vec> { + let mut spans = vec![Span::raw(" ".repeat(HELP_ENTRY_INDENT as usize))]; + + for (index, chip) in key_chips(keys).into_iter().enumerate() { + if index > 0 { + spans.push(Span::styled("/", Style::default().fg(theme::TEXT_DISABLED))); + } + spans.extend(key_chip_spans(chip)); + } + + spans +} + +fn key_chip_spans(label: String) -> Vec> { + vec![Span::styled( + format!(" {label} "), + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + )] +} + +fn key_chips(keys: &str) -> Vec { + keys.split(" / ") + .flat_map(|group| { + let group = group.trim(); + if should_split_shortcut_group(group) { + group.split_whitespace().map(str::to_string).collect() + } else { + vec![group.to_string()] + } + }) + .collect() +} + +fn spans_display_width(spans: &[Span<'_>]) -> u16 { + spans.iter().map(|span| display_width(&span.content)).sum() +} + +fn line_with_right_aligned_description( + mut spans: Vec>, + description: String, + width: u16, +) -> Line<'static> { + let prefix_width = spans_display_width(&spans); + let description_width = display_width(&description); + let gap = width.saturating_sub(prefix_width + description_width); + + spans.push(Span::raw(" ".repeat(gap as usize))); + spans.push(description_span(description)); + + Line::from(spans) +} + +fn right_aligned_description_line(description: String, width: u16) -> Line<'static> { + let description_width = display_width(&description); + let gap = width.saturating_sub(description_width); + + Line::from(vec![ + Span::raw(" ".repeat(gap as usize)), + description_span(description), + ]) +} + +fn description_span(description: String) -> Span<'static> { + Span::styled(description, Style::default().fg(theme::TEXT_SECONDARY)) +} + +fn wrap_text(text: &str, width: u16) -> Vec { + if width == 0 { + return Vec::new(); + } + + let mut lines = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() { + append_wrapped_word(&mut lines, &mut current, word, width); + } + + if !current.is_empty() { + lines.push(current); + } + + lines +} + +fn append_wrapped_word(lines: &mut Vec, current: &mut String, word: &str, width: u16) { + let word_width = display_width(word); + let current_width = display_width(current); + + if current.is_empty() && word_width <= width { + current.push_str(word); + } else if !current.is_empty() && current_width + 1 + word_width <= width { + current.push(' '); + current.push_str(word); + } else { + if !current.is_empty() { + lines.push(std::mem::take(current)); + } + append_word_chunks(lines, current, word, width); + } +} + +fn append_word_chunks(lines: &mut Vec, current: &mut String, word: &str, width: u16) { + if display_width(word) <= width { + current.push_str(word); + return; + } + + for chunk in split_word_to_width(word, width) { + if current.is_empty() { + current.push_str(&chunk); + } else { + lines.push(std::mem::take(current)); + current.push_str(&chunk); + } + } +} + +fn split_word_to_width(word: &str, width: u16) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut used = 0; + + for ch in word.chars() { + let char_width = if ch.is_ascii() { 1 } else { 2 }; + if used + char_width > width && !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + used = 0; + } + current.push(ch); + used += char_width; + } + + if !current.is_empty() { + chunks.push(current); + } + + chunks +} + +fn should_split_shortcut_group(group: &str) -> bool { + let parts: Vec<&str> = group.split_whitespace().collect(); + + parts.len() > 1 + && parts.iter().all(|part| { + part.chars().count() == 1 && part.chars().all(|ch| ch.is_ascii_alphabetic()) + }) +} + +fn pad_line(line: &mut Line<'static>, width: u16) { + let line_width = line_display_width(line); + if line_width < width { + line.spans + .push(Span::raw(" ".repeat((width - line_width) as usize))); + } +} + +fn help_footer_line(scroll: usize, visible_lines: usize, max_scroll: usize) -> Line<'static> { + let total_pages = if max_scroll == 0 { + 1 + } else { + (max_scroll + visible_lines) / visible_lines + }; + let current_page = (scroll / visible_lines).saturating_add(1).min(total_pages); + + Line::from(vec![ + Span::styled("Press ESC or q to close", Style::default().fg(theme::TEXT)), + Span::styled( + " | j/k scroll | ", + Style::default().fg(theme::TEXT_SECONDARY), + ), + Span::styled( + format!("Page {current_page}/{total_pages}"), + Style::default().fg(theme::ACCENT), + ), + ]) +} diff --git a/src/ui/render/mod.rs b/src/ui/render/mod.rs new file mode 100644 index 0000000..c95888b --- /dev/null +++ b/src/ui/render/mod.rs @@ -0,0 +1,337 @@ +use anyhow::Result; +use crossterm::{ + event::{self, Event, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, Terminal, +}; +use std::{io, time::Duration}; + +mod help_overlay; +mod spreadsheet; +mod status; + +use help_overlay::draw_help_popup; +use spreadsheet::{draw_spreadsheet, draw_title_with_tabs, update_visible_area}; +use status::{draw_status_bar, status_bar_height}; + +#[cfg(test)] +use help_overlay::{help_entry_lines, help_overlay_lines}; + +use crate::app::AppState; +use crate::app::InputMode; +use crate::app::VimMode; +use crate::ui::handlers::handle_key_event; +use crate::ui::theme; +use crate::utils::cell_reference; + +pub fn run_app(mut app_state: AppState) -> Result<()> { + // Setup terminal + let mut terminal = setup_terminal()?; + + // Main event loop + while !app_state.should_quit { + terminal.draw(|f| ui(f, &mut app_state))?; + + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + handle_key_event(&mut app_state, key); + } + } + } + } + + // Restore terminal + restore_terminal(&mut terminal)?; + + Ok(()) +} + +/// Setup the terminal for the application +fn setup_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + stdout.execute(EnterAlternateScreen)?; + + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + + Ok(terminal) +} + +/// Restore the terminal to its original state +fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + terminal.backend_mut().execute(LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} + +fn ui(f: &mut Frame, app_state: &mut AppState) { + let area = f.area(); + f.render_widget(Clear, area); + let status_bar_height = status_bar_height(app_state, area.width); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(app_state.info_panel_height as u16), + Constraint::Length(status_bar_height), + ]) + .split(area); + + draw_title_with_tabs(f, app_state, chunks[0]); + + update_visible_area(app_state, chunks[1]); + draw_spreadsheet(f, app_state, chunks[1]); + draw_info_panel(f, app_state, chunks[2]); + if status_bar_height > 0 { + draw_status_bar(f, app_state, chunks[3]); + } + + // If in help mode, draw the help popup over everything else + if let InputMode::Help = app_state.input_mode { + draw_help_popup(f, app_state, area); + } + + // If in lazy loading mode or CommandInLazyLoading mode and the current sheet is not loaded, draw the lazy loading overlay + match app_state.input_mode { + InputMode::LazyLoading | InputMode::CommandInLazyLoading => { + let current_index = app_state.workbook.get_current_sheet_index(); + if !app_state.workbook.is_sheet_loaded(current_index) { + draw_lazy_loading_overlay(f, app_state, chunks[1]); + } else if matches!(app_state.input_mode, InputMode::LazyLoading) { + // If the sheet is loaded, switch back to Normal mode + app_state.input_mode = crate::app::InputMode::Normal; + } + } + _ => {} + } +} + +pub(super) fn display_width(text: &str) -> u16 { + text.chars() + .fold(0, |acc, ch| acc + if ch.is_ascii() { 1 } else { 2 }) +} + +pub(super) fn line_display_width(line: &Line<'_>) -> u16 { + line.spans + .iter() + .map(|span| display_width(&span.content)) + .sum() +} + +fn draw_info_panel(f: &mut Frame, app_state: &mut AppState, area: Rect) { + if area.height < 4 { + if matches!(app_state.input_mode, InputMode::Editing) { + draw_editing_panel(f, app_state, area); + } else { + draw_cell_details(f, app_state, area); + } + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + if matches!(app_state.input_mode, InputMode::Editing) { + draw_editing_panel(f, app_state, chunks[0]); + } else { + draw_cell_details(f, app_state, chunks[0]); + } + draw_notifications(f, app_state, chunks[1]); +} + +fn draw_cell_details(f: &mut Frame, app_state: &AppState, area: Rect) { + let content = app_state.get_cell_content(app_state.selected_cell.0, app_state.selected_cell.1); + let cell_ref = cell_reference(app_state.selected_cell); + let value_type = cell_value_type(&content); + let length = content.chars().count(); + + let title = format!(" Cell {cell_ref} {value_type} Len {length} "); + let block = panel_block(title, theme::TEXT); + let paragraph = Paragraph::new(content) + .block(block) + .style(theme::surface()) + .wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(paragraph, area); +} + +fn draw_editing_panel(f: &mut Frame, app_state: &AppState, area: Rect) { + let cell_ref = cell_reference(app_state.selected_cell); + let mode = app_state.vim_state.as_ref().map(|state| state.mode); + let input_block = panel_block_line(editing_title_line(cell_ref, mode), theme::ACCENT); + let inner_area = input_block.inner(area); + let padded_area = Rect { + x: inner_area.x.saturating_add(1), + y: inner_area.y, + width: inner_area.width.saturating_sub(2), + height: inner_area.height, + }; + + f.render_widget(input_block, area); + f.render_widget(&app_state.text_area, padded_area); +} + +fn draw_notifications(f: &mut Frame, app_state: &AppState, area: Rect) { + let lines = if app_state.notification_messages.is_empty() { + vec![Line::from(Span::styled( + "No notifications", + Style::default().fg(theme::TEXT_SECONDARY), + ))] + } else { + app_state + .notification_messages + .iter() + .rev() + .take(4) + .enumerate() + .map(|(index, message)| { + let color = if index == 0 { + theme::TEXT + } else { + theme::TEXT_SECONDARY + }; + Line::from(Span::styled(message.clone(), Style::default().fg(color))) + }) + .collect() + }; + + let paragraph = Paragraph::new(lines) + .block(panel_block(" NOTIFICATIONS ".to_string(), theme::TEXT)) + .style(theme::surface()) + .wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(paragraph, area); +} + +fn panel_block(title: String, border_color: Color) -> Block<'static> { + panel_block_line( + Line::from(Span::styled( + title, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )), + border_color, + ) +} + +fn panel_block_line(title: Line<'static>, border_color: Color) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(border_color)) + .style(theme::surface()) +} + +fn editing_title_line(cell_ref: String, mode: Option) -> Line<'static> { + let mut spans = vec![ + Span::styled( + " Editing Cell ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + cell_ref, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + ]; + + if let Some(mode) = mode { + spans.push(Span::styled( + " - ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + mode.to_string(), + Style::default() + .fg(vim_mode_color(mode)) + .add_modifier(Modifier::BOLD), + )); + } + + spans.push(Span::styled( + " ", + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )); + + Line::from(spans) +} + +fn vim_mode_color(mode: VimMode) -> Color { + match mode { + VimMode::Normal => theme::SUCCESS, + VimMode::Insert => theme::ACCENT, + VimMode::Visual => theme::SEARCH, + VimMode::Operator(_) => theme::WARNING, + } +} + +fn cell_value_type(content: &str) -> &'static str { + if content.is_empty() { + "Blank" + } else if content.starts_with("Formula: ") { + "Formula" + } else if content.parse::().is_ok() { + "Number" + } else { + "String" + } +} + +fn draw_lazy_loading_overlay(f: &mut Frame, _app_state: &AppState, area: Rect) { + // Create a semi-transparent overlay + let overlay = Block::default() + .style(theme::surface()) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::ACCENT)); + + f.render_widget(Clear, area); + f.render_widget(overlay, area); + + // Calculate center position for the message + let message = "Sheet not loaded Enter load [ ] switch sheet : command"; + let width = message.len() as u16; + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + area.height / 2; + + if x < area.width && y < area.height { + let message_area = Rect { + x, + y, + width: width.min(area.width), + height: 1, + }; + + let message_widget = Paragraph::new(message).style( + Style::default() + .fg(theme::WARNING) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(message_widget, message_area); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/ui/render/spreadsheet.rs b/src/ui/render/spreadsheet.rs new file mode 100644 index 0000000..21bc98d --- /dev/null +++ b/src/ui/render/spreadsheet.rs @@ -0,0 +1,421 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + Frame, +}; + +use crate::app::{AppState, InputMode}; +use crate::ui::theme; +use crate::utils::index_to_col_name; + +use super::display_width; + +const TABLE_COLUMN_SPACING: usize = 1; + +/// Update the visible area of the spreadsheet based on the available space +pub(super) fn update_visible_area(app_state: &mut AppState, area: Rect) { + // Calculate visible rows based on available height (subtract header and borders) + app_state.visible_rows = (area.height as usize).saturating_sub(3); + + // Ensure the selected column is visible + app_state.ensure_column_visible(app_state.selected_cell.1); + + // Update row number width based on the maximum row number + app_state.update_row_number_width(); + + // Calculate available width for columns (subtract row numbers and borders) + let available_width = data_columns_available_width(app_state, area); + ensure_selected_column_fully_visible(app_state, available_width); + let visible_cols = visible_data_column_widths(app_state, available_width).len(); + + // Ensure at least one column is visible + app_state.visible_cols = visible_cols.max(1); +} + +fn data_columns_available_width(app_state: &AppState, area: Rect) -> usize { + (area.width as usize).saturating_sub(app_state.row_number_width + 2 + TABLE_COLUMN_SPACING) +} + +fn ensure_selected_column_fully_visible(app_state: &mut AppState, available_width: usize) { + let selected_col = app_state.selected_cell.1; + + if selected_col < app_state.start_col { + app_state.start_col = selected_col; + } + + while app_state.start_col < selected_col + && columns_width(app_state, app_state.start_col, selected_col) > available_width + { + app_state.start_col += 1; + } +} + +fn columns_width(app_state: &AppState, start_col: usize, end_col: usize) -> usize { + let col_count = end_col.saturating_sub(start_col) + 1; + let content_width = (start_col..=end_col) + .map(|col| app_state.get_column_width(col)) + .sum::(); + + content_width + TABLE_COLUMN_SPACING * col_count.saturating_sub(1) +} + +fn visible_data_column_widths(app_state: &AppState, available_width: usize) -> Vec { + let sheet = app_state.workbook.get_current_sheet(); + let max_col = sheet.max_cols.max(app_state.start_col); + let mut widths = Vec::new(); + let mut width_used = 0; + + for col_idx in app_state.start_col..=max_col { + let col_width = app_state.get_column_width(col_idx); + let spacing = if widths.is_empty() { + 0 + } else { + TABLE_COLUMN_SPACING + }; + + if width_used + spacing >= available_width { + break; + } + + let remaining_width = available_width - width_used - spacing; + let render_width = col_width.min(remaining_width); + widths.push(render_width); + width_used += spacing + render_width; + + if render_width < col_width { + break; + } + } + + if widths.is_empty() { + widths.push( + app_state + .get_column_width(app_state.start_col) + .min(available_width), + ); + } + + widths +} + +pub(super) fn draw_spreadsheet(f: &mut Frame, app_state: &AppState, area: Rect) { + // Calculate visible row and column ranges + let start_row = app_state.start_row; + let end_row = start_row + app_state.visible_rows - 1; + let start_col = app_state.start_col; + let data_column_widths = + visible_data_column_widths(app_state, data_columns_available_width(app_state, area)); + let visible_cols = data_column_widths.len().max(1); + let end_col = start_col + visible_cols - 1; + + let mut constraints = Vec::with_capacity(visible_cols + 1); + constraints.push(Constraint::Length(app_state.row_number_width as u16)); // Dynamic row header width + + for width in data_column_widths { + constraints.push(Constraint::Length(width as u16)); + } + + // Set table style based on current mode + let is_editing = matches!(app_state.input_mode, InputMode::Editing); + let table_block = Block::default() + .style(theme::base()) + .borders(Borders::ALL) + .border_style(if is_editing { + Style::default().fg(theme::GRID) + } else { + Style::default().fg(theme::ACCENT) + }); + let header_style = if is_editing { + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::TEXT_DISABLED) + } else { + theme::muted() + }; + let cell_style = if is_editing { + Style::default() + .bg(theme::BACKGROUND) + .fg(theme::TEXT_DISABLED) + } else { + theme::base() + }; + // Create header row + let mut header_cells = Vec::with_capacity(app_state.visible_cols + 1); + header_cells.push(Cell::from("").style(header_style)); + + // Add column headers + for col in start_col..=end_col { + let col_name = index_to_col_name(col); + header_cells.push(Cell::from(col_name).style(header_style)); + } + + let header = Row::new(header_cells).height(1); + + // Create data rows + let rows = (start_row..=end_row).map(|row| { + let mut cells = Vec::with_capacity(app_state.visible_cols + 1); + + // Add row header + cells.push(Cell::from(row.to_string()).style(header_style)); + + // Add cells for this row + for col in start_col..=end_col { + let content = if app_state.selected_cell == (row, col) + && matches!(app_state.input_mode, InputMode::Editing) + { + // Handle editing mode content + let current_content = app_state.text_area.lines().join("\n"); + let col_width = app_state.get_column_width(col); + + // Calculate display width + let display_width = current_content + .chars() + .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }); + + if display_width > col_width.saturating_sub(2) { + // Truncate content if it's too wide + let mut result = String::with_capacity(col_width); + let mut cumulative_width = 0; + + // Process characters from the end to show the most recent input + for c in current_content.chars().rev().take(col_width * 2) { + let char_width = if c.is_ascii() { 1 } else { 2 }; + if cumulative_width + char_width <= col_width.saturating_sub(2) { + cumulative_width += char_width; + result.push(c); + } else { + break; + } + } + + // Reverse the characters to get the correct order + result.chars().rev().collect::() + } else { + current_content + } + } else { + // Handle normal cell content + let content = app_state.get_cell_content(row, col); + let col_width = app_state.get_column_width(col); + + // Calculate display width + let display_width = content + .chars() + .fold(0, |acc, c| acc + if c.is_ascii() { 1 } else { 2 }); + + if display_width > col_width { + // Truncate content if it's too wide + let mut result = String::with_capacity(col_width); + let mut current_width = 0; + + for c in content.chars() { + let char_width = if c.is_ascii() { 1 } else { 2 }; + if current_width + char_width < col_width { + result.push(c); + current_width += char_width; + } else { + break; + } + } + + if !content.is_empty() && result.len() < content.len() { + result.push('…'); + } + + result + } else { + content + } + }; + + // Determine cell style + let style = if app_state.selected_cell == (row, col) { + Style::default().bg(Color::White).fg(Color::Black) + } else if app_state.highlight_enabled && app_state.search_results.contains(&(row, col)) + { + Style::default().bg(theme::SEARCH).fg(Color::Black) + } else { + cell_style + }; + + cells.push(Cell::from(content).style(style)); + } + + Row::new(cells) + }); + + // Create table with header and rows + let table = Table::new( + // Combine header and data rows + std::iter::once(header).chain(rows), + constraints, + ) + .block(table_block) + .column_spacing(TABLE_COLUMN_SPACING as u16) + .style(cell_style); + + f.render_widget(table, area); +} + +pub(super) fn draw_title_with_tabs(f: &mut Frame, app_state: &AppState, area: Rect) { + let is_editing = matches!(app_state.input_mode, InputMode::Editing); + let sheet_names = app_state.workbook.get_sheet_names(); + let current_index = app_state.workbook.get_current_sheet_index(); + + let file_name = app_state + .file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Untitled"); + + let brand_content = " EXCEL-CLI "; + let title_content = format!(" {file_name} "); + + let brand_width = display_width(brand_content); + let title_width = display_width(&title_content); + let max_title_width = (area.width / 3).min(title_width); + + let mut tab_widths = Vec::new(); + let mut total_width = 0; + let mut visible_tabs = Vec::new(); + + let horizontal_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(brand_width), + Constraint::Length(max_title_width), + Constraint::Min(0), + ]) + .split(area); + + let title_style = if is_editing { + Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) + } else { + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY) + }; + let brand_style = Style::default() + .bg(Color::Black) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD); + + let brand_widget = Paragraph::new(brand_content).style(brand_style); + let title_widget = Paragraph::new(title_content).style(title_style); + + f.render_widget(brand_widget, horizontal_layout[0]); + f.render_widget(title_widget, horizontal_layout[1]); + + let tabs_area = horizontal_layout[2]; + let rows_cols = sheet_rows_cols(app_state); + let rows_cols_plain = format!("Rows/Cols: {rows_cols}"); + let base_rows_width = display_width(&rows_cols_plain); + let total_tab_width: u16 = sheet_names.iter().map(|name| display_width(name)).sum(); + let visible_tabs_width = tabs_area.width.saturating_sub(base_rows_width); + let tabs_overflow = total_tab_width > visible_tabs_width; + let rows_cols_plain = if tabs_overflow { + format!("... {rows_cols_plain}") + } else { + rows_cols_plain + }; + let rows_cols_width = display_width(&rows_cols_plain); + let available_width = tabs_area.width as usize; + + for (i, name) in sheet_names.iter().enumerate() { + let tab_width = display_width(name) as usize; + + if total_width + tab_width <= available_width { + tab_widths.push(tab_width as u16); + total_width += tab_width; + visible_tabs.push(i); + } else { + if !visible_tabs.contains(¤t_index) { + while !visible_tabs.is_empty() && total_width + tab_width > available_width { + let removed_width = tab_widths.remove(0) as usize; + visible_tabs.remove(0); + total_width -= removed_width; + } + + if total_width + tab_width <= available_width { + tab_widths.push(tab_width as u16); + visible_tabs.push(current_index); + } + } + break; + } + } + + // Create constraints for tab layout + let mut tab_constraints = Vec::new(); + for &width in &tab_widths { + tab_constraints.push(Constraint::Length(width)); + } + tab_constraints.push(Constraint::Min(0)); // Filler space + + let tab_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(tab_constraints) + .split(tabs_area); + + // Render each visible tab + for (layout_idx, &sheet_idx) in visible_tabs.iter().enumerate() { + if layout_idx >= tab_layout.len() - 1 { + break; + } + + let name = &sheet_names[sheet_idx]; + let is_current = sheet_idx == current_index; + + let style = if is_editing { + Style::default().bg(Color::Black).fg(theme::TEXT_DISABLED) + } else if is_current { + Style::default() + .bg(Color::Black) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY) + }; + + let tab_widget = Paragraph::new(name.to_string()) + .style(style) + .alignment(ratatui::layout::Alignment::Center); + + f.render_widget(tab_widget, tab_layout[layout_idx]); + } + + let rows_cols_rect = Rect { + x: tabs_area.x + + tabs_area + .width + .saturating_sub(rows_cols_width.min(tabs_area.width)), + y: tabs_area.y, + width: rows_cols_width.min(tabs_area.width), + height: 1, + }; + let mut rows_cols_spans = Vec::new(); + if tabs_overflow { + rows_cols_spans.push(Span::styled( + "... ", + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY), + )); + } + rows_cols_spans.push(Span::styled( + "Rows/Cols: ", + Style::default().bg(Color::Black).fg(theme::TEXT_SECONDARY), + )); + rows_cols_spans.push(Span::styled( + rows_cols, + Style::default().bg(Color::Black).fg(theme::ACCENT), + )); + + let rows_cols_widget = Paragraph::new(Line::from(rows_cols_spans)) + .style(Style::default().bg(Color::Black)) + .alignment(ratatui::layout::Alignment::Right); + f.render_widget(rows_cols_widget, rows_cols_rect); +} + +fn sheet_rows_cols(app_state: &AppState) -> String { + let sheet = app_state.workbook.get_current_sheet(); + format!("{} x {}", sheet.max_rows, sheet.max_cols) +} diff --git a/src/ui/render/status.rs b/src/ui/render/status.rs new file mode 100644 index 0000000..6835b5e --- /dev/null +++ b/src/ui/render/status.rs @@ -0,0 +1,268 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use crate::app::{AppState, InputMode}; +use crate::ui::theme; + +use super::line_display_width; + +pub(super) fn status_bar_height(app_state: &AppState, width: u16) -> u16 { + let _ = width; + if matches!(app_state.input_mode, InputMode::Help) { + 0 + } else { + 1 + } +} + +pub(super) fn draw_status_bar(f: &mut Frame, app_state: &AppState, area: Rect) { + match app_state.input_mode { + InputMode::Normal => { + let left = Line::from(vec![status_badge("NORMAL", theme::ACCENT)]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Edit"), + (":", "Command"), + ("/", "Search"), + (":w", "Save"), + ])); + render_status_sections(f, area, left, Some(right)); + } + + InputMode::Editing => { + let left = Line::from(vec![status_badge("EDIT", theme::SUCCESS)]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Save"), + ("Esc", "Normal"), + ("i", "Insert"), + ("v", "Visual"), + ])); + render_status_sections(f, area, left, Some(right)); + } + + InputMode::Command | InputMode::CommandInLazyLoading => { + let mut left_spans = vec![ + status_badge("COMMAND", theme::WARNING), + Span::raw(" "), + Span::styled(":", Style::default().fg(theme::TEXT)), + ]; + left_spans.extend(parse_command(&app_state.input_buffer)); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Run"), + ("Esc", "Cancel"), + ("A1", "Jump"), + ])); + render_status_sections(f, area, Line::from(left_spans), Some(right)); + } + + InputMode::SearchForward | InputMode::SearchBackward => { + let prefix = if matches!(app_state.input_mode, InputMode::SearchForward) { + "/" + } else { + "?" + }; + let query = app_state.text_area.lines().join("\n"); + let left_spans = vec![ + status_badge("SEARCH", theme::SEARCH), + Span::raw(" "), + Span::styled(prefix.to_string(), Style::default().fg(theme::TEXT)), + Span::styled(query, Style::default().fg(theme::TEXT)), + ]; + let right = Line::from(shortcut_spans(&[ + ("Enter", "Apply"), + ("Esc", "Cancel"), + ("n/N", "Navigate"), + ])); + render_status_sections(f, area, Line::from(left_spans), Some(right)); + } + + InputMode::Help => { + // No status bar in help mode + } + + InputMode::LazyLoading => { + let left = Line::from(vec![ + status_badge("LAZY", theme::WARNING), + Span::raw(" "), + subtle_span("State "), + Span::styled("not loaded", Style::default().fg(theme::WARNING)), + ]); + let right = Line::from(shortcut_spans(&[ + ("Enter", "Load"), + ("[ ]", "Switch"), + (":", "Command"), + ])); + render_status_sections(f, area, left, Some(right)); + } + } +} + +// Parse command input and identify keywords and parameters for highlighting +fn parse_command(input: &str) -> Vec> { + if input.is_empty() { + return vec![Span::raw("")]; + } + + let known_commands = [ + "w", + "wq", + "q", + "q!", + "x", + "y", + "d", + "put", + "pu", + "nohlsearch", + "noh", + "help", + "addsheet", + "delsheet", + ]; + + let commands_with_params = ["cw", "ej", "eja", "sheet", "dr", "dc", "addsheet"]; + + let special_keywords = ["fit", "min", "all", "h", "v", "horizontal", "vertical"]; + + // Check if input is a simple command without parameters + if known_commands.contains(&input) { + return vec![Span::styled(input, Style::default().fg(theme::WARNING))]; + } + + // Extract command and parameters + let parts: Vec<&str> = input.split_whitespace().collect(); + if parts.is_empty() { + return vec![Span::raw(input)]; + } + + let cmd = parts[0]; + + // Check if it's a known command with parameters + if commands_with_params.contains(&cmd) || (cmd.starts_with("ej") && cmd.len() <= 3) { + let mut spans = Vec::new(); + + spans.push(Span::styled(cmd, Style::default().fg(theme::WARNING))); + + // Add parameters if they exist + if parts.len() > 1 { + spans.push(Span::raw(" ")); + + for i in 1..parts.len() { + // Determine style based on whether it's a special keyword + let style = if special_keywords.contains(&parts[i]) { + Style::default().fg(theme::WARNING) + } else { + Style::default().fg(theme::ACCENT) + }; + + spans.push(Span::styled(parts[i], style)); + + // Add space between parameters + if i < parts.len() - 1 { + spans.push(Span::raw(" ")); + } + } + } + + return spans; + } + + // For cell references or unknown commands, return as is + vec![Span::raw(input)] +} + +fn status_bar_style() -> Style { + Style::default().bg(Color::Black).fg(theme::TEXT) +} + +fn status_badge(label: &'static str, color: Color) -> Span<'static> { + Span::styled( + format!(" {label} "), + Style::default() + .bg(color) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) +} + +fn subtle_span(text: impl Into) -> Span<'static> { + Span::styled(text.into(), Style::default().fg(theme::TEXT_SECONDARY)) +} + +fn shortcut_key(key: &str) -> Span<'static> { + Span::styled( + format!("[{key}]"), + Style::default() + .bg(theme::SURFACE_MUTED) + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ) +} + +fn shortcut_spans(entries: &[(&str, &str)]) -> Vec> { + let mut spans = Vec::new(); + + for (index, (key, label)) in entries.iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" ")); + } + spans.push(shortcut_key(key)); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + (*label).to_string(), + Style::default().fg(theme::TEXT), + )); + } + + spans +} + +fn render_single_status_line<'a>( + f: &mut Frame, + area: Rect, + line: Line<'a>, + alignment: ratatui::layout::Alignment, +) { + let status_widget = Paragraph::new(line) + .style(status_bar_style()) + .alignment(alignment); + f.render_widget(status_widget, area); +} + +fn render_status_sections<'a, 'b>( + f: &mut Frame, + area: Rect, + left: Line<'a>, + right: Option>, +) { + let Some(right_line) = right else { + render_single_status_line(f, area, left, ratatui::layout::Alignment::Left); + return; + }; + + let right_width = line_display_width(&right_line).saturating_add(1); + if right_width >= area.width { + render_single_status_line(f, area, right_line, ratatui::layout::Alignment::Right); + return; + } + + let sections = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(area.width.saturating_sub(right_width)), + Constraint::Length(right_width), + ]) + .split(area); + + render_single_status_line(f, sections[0], left, ratatui::layout::Alignment::Left); + render_single_status_line( + f, + sections[1], + right_line, + ratatui::layout::Alignment::Right, + ); +} diff --git a/src/ui/render/tests.rs b/src/ui/render/tests.rs new file mode 100644 index 0000000..ff86b75 --- /dev/null +++ b/src/ui/render/tests.rs @@ -0,0 +1,632 @@ +use ratatui::{backend::TestBackend, style::Color, Terminal}; +use std::path::PathBuf; + +use super::{theme, ui}; +use crate::app::{AppState, HelpEntry, InputMode}; +use crate::excel::{Cell, Sheet, Workbook}; + +fn app_with_sheet() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 3]; 3]; + data[1][1] = Cell::new("Name".to_string(), false); + data[1][2] = Cell::new("Name".to_string(), false); + data[2][1] = Cell::new("Ada".to_string(), false); + data[2][2] = Cell::new("10".to_string(), false); + + let sheet = Sheet { + name: "Data".to_string(), + data, + max_rows: 2, + max_cols: 2, + is_loaded: true, + }; + let app = AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("scores.xlsx"), + ) + .unwrap(); + app +} + +fn app_with_many_sheets() -> AppState<'static> { + let make_sheet = |name: &str| Sheet { + name: name.to_string(), + data: vec![vec![Cell::empty(); 2]; 2], + max_rows: 1, + max_cols: 1, + is_loaded: true, + }; + + AppState::new( + Workbook::from_sheets_for_test(vec![ + make_sheet("Alpha"), + make_sheet("Beta"), + make_sheet("Gamma"), + make_sheet("Delta"), + make_sheet("Epsilon"), + make_sheet("Zeta"), + ]), + PathBuf::from("many.xlsx"), + ) + .unwrap() +} + +fn app_with_long_c22_cell() -> AppState<'static> { + let mut data = vec![vec![Cell::empty(); 5]; 24]; + data[20][1] = Cell::new("分类甲".to_string(), false); + data[20][2] = Cell::new("示例能源服务股份有限公司".to_string(), false); + data[20][3] = Cell::new("Example Energy Services Company Limited".to_string(), false); + data[20][4] = Cell::new( + "示例省示例市示例区示例路100号示例大厦A座10层、20层、30层".to_string(), + false, + ); + data[21][1] = Cell::new("分类甲".to_string(), false); + data[21][2] = Cell::new("示例一致服务集团股份有限公司".to_string(), false); + data[21][3] = Cell::new( + "Example Unified Services Corporation Limited".to_string(), + false, + ); + data[21][4] = Cell::new( + "示例省示例市示例区样例四路15号示例服务大厦".to_string(), + false, + ); + data[22][1] = Cell::new("分类甲".to_string(), false); + data[22][2] = Cell::new("示例跨区域资产服务集团股份有限公司".to_string(), false); + data[22][3] = Cell::new( + "Example International Research Operations and Holdings Company Limited".to_string(), + false, + ); + data[22][4] = Cell::new( + "示例省示例市示例区样例南路示例广场45-48楼".to_string(), + false, + ); + + let sheet = Sheet { + name: "示例表".to_string(), + data, + max_rows: 23, + max_cols: 4, + is_loaded: true, + }; + + AppState::new( + Workbook::from_sheets_for_test(vec![sheet]), + PathBuf::from("sample.xlsx"), + ) + .unwrap() +} + +fn rendered_lines(terminal: &Terminal) -> Vec { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + + buffer + .content + .chunks(width) + .map(|row| row.iter().map(|cell| cell.symbol()).collect()) + .collect() +} + +fn text_fg_at(terminal: &Terminal, needle: &str) -> Color { + let lines = rendered_lines(terminal); + let row = line_index(&lines, needle); + let col = lines[row] + .find(needle) + .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")); + let offset = needle + .chars() + .position(|ch| !ch.is_whitespace()) + .unwrap_or(0); + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col + offset].fg +} + +fn fg_at(terminal: &Terminal, row: usize, col: usize) -> Color { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].fg +} + +fn bg_at(terminal: &Terminal, row: usize, col: usize) -> Color { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].bg +} + +fn symbol_at(terminal: &Terminal, row: usize, col: usize) -> String { + let buffer = terminal.backend().buffer(); + let width = buffer.area.width as usize; + buffer.content[row * width + col].symbol().to_string() +} + +#[test] +fn auto_fit_all_renders_full_long_cell_content() { + let backend = TestBackend::new(100, 36); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_long_c22_cell(); + let expected = "Example International Research Operations and Holdings Company Limited"; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + app.input_buffer = "cw fit all".to_string(); + app.execute_command(); + app.input_buffer = "C22".to_string(); + app.execute_command(); + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let row = lines + .iter() + .find(|line| line.contains("│22")) + .unwrap_or_else(|| panic!("expected row 22 to render:\n{}", lines.join("\n"))); + + assert!(row.contains(expected), "{row}"); +} + +#[test] +fn auto_fit_all_does_not_shrink_visible_fitted_columns() { + let backend = TestBackend::new(148, 59); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_long_c22_cell(); + let expected = "Example International Research Operations and Holdings Company Limited"; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + app.input_buffer = "cw fit all".to_string(); + app.execute_command(); + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let row = lines + .iter() + .find(|line| line.contains("│22")) + .unwrap_or_else(|| panic!("expected row 22 to render:\n{}", lines.join("\n"))); + + assert!(row.contains(expected), "{row}"); +} + +#[test] +fn auto_fit_all_shows_partial_next_column_without_shrinking_fitted_columns() { + let backend = TestBackend::new(148, 59); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_long_c22_cell(); + let full_c_cell = "Example International Research Operations and Holdings Company Limited"; + let partial_d_cell = "示 例 省 示 例 市"; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + app.input_buffer = "cw fit all".to_string(); + app.execute_command(); + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let row = lines + .iter() + .find(|line| line.contains("│22")) + .unwrap_or_else(|| panic!("expected row 22 to render:\n{}", lines.join("\n"))); + + assert!(row.contains(full_c_cell), "{row}"); + assert!(row.contains(partial_d_cell), "{row}"); +} + +fn line_index(lines: &[String], needle: &str) -> usize { + lines + .iter() + .position(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("expected rendered output to contain {needle}")) +} + +fn help_overlay_text(width: u16) -> String { + super::help_overlay_lines(width) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn renders_help_overlay_as_structured_command_reference() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.show_help(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = rendered_lines(&terminal).join("\n"); + + assert!(matches!(app.input_mode, InputMode::Help)); + assert!(rendered.contains("COMMAND HELP")); + assert!(rendered.contains("NAVIGATION")); + assert!(rendered.contains("ACTIONS")); + assert!(rendered.contains("SEARCH")); + assert!(rendered.contains("FILE & APP")); + assert!(rendered.contains("JUMP & SHEETS")); + assert!(rendered.contains("Press ESC or q to close")); + assert!(rendered.contains("Page ")); + assert!(!rendered.contains("preview")); + assert!(!rendered.contains("findings")); +} + +#[test] +fn help_overlay_uses_solid_backdrop_to_hide_underlying_sheet() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.show_help(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + assert_eq!(symbol_at(&terminal, 0, 0), " "); + assert_eq!(bg_at(&terminal, 0, 0), theme::BACKGROUND); +} + +#[test] +fn help_entries_render_grouped_shortcuts_as_individual_chips() { + let entry = HelpEntry { + keys: "h j k l / arrows", + description: "Move cell", + }; + + let line_text = super::help_entry_lines(&entry, 60)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(line_text.contains(" h ")); + assert!(line_text.contains(" j ")); + assert!(line_text.contains(" k ")); + assert!(line_text.contains(" l ")); + assert!(line_text.contains(" arrows ")); + assert!(line_text.contains(" h / j / k / l / arrows ")); + assert!(!line_text.contains(" / ")); + assert!(!line_text.contains("")); + assert!(!line_text.contains("")); + assert!(!line_text.contains("‹")); + assert!(!line_text.contains("›")); + assert!(!line_text.contains(" h j k l ")); +} + +#[test] +fn help_entry_descriptions_align_to_the_right_edge() { + let short_entry = HelpEntry { + keys: "h", + description: "Move cell", + }; + let long_entry = HelpEntry { + keys: "Ctrl+arrows", + description: "Jump to next non-empty cell", + }; + + let short_line = super::help_entry_lines(&short_entry, 42)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let long_line = super::help_entry_lines(&long_entry, 42)[0] + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(super::display_width(&short_line), 42); + assert_eq!(super::display_width(&long_line), 42); + assert!(short_line.ends_with("Move cell")); + assert!(long_line.ends_with("Jump to next non-empty")); +} + +#[test] +fn help_entry_keeps_description_on_first_line_for_long_shortcut_groups() { + let entry = HelpEntry { + keys: ":noh / :nohlsearch", + description: "Disable search highlighting", + }; + + let rendered = super::help_entry_lines(&entry, 44) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + + assert!(rendered[0].contains(":noh")); + assert!(rendered[0].contains(":nohlsearch")); + assert!(rendered[0].contains("Disable search")); + assert_eq!(super::display_width(&rendered[0]), 44); + assert!(rendered[0].ends_with("Disable search")); + assert_eq!(super::display_width(&rendered[1]), 44); + assert!(rendered[1].ends_with("highlighting")); +} + +#[test] +fn help_entry_descriptions_wrap_right_aligned_inside_column_width() { + let entry = HelpEntry { + keys: ":sheet ", + description: "Switch sheet by exact name or one based index", + }; + + let lines = super::help_entry_lines(&entry, 34); + let rendered = lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>(); + + let normalized = rendered + .join(" ") + .split_whitespace() + .collect::>() + .join(" "); + + assert!(rendered.len() > 1); + assert!(rendered.iter().all(|line| super::display_width(line) <= 34)); + assert!(normalized.contains("one based index")); + assert!(rendered.iter().all(|line| { + super::display_width(line) == 34 || !line.contains(|ch: char| ch.is_alphabetic()) + })); +} + +#[test] +fn help_overlay_model_lists_complete_command_reference() { + let help_text = help_overlay_text(112); + + for required in [ + ":cw fit all", + ":dr ", + ":dc ", + ":ej ", + ":eja ", + "EDIT MODE", + "HELP CONTROLS", + ] { + assert!( + help_text.contains(required), + "expected help overlay to contain {required}" + ); + } + + assert!(!help_text.contains("preview")); + assert!(!help_text.contains("findings")); +} + +#[test] +fn renders_help_overlay_later_command_sections_when_scrolled() { + let backend = TestBackend::new(120, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.show_help(); + app.help_scroll = 17; + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let mid_page = rendered_lines(&terminal).join("\n"); + + assert!(mid_page.contains("ROWS & COLUMNS")); + assert!(mid_page.contains(":cw fit all")); + assert!(mid_page.contains(":dr ")); + assert!(mid_page.contains(":dc ")); +} + +#[test] +fn renders_visual_refresh_shell_with_inspector_and_short_status() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(rendered.contains("EXCEL-CLI")); + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(rendered.contains("NORMAL")); + assert!(rendered.contains("[:w] Save")); + assert!(!rendered.contains("INSPECTOR")); + assert!(!rendered.contains("Run Diagnostics")); + assert!(!rendered.contains("Settings")); + assert!(!rendered.contains("Execute Script")); + assert!(!rendered.contains("Findings")); + assert!(!rendered.contains("Columns")); + assert!(!rendered.contains("Preview")); +} + +#[test] +fn renders_normal_mode_status_bar_as_single_row_on_wide_layout() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let status_row = &lines[lines.len() - 1]; + let title_row = &lines[0]; + + assert!(status_row.contains(" NORMAL ")); + assert!(status_row.contains("[Enter] Edit")); + assert!(status_row.contains("[/] Search")); + assert!(status_row.contains("[:w] Save")); + assert!(status_row.trim_end().ends_with("[:w] Save")); + assert!(!status_row.contains("Rows/Cols")); + assert!(!status_row.contains("Findings")); + assert!(!status_row.contains("Columns")); + assert!(!status_row.contains("Preview")); + assert!(title_row.contains("Rows/Cols: 2 x 2")); + assert!(title_row.trim_end().ends_with("Rows/Cols: 2 x 2")); +} + +#[test] +fn renders_cell_panel_above_notifications_in_vertical_info_layout() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let cell_row = line_index(&lines, "Cell A1"); + let notifications_row = line_index(&lines, " NOTIFICATIONS "); + + assert!(cell_row < notifications_row); +} + +#[test] +fn does_not_render_analysis_tabs_or_inspector_shell() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let full_text = lines.join("\n"); + + assert!(!full_text.contains("INSPECTOR")); + assert!(!full_text.contains("Analysis Panel")); + assert!(!full_text.contains(" Details Preview Findings Columns ")); + assert!(!full_text.contains("Query Preview")); + assert!(!full_text.contains("FINDINGS")); + assert!(!full_text.contains("COLUMNS PROFILE")); +} + +#[test] +fn renders_cell_details_with_dynamic_title_and_compact_fields() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.selected_cell = (2, 2); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + + assert!(matches!(app.input_mode, InputMode::Normal)); + assert!(rendered.contains("Cell B2 Number Len 2")); + assert!(rendered.contains("10")); + assert!(!rendered.contains("Type: Number")); + assert!(!rendered.contains("Length: 2")); + assert!(!rendered.contains("Content: 10")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(!rendered.contains("SHEET CONTEXT")); + assert!(!rendered.contains("QUALITY")); + assert!(!rendered.contains("No findings for active cell")); +} + +#[test] +fn renders_notifications_panel_when_inspector_moves_below_table() { + let backend = TestBackend::new(90, 28); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.add_notification("Loaded 2 findings".to_string()); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("Loaded 2 findings")); + assert!(rendered.contains("NOTIFICATIONS")); +} + +#[test] +fn renders_editing_panel_with_vim_mode_in_title_and_without_status_mode() { + let backend = TestBackend::new(140, 40); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + app.start_editing(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let full_text = lines.join("\n"); + let status_row = &lines[lines.len() - 1]; + let title_row = &lines[0]; + + assert!(full_text.contains("Editing Cell A1")); + assert!(full_text.contains("NORMAL")); + assert!(!full_text.contains("TARGET CELL")); + assert!(!full_text.contains("INPUT BUFFER [EDITING]")); + assert_eq!( + fg_at(&terminal, line_index(&lines, " Editing Cell A1 "), 0), + theme::ACCENT + ); + assert_eq!(text_fg_at(&terminal, "NORMAL"), theme::SUCCESS); + assert!(status_row.contains(" EDIT ")); + assert!(status_row.contains("[Enter] Save")); + assert!(status_row.trim_end().ends_with("[v] Visual")); + assert!(!status_row.contains("Rows/Cols")); + assert!(!status_row.contains("Mode ")); + assert!(title_row.contains("Rows/Cols: 2 x 2")); +} + +#[test] +fn removed_analysis_modes_do_not_appear_in_rendered_ui() { + let backend = TestBackend::new(140, 32); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_sheet(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + + assert!(rendered.contains("Cell A1")); + assert!(rendered.contains("NOTIFICATIONS")); + assert!(!rendered.contains("Findings")); + assert!(!rendered.contains("Preview")); + assert!(!rendered.contains("Columns")); + assert!(!rendered.contains("COLUMNS PROFILE")); + assert!(!rendered.contains("SHEET PROFILE")); +} + +#[test] +fn renders_rows_cols_in_top_right_with_overflow_hint_when_tabs_exceed_space() { + let backend = TestBackend::new(60, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = app_with_many_sheets(); + + terminal.draw(|frame| ui(frame, &mut app)).unwrap(); + + let lines = rendered_lines(&terminal); + let title_row = &lines[0]; + + assert!(title_row.contains("Rows/Cols: 1 x 1")); + assert!(title_row.trim_end().ends_with("... Rows/Cols: 1 x 1")); + assert!(title_row.contains("Alpha")); + assert!(!title_row.contains("Zeta")); +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..5d1f95b --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,25 @@ +use ratatui::style::{Color, Style}; + +pub const BACKGROUND: Color = Color::Rgb(11, 16, 32); +pub const SURFACE: Color = Color::Rgb(17, 24, 39); +pub const SURFACE_MUTED: Color = Color::Rgb(31, 41, 55); +pub const GRID: Color = Color::Rgb(55, 65, 81); +pub const TEXT: Color = Color::Rgb(229, 231, 235); +pub const TEXT_SECONDARY: Color = Color::Rgb(156, 163, 175); +pub const TEXT_DISABLED: Color = Color::Rgb(107, 114, 128); +pub const ACCENT: Color = Color::Rgb(56, 189, 248); +pub const SEARCH: Color = Color::Rgb(250, 204, 21); +pub const WARNING: Color = Color::Rgb(245, 158, 11); +pub const SUCCESS: Color = Color::Rgb(34, 197, 94); + +pub fn base() -> Style { + Style::default().bg(BACKGROUND).fg(TEXT) +} + +pub fn surface() -> Style { + Style::default().bg(SURFACE).fg(TEXT) +} + +pub fn muted() -> Style { + Style::default().bg(SURFACE_MUTED).fg(TEXT_SECONDARY) +} diff --git a/tests/fixtures/invalid_shared_strings.xlsx b/tests/fixtures/invalid_shared_strings.xlsx index 233e87c..099b63e 100644 Binary files a/tests/fixtures/invalid_shared_strings.xlsx and b/tests/fixtures/invalid_shared_strings.xlsx differ diff --git a/tests/headless_inspect_test.rs b/tests/headless_inspect_test.rs index 110ea80..2faf8b3 100644 --- a/tests/headless_inspect_test.rs +++ b/tests/headless_inspect_test.rs @@ -699,6 +699,37 @@ fn test_read_range_preserves_typed_values() { assert!(rows[0][5].is_null()); } +#[test] +fn test_read_range_text_preserves_cell_rendering_contract() { + let temp_dir = std::env::temp_dir(); + let file_path = temp_dir.join("excel_cli_test_read_range_typed_text.xlsx"); + create_read_contract_workbook(&file_path); + + let output = Command::new(excel_cli_bin()) + .arg("read") + .arg("range") + .arg(&file_path) + .arg("--sheet") + .arg("TypedCells") + .arg("--range") + .arg("A2:F2") + .arg("--format") + .arg("text") + .output() + .expect("Failed to execute excel-cli"); + + assert!(output.status.success()); + assert!( + output.stderr.is_empty(), + "Expected empty stderr on success. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "hello\t42.5\t2024-02-03\ttrue\t85\t\n" + ); +} + #[test] fn test_read_rows_json() { let temp_dir = std::env::temp_dir(); diff --git a/tests/help_and_version_cli_test.rs b/tests/help_and_version_cli_test.rs index 1027a5e..a5f5b6c 100644 --- a/tests/help_and_version_cli_test.rs +++ b/tests/help_and_version_cli_test.rs @@ -142,5 +142,8 @@ fn version_prints_to_stdout_and_exits_zero() { ); let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "excel-cli 1.3.0"); + assert_eq!( + stdout.trim(), + format!("excel-cli {}", env!("CARGO_PKG_VERSION")) + ); } diff --git a/tests/malformed_xlsx_test.rs b/tests/malformed_xlsx_test.rs index 2454d0a..ba2bbe4 100644 --- a/tests/malformed_xlsx_test.rs +++ b/tests/malformed_xlsx_test.rs @@ -87,3 +87,31 @@ fn malformed_xlsx_read_cell_returns_controlled_error() { stderr ); } + +#[test] +fn missing_workbook_returns_file_error() { + let missing_path = std::env::temp_dir().join("excel_cli_missing_workbook_file_error.xlsx"); + let _ = std::fs::remove_file(&missing_path); + + let output = Command::new(excel_cli_bin()) + .arg("inspect") + .arg("workbook") + .arg(&missing_path) + .output() + .expect("Failed to execute excel-cli"); + + assert!( + !output.status.success(), + "Expected missing workbook failure" + ); + assert_eq!(output.status.code(), Some(3)); + assert!( + output.stdout.is_empty(), + "stdout should be empty: {}", + String::from_utf8_lossy(&output.stdout) + ); + + let stderr: serde_json::Value = + serde_json::from_slice(&output.stderr).expect("stderr should be valid JSON"); + assert_eq!(stderr["error"]["code"], "file_error"); +}