diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 8004f80..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 -updates: - # Maintain dependencies for Cargo - - package-ecosystem: "cargo" - directory: "/" - schedule: - interval: "daily" - # Disables regular version updates while allowing security PRs - open-pull-requests-limit: 0 - - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9da79dd..4bea30f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: fetch-depth: 0 - name: Download build artifacts - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v5 with: path: artifacts @@ -33,17 +33,14 @@ jobs: run: | mkdir -p target for platform in $(cd artifacts; ls | sed 's/^tsdl\.//'); do - exe=$(ls artifacts/tsdl.$platform/tsdl*) + exe="artifacts/tsdl.$platform/tsdl" gzip --stdout --name $exe > target/tsdl-$platform.gz done rm -rf artifacts ls -l target/ - - name: Install git-cliff - uses: taiki-e/install-action@git-cliff - - name: Generate changelog - run: git-cliff -vv --latest --strip header --output CHANGES.md + run: scripts/changelog.sh "${GITHUB_REF_NAME#v}" > CHANGES.md - name: Create release uses: softprops/action-gh-release@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index a921ea8..78d3c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,50 @@ - `tsdl selfupdate patch` / `minor` / `major` filters compatible releases. - `tsdl selfupdate 2.5.0` installs an exact version with downgrade confirmation. +### Performance + +- Artifact (wasm, dylib, so) discovery is now exact except for custom `script` + builds. + +### UI/UX + +The display backend moved to ratatui. The information displayed are more +informative and more coherent. + +### Changed + +- **cache**: Parser builds that track moving refs such as `HEAD` or branches now + notice when those refs move and rebuild the affected parsers. The parser cache + format changed for this major release and older cache files are not migrated. +- Downloaded `tree-sitter` cli are now verified upon download (no crypto). + +### Bug Fixes + +- **lock**: Use OS-level build locks to avoid concurrent lock acquisition races, + and replace `--unlock` with an interactive SIGTERM takeover flow controlled by + `--unlock-timeout`. +- **shutdown**: Handle `SIGTERM`/`SIGINT` gracefully by terminating running build + command process groups before releasing the build lock. +- **install**: Avoid replacing existing output files unless they are already the + same file, have identical contents, or `--force` is used; replacements are now + installed through visible temporary hardlinks. +- `--jobs` is a strictly positive number now. +- `tsdl` now fails on repos with no `grammar.js`. +- `tsdl config default` and `tsdl selfupdate` no longer : + - crash on malformed configs, + - write any logs _unless_ `--log` is explicitly set. +- Cycling through `--target` values `{all,native,wasm}` would not invalidate the + cache for artifacts built in the previous calls. This used to happen: + ```sh + tsdl build --target native # cache says native + tsdl build --target wasm # cache says wasm + tsdl build --target native # cache misses + ``` + +### Maintenance + +- Bumped to rust 2024 + ## [2.0.0] - 2026-02-20 This is a major rewrite, moving to a single-threaded async runtime, with actors. @@ -143,5 +187,3 @@ Some people call it fun, believe it or not … ### Features - **tsdl**: Working implementation - - diff --git a/Cargo.lock b/Cargo.lock index a912e35..74bc9e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,13 +19,19 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -78,9 +84,18 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "assert_cmd" @@ -113,9 +128,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -123,28 +138,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "atomic" version = "0.6.1" @@ -179,9 +172,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -189,14 +182,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -236,11 +230,32 @@ dependencies = [ "console 0.15.11", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -253,22 +268,22 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -277,11 +292,17 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -291,15 +312,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "camino" -version = "1.2.2" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -325,14 +346,23 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", +] + +[[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.63" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -348,15 +378,26 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -374,9 +415,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -386,14 +427,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -427,6 +468,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -458,9 +513,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -569,18 +624,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -588,18 +643,45 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] [[package]] name = "crypto-common" @@ -620,6 +702,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -644,9 +736,49 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -662,9 +794,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_more" @@ -685,7 +814,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -695,28 +824,6 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" -[[package]] -name = "diff-struct" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79aac083112b31f7cb768b24b893dc0c34c296a4b06b250c407bfd495e42075c" -dependencies = [ - "diff_derive", - "num", - "serde", -] - -[[package]] -name = "diff_derive" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe165e7ead196bbbf44c7ce11a7a21157b5c002ce46d7098ff9c556784a4912d" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "difflib" version = "0.4.0" @@ -739,7 +846,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -750,19 +857,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -807,9 +914,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "encode_unicode" @@ -826,18 +933,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum_dispatch" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -854,11 +949,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fiat-crypto" @@ -867,17 +981,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] -name = "figment" -version = "0.10.19" +name = "filedescriptor" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ - "atomic", - "pear", - "serde", - "toml 0.8.23", - "uncased", - "version_check", + "libc", + "thiserror 1.0.69", + "winapi", ] [[package]] @@ -892,9 +1003,21 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" @@ -923,9 +1046,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -936,6 +1059,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -944,9 +1077,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -959,9 +1092,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -969,15 +1102,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -986,32 +1119,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-timer" @@ -1021,9 +1154,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1066,24 +1199,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1094,15 +1226,15 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -1117,16 +1249,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags", + "bitflags 2.13.1", "ignore", "walkdir", ] [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1143,10 +1275,12 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1155,6 +1289,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -1177,11 +1316,17 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1189,9 +1334,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1199,9 +1344,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1228,24 +1373,24 @@ dependencies = [ "serde", "serde_derive", "sysinfo 0.38.4", - "toml 1.1.2+spec-1.1.0", + "toml", "uuid", ] [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1383,10 +1528,10 @@ dependencies = [ ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" @@ -1411,9 +1556,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -1433,17 +1578,15 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "console 0.16.3", + "console 0.16.4", "portable-atomic", "unicode-width", "unit-prefix", @@ -1460,16 +1603,23 @@ dependencies = [ ] [[package]] -name = "inlinable_string" -version = "0.1.15" +name = "instability" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1477,6 +1627,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1495,7 +1654,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -1510,7 +1669,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1529,31 +1688,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1561,20 +1736,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "libc" -version = "0.2.186" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" @@ -1590,11 +1774,29 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" -version = "0.4.30" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "lru-slab" @@ -1602,11 +1804,42 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" @@ -1620,15 +1853,39 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1653,39 +1910,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.2" @@ -1693,34 +1917,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" +name = "num-derive" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ - "num-bigint", - "num-integer", - "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1742,6 +1946,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -1757,7 +1970,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -1774,7 +1987,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] @@ -1827,26 +2040,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "pear" -version = "0.2.9" +name = "ordered-float" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", + "num-traits", ] [[package]] -name = "pear_codegen" -version = "0.2.9" +name = "palette" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", "proc-macro2", - "proc-macro2-diagnostics", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", ] [[package]] @@ -1855,6 +2110,100 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1871,11 +2220,17 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "potential_utf" @@ -1892,15 +2247,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "predicates" version = "3.1.4" @@ -1941,47 +2287,24 @@ dependencies = [ "yansi", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "version_check", - "yansi", -] - [[package]] name = "quick-xml" version = "0.38.4" @@ -1993,9 +2316,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -2005,7 +2328,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -2013,21 +2336,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -2035,23 +2359,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2070,22 +2394,22 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "rand_chacha", - "rand_core 0.9.5", + "rand_core 0.6.4", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2099,18 +2423,133 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core 0.10.1", +] + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2120,9 +2559,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2131,9 +2570,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -2220,21 +2659,21 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-ident", ] [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2251,7 +2690,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -2260,9 +2699,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -2276,9 +2715,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2288,9 +2727,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2337,9 +2776,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2359,13 +2804,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -2430,9 +2881,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2440,29 +2891,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2471,15 +2922,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -2526,6 +2968,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2548,15 +3011,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -2568,6 +3031,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2576,15 +3045,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2617,12 +3086,39 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2648,9 +3144,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -2674,7 +3181,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2693,9 +3200,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.39.3" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", @@ -2724,56 +3231,153 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2782,15 +3386,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2808,9 +3412,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -2823,9 +3427,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -2839,13 +3443,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] @@ -2860,51 +3464,31 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.3", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", + "winnow", ] [[package]] @@ -2918,50 +3502,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.3", + "winnow", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -2984,7 +3548,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -3027,7 +3591,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -3040,7 +3604,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3053,16 +3617,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -3102,27 +3656,25 @@ dependencies = [ "assert_cmd", "assert_fs", "async-compression", - "async-stream", "atty", "better-panic", "cargo_metadata", "clap", "clap-verbosity-flag", - "console 0.16.3", + "console 0.16.4", "const-str", + "crossterm", "derive_more", - "diff-struct", - "enum_dispatch", - "figment", + "fs2", "futures", "human-panic", - "ignore", - "indicatif", "indoc", + "libc", "log", "num_cpus", "predicates", "pretty_assertions", + "ratatui", "reqwest", "rstest", "self_update", @@ -3130,13 +3682,12 @@ dependencies = [ "serde", "serde_json", "sha1", - "sysinfo 0.39.3", + "sysinfo 0.39.6", "tempfile", "tokio", - "toml 1.1.2+spec-1.1.0", + "toml", "tracing", "tracing-appender", - "tracing-error", "tracing-log", "tracing-subscriber", "url", @@ -3149,13 +3700,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "uncased" -version = "0.9.10" +name = "ucd-trie" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" @@ -3165,9 +3713,20 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] [[package]] name = "unicode-width" @@ -3266,11 +3825,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.2", + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -3285,6 +3847,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -3321,27 +3892,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -3352,9 +3914,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -3362,9 +3924,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3372,96 +3934,134 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "web-sys" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ - "leb128fmt", - "wasmparser", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", + "rustls-pki-types", ] [[package]] -name = "web-sys" -version = "0.3.99" +name = "webpki-roots" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ - "js-sys", - "wasm-bindgen", + "rustls-pki-types", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "wezterm-bidi" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" dependencies = [ - "js-sys", - "wasm-bindgen", + "log", + "wezterm-dynamic", ] [[package]] -name = "webpki-root-certs" -version = "1.0.7" +name = "wezterm-blob-leases" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ - "rustls-pki-types", + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", ] [[package]] -name = "webpki-roots" -version = "1.0.7" +name = "wezterm-color-types" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" dependencies = [ - "rustls-pki-types", + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", ] [[package]] @@ -3548,7 +4148,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3559,7 +4159,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3602,7 +4202,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -3611,16 +4211,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3638,31 +4229,14 @@ 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_i686_gnullvm 0.52.6", - "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", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3680,208 +4254,63 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -3906,9 +4335,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3923,30 +4352,10 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -3964,15 +4373,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -4004,7 +4413,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4015,11 +4424,11 @@ checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" dependencies = [ "base64", "ed25519-dalek", - "thiserror", + "thiserror 2.0.20", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index e42374a..009fe13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [] authors = ["Firas al-Khalil "] build = "build.rs" description = "A downloader/builder of many tree-sitter parsers" -edition = "2021" +edition = "2024" name = "tsdl" version = "2.0.0" # managed by release.sh license = "MIT" @@ -27,11 +27,13 @@ force = false fresh = false from = "https://github.com/tree-sitter/tree-sitter-" lock-file = "tsdl.lock" +log-file = "log" out-dir = "parsers" prefix = "libtree-sitter-" ref = "master" show-config = false sys = false +unlock-timeout = 10 [package.metadata.tree-sitter] # TODO: make it accept true git refs. For now it only uses versions. @@ -40,26 +42,21 @@ repo = "https://github.com/tree-sitter/tree-sitter" [dependencies] async-compression = { version = "0.4", features = ["tokio", "gzip"] } -async-stream = "0.3" atty = "0.2" better-panic = "0.3" clap = { version = "4.6", features = ["cargo", "derive", "env"] } clap-verbosity-flag = "3.0" console = "0.16" -derive_more = { version = "2", features = ["as_ref", "deref", "display"] } -diff-struct = "0.5" -enum_dispatch = "0.3" -figment = { version = "0.10", features = ["toml", "env"] } +crossterm = "0.29" +derive_more = { version = "2", features = ["display"] } +fs2 = "0.4" futures = "0.3" +libc = "0.2" human-panic = "2.0" -ignore = "0.4" -indicatif = "0.18" +ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color", "macros"] } log = "0.4" num_cpus = "1.17" -reqwest = { version = "0.13", default-features = false, features = [ - "http2", - "rustls", -] } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } sha1 = "0.11" self_update = { version = "0.44", default-features = false, features = [ "compression-flate2", @@ -74,13 +71,13 @@ tokio = { version = "1", features = [ "fs", "macros", "process", + "signal", "sync", "time", ] } toml = "1.1" tracing = "0.1" tracing-appender = "0.2" -tracing-error = "0.2" tracing-log = "0.2" tracing-subscriber = "0.3" url = { version = "2.5", features = ["serde"] } diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 04cdf43..0000000 --- a/TODO.md +++ /dev/null @@ -1,10 +0,0 @@ -# TODO - -## Configuration - -- [ ] Investigate a figment replacement / custom impl to merge different configuration - sources. - -## Tests - -- [ ] changing log file destination from command line, apparently it's not working. diff --git a/build.rs b/build.rs index 4792153..41ee3de 100644 --- a/build.rs +++ b/build.rs @@ -6,23 +6,23 @@ use std::{env, fs}; /// Maps targets to Tree Sitter platform strings. const TARGETS: &[(&str, &str)] = &[ - ("linux-arm", "arm-unknown-linux-gnueabi"), - ("linux-arm64", "aarch64-unknown-linux-gnu"), - ("linux-x64", "x86_64-unknown-linux-gnu"), - ("linux-x86", "i686-unknown-linux-gnu"), - ("macos-arm64", "aarch64-apple-darwin"), - ("macos-x64", "x86_64-apple-darwin"), + ("linux-arm", "arm-unknown-linux-gnueabi"), + ("linux-arm64", "aarch64-unknown-linux-gnu"), + ("linux-x64", "x86_64-unknown-linux-gnu"), + ("linux-x86", "i686-unknown-linux-gnu"), + ("macos-arm64", "aarch64-apple-darwin"), + ("macos-x64", "x86_64-apple-darwin"), ]; const fn platform_for_target(target: &str) -> &str { - let mut i = 0; - while i < TARGETS.len() { - if const_str::equal!(TARGETS[i].1, target) { - return TARGETS[i].0; - } - i += 1; + let mut i = 0; + while i < TARGETS.len() { + if const_str::equal!(TARGETS[i].1, target) { + return TARGETS[i].0; } - target + i += 1; + } + target } /// Generates a Rust file with `pub const` definitions. @@ -55,6 +55,13 @@ macro_rules! generate_consts { writeln!($buf, "pub const {}: bool = {};", stringify!($name), val).unwrap(); }; + // Case: JSON u64 + (@expand $buf:expr, $name:ident, u64, json($obj:expr, $key:literal)) => { + let val = $obj.get($key).expect(concat!("Key not found: ", $key)) + .as_u64().expect(concat!("Key not an unsigned integer: ", $key)); + writeln!($buf, "pub const {}: u64 = {};", stringify!($name), val).unwrap(); + }; + // Case: Raw Expression (String) (@expand $buf:expr, $name:ident, str, expr($val:expr)) => { writeln!($buf, "pub const {}: &str = {:?};", stringify!($name), $val).unwrap(); @@ -62,89 +69,91 @@ macro_rules! generate_consts { } fn main() { - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=Cargo.toml"); - - let out_dir = env::var_os("OUT_DIR").unwrap(); - let build_target = env::var("TARGET").unwrap(); - - // 1. Get Metadata - let metadata = MetadataCommand::new().exec().unwrap(); - let meta = metadata - .root_package() - .unwrap() - .metadata - .as_object() - .unwrap(); - - // 2. Prep dynamic values - let tsdl_bin_build_dir = PathBuf::from(file!()) - .parent() - .unwrap() - .join("src") - .canonicalize() - .unwrap() - .join(""); // Ensure trailing slash logic if needed, or handle in string - - // Note: User original code added a trailing slash via format string, - // we convert to string here for the macro. - let tsdl_bin_str = format!("{}/", tsdl_bin_build_dir.to_str().unwrap()); - - let ts_platform = platform_for_target(&build_target); - - // 3. Generate TSDL Consts - let tsdl = meta.get("tsdl").expect("missing [metadata.tsdl]"); - generate_consts!( - Path::new(&out_dir).join("tsdl_consts.rs"), - TSDL_BIN_BUILD_DIR : str = expr(tsdl_bin_str), - TSDL_BUILD_DIR : str = json(tsdl, "build-dir"), - TSDL_CACHE_FILE : str = json(tsdl, "cache-file"), - TSDL_CONFIG_FILE : str = json(tsdl, "config-file"), - TSDL_FORCE : bool = json(tsdl, "force"), - TSDL_FRESH : bool = json(tsdl, "fresh"), - TSDL_FROM : str = json(tsdl, "from"), - TSDL_LOCK_FILE : str = json(tsdl, "lock-file"), - TSDL_OUT_DIR : str = json(tsdl, "out-dir"), - TSDL_PREFIX : str = json(tsdl, "prefix"), - TSDL_REF : str = json(tsdl, "ref"), - TSDL_SHOW_CONFIG : bool = json(tsdl, "show-config"), - ); - - // 4. Generate Tree Sitter Consts - let tree_sitter = meta - .get("tree-sitter") - .expect("missing [metadata.tree-sitter]"); - generate_consts!( - Path::new(&out_dir).join("tree_sitter_consts.rs"), - TREE_SITTER_PLATFORM : str = expr(ts_platform), - TREE_SITTER_REPO : str = json(tree_sitter, "repo"), - TREE_SITTER_VERSION : str = json(tree_sitter, "version"), - ); - - // 5. Generate Version/SHA - let sha1 = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| format!(" ({})", s.trim())) - .unwrap_or_default(); - - fs::write( - Path::new(&out_dir).join("tsdl.version"), - format!("{}{}", env!("CARGO_PKG_VERSION"), sha1), - ) + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=Cargo.toml"); + + let out_dir = env::var_os("OUT_DIR").unwrap(); + let build_target = env::var("TARGET").unwrap(); + + // 1. Get Metadata + let metadata = MetadataCommand::new().exec().unwrap(); + let meta = metadata + .root_package() + .unwrap() + .metadata + .as_object() .unwrap(); - // 6. FIXME: Control tests on CI (for some reason, wasm is not passing) - let is_macos = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos"); - let is_linux = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux"); - let in_github = std::env::var("GITHUB_ACTIONS").is_ok(); - - if is_macos || (is_linux && !in_github) { - println!("cargo:rustc-cfg=enable_wasm_cases"); - } - - // Register the cfg so check-cfg is happy - println!("cargo:rustc-check-cfg=cfg(enable_wasm_cases)"); + // 2. Prep dynamic values + let tsdl_bin_build_dir = PathBuf::from(file!()) + .parent() + .unwrap() + .join("src") + .canonicalize() + .unwrap() + .join(""); // Ensure trailing slash logic if needed, or handle in string + + // Note: User original code added a trailing slash via format string, + // we convert to string here for the macro. + let tsdl_bin_str = format!("{}/", tsdl_bin_build_dir.to_str().unwrap()); + + let ts_platform = platform_for_target(&build_target); + + // 3. Generate TSDL Consts + let tsdl = meta.get("tsdl").expect("missing [metadata.tsdl]"); + generate_consts!( + Path::new(&out_dir).join("tsdl_consts.rs"), + BIN_BUILD_DIR : str = expr(tsdl_bin_str), + BUILD_DIR : str = json(tsdl, "build-dir"), + CACHE_FILE : str = json(tsdl, "cache-file"), + CONFIG_FILE : str = json(tsdl, "config-file"), + FORCE : bool = json(tsdl, "force"), + FRESH : bool = json(tsdl, "fresh"), + FROM : str = json(tsdl, "from"), + LOCK_FILE : str = json(tsdl, "lock-file"), + LOG_FILE : str = json(tsdl, "log-file"), + PARSER_OUT_DIR : str = json(tsdl, "out-dir"), + UNLOCK_TIMEOUT: u64 = json(tsdl, "unlock-timeout"), + PREFIX : str = json(tsdl, "prefix"), + REF : str = json(tsdl, "ref"), + SHOW_CONFIG : bool = json(tsdl, "show-config"), + ); + + // 4. Generate Tree Sitter Consts + let tree_sitter = meta + .get("tree-sitter") + .expect("missing [metadata.tree-sitter]"); + generate_consts!( + Path::new(&out_dir).join("tree_sitter_consts.rs"), + PLATFORM : str = expr(ts_platform), + REPO : str = json(tree_sitter, "repo"), + VERSION : str = json(tree_sitter, "version"), + ); + + // 5. Generate Version/SHA + let sha1 = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| format!(" ({})", s.trim())) + .unwrap_or_default(); + + fs::write( + Path::new(&out_dir).join("tsdl.version"), + format!("{}{}", env!("CARGO_PKG_VERSION"), sha1), + ) + .unwrap(); + + // 6. FIXME: Control tests on CI (for some reason, wasm is not passing) + let is_macos = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos"); + let is_linux = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux"); + let in_github = std::env::var("GITHUB_ACTIONS").is_ok(); + + if is_macos || (is_linux && !in_github) { + println!("cargo:rustc-cfg=enable_wasm_cases"); + } + + // Register the cfg so check-cfg is happy + println!("cargo:rustc-check-cfg=cfg(enable_wasm_cases)"); } diff --git a/cliff.toml b/cliff.toml deleted file mode 100644 index 5a32143..0000000 --- a/cliff.toml +++ /dev/null @@ -1,85 +0,0 @@ -[changelog] -# changelog header -header = """ -# Changelog\n -""" -# template for the changelog body -# https://keats.github.io/tera/docs/#introduction -body = """ -{%- macro remote_url() -%} - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} -{%- endmacro -%} - -{% macro print_commit(commit) -%} - - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\ - {% if commit.breaking %}[*breaking*] {% endif %}\ - {{ commit.message | upper_first }} - \ - ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ -{% endmacro -%} - -{% if version -%}\ - ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} -{% else -%}\ - ## [unreleased] -{% endif -%}\ -{% for group, commits in commits | group_by(attribute="group") %} - ### {{ group | striptags | trim | upper_first }} - {% for commit in commits | filter(attribute="scope") | sort(attribute="scope") %} - {{ self::print_commit(commit=commit) }} - {%- endfor %} - {% for commit in commits %} - {%- if not commit.scope -%} - {{ self::print_commit(commit=commit) }} - {% endif -%} - {% endfor -%} -{% endfor %} -""" -# template for the changelog footer -footer = """ -{% for release in releases -%} - {% if release.version -%} - {% if release.previous.version -%} - [{{ release.version | trim_start_matches(pat="v") }}]: \ - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\ - /compare/{{ release.previous.version }}..{{ release.version }} - {% endif -%} - {% else -%} - [unreleased]: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}\ - /compare/{{ release.previous.version }}..HEAD - {% endif -%} -{% endfor %} - -""" -# remove the leading and trailing whitespace from the templates -trim = true - -[git] -# parse the commits based on https://www.conventionalcommits.org -conventional_commits = true -# filter out the commits that are not conventional -filter_unconventional = false -# process each line of a commit as an individual commit -split_commits = false -# regex for parsing and grouping commits -commit_parsers = [ - { message = "!:", group = "Breaking" }, - { message = "^feat", group = "Features" }, - { message = "^fix", group = "Bug Fixes" }, - { message = "^perf", group = "Performance" }, - { message = "^doc", group = "Documentation" }, - { message = "^refactor", skip = true }, - { message = "^test", skip = true }, - { message = "^style", skip = true }, - { message = "^chore", skip = true }, - { message = ".*", group = "Other" }, -] -# filter out the commits that are not matched by commit parsers -filter_commits = true -# sort the tags topologically -topo_order = false -# sort the commits inside sections by oldest/newest order -sort_commits = "oldest" - -[remote.github] -owner = "stackmystack" -repo = "tsdl" diff --git a/docs/cache.md b/docs/cache.md new file mode 100644 index 0000000..1d8b0a9 --- /dev/null +++ b/docs/cache.md @@ -0,0 +1,94 @@ +# Cache Semantics + +TSDL caches parser builds in `/cache.toml`. The cache is a build +reuse decision, not only a list of produced files. A cache entry is reused only +when the parser source, build inputs, and expected artifacts still match the +current request. + +## Cache Entries + +Each grammar is cached independently under a `language/grammar` key. For +example, the JSON parser is cached as `json/json`, while a repository with +multiple grammars can produce keys such as `typescript/typescript` and +`typescript/tsx`. + +Each entry records: + +- the `grammar.js` content hash; +- the parser repository URL; +- the requested parser git ref; +- the parser source cache identity; +- the resolved tree-sitter CLI release tag, platform, and repository; +- the build script, output prefix, and target; +- the expected build artifacts on disk. + +If any of these values no longer match, the grammar is rebuilt. + +## Parser Git Refs + +Parser refs are first interpreted from the user definition in `parsers.toml` or +from the default unpinned build behavior. + +Stable parser refs are cached by the requested ref only: + +- full 40-character commit SHAs; +- dotted versions such as `0.21.0`, normalized to `v0.21.0`; +- `v` plus dotted versions such as `v0.21.0`; +- explicit tag refs under `refs/tags/...`. + +Moving parser refs are cached by both the requested ref and the checked-out +commit: + +- `HEAD`; +- branches such as `master`, `main`, or `dev`; +- explicit branch refs under `refs/heads/...`; +- other unqualified names such as `release`, `stable`, or `vnext`. + +The requested ref always remains part of the cache identity. If `main` and +`stable` point to the same commit, they are still different cache identities. + +## Moving Ref Behavior + +Moving refs are resolved before cache lookup. TSDL checks out the requested ref, +reads the resulting `HEAD` commit, and compares that commit with the cached +commit for the same requested ref. + +This means: + +- if a branch or `HEAD` has not moved, the cache can be reused; +- if a branch or `HEAD` has moved, the affected grammars are rebuilt; +- if the requested ref changes, the cache misses even when both refs currently + point to the same commit. + +Tag-like parser refs are not remotely resolved for cache purposes. A tag ref is +treated as stable by name. If a remote tag is moved, TSDL does not detect that +movement from the cache alone; use `--force` or `--fresh` when you need to force +a rebuild. + +## Tree-Sitter CLI Identity + +The tree-sitter CLI version is resolved to the actual release tag that exists in +the configured tree-sitter repository. The cache records that resolved release +tag rather than the raw input string. + +For example, if both `0.26.5` and `v0.26.5` resolve to the same release tag, the +parser cache treats them as the same tree-sitter CLI identity. + +Changing the tree-sitter CLI release tag, platform, or repository invalidates +the affected parser cache entries. + +## Cache Format + +The parser source identity is intentionally small. Stable refs store only that +they are stable because the requested ref is already part of the build spec. +Moving refs additionally store the checked-out commit. + +Older cache files from previous major versions are not migrated; rebuild after +upgrading when needed. + +## Manual Controls + +Use `--force` to ignore cache reuse for the current build. Use `--fresh` to clear +the build directory before building. These options override normal cache reuse +and are useful when remote state was intentionally changed outside the semantics +above. diff --git a/docs/release.md b/docs/release.md index 90b6308..f9e84a7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,8 +6,9 @@ releases are automated via [GitHub actions](./.github/workflows/release.yml) and triggered by pushing a tag. -1. Run the [release script](./scripts/release.sh): `scripts/release.sh`. - The current version will be computed automatically if no version `v[X.Y.Z]` was passed. +1. Run the [release script](./scripts/release.sh): + `scripts/release.sh v`. + The version is required. 2. Push the changes: `git push` 3. Check if [Continuous Integration](https://github.com/stackmystack/tsdl/actions) workflow is completed successfully. diff --git a/justfile b/justfile index 664e0ef..43f6f3d 100644 --- a/justfile +++ b/justfile @@ -34,12 +34,22 @@ fmt-check: lint: clippy fmt-check typos setup: - cargo install git-cliff cargo-nextest typos-cli + cargo install cargo-nextest typos-cli # cmd::build::build_implicit_pinned_and_unpinned is flaky. test *args="--retries 0": cargo nextest run {{ args }} +test-signals: + @echo "=== signal-handling smoke test ===" + @bash tests/test_signal.sh + + @echo "=== lock takeover smoke test ===" + @sh tests/test_lock_takeover.sh + + @echo "=== second-signal escalation test ===" + @sh tests/test_second_signal.sh + typos: typos --sort diff --git a/rustfmt.toml b/rustfmt.toml index b1483ac..c0d10f6 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,5 +1,2 @@ -# This file intentionally left almost blank -# -# The empty `rustfmt.toml` makes rustfmt use the default configuration, -# overriding any which may be found in the contributor's home or parent -# folders. +match_arm_leading_pipes = "Always" +tab_spaces = 2 diff --git a/scripts/changelog.sh b/scripts/changelog.sh new file mode 100755 index 0000000..dd111ac --- /dev/null +++ b/scripts/changelog.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +query=${1:?"Usage: $0 "} + +# Strip leading v, lowercase for case-insensitive matching. +query_lower=$(echo "${query#v}" | tr '[:upper:]' '[:lower:]') + +awk -v q="$query_lower" ' + /^## \[/ { + if (found) exit + idx = index($0, "[") + end = index($0, "]") + if (idx > 0 && end > idx) { + heading = substr($0, idx + 1, end - idx - 1) + if (tolower(heading) == q) found = 1 + } + } + found +' CHANGELOG.md diff --git a/scripts/release.sh b/scripts/release.sh index 7da6710..1f91b63 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,31 +1,71 @@ #!/usr/bin/env bash +set -euo pipefail -set -ex - -version=${1:-"$(git cliff --bumped-version)"} +version=${1:?"Usage: $0 vX.Y.Z"} +date=$(date +%Y-%m-%d) +version_bare=${version#v} echo "Preparing $version..." -# lint and test first + +# Lint and test first. just lint just test -# update the version + +# Update Cargo.toml version. msg="# managed by release.sh" -sed -E "s/^version = .*[[:space:]]+$msg$/version = \"${version#v}\" $msg/" Cargo.toml > Cargo.toml.tmp +sed -E "s/^version = .*[[:space:]]+$msg$/version = \"${version_bare}\" $msg/" \ + Cargo.toml > Cargo.toml.tmp mv Cargo.toml.tmp Cargo.toml -# update the changelog -git cliff --unreleased --tag "$version" --prepend CHANGELOG.md -git add -A && git commit -m "chore(release): $version" + +# Move [Unreleased] content under a new version heading in CHANGELOG.md. +# If [Unreleased] is empty, no section is created. +awk -v ver="$version_bare" -v date="$date" ' +/^## \[Unreleased\]/ { + header = $0 + in_unreleased = 1 + next +} +in_unreleased && /^## \[/ { + print header + print "" + if (content != "") { + printf "## [%s] - %s\n\n", ver, date + printf "%s", content + } + in_unreleased = 0 + print + next +} +in_unreleased { + if (!started && $0 ~ /^[[:space:]]*$/) next + started = 1 + content = content $0 "\n" + next +} +{ print } +END { + if (in_unreleased) { + print header + print "" + if (content != "") { + printf "## [%s] - %s\n\n", ver, date + printf "%s", content + } + } +} +' CHANGELOG.md > CHANGELOG.md.tmp +mv CHANGELOG.md.tmp CHANGELOG.md + +# Commit. +git add -A +git commit -m "chore(release): $version" + +# Extract the new section for the tag message. +tag_body=$(scripts/changelog.sh "$version_bare") + +git tag -a "$version" -m "Release $version" -m "$tag_body" + git show -# generate a changelog for the tag message -export GIT_CLIFF_TEMPLATE="\ - {% for group, commits in commits | group_by(attribute=\"group\") %} - {{ group | upper_first }}\ - {% for commit in commits %} - - {% if commit.breaking %}(breaking) {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end=\"\") }})\ - {% endfor %} - {% endfor %}" -changelog=$(git cliff --unreleased --strip all) -# create a tag -git tag -a "$version" -m "Release $version" -m "$changelog" + echo "Done!" -echo "Now push the commit (git push origin master) and the tag (git push origin refs/tags/$version)." +echo "Now push the commit and tag." diff --git a/src/actors/cache.rs b/src/actors/cache.rs index 3e63ebd..e8a87b0 100644 --- a/src/actors/cache.rs +++ b/src/actors/cache.rs @@ -1,192 +1,251 @@ +//! Cache actor: async message handler for cache reads, rebuild decisions, and +//! updates. + use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; +use tracing::info; use crate::{ - actors::{Addr, Response}, - build::BuildSpec, - cache::{Db, Entry, Update}, - TsdlResult, + Result, + actors::{Addr, Response}, + build, cache, parser, }; +// ============================================================ +// Enums +// ============================================================ + +/// Messages sent to the cache actor. +/// +/// Each variant is a cache operation: query, update, save, or +/// compatibility check. Response-bearing variants carry a +/// [`oneshot::Sender`] for the reply. +#[derive(Debug)] +pub enum CacheMessage { + /// Query if a parser needs rebuild + NeedsRebuild { + hash: cache::GrammarHash, + name: cache::Key, + revision: cache::Revision, + spec: Arc, + tx: oneshot::Sender, + }, + /// `cache::Update` a cache entry + Update { + entry: cache::Entry, + name: cache::Key, + }, + /// Save cache to disk + Save { tx: oneshot::Sender> }, + /// Check if cache contains entries compatible with a language spec. + HasCompatibleEntries { + language: parser::LanguageName, + spec: Arc, + tx: oneshot::Sender, + }, + /// Get a cache entry + Get { + name: cache::Key, + tx: oneshot::Sender>, + }, +} + #[derive(Debug)] #[allow(dead_code)] enum ResponseKind<'a> { - CacheGet { name: &'a str }, - NeedsClone { language: &'a str }, - NeedsRebuild { name: &'a str, hash: &'a str }, - SaveComplete, + CacheGet { + name: &'a cache::Key, + }, + HasCompatibleEntries { + language: &'a parser::LanguageName, + }, + NeedsRebuild { + name: &'a cache::Key, + hash: &'a cache::GrammarHash, + }, + SaveComplete, } -#[derive(Debug)] -pub enum CacheMessage { - /// Query if a parser needs rebuild - NeedsRebuild { - hash: Arc, - name: Arc, - spec: Arc, - tx: oneshot::Sender, - }, - /// Update a cache entry - Update { entry: Entry, name: Arc }, - /// Save cache to disk - Save { tx: oneshot::Sender> }, - /// Check if clone is needed for a language - NeedsClone { - language: Arc, - spec: Arc, - tx: oneshot::Sender, - }, - /// Get a cache entry - Get { - name: Arc, - tx: oneshot::Sender>, - }, +// ============================================================ +// Structs +// ============================================================ + +/// The Cache Actor: Manages cache state and processes messages +pub struct CacheActor { + /// In-memory cache database. + db: cache::Db, + /// File-backed storage for persistence. + store: cache::Store, + /// When true, every query returns a miss. + force: bool, + /// Message receiver channel. + rx: mpsc::Receiver, } /// The Cache Handle: Public interface for sending cache operations #[derive(Debug, Clone)] pub struct CacheAddr { - tx: mpsc::Sender, + /// Sender half of the channel to the cache actor. + tx: mpsc::Sender, } +// ============================================================ +// Impls +// ============================================================ + impl Addr for CacheAddr { - type Message = CacheMessage; + type Message = CacheMessage; - fn name() -> &'static str { - "CacheAddr" - } + fn name() -> &'static str { + "CacheAddr" + } - fn sender(&self) -> &mpsc::Sender { - &self.tx - } + fn sender(&self) -> &mpsc::Sender { + &self.tx + } } -impl CacheAddr { - #[must_use] - pub fn new(tx: mpsc::Sender) -> Self { - Self { tx } - } - - /// Accepts any string type (String, &str, Arc) with minimal cloning - pub async fn get>>(&self, name: S) -> Option { - self.request(|tx| CacheMessage::Get { - name: name.into(), +impl CacheActor { + /// Process incoming cache messages until the channel closes. + async fn run(mut self) { + while let Some(msg) = self.rx.recv().await { + use CacheMessage::{Get, HasCompatibleEntries, NeedsRebuild, Save, Update}; + + match msg { + | Get { name, tx } => { + Response { tx, - }) - .await - } + kind: ResponseKind::CacheGet { name: &name }, + } + .send(self.db.get(&name).cloned()); + } - pub async fn needs_clone>>(&self, language: S, spec: Arc) -> bool { - self.request(|tx| CacheMessage::NeedsClone { - language: language.into(), - spec, + | HasCompatibleEntries { language, spec, tx } => { + Response { tx, - }) - .await - } + kind: ResponseKind::HasCompatibleEntries { + language: &language, + }, + } + .send( + !self.force + && self + .db + .has_compatible_entry_for_language(&language, spec.as_ref()), + ); + } - pub async fn needs_rebuild>>( - &self, - name: S, - hash: S, - spec: Arc, - ) -> bool { - self.request(|tx| CacheMessage::NeedsRebuild { - name: name.into(), - hash: hash.into(), - spec, + | NeedsRebuild { + hash, + name, + spec, + revision, + tx, + } => { + let decision = if self.force { + cache::Decision::miss(cache::MissReason::CacheIgnored) + } else { + self.db.rebuild_decision(&name, &hash, &revision, &spec) + }; + if decision.needs_rebuild() { + info!("Cache miss for {name}: {}", decision.short_message()); + } + + Response { tx, - }) - .await - } - - pub async fn save(&self) -> TsdlResult<()> { - self.request(|tx| CacheMessage::Save { tx }).await - } - - pub async fn update(&self, update: Update) { - self.fire(CacheMessage::Update { - entry: update.entry, - name: update.name, - }) - .await; - } -} + kind: ResponseKind::NeedsRebuild { + name: &name, + hash: &hash, + }, + } + .send(decision); + } -/// The Cache Actor: Manages cache state and processes messages -pub struct CacheActor { - db: Db, - force: bool, - rx: mpsc::Receiver, -} + | Save { tx } => { + Response { + tx, + kind: ResponseKind::SaveComplete, + } + .send(self.store.save(&self.db).await); + } -impl CacheActor { - async fn run(mut self) { - while let Some(msg) = self.rx.recv().await { - match msg { - CacheMessage::NeedsRebuild { - hash, - name, - spec, - tx, - } => { - Response { - tx, - kind: ResponseKind::NeedsRebuild { - name: &name, - hash: &hash, - }, - } - .send(self.db.needs_rebuild(&name, &hash, &spec)); - } - - CacheMessage::Update { entry, name } => { - self.db.set(name.to_string(), entry); - } - - CacheMessage::Save { tx } => { - Response { - tx, - kind: ResponseKind::SaveComplete, - } - .send(self.db.save()); - } - - CacheMessage::NeedsClone { language, spec, tx } => { - Response { - tx, - kind: ResponseKind::NeedsClone { - language: &language, - }, - } - .send( - self.force - || self - .db - .parsers - .iter() - .find(|(key, _)| key.starts_with(&format!("{language}/"))) - .is_none_or(|(_, entry)| entry.spec != spec), - ); - } - - CacheMessage::Get { name, tx } => { - Response { - tx, - kind: ResponseKind::CacheGet { name: &name }, - } - .send(self.db.get(&name).cloned()); - } - } + | Update { entry, name } => { + self.db.set(name, entry); } + } } + } + + /// Spawn the cache actor in a background task and return an address handle. + #[must_use] + pub fn spawn(db: cache::Db, force: bool, store: cache::Store) -> CacheAddr { + let (tx, rx) = mpsc::channel(64); + let actor = Self { + db, + store, + force, + rx, + }; + tokio::spawn(actor.run()); + CacheAddr::new(tx) + } +} - #[must_use] - pub fn spawn(db: Db, force: bool) -> CacheAddr { - let (tx, rx) = mpsc::channel(64); - let actor = Self { db, force, rx }; - tokio::spawn(actor.run()); - CacheAddr::new(tx) - } +impl CacheAddr { + /// Create a new cache address from a sender. + #[must_use] + pub fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } + + /// Accepts any string type (`String`, `&str`, `Arc`) with minimal cloning + pub async fn get(&self, name: cache::Key) -> Option { + self.request(|tx| CacheMessage::Get { name, tx }).await + } + + /// Check if the cache has compatible entries for a language (same repo + git ref). + pub async fn has_compatible_entries( + &self, + language: parser::LanguageName, + spec: Arc, + ) -> bool { + self + .request(|tx| CacheMessage::HasCompatibleEntries { language, spec, tx }) + .await + } + + /// Ask the cache actor whether a parser needs rebuilding. + pub async fn needs_rebuild( + &self, + name: cache::Key, + hash: cache::GrammarHash, + revision: cache::Revision, + spec: Arc, + ) -> cache::Decision { + self + .request(|tx| CacheMessage::NeedsRebuild { + name, + hash, + revision, + spec, + tx, + }) + .await + } + + /// Save the cache to disk. + pub async fn save(&self) -> Result<()> { + self.request(|tx| CacheMessage::Save { tx }).await + } + + /// Send a cache update (fire-and-forget). + pub async fn update(&self, update: cache::Update) { + self + .fire(CacheMessage::Update { + entry: update.entry, + name: update.name, + }) + .await; + } } diff --git a/src/actors/display.rs b/src/actors/display.rs index 00076d7..4fc7b3b 100644 --- a/src/actors/display.rs +++ b/src/actors/display.rs @@ -1,286 +1,1689 @@ -use std::{collections::HashMap, sync::Arc}; +//! Display actor: ratatui inline render loop (fancy) or plain-text output +//! (plain). Receives progress updates and materializes terminal output. +use std::io; +use std::num::NonZeroU64; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use ratatui::backend::Backend; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Clear, Paragraph, Widget}; use tokio::sync::{mpsc, oneshot}; +use tokio::time; +use tracing::error; -use crate::{ - actors::{Addr, Response}, - display::{Progress, ProgressBar, UpdateKind}, - git::GitRef, - TsdlResult, -}; +use crate::actors::Addr; +use crate::display; +use crate::git; -#[derive(Debug)] -#[allow(dead_code)] -enum DisplayResponseKind<'a> { - RegisterGrammar { language: &'a str, name: &'a str }, - RegisterLanguage { name: &'a str }, -} +// ============================================================ +// Enums +// ============================================================ +/// Messages sent to the display actor. +/// +/// Covers registration of new progress rows, per-row updates, and final +/// shutdown. #[derive(Debug)] -pub enum DisplayMessage { - RegisterLanguage { - git_ref: GitRef, - name: Arc, - num_tasks: usize, - tx: oneshot::Sender, - }, +pub enum Message { + /// Register a repo-level progress line. Returns a `ProgressAddr`. + RegisterLanguage { + git_ref: git::Ref, + name: Arc, + num_tasks: usize, + tx: oneshot::Sender, + }, - Println { - msg: Arc, - }, + /// Register a grammar-level progress line. Returns a `ProgressAddr`. + RegisterGrammar { + git_ref: git::Ref, + language: Arc, + name: Arc, + num_tasks: usize, + tx: oneshot::Sender, + }, - RegisterGrammar { - git_ref: GitRef, - language: Arc, - name: Arc, - num_tasks: usize, - tx: oneshot::Sender, - }, + /// Plain-mode reference line. Fancy mode ignores this because refs are rendered per row. + RegisterReference { git_ref: git::Ref, name: Arc }, - UnregisterLanguage { - name: Arc, - }, + /// Update a specific bar. + Update { + id: display::ItemId, + kind: UpdateKind, + msg: Arc, + }, - Update { - id: u64, - kind: UpdateKind, - msg: String, - }, + /// Flush and close the display actor. The response is sent after cleanup. + Shutdown { + interrupted: bool, + tx: oneshot::Sender<()>, + }, +} - Tick, +/// The kind of update to apply to a progress bar. +#[derive(Debug, Clone, Copy)] +pub enum UpdateKind { + /// Update the status message only. + Msg, + /// Advance to the next step. + Step, + /// Mark the outcome as cached (used before clone is skipped). + SetOutcomeCached, + /// Mark the outcome as built (used before clone proceeds). + SetOutcomeBuilt, + /// Mark the row as cancelled (shutdown). + Cancel, + /// Mark the row as cached at completion. + Cached, + /// Mark the row as finished (built) at completion. + Fin, + /// Mark the row as failed. + Err, } -/// The Manager Handle: Only used to register/unregister tasks. +// ============================================================ +// Structs +// ============================================================ + +/// The display actor: owns the display state and the message receiver. +pub struct DisplayActor { + /// Shared display state (repos, grammars, layout). + state: display::State, + /// Monotonically increasing ID counter for new rows. + next_id: display::ItemId, + /// Maximum name column width for plain-mode alignment. + plain_name_width: usize, + /// Whether any progress line has been printed (plain mode). + plain_progress_started: bool, + /// Cached rendered cells for the fancy backend. + grid: display::GridCache, + /// Ordered list of visible row specs. + row_specs: Vec, + /// Whether the row list has changed since last render. + rows_dirty: bool, + /// Cached terminal width from the last render cycle. + last_term_width: Option, + /// Receiver for incoming display messages. + rx: mpsc::Receiver, + /// Cloned sender used to construct child [`ProgressAddr`] handles. + tx: mpsc::Sender, +} + +/// Handle for sending display messages (register languages, update progress). #[derive(Debug, Clone)] pub struct DisplayAddr { - tx: mpsc::Sender, + #[allow(dead_code)] + mode: display::Mode, + /// Sender to the display actor. + tx: mpsc::Sender, } -impl Addr for DisplayAddr { - type Message = DisplayMessage; +struct PlainLine { + name: String, + step: usize, + total: usize, + message: String, +} - fn name() -> &'static str { - "DisplayAddr" - } +/// Handle for updating a specific progress bar (repo or grammar). +#[derive(Debug, Clone)] +pub struct ProgressAddr { + /// Target row ID for updates. + id: display::ItemId, + /// Sender to the display actor. + tx: mpsc::Sender, +} - fn sender(&self) -> &mpsc::Sender { - &self.tx - } +// ============================================================ +// Free functions +// ============================================================ + +/// Clear from the cursor position to the bottom of the terminal. +fn clear_from_cursor_down() -> io::Result<()> { + crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::FromCursorDown) + ) } -impl DisplayAddr { - #[must_use] - pub fn new(tx: mpsc::Sender) -> Self { - Self { tx } - } - - pub async fn add_grammar>>( - &self, - git_ref: GitRef, - language: S, - name: S, - num_tasks: usize, - ) -> ProgressAddr { - self.request(|tx| DisplayMessage::RegisterGrammar { - git_ref, - language: language.into(), - name: name.into(), - num_tasks, - tx, - }) - .await - } - - pub async fn add_language>>( - &self, - git_ref: GitRef, - name: S, - num_tasks: usize, - ) -> ProgressAddr { - self.request(|tx| DisplayMessage::RegisterLanguage { - git_ref, - name: name.into(), - num_tasks, - tx, - }) - .await - } - - pub async fn println>>(&self, msg: S) { - self.fire(DisplayMessage::Println { msg: msg.into() }).await; - } - - pub async fn remove_language>>(&self, name: S) -> TsdlResult<()> { - self.fire(DisplayMessage::UnregisterLanguage { name: name.into() }) - .await; - Ok(()) - } - - pub async fn tick(&self) { - self.fire(DisplayMessage::Tick {}).await; - } +/// Measure the current visible height of the terminal viewport. +fn current_viewport_height( + terminal: &mut ratatui::Terminal, +) -> Result { + terminal.autoresize()?; + Ok(terminal.get_frame().area().height.max(1)) } -/// The Task Handle: Dedicated to controlling a specific progress bar. -#[derive(Debug, Clone)] -pub struct ProgressAddr { - id: u64, - tx: mpsc::Sender, +/// Draw a set of lines into the terminal frame. +fn draw_lines( + terminal: &mut ratatui::Terminal, + lines: Vec>, +) -> Result<(), B::Error> { + terminal + .draw(|frame| { + let area = frame.area(); + frame.render_widget(Paragraph::new(lines), area); + }) + .map(|_| ()) } -impl ProgressAddr { - /// Takes Into directly as the message must be owned to be sent - pub fn msg>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::Msg, - msg: msg.into(), - }); - } - - pub fn step>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::Step, - msg: msg.into(), - }); - } - - pub fn fin>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::Fin, - msg: msg.into(), - }); - } - - pub fn err>(&self, msg: S) { - let _ = self.tx.try_send(DisplayMessage::Update { - id: self.id, - kind: UpdateKind::Err, - msg: msg.into(), - }); +/// Draw lines into the viewport, clearing previous content and setting the +/// cursor below the last visible line. +fn draw_lines_in_viewport( + terminal: &mut ratatui::Terminal, + lines: Vec>, +) -> Result<(), B::Error> { + let line_count = lines.len(); + + terminal + .draw(move |frame| { + let area = frame.area(); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines), area); + + let cursor_offset = line_count + .saturating_sub(1) + .min(usize::from(area.height.saturating_sub(1))); + let cursor_y = area.y + u16::try_from(cursor_offset).unwrap_or(0); + frame.set_cursor_position((area.x, cursor_y)); + }) + .map(|_| ()) +} + +/// Transition an item state to `Done`. Returns an error if the state is not +/// `InProgress(Some(_))`. +fn finish_item(state: display::ItemState) -> Result { + use display::ItemState::{Cancelled, Done, Failed, InProgress, New}; + + match state { + | Cancelled | Done(_) | Failed => { + invalid_finish("finish update received for terminal item state", state) } + | InProgress(Some(outcome)) => Ok(Done(outcome)), + | New | InProgress(None) => invalid_finish( + "finish update received before cached/built path was set", + state, + ), + } } -pub struct DisplayActor { - handles: HashMap, - next_id: u64, - progress: Progress, - rx: mpsc::Receiver, - tx: mpsc::Sender, +/// Push lines above the current viewport, scrolling content up. +fn insert_lines_before_viewport( + terminal: &mut ratatui::Terminal, + mut lines: Vec>, +) -> Result<(), B::Error> { + while !lines.is_empty() { + let rest = if lines.len() > usize::from(u16::MAX) { + lines.split_off(usize::from(u16::MAX)) + } else { + Vec::new() + }; + let height = u16::try_from(lines.len()).unwrap_or(u16::MAX); + + terminal.insert_before(height, move |buf| { + Paragraph::new(lines).render(buf.area, buf); + })?; + + lines = rest; + } + + Ok(()) +} + +/// Log and return an error for an invalid finish transition. +fn invalid_finish(reason: &str, state: display::ItemState) -> Result { + let message = format!("{reason}: {state:?}"); + error!("{message}"); + debug_assert!( + matches!(state, display::ItemState::InProgress(Some(_))), + "{message}" + ); + Err(message) +} + +/// Set the success outcome on an item state without changing its progress phase. +fn mark_success(state: display::ItemState, outcome: display::SuccessOutcome) -> display::ItemState { + use display::ItemState::{Cancelled, Done, Failed, InProgress, New}; + + match state { + | Cancelled | Done(_) | Failed => state, + | New | InProgress(_) => InProgress(Some(outcome)), + } +} + +/// Format a plain-text message for a grammar row. +fn plain_grammar_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { + use UpdateKind::{Cached, Cancel, Err, Fin, Msg, SetOutcomeBuilt, SetOutcomeCached, Step}; + use display::{ + ItemState::{Cancelled, Done, Failed, InProgress, New}, + SuccessOutcome, + }; + + match kind { + | Cached => "cached".to_string(), + | Cancel | Msg | SetOutcomeBuilt | SetOutcomeCached | Step => msg.to_string(), + | Err => format!("failed: {msg}"), + | Fin => match state { + | Done(SuccessOutcome::Cached) => "cached".to_string(), + | Done(SuccessOutcome::Built) => "built".to_string(), + | New | InProgress(_) | Cancelled | Failed => msg.to_string(), + }, + } +} + +/// Format a plain-text message for a repo row. +fn plain_repo_message(kind: UpdateKind, state: display::ItemState, msg: &str) -> String { + use UpdateKind::{Cached, Cancel, Err, Fin, Msg, SetOutcomeBuilt, SetOutcomeCached, Step}; + use display::ItemState::{Cancelled, Done, Failed, InProgress, New}; + + match kind { + | Cached => "cached".to_string(), + | Err => format!("failed: {msg}"), + | Fin => match state { + | Cancelled | Failed | New | InProgress(_) => msg.to_string(), + | Done(_) => "done".to_string(), + }, + | Cancel | Msg | SetOutcomeBuilt | SetOutcomeCached | Step => msg.to_string(), + } +} + +/// Render the final report: insert prefix lines above the viewport, then +/// replace viewport content with the final lines. +fn render_final_report( + terminal: &mut ratatui::Terminal, + lines: Vec>, +) -> Result<(), B::Error> { + let viewport_height = current_viewport_height(terminal)?; + let (prefix, suffix) = split_lines_for_viewport(lines, viewport_height); + insert_lines_before_viewport(terminal, prefix)?; + draw_lines_in_viewport(terminal, suffix) +} + +fn spacer() -> Span<'static> { + Span::raw(" ") +} + +/// Split a list of lines into a prefix (above viewport) and a suffix that +/// fits within the viewport height. +fn split_lines_for_viewport( + mut lines: Vec>, + viewport_height: u16, +) -> (Vec>, Vec>) { + let suffix_len = usize::from(viewport_height).min(lines.len()); + let split_at = lines.len() - suffix_len; + let suffix = lines.split_off(split_at); + (lines, suffix) +} + +/// Transition `New` or `InProgress` into `InProgress(None)`. +fn start_item(state: display::ItemState) -> display::ItemState { + use display::ItemState::{Cancelled, Done, Failed, InProgress, New}; + + match state { + | Cancelled | Done(_) | Failed => state, + | InProgress(outcome) => InProgress(outcome), + | New => InProgress(None), + } +} + +// ============================================================ +// Impls +// ============================================================ + +impl Addr for DisplayAddr { + type Message = Message; + + /// Return the actor name for tracing. + fn name() -> &'static str { + "DisplayAddr" + } + + /// Return a reference to the underlying sender. + fn sender(&self) -> &mpsc::Sender { + &self.tx + } } impl DisplayActor { - fn finish(&mut self, id: u64, f: F) - where - F: FnOnce(&ProgressBar), + /// Aggregate the outcome of finished grammar children for a repo. + /// Returns `Some(Built)` if any child built, `Some(Cached)` if all are + /// cached, or `None` if no children finished. + fn aggregate_child_done_outcome( + &self, + repo_id: display::ItemId, + ) -> Option { + use display::{ + ItemState::{Cancelled, Done, Failed, InProgress, New}, + SuccessOutcome::{Built, Cached}, + }; + + let mut saw_cached = false; + + for grammar in self + .state + .grammars + .values() + .filter(|g| g.repo_id == Some(repo_id)) { - self.forward(id, f); - self.handles.remove(&id); + match grammar.state { + | Done(Built) => { + return Some(Built); + } + | Done(Cached) => saw_cached = true, + | New | InProgress(_) | Cancelled | Failed => {} + } } - fn forward(&self, id: u64, f: F) - where - F: FnOnce(&ProgressBar), + saw_cached.then_some(Cached) + } + + /// Aggregate the live outcome of still-active grammar children for a repo. + fn aggregate_child_live_outcome( + &self, + repo_id: display::ItemId, + ) -> Option { + let mut saw_cached = false; + let mut saw_unknown = false; + + for grammar in self + .state + .grammars + .values() + .filter(|g| g.repo_id == Some(repo_id)) { - if let Some(h) = self.handles.get(&id) { - f(h); + use display::{ + ItemState::{Cancelled, Done, Failed, InProgress, New}, + SuccessOutcome::{Built, Cached}, + }; + + match grammar.state { + | Cancelled | Failed => {} + | Done(Built) | InProgress(Some(Built)) => { + return Some(Built); } + | Done(Cached) | InProgress(Some(Cached)) => saw_cached = true, + | InProgress(None) | New => saw_unknown = true, + } + } + + if saw_unknown { + None + } else if saw_cached { + Some(display::SuccessOutcome::Cached) + } else { + None } + } - async fn run(mut self) { - while let Some(msg) = self.rx.recv().await { - match msg { - DisplayMessage::RegisterLanguage { - git_ref, - ref name, - num_tasks, - tx, - } => { - let res = self.register({ - let name = name.clone(); - move |p| p.register(name, git_ref, num_tasks) - }); - - Response { - tx, - kind: DisplayResponseKind::RegisterLanguage { name }, - } - .send(res); - } - - DisplayMessage::Println { msg } => { - self.progress.prinltn(msg); - } - - DisplayMessage::RegisterGrammar { - git_ref, - ref language, - ref name, - num_tasks, - tx, - } => { - let res = self.register(|p| { - let language = language.clone(); - let name = name.clone(); - p.register(format!("{language}/{name}").into(), git_ref, num_tasks) - }); - Response { - tx, - kind: DisplayResponseKind::RegisterGrammar { language, name }, - } - .send(res); - } - - DisplayMessage::UnregisterLanguage { name } => { - self.handles.retain(|_, h| name != h.name); - } - - DisplayMessage::Update { id, kind, ref msg } => match kind { - UpdateKind::Msg => self.forward(id, |h| h.msg(msg)), - UpdateKind::Step => self.forward(id, |h| h.step(msg)), - UpdateKind::Fin => self.finish(id, |h| h.fin(msg)), - UpdateKind::Err => self.finish(id, |h| h.err(msg)), - }, - - DisplayMessage::Tick => { - self.progress.tick(); - } - } + /// Apply a state update to a grammar entry. + fn apply_grammar_update(grammar: &mut display::GrammarEntry, kind: UpdateKind, msg: Arc) { + if !grammar.state.is_live() { + return; + } + + match kind { + | UpdateKind::Cached => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Done(display::SuccessOutcome::Cached); + grammar.step = grammar.total; + grammar.msg = msg; + } + + | UpdateKind::Cancel => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Cancelled; + grammar.step = grammar.total; + grammar.msg = msg; + } + + | UpdateKind::Err => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Failed; + grammar.msg = msg; + } + + | UpdateKind::Fin => { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + match finish_item(grammar.state) { + | Ok(state) => { + grammar.state = state; + grammar.msg = msg; + } + | Err(message) => { + grammar.state = display::ItemState::Failed; + grammar.msg = message.into(); + } } + grammar.step = grammar.total; + } + + | UpdateKind::Msg => { + grammar.msg = msg; + } + + | UpdateKind::SetOutcomeCached => { + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Cached); + } + + | UpdateKind::SetOutcomeBuilt => { + grammar.state = mark_success(grammar.state, display::SuccessOutcome::Built); + } + + | UpdateKind::Step => { + grammar.state = start_item(grammar.state); + grammar.step += 1; + grammar.msg = msg; + } } + } - fn register(&mut self, create: F) -> ProgressAddr - where - F: FnOnce(&mut Progress) -> ProgressBar, - { - // 1. Create inner handle - let inner = create(&mut self.progress); - - // 2. Register in actor state - let id = self.next_id; - self.next_id += 1; - self.handles.insert(id, inner); - - // 3. Return client handle - ProgressAddr { - id, - tx: self.tx.clone(), + /// Apply a state update to a repo entry. + fn apply_repo_update(repo: &mut display::RepoEntry, kind: UpdateKind, msg: Arc) { + use display::ItemState::{Cancelled, Done, Failed}; + use display::SuccessOutcome::{Built, Cached}; + + if !repo.state.is_live() { + return; + } + + match kind { + | UpdateKind::Cached => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = Done(Cached); + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = msg; + } + | UpdateKind::Cancel => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = Cancelled; + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = msg; + } + + | UpdateKind::Err => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = Failed; + repo.msg = msg; + } + + | UpdateKind::Fin => { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + match finish_item(repo.state) { + | Ok(state) => { + repo.state = state; + repo.msg = msg; + } + | Err(message) => { + repo.state = Failed; + repo.msg = message.into(); + } + } + if repo.total > 0 { + repo.step = repo.total; + } + } + + | UpdateKind::Msg => { + repo.msg = msg; + } + + | UpdateKind::SetOutcomeCached => { + repo.state = mark_success(repo.state, Cached); + } + + | UpdateKind::SetOutcomeBuilt => { + repo.state = mark_success(repo.state, Built); + } + + | UpdateKind::Step => { + repo.state = start_item(repo.state); + repo.step += 1; + repo.msg = msg; + } + } + } + + /// Dispatch an update to the appropriate entry (repo or grammar) and sync + /// the parent repo if needed. + fn apply_update(&mut self, id: display::ItemId, kind: UpdateKind, msg: Arc) { + if let Some(repo) = self.state.repos.get_mut(&id) { + Self::apply_repo_update(repo, kind, msg); + self.grid.mark_dirty(id); + return; + } + + let mut maybe_parent_id: Option = None; + if let Some(grammar) = self.state.grammars.get_mut(&id) { + use UpdateKind::{Cached, Cancel, Err, Fin, SetOutcomeBuilt, SetOutcomeCached, Step}; + + Self::apply_grammar_update(grammar, kind, msg); + self.grid.mark_dirty(id); + + if matches!( + kind, + Cached | Cancel | Err | Fin | SetOutcomeBuilt | SetOutcomeCached | Step + ) { + maybe_parent_id = grammar.repo_id; + } + } + + if let Some(repo_id) = maybe_parent_id { + self.sync_parent_repo(repo_id); + } + } + + /// Cancel all live rows (used on interrupt). + fn cancel_live_rows(&mut self) { + let mut parent_ids = Vec::new(); + + for (id, grammar) in &mut self.state.grammars { + if grammar.state.is_live() { + grammar.frozen_elapsed = Some(grammar.started_at.elapsed()); + grammar.state = display::ItemState::Cancelled; + grammar.step = grammar.total; + grammar.msg = Arc::from("cancelled"); + if let Some(repo_id) = grammar.repo_id { + parent_ids.push(repo_id); } + self.grid.mark_dirty(*id); + } + } + + parent_ids.sort_unstable(); + parent_ids.dedup(); + for repo_id in parent_ids { + self.sync_parent_repo(repo_id); } - #[must_use] - pub fn spawn(progress: Progress) -> DisplayAddr { - let (tx, rx) = mpsc::channel(64); - let actor = Self { - handles: HashMap::new(), - next_id: 1, - progress, - rx, - tx: tx.clone(), - }; - tokio::spawn(actor.run()); - DisplayAddr::new(tx) + for (id, repo) in &mut self.state.repos { + if repo.state.is_live() { + repo.frozen_elapsed = Some(repo.started_at.elapsed()); + repo.state = display::ItemState::Cancelled; + if repo.total > 0 { + repo.step = repo.total; + } + repo.msg = Arc::from("cancelled"); + self.grid.mark_dirty(*id); + } } + } + + /// Render the final fancy report and clean up the terminal. + fn finish_fancy>( + &mut self, + terminal: &mut ratatui::Terminal, + interrupted: bool, + tx: oneshot::Sender<()>, + ) { + if interrupted { + self.cancel_live_rows(); + } + let term_width = terminal.size().map_or(80, |s| s.width); + let mut final_lines = self.materialize(term_width); + final_lines.push(Line::from("")); + + match render_final_report(terminal, final_lines) { + | Err(err) => { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display final report failed: {err}"); + } + | Ok(()) => { + ratatui::restore(); + if let Err(err) = clear_from_cursor_down() { + println!(); + eprintln!("tsdl: fancy display cleanup failed: {err}"); + } + } + } + + let _ = tx.send(()); + } + + /// Process a single message from the channel. + fn handle_message(&mut self, msg: Message) { + use Message::{RegisterGrammar, RegisterLanguage, RegisterReference, Shutdown, Update}; + + match msg { + | RegisterLanguage { + git_ref, + name, + num_tasks, + tx, + } => { + let addr = self.register_repo(name, git_ref, num_tasks); + let _ = tx.send(addr); + } + + | RegisterGrammar { + git_ref, + language, + name, + num_tasks, + tx, + } => { + let addr = self.register_grammar(language, name, git_ref, num_tasks); + let _ = tx.send(addr); + } + + | RegisterReference { .. } => {} + + | Shutdown { interrupted, tx } => { + if interrupted { + self.cancel_live_rows(); + } + let _ = tx.send(()); + } + + | Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + } + } + } + + /// Assemble `Vec` from the grid cache. Rebuilds row order and layout + /// only when items have been added. Otherwise reuses the cached row order + /// and recomputes only stale cells (dirty items + running clocks). + fn materialize(&mut self, term_width: u16) -> Vec> { + let term_w = term_width as usize; + + if self.last_term_width != Some(term_w) { + self.grid.invalidate_column(display::Column::Msg); + self.last_term_width = Some(term_w); + } + + // Rebuild row order if items were added + if self.rows_dirty { + self.row_specs = self.state.compute_row_order(); + let new_layout = self.state.compute_layout(&self.row_specs); + + // Invalidate columns whose width changed + if new_layout.ref_ != self.grid.layout.ref_ { + self.grid.invalidate_column(display::Column::Ref); + } + if new_layout.step != self.grid.layout.step { + self.grid.invalidate_column(display::Column::Step); + } + if new_layout.name != self.grid.layout.name { + self.grid.invalidate_column(display::Column::Name); + } + if new_layout.fixed_width != self.grid.layout.fixed_width { + self.grid.invalidate_column(display::Column::Msg); + } + self.grid.layout = new_layout; + self.rows_dirty = false; + } + + // Assemble rows from the grid cache + let layout = self.grid.layout.clone(); + let mut lines: Vec> = Vec::with_capacity(self.row_specs.len()); + + for spec in &self.row_specs { + let info = self.state.get_item_info(spec); + let item_id = spec.id; + let is_dirty = self.grid.dirty_items.contains(&item_id); + + // TIME: stale when dirty OR clock is still running + let time_stale = is_dirty || info.state().is_live(); + let time = self.grid.cell( + item_id, + display::Column::Time, + || display::compute_time_cell(&info, &layout), + time_stale, + ); + + let gref = self.grid.cell( + item_id, + display::Column::Ref, + || display::compute_ref_cell(&info, &layout), + is_dirty, + ); + + let step = self.grid.cell( + item_id, + display::Column::Step, + || display::compute_step_cell(&info, &layout), + is_dirty, + ); + + let icon = self.grid.cell( + item_id, + display::Column::Icon, + || display::compute_icon_cell(&info), + is_dirty, + ); + + let name = self.grid.cell( + item_id, + display::Column::Name, + || display::compute_name_cell(&spec.display_name, spec.indent, &info, &layout), + is_dirty, + ); + + let msg = self.grid.cell( + item_id, + display::Column::Msg, + || display::compute_msg_cell(&info, &layout, term_w), + is_dirty, + ); + + lines.push(Line::from(vec![ + time, + spacer(), + gref, + spacer(), + step, + spacer(), + icon, + spacer(), + name, + spacer(), + msg, + ])); + } + + // Footer + if !lines.is_empty() { + lines.push(Line::from("")); + } + lines.push(self.state.footer_build_line()); + lines.push(self.state.footer_out_line()); + lines.push(Line::from("")); + lines.push(self.state.format_footer_counts()); + + self.grid.clear_dirty(); + + lines + } + + /// Build a plain-text progress line for an item (repo or grammar). + fn plain_progress_line(&self, id: display::ItemId, kind: UpdateKind) -> Option { + if let Some(repo) = self.state.repos.get(&id) { + return Some(PlainLine { + name: repo.name.to_string(), + step: repo.step, + total: repo.total, + message: plain_repo_message(kind, repo.state, &repo.msg), + }); + } + + self.state.grammars.get(&id).map(|grammar| PlainLine { + name: format!("{}/{}", grammar.repo, grammar.name), + step: grammar.step, + total: grammar.total, + message: plain_grammar_message(kind, grammar.state, &grammar.msg), + }) + } + + /// Print the build and out directory metadata lines (plain mode). + fn print_plain_metadata(&self) { + println!("build: {}", self.state.build_dir.display()); + println!("out: {}", self.state.out_dir.display()); + println!(); + } + + /// Print a plain progress line with name, step/total, and message. + fn print_plain_progress(&mut self, line: &PlainLine) { + if !self.plain_progress_started { + println!(); + self.plain_progress_started = true; + } + + let width = self.update_plain_name_width(&line.name); + println!( + "{name:, + name: Arc, + git_ref: git::Ref, + num_tasks: usize, + ) -> ProgressAddr { + let id = self.next_id; + self.next_id = self.next_id.next_after(); + + let repo_id = self + .state + .repos + .iter() + .find(|(_, r)| r.name == language) + .map(|(id, _)| *id); + + self.state.grammars.insert( + id, + display::GrammarEntry { + frozen_elapsed: None, + git_ref, + msg: Arc::from(""), + name, + repo: language, + repo_id, + started_at: Instant::now(), + state: display::ItemState::New, + step: 0, + total: num_tasks, + }, + ); + + self.rows_dirty = true; + if let Some(repo_id) = repo_id { + self.sync_parent_repo(repo_id); + } + + ProgressAddr { + id, + tx: self.tx.clone(), + } + } + + /// Register a new repo progress row and return its address. + fn register_repo(&mut self, name: Arc, git_ref: git::Ref, num_tasks: usize) -> ProgressAddr { + let id = self.next_id; + self.next_id = self.next_id.next_after(); + + self.state.repos.insert( + id, + display::RepoEntry { + frozen_elapsed: None, + git_ref, + msg: Arc::from(""), + name, + started_at: Instant::now(), + state: display::ItemState::New, + step: 0, + total: num_tasks, + }, + ); + + self.rows_dirty = true; + + ProgressAddr { + id, + tx: self.tx.clone(), + } + } + + /// Run the actor event loop, dispatching to fancy or plain mode. + async fn run(mut self) { + if self.state.mode == display::Mode::Fancy { + self.run_fancy().await; + } else { + self.run_plain().await; + } + } + + // ── Fancy mode ──────────────────────────────────────────────────── + + /// Run the fancy (ratatui) render loop. + async fn run_fancy(&mut self) { + let viewport_height = crossterm::terminal::size().map_or(40, |(_, h)| h).min(40); + + let mut terminal = match ratatui::Terminal::with_options( + ratatui::backend::CrosstermBackend::new(io::stdout()), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Inline(viewport_height), + }, + ) { + | Ok(terminal) => terminal, + | Err(err) => { + eprintln!("tsdl: fancy display unavailable; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } + }; + + // One-time bootstrap render so the user sees state immediately. + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } + + let mut tick_interval = time::interval(Duration::from_millis(100)); + + loop { + // ── wait for the next event ───────────────────────────── + let mut shutdown = None; + tokio::select! { + msg = self.rx.recv() => { + match msg { + Some(Message::Shutdown { interrupted, tx }) => { + shutdown = Some((interrupted, tx)); + } + Some(other) => self.handle_message(other), + None => break, + } + } + _ = tick_interval.tick() => { + // fall through to drain + render + } + } + + // ── drain any messages that queued up concurrently ───── + while let Ok(msg) = self.rx.try_recv() { + match msg { + | Message::Shutdown { interrupted, tx } => { + shutdown = Some((interrupted, tx)); + break; + } + | other => self.handle_message(other), + } + } + + // ── shutdown after draining ──────────────────────────── + if let Some((interrupted, tx)) = shutdown { + self.finish_fancy(&mut terminal, interrupted, tx); + return; + } + + // ── render once per event cycle ──────────────────────── + let lines = self.materialize(terminal.size().map_or(80, |s| s.width)); + if let Err(err) = draw_lines(&mut terminal, lines) { + ratatui::restore(); + println!(); + eprintln!("tsdl: fancy display failed; falling back to plain progress: {err}"); + self.run_plain().await; + return; + } + } + + // #[allow(unreachable_code)] + // ratatui::restore(); + // println!(); + } + + // ── Plain mode ──────────────────────────────────────────────────── + + /// Run the plain-text progress loop. + async fn run_plain(&mut self) { + self.print_plain_metadata(); + + while let Some(msg) = self.rx.recv().await { + use Message::{RegisterGrammar, RegisterLanguage, RegisterReference, Shutdown, Update}; + use UpdateKind::{Msg, SetOutcomeBuilt, SetOutcomeCached}; + + match msg { + | RegisterLanguage { + git_ref, + name, + num_tasks, + tx, + } => { + let addr = self.register_repo(name, git_ref, num_tasks); + let _ = tx.send(addr); + } + | RegisterGrammar { + git_ref, + language, + name, + num_tasks, + tx, + } => { + let plain_name = format!("{language}/{name}"); + self.update_plain_name_width(&plain_name); + let addr = self.register_grammar(language, name, git_ref, num_tasks); + let _ = tx.send(addr); + } + | RegisterReference { git_ref, name } => { + self.print_plain_ref(&name, git_ref.short()); + } + | Update { id, kind, msg } => { + self.apply_update(id, kind, msg); + if matches!(kind, Msg | SetOutcomeCached | SetOutcomeBuilt) { + continue; + } + if let Some(line) = self.plain_progress_line(id, kind) { + self.print_plain_progress(&line); + } + } + | Shutdown { interrupted, tx } => { + if interrupted { + self.cancel_live_rows(); + } + self.print_plain_summary(); + let _ = tx.send(()); + break; + } + } + } + } + + /// Spawn the display actor in a background task and return an address handle. + #[must_use] + pub fn spawn(build_dir: PathBuf, mode: display::Mode, out_dir: PathBuf) -> DisplayAddr { + let (tx, rx) = mpsc::channel(256); + let actor = Self { + state: display::State::new(build_dir, mode, out_dir), + next_id: display::ItemId::new(NonZeroU64::MIN), + plain_name_width: 16, + plain_progress_started: false, + grid: display::GridCache::new(), + row_specs: Vec::new(), + rows_dirty: true, + last_term_width: None, + rx, + tx: tx.clone(), + }; + + tokio::spawn(async move { + actor.run().await; + }); + + DisplayAddr::new(mode, tx) + } + + /// Sync a parent repo's state based on its children's aggregated outcomes. + fn sync_parent_repo(&mut self, repo_id: display::ItemId) { + let has_any = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id)); + if !has_any { + return; + } + + let any_failed = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id) && g.state == display::ItemState::Failed); + let any_cancelled = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id) && g.state == display::ItemState::Cancelled); + let any_active = self + .state + .grammars + .values() + .any(|g| g.repo_id == Some(repo_id) && g.state.is_live()); + + let live_outcome = self.aggregate_child_live_outcome(repo_id); + let done_outcome = self.aggregate_child_done_outcome(repo_id); + + if let Some(repo) = self.state.repos.get_mut(&repo_id) { + if any_active { + repo.state = display::ItemState::InProgress(live_outcome); + repo.msg = Arc::from("building"); + repo.frozen_elapsed = None; + } else if any_failed { + repo.state = display::ItemState::Failed; + repo.msg = Arc::from("failed"); + repo + .frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); + } else if any_cancelled { + repo.state = display::ItemState::Cancelled; + repo.msg = Arc::from("cancelled"); + repo + .frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); + } else { + repo.state = + display::ItemState::Done(done_outcome.unwrap_or(display::SuccessOutcome::Built)); + repo.msg = Arc::from("done"); + repo + .frozen_elapsed + .get_or_insert_with(|| repo.started_at.elapsed()); + } + } + + self.grid.mark_dirty(repo_id); + } + + /// Update the max plain name width and return the current value. + fn update_plain_name_width(&mut self, name: &str) -> usize { + self.plain_name_width = self.plain_name_width.max(name.chars().count()); + self.plain_name_width + } +} + +impl DisplayAddr { + /// Register a repo-level progress row. + pub async fn add_language>>( + &self, + name: S, + git_ref: git::Ref, + num_tasks: usize, + ) -> ProgressAddr { + self + .request(|tx| Message::RegisterLanguage { + git_ref, + name: name.into(), + num_tasks, + tx, + }) + .await + } + + /// Register a grammar-level progress row. + pub async fn add_grammar>>( + &self, + language: S, + name: S, + git_ref: git::Ref, + num_tasks: usize, + ) -> ProgressAddr { + self + .request(|tx| Message::RegisterGrammar { + git_ref, + language: language.into(), + name: name.into(), + num_tasks, + tx, + }) + .await + } + + /// Create a new `DisplayAddr` wrapping a channel sender. + #[must_use] + pub fn new(mode: display::Mode, tx: mpsc::Sender) -> Self { + Self { mode, tx } + } + + /// Send a reference line (plain mode; ignored in fancy mode). + pub async fn reference>>(&self, name: S, git_ref: git::Ref) { + self + .fire(Message::RegisterReference { + git_ref, + name: name.into(), + }) + .await; + } + + /// Shut down the display actor, waiting for cleanup to complete. + pub async fn shutdown(&self, interrupted: bool) { + self + .request(|tx| Message::Shutdown { interrupted, tx }) + .await; + } +} + +impl ProgressAddr { + /// Send a message update (fire-and-forget via `try_send`). + pub fn msg>>(&self, msg: S) { + let _ = self.tx.try_send(Message::Update { + id: self.id, + kind: UpdateKind::Msg, + msg: msg.into(), + }); + } + + /// Advance to the next step (fire-and-forget via `try_send`). + pub fn step>>(&self, msg: S) { + let _ = self.tx.try_send(Message::Update { + id: self.id, + kind: UpdateKind::Step, + msg: msg.into(), + }); + } + + /// Send a state update message, awaiting capacity. + async fn send_state_update(&self, kind: UpdateKind, msg: Arc) { + let _ = self + .tx + .send(Message::Update { + id: self.id, + kind, + msg, + }) + .await; + } + + /// Mark the outcome as cached (async). + pub async fn set_outcome_cached(&self) { + self + .send_state_update(UpdateKind::SetOutcomeCached, Arc::from("")) + .await; + } + + /// Cancel the progress row. + pub async fn cancel(&self) { + self + .send_state_update(UpdateKind::Cancel, Arc::from("cancelled")) + .await; + } + + /// Mark the outcome as built (async). + pub async fn set_outcome_built(&self) { + self + .send_state_update(UpdateKind::SetOutcomeBuilt, Arc::from("")) + .await; + } + + /// Complete as cached (async). + pub async fn cached>>(&self, msg: S) { + self.send_state_update(UpdateKind::Cached, msg.into()).await; + } + + /// Complete as built (async). + pub async fn fin>>(&self, msg: S) { + self.send_state_update(UpdateKind::Fin, msg.into()).await; + } + + /// Complete as failed (async). + pub async fn err>>(&self, msg: S) { + self.send_state_update(UpdateKind::Err, msg.into()).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn actor() -> DisplayActor { + let (tx, rx) = mpsc::channel(1); + DisplayActor { + state: display::State::new( + PathBuf::from("build"), + display::Mode::Fancy, + PathBuf::from("out"), + ), + next_id: display::ItemId::new(NonZeroU64::MIN), + plain_name_width: 16, + plain_progress_started: false, + grid: display::GridCache::new(), + row_specs: Vec::new(), + rows_dirty: true, + last_term_width: None, + rx, + tx, + } + } + + fn line_text(line: &Line<'_>) -> String { + line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn split_lines_for_viewport_keeps_the_visible_suffix() { + let lines = vec![ + Line::from("one"), + Line::from("two"), + Line::from("three"), + Line::from("four"), + ]; + + let (prefix, suffix) = split_lines_for_viewport(lines, 2); + + assert_eq!(line_text(&prefix[0]), "one"); + assert_eq!(line_text(&prefix[1]), "two"); + assert_eq!(line_text(&suffix[0]), "three"); + assert_eq!(line_text(&suffix[1]), "four"); + } + + #[test] + fn render_final_report_inserts_prefix_and_replaces_viewport() { + use ratatui::backend::TestBackend; + use ratatui::style::{Color, Style}; + use ratatui::{Terminal, TerminalOptions, Viewport}; + + let backend = TestBackend::new(20, 5); + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(2), + }, + ) + .unwrap(); + + draw_lines( + &mut terminal, + vec![Line::from("old viewport 1"), Line::from("old viewport 2")], + ) + .unwrap(); + + render_final_report( + &mut terminal, + vec![ + Line::from("final 1"), + Line::from("final 2"), + Line::from("final 3"), + Line::from(Span::styled("final 4", Style::default().fg(Color::Blue))), + Line::from(""), + ], + ) + .unwrap(); + + terminal.backend().assert_buffer_lines(vec![ + Line::from("final 1 "), + Line::from("final 2 "), + Line::from("final 3 "), + Line::from(vec![ + Span::styled("final 4", Style::default().fg(Color::Blue)), + Span::raw(" "), + ]), + Line::from(" "), + ]); + terminal.backend().assert_scrollback_empty(); + terminal.backend_mut().assert_cursor_position((0, 4)); + } + + #[test] + fn summary_counts_include_repo_only_rows() { + let mut actor = actor(); + + let cached = actor.register_repo("tree-sitter-cli".into(), git::Ref::head(), 2); + actor.apply_update(cached.id, UpdateKind::SetOutcomeCached, Arc::from("")); + actor.apply_update(cached.id, UpdateKind::Fin, Arc::from("done")); + + let built = actor.register_repo("standalone".into(), git::Ref::head(), 1); + actor.apply_update(built.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); + actor.apply_update(built.id, UpdateKind::Fin, Arc::from("done")); + + let active = actor.register_repo("active".into(), git::Ref::head(), 1); + actor.apply_update(active.id, UpdateKind::Step, Arc::from("working")); + + let failed = actor.register_repo("failed".into(), git::Ref::head(), 1); + actor.apply_update(failed.id, UpdateKind::Err, Arc::from("failed")); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 1, + built: 1, + cached: 1, + cancelled: 0, + failed: 1, + } + ); + } + + #[test] + fn summary_counts_do_not_double_count_parent_repos_with_grammars() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); + actor.apply_update(repo.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); + actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); + + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); + actor.apply_update(grammar.id, UpdateKind::SetOutcomeCached, Arc::from("")); + actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 1, + cancelled: 0, + failed: 0, + } + ); + } + + #[test] + fn cancelled_grammar_is_terminal_and_updates_parent_repo() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); + + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(grammar_entry.state, display::ItemState::Cancelled); + assert!(!grammar_entry.state.is_live()); + assert_eq!(grammar_entry.step, grammar_entry.total); + assert!(grammar_entry.frozen_elapsed.is_some()); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); + assert!(!repo_entry.state.is_live()); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + assert!(repo_entry.frozen_elapsed.is_some()); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 0, + } + ); + } + + #[test] + fn cancelled_child_does_not_cancel_parent_while_sibling_is_active() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let cancelled = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); + let active = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); + + actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(active.id, UpdateKind::Step, Arc::from("building")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::InProgress(None)); + assert_eq!(repo_entry.msg.as_ref(), "building"); + assert!(repo_entry.frozen_elapsed.is_none()); + + actor.apply_update(active.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + assert!(repo_entry.frozen_elapsed.is_some()); + } + + #[test] + fn failed_child_wins_over_cancelled_child_when_parent_terminal() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let failing = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); + let cancelled = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); + + actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); + actor.apply_update(cancelled.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(cancelled.id, UpdateKind::Cancel, Arc::from("cancelled")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Failed); + assert_eq!(repo_entry.msg.as_ref(), "failed"); + assert!(repo_entry.frozen_elapsed.is_some()); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 1, + } + ); + } + + #[test] + #[should_panic(expected = "finish update received before cached/built path was set")] + fn finish_without_cached_or_built_path_panics() { + let mut actor = actor(); + let repo = actor.register_repo("json".into(), git::Ref::head(), 2); + + actor.apply_update(repo.id, UpdateKind::Step, Arc::from("scanning")); + actor.apply_update(repo.id, UpdateKind::Fin, Arc::from("done")); + } + + #[test] + fn interrupted_shutdown_cancels_live_rows() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let grammar = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + + actor.cancel_live_rows(); + + let grammar_entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(grammar_entry.state, display::ItemState::Cancelled); + assert_eq!(grammar_entry.step, grammar_entry.total); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Cancelled); + assert_eq!(repo_entry.msg.as_ref(), "cancelled"); + + assert_eq!( + actor.state.summary(), + display::BuildSummary { + building: 0, + built: 0, + cached: 0, + cancelled: 1, + failed: 0, + } + ); + } + + #[test] + fn terminal_direct_updates_are_absorbing() { + let mut actor = actor(); + + let repo = actor.register_repo("json".into(), git::Ref::head(), 4); + actor.apply_update(repo.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(repo.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(repo.id, UpdateKind::Cached, Arc::from("done")); + actor.apply_update(repo.id, UpdateKind::Err, Arc::from("failed")); + + let entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(entry.state, display::ItemState::Cancelled); + assert_eq!(entry.msg.as_ref(), "cancelled"); + + let grammar = actor.register_grammar("json".into(), "json".into(), git::Ref::head(), 4); + actor.apply_update(grammar.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(grammar.id, UpdateKind::Cancel, Arc::from("cancelled")); + actor.apply_update(grammar.id, UpdateKind::Cached, Arc::from("done")); + actor.apply_update(grammar.id, UpdateKind::Err, Arc::from("failed")); + + let entry = actor.state.grammars.get(&grammar.id).unwrap(); + assert_eq!(entry.state, display::ItemState::Cancelled); + assert_eq!(entry.msg.as_ref(), "cancelled"); + } + + #[test] + fn parent_repo_stays_active_while_any_child_is_active() { + let mut actor = actor(); + + let repo = actor.register_repo("typescript".into(), git::Ref::head(), 2); + let failing = actor.register_grammar( + "typescript".into(), + "typescript".into(), + git::Ref::head(), + 4, + ); + let pending = actor.register_grammar("typescript".into(), "tsx".into(), git::Ref::head(), 4); + + actor.apply_update(failing.id, UpdateKind::Step, Arc::from("building")); + actor.apply_update(failing.id, UpdateKind::Err, Arc::from("build failed")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::InProgress(None)); + assert_eq!(repo_entry.msg.as_ref(), "building"); + assert!(repo_entry.frozen_elapsed.is_none()); + + actor.apply_update(pending.id, UpdateKind::SetOutcomeBuilt, Arc::from("")); + actor.apply_update(pending.id, UpdateKind::Fin, Arc::from("done")); + + let repo_entry = actor.state.repos.get(&repo.id).unwrap(); + assert_eq!(repo_entry.state, display::ItemState::Failed); + assert_eq!(repo_entry.msg.as_ref(), "failed"); + assert!(repo_entry.frozen_elapsed.is_some()); + } + + #[test] + fn materialize_recomputes_message_cells_when_terminal_width_changes() { + let mut actor = actor(); + let progress = actor.register_repo("json".into(), git::Ref::head(), 1); + let message = "abcdefghijklmnop"; + + actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); + + let narrow_lines = actor.materialize(30); + let narrow_row = line_text(&narrow_lines[0]); + assert!(narrow_row.contains("abcd…")); + assert!(!narrow_row.contains(message)); + + let wide_lines = actor.materialize(80); + let wide_row = line_text(&wide_lines[0]); + assert!(wide_row.contains(message)); + } + + #[test] + fn materialize_recomputes_message_cells_when_layout_width_changes() { + let mut actor = actor(); + let progress = actor.register_repo("json".into(), git::Ref::head(), 1); + let message = "abcdefghijklmnop"; + + actor.apply_update(progress.id, UpdateKind::Msg, Arc::from(message)); + + let initial_lines = actor.materialize(42); + let initial_row = line_text(&initial_lines[0]); + assert!(initial_row.contains(message)); + + actor.register_repo("zzzzzzzzzzzzzzzzzzzz".into(), git::Ref::head(), 1); + + let updated_lines = actor.materialize(42); + let updated_row = updated_lines + .iter() + .map(line_text) + .find(|line| line.contains("json")) + .expect("json row should still be rendered"); + assert!(updated_row.contains('…')); + assert!(!updated_row.contains(message)); + } } diff --git a/src/actors/mod.rs b/src/actors/mod.rs index debb364..5325c24 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -1,92 +1,329 @@ +//! Actor system: stream-based build pipeline with `Addr` trait, cache actor, +//! and display actor. + mod cache; mod display; -use std::{path::PathBuf, sync::Arc}; +use std::{num::NonZeroUsize, path::Path}; pub use cache::{CacheActor, CacheAddr}; -pub use display::{DisplayActor, DisplayAddr, DisplayMessage, ProgressAddr}; -use futures::{stream, StreamExt}; -use tokio::sync::{mpsc, oneshot}; +pub use display::{DisplayActor, DisplayAddr, Message, ProgressAddr}; +use futures::{StreamExt, stream}; +use tokio::sync::{mpsc, oneshot, watch}; + +use tracing::{debug, info}; -use crate::{ - args::TreeSitter, - error::TsdlError, - parser::{GrammarBuild, LanguageBuild}, - tree_sitter, TsdlResult, -}; +use crate::{Error, Result, args, parser, shutdown, tree_sitter}; + +// ============================================================ +// Traits +// ============================================================ pub trait Addr { - type Message; + type Message; - fn name() -> &'static str; - fn sender(&self) -> &mpsc::Sender; + fn name() -> &'static str; + fn sender(&self) -> &mpsc::Sender; - #[allow(async_fn_in_trait)] - async fn fire(&self, msg: Self::Message) { - self.sender() - .send(msg) - .await - .unwrap_or_else(|_| panic!("{}: cannot send: channel closed", Self::name())); - } + #[allow(async_fn_in_trait)] + async fn fire(&self, msg: Self::Message) { + self + .sender() + .send(msg) + .await + .unwrap_or_else(|_| panic!("{}: cannot send: channel closed", Self::name())); + } - #[allow(async_fn_in_trait)] - async fn request(&self, msg: F) -> T - where - F: FnOnce(oneshot::Sender) -> Self::Message, - { - let (tx, rx) = oneshot::channel(); + #[allow(async_fn_in_trait)] + async fn request(&self, msg: F) -> T + where + F: FnOnce(oneshot::Sender) -> Self::Message, + { + let (tx, rx) = oneshot::channel(); - self.sender() - .send(msg(tx)) - .await - .unwrap_or_else(|_| panic!("{}: cannot send: channel closed", Self::name())); + self + .sender() + .send(msg(tx)) + .await + .unwrap_or_else(|_| panic!("{}: cannot send: channel closed", Self::name())); - rx.await - .unwrap_or_else(|_| panic!("{}: cannot recv: channel closed", Self::name())) - } + rx.await + .unwrap_or_else(|_| panic!("{}: cannot recv: channel closed", Self::name())) + } } +// ============================================================ +// Structs +// ============================================================ + +/// Wraps a [`oneshot::Sender`] with a debug-friendly kind label for logging. pub struct Response { - pub kind: K, - pub tx: oneshot::Sender, + /// Debug label identifying which variant generated this response. + pub kind: K, + /// Channel to send the response value back. + pub tx: oneshot::Sender, } -impl Response { - /// # Panics - /// - /// Will panic channel is closed. - pub fn send(self, value: T) { - self.tx - .send(value) - .unwrap_or_else(|_| panic!("cannot send response: {:?}", self.kind)); +// ============================================================ +// Free functions +// ============================================================ + +/// Clone a parser repo, scan it for grammar.js files, and produce a +/// `GrammarBuild` for each one after resolving the tree-sitter CLI. +async fn discover_grammars( + cache: CacheAddr, + display: DisplayAddr, + language: parser::LanguageBuild, + prepared_rx: &mut watch::Receiver>>, +) -> Result> { + shutdown::test_delay().await; + shutdown::check()?; + debug!("[discover] lang={}", language.name); + + let progress = display + .add_language( + language.name.as_arc(), + language.spec.git_ref.requested().clone(), + 2, + ) + .await; + + // Phase 1: resolve the parser revision (cache check + possible clone). + // This runs concurrently with tree-sitter CLI preparation — it only + // touches the cache actor and git subprocesses, neither of which depend + // on the resolved tree-sitter version. + let revision = resolve_revision(&cache, &language, &progress).await?; + + // Phase 2: scan for grammar.js files (git ls-files + hashing). + // Also concurrent with CLI preparation. + progress.step("scanning"); + let grammars = match language.discover_grammars().await { + | Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("scan failed").await; + } + return Err(e); + } + | Ok(grammars) => grammars, + }; + progress.fin("done").await; + + // Phase 3: wait for the tree-sitter CLI to be ready so we have the + // resolved tree-sitter version for cache comparisons and the binary path + // for computing artifact paths. + let prepared = wait_for_prepared(prepared_rx).await?; + let ts_cli = prepared.path; + let language = language.with_tree_sitter(prepared.tree_sitter); + + // Map the raw discovery data into Build structs, now with the resolved + // tree-sitter version available for accurate cache-key comparison. + let mut builds = Vec::new(); + for (name, hash, dir) in grammars { + shutdown::test_delay().await; + shutdown::check()?; + + let key = crate::cache::Key::new(&language.name, &name); + let artifacts = parser::GrammarBuild::required_artifacts_for( + &name, + &language.output.build_dir, + &language.spec, + &ts_cli, + )?; + let cache_decision = cache + .needs_rebuild(key, hash.clone(), revision.clone(), language.spec.clone()) + .await; + + // Cache actor checks in-memory metadata only. When it reports a hit + // we still verify that the expected output files exist on disk. + let cache_decision = if cache_decision.is_hit() { + crate::cache::verify_artifacts(artifacts).await + } else { + cache_decision + }; + + let progress = display + .add_grammar( + language.name.as_arc(), + name.as_arc(), + language.spec.git_ref.requested().clone(), + 4, + ) + .await; + + builds.push(parser::GrammarBuild { + context: language.context.clone(), + cache_decision, + dir, + hash, + language: language.name.clone(), + name, + output: language.output.clone(), + progress, + revision: revision.clone(), + spec: language.spec.clone(), + ts_cli: ts_cli.clone(), + }); + } + + Ok(builds) +} + +/// Determine the revision (stable or moving commit) for a parser and clone it if needed. +async fn resolve_revision( + cache: &CacheAddr, + language: &parser::LanguageBuild, + progress: &ProgressAddr, +) -> Result { + if language.spec.git_ref.is_moving() { + info!( + "Resolving moving parser git ref for {}: {}", + language.name, + language.spec.git_ref.requested().as_str() + ); + progress.set_outcome_built().await; + progress.step("cloning"); + match language.checkout().await { + | Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("clone failed").await; + } + Err(e) + } + | Ok(checkout) => { + info!( + "Resolved parser {} git ref {} to commit {}", + language.name, + language.spec.git_ref.requested().as_str(), + checkout.commit.as_str() + ); + Ok(crate::cache::Revision::moving(checkout.commit)) + } + } + } else { + let has_compatible_entries = cache + .has_compatible_entries(language.name.clone(), language.spec.clone()) + .await; + let checkout_needed = !has_compatible_entries || !language.is_checkout_usable().await; + + if checkout_needed { + progress.set_outcome_built().await; + progress.step("cloning"); + if let Err(e) = language.checkout().await { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("clone failed").await; + } + return Err(e); + } + } else { + progress.set_outcome_cached().await; } + + Ok(crate::cache::Revision::stable()) + } } /// The entire build pipeline. pub async fn run( - build_dir: &PathBuf, - cache: CacheAddr, - display: DisplayAddr, - jobs: usize, - languages: Vec, - tree_sitter: &TreeSitter, -) -> TsdlResult<()> { - let ts_cli = Arc::new(tree_sitter::prepare(build_dir, display.clone(), tree_sitter).await?); - - let mut errors : Vec = - // 1. Source: Create a stream from the input list + build_dir: &Path, + cache: CacheAddr, + display: DisplayAddr, + jobs: NonZeroUsize, + languages: Vec, + tree_sitter: &args::TreeSitter, +) -> Result<()> { + let tree_sitter_ref = match tree_sitter::display_tree_sitter_ref(&tree_sitter.version) { + | Err(err) => { + display.shutdown(false).await; + return Err(Error::Context { + message: format!("Parsing tree-sitter git ref {:?}", tree_sitter.version), + source: err.into(), + }); + } + | Ok(git_ref) => git_ref, + }; + + display.reference("tree-sitter-cli", tree_sitter_ref).await; + for language in &languages { + display + .reference( + language.name.as_arc(), + language.spec.git_ref.requested().clone(), + ) + .await; + } + + let result = run_inner( + build_dir, + cache, + display.clone(), + jobs, + languages, + tree_sitter, + ) + .await; + + let interrupted = shutdown::current().and_then(|s| s.reason()); + + // We need to shut down before returning the results to avoid display + // issues with the ratatui backend. + display.shutdown(interrupted.is_some()).await; + + // If shutdown was signalled, suppress build errors — they're expected + // cancellation artefacts, not real failures — but still return the + // interruption so the top-level process exits with the signal status. + if let Some(signal) = interrupted { + info!("pipeline shutdown signalled by {signal}, suppressing build errors"); + return Err(Error::Interrupted { signal }); + } + + result +} + +/// Core pipeline: prepare tree-sitter CLI concurrently, discover grammars, build +/// them, and accumulate results. +async fn run_inner( + build_dir: &Path, + cache: CacheAddr, + display: DisplayAddr, + jobs: NonZeroUsize, + languages: Vec, + tree_sitter: &args::TreeSitter, +) -> Result<()> { + // Spawn tree-sitter CLI preparation concurrently so its network I/O + // (git ls-remote) overlaps with language discovery work. + let (prepared_tx, prepared_rx) = watch::channel(None::>); + let prepare_build_dir = build_dir.to_path_buf(); + let prepare_display = display.clone(); + let prepare_tree_sitter = tree_sitter.clone(); + tokio::spawn(async move { + let result = + tree_sitter::prepare(&prepare_build_dir, prepare_display, &prepare_tree_sitter).await; + let _ = prepared_tx.send(Some(result)); + }); + + let prepared_rx = prepared_rx; + let languages_empty = languages.is_empty(); + + let mut errors: Vec = + // 1. Create a stream from the input list stream::iter(languages) // 2. Stage: Discovery // Transform Language -> Future>> .map(|language| { - let (cache, display, ts_cli) = (cache.clone(), display.clone(), ts_cli.clone()); + let (cache, display) = (cache.clone(), display.clone()); + let mut rx = prepared_rx.clone(); async move { - // We refactor `discover` to return the list instead of sending messages - discover_grammars(cache, display, language, ts_cli).await + discover_grammars(cache, display, language, &mut rx).await } }) // Run up to `concurrency` discovery tasks at once - .buffer_unordered(jobs) + .buffer_unordered(jobs.into()) // 3. Flattening & Error Propagation // Turn the stream of "Lists of Grammars" into a flat stream of "Individual Grammars" // If discovery failed, pass the error through as an Item @@ -107,87 +344,86 @@ pub async fn run( } }) // Run up to `concurrency` build tasks at once - .buffer_unordered(jobs) + .buffer_unordered(jobs.into()) // 5. Sink: Accumulator // Fold the stream into the final error vector. // This implicitly waits for ALL tasks to finish. .fold(Vec::new(), |mut errors, result| { let cache = cache.clone(); async move { + let is_shutdown = shutdown::current().is_some_and(|s| s.is_cancelled()); match result { - Ok(Some(update)) => cache.update(update).await, // Side-effect: Cache Update - Ok(None) => {} // Cache hit - Err(e) => errors.push(e), // Accumulate Error + Ok(Some(update)) => cache.update(update).await, + Ok(None) => {} + Err(e) if is_shutdown => { + debug!("[pipeline:fold] shutdown, dropping error: {e}"); + } + Err(e) => errors.push(e), } errors } }) .await; - if let Err(e) = cache.save().await { - errors.push(e); - } + // Drain the CLI preparation result so we never exit before the binary is + // downloaded. + let mut prepared_rx = prepared_rx; + if let Err(e) = wait_for_prepared(&mut prepared_rx).await + && languages_empty + { + errors.push(e); + // else: the pipeline already consumed `prepared_rx` via each + // `discover_grammars` call; a second read finding an error is a + // redundant delivery — suppress it to avoid double-counting. + } - if errors.is_empty() { - Ok(()) - } else { - Err(TsdlError::Build(errors)) - } -} + if let Err(e) = cache.save().await { + errors.push(e); + } -// --- Helper Refactors (Moving logic out of Actor impls) --- + if errors.is_empty() { + Ok(()) + } else { + Err(Error::Build { errors }) + } +} -async fn discover_grammars( - cache: CacheAddr, - display: DisplayAddr, - language: LanguageBuild, - ts_cli: Arc, -) -> TsdlResult> { - let progress = display - .add_language(language.spec.git_ref.clone(), language.name.clone(), 3) - .await; - - // ... (Clone logic same as original) ... - if cache - .needs_clone(language.name.clone(), language.spec.clone()) - .await - { - progress.step("cloning"); - language.clone().await?; +/// Wait for the tree-sitter CLI preparation task to finish, returning the +/// prepared CLI or the error that preparation failed with. +async fn wait_for_prepared( + rx: &mut watch::Receiver>>, +) -> Result { + loop { + if let Some(result) = rx.borrow().as_ref() { + return match result { + | Err(e) => Err(Error::Message { + message: format!("tree-sitter CLI preparation failed: {e}"), + }), + | Ok(prepared) => Ok(prepared.clone()), + }; } - - progress.step("scanning"); - let grammars = language.discover_grammars().await?; - - // Map the raw discovery data into the Build struct immediately - let mut builds = Vec::new(); - for (name, dir, hash) in grammars { - let key = format!("{}/{}", language.name, name); - let entry = cache.get(key).await; - let name_arc: std::sync::Arc = name.into(); - - let progress = display - .add_grammar( - language.spec.git_ref.clone(), - name_arc.clone(), - language.name.clone(), - 4, - ) - .await; - - builds.push(GrammarBuild { - context: language.context.clone(), - dir: dir.into(), - entry, - hash: hash.into(), - language: language.name.clone(), - name: name_arc, - output: language.output.clone(), - progress, - spec: language.spec.clone(), - ts_cli: ts_cli.clone(), - }); + if rx.changed().await.is_err() { + return Err(Error::Message { + message: "tree-sitter CLI preparation failed unexpectedly (task panicked or was dropped)" + .into(), + }); } + } +} + +// ============================================================ +// Impls +// ============================================================ - Ok(builds) +impl Response { + /// Send a value through the response channel. + /// # Panics + /// + /// Will panic channel is closed. + pub fn send(self, value: T) { + self + .tx + .send(value) + .unwrap_or_else(|_| panic!("cannot send response: {:?}", self.kind)); + } } diff --git a/src/app.rs b/src/app.rs index 9824f9c..c45ef1b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,29 +1,161 @@ -use std::path::PathBuf; +//! Application entry point: resolves the build command, acquires locks, and +//! orchestrates the builder. +use std::path::{Path, PathBuf}; + +use clap::ArgMatches; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use crate::{args::Args, args::BuildCommand, config, display, TsdlResult}; +use crate::{Result, ResultExt, args, config, display, logging}; + +// ============================================================ +// Enums +// ============================================================ + +/// The selected command after resolving only the configuration it needs. +pub enum ResolvedCommand { + Build(ResolvedBuild), + ConfigCurrent(ResolvedBuild), + ConfigDefault, + Selfupdate { force: bool, target: String }, +} + +// ============================================================ +// Structs +// ============================================================ -/// Application containing all resolved configuration and state. +/// Resolved application state, ready to run. pub struct App { - pub command: BuildCommand, - pub config_path: PathBuf, - pub progress: display::Progress, - pub verbose: Verbosity, -} - -impl App { - /// Create application from CLI arguments. - /// This resolves and merges all configuration sources (CLI, config file, defaults). - pub fn new(args: &Args) -> TsdlResult { - let command = config::current(&args.config, args.command.as_build())?; - let progress = display::current(&args.progress, &args.verbose); - - Ok(Self { - command, - progress, - config_path: args.config.clone(), - verbose: args.verbose, - }) + pub command: ResolvedCommand, + pub config_path: PathBuf, + pub logging: logging::Session, + pub progress_mode: display::Mode, + pub verbose: Verbosity, +} + +/// Resolved build state for commands that actually need build configuration. +pub struct ResolvedBuild { + pub command: args::BuildCommand, + pub provenance: config::BuildProvenance, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Resolve the build configuration for a given purpose (e.g. "build", "config +/// current") and return both the resolved command and its provenance. +fn resolve_build( + config_path: &Path, + matches: Option<&ArgMatches>, + purpose: &str, +) -> Result { + let (command, provenance) = config::current_with_provenance(config_path, matches) + .with_context(|| format!("Resolving build configuration for {purpose}"))?; + + Ok(ResolvedBuild { + command, + provenance, + }) +} + +/// map cli args + matches into a [`resolvedcommand`] (build, config, or self-update). +fn resolve_command(args: &args::Args, matches: &ArgMatches) -> Result { + use args::{ + Command::{Build, Config, Selfupdate}, + ConfigCommand::{Current, Default}, + }; + + match &args.command { + | Build => resolve_build(&args.config, args::build_matches(matches), "`build`") + .map(ResolvedCommand::Build), + | Config { command: Current } => { + resolve_build(&args.config, None, "`config current`").map(ResolvedCommand::ConfigCurrent) } + | Config { command: Default } => Ok(ResolvedCommand::ConfigDefault), + | Selfupdate { force, target } => Ok(ResolvedCommand::Selfupdate { + force: *force, + target: target.clone(), + }), + } +} + +/// Parse CLI args, resolve the command, and initialise logging. +pub fn setup() -> Result { + let (args, matches) = config::parse_with_matches(); + let command = resolve_command(&args, &matches)?; + let logging = logging::init( + command.logging_policy(args.log.as_deref()), + args.log_color, + args.verbose, + )?; + + let progress_mode = display::mode_from_args(&args.progress, &args.verbose); + + Ok(App { + command, + config_path: args.config, + logging, + progress_mode, + verbose: args.verbose, + }) +} + +// ============================================================ +// Impls +// ============================================================ + +impl ResolvedCommand { + /// Return a logging policy based on the command type. + fn logging_policy<'a>(&'a self, explicit: Option<&'a Path>) -> logging::Policy<'a> { + use logging::{Implicit::BuildDir, Policy}; + + let implicit = match self { + | Self::Build(build) => BuildDir { + dir: &build.command.build_dir, + }, + | _ => logging::Implicit::None, + }; + + Policy { explicit, implicit } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_with_config(config: &Path, command: &[&str]) -> (args::Args, ArgMatches) { + let mut argv = vec![ + "tsdl".into(), + "--config".into(), + config.as_os_str().to_owned(), + ]; + argv.extend(command.iter().map(Into::into)); + config::try_parse_from_with_matches(argv).unwrap() + } + + #[test] + fn config_default_does_not_resolve_build_config() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("parsers.toml"); + std::fs::write(&config_path, "not valid toml =").unwrap(); + + let (args, matches) = parse_with_config(&config_path, &["config", "default"]); + let command = resolve_command(&args, &matches).unwrap(); + + assert!(matches!(command, ResolvedCommand::ConfigDefault)); + } + + #[test] + fn selfupdate_does_not_resolve_build_config() { + let temp = tempfile::tempdir().unwrap(); + let config_path = temp.path().join("parsers.toml"); + std::fs::write(&config_path, "not valid toml =").unwrap(); + + let (args, matches) = parse_with_config(&config_path, &["selfupdate"]); + let command = resolve_command(&args, &matches).unwrap(); + + assert!(matches!(command, ResolvedCommand::Selfupdate { .. })); + } } diff --git a/src/args.rs b/src/args.rs index 4c0bbac..3503192 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,349 +1,440 @@ -use std::{collections::BTreeMap, fmt, path::PathBuf}; +//! CLI argument definitions using clap. + +use std::{collections::BTreeMap, fmt, num::NonZeroUsize, path::PathBuf}; use clap::{ - builder::styling::{AnsiColor, Color, Style}, - crate_authors, + ArgMatches, + builder::styling::{AnsiColor, Color, Style}, + crate_authors, }; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use diff::Diff; use serde::{Deserialize, Serialize}; use crate::consts::{ - TREE_SITTER_PLATFORM, TREE_SITTER_REPO, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_CONFIG_FILE, - TSDL_FORCE, TSDL_FRESH, TSDL_OUT_DIR, TSDL_PREFIX, TSDL_SHOW_CONFIG, + BUILD_DIR, CONFIG_FILE, FORCE, FRESH, PARSER_OUT_DIR, PLATFORM, PREFIX, REPO, SHOW_CONFIG, + UNLOCK_TIMEOUT, VERSION, }; +// ============================================================ +// Constants +// ============================================================ + const TSDL_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/tsdl.version")); -/// Command-line arguments. -#[derive(Clone, Debug, Deserialize, clap::Parser, Serialize)] -#[command(author = crate_authors!("\n"), version = TSDL_VERSION, about, styles=get_styles(), allow_external_subcommands = true)] -#[command(help_template( - "{before-help}{name} {version} -{author-with-newline}{about-with-newline} -{usage-heading} {usage} +// ============================================================ +// Enums +// ============================================================ -{all-args}{after-help}" -))] -pub struct Args { +/// CLI commands: build parsers, manage config, or self-update. +#[derive(clap::Subcommand, Clone, Debug)] +pub enum Command { + /// Build one or many parsers. + #[command(visible_alias = "b")] + Build, + + /// Configuration helpers. + #[command(visible_alias = "c")] + Config { #[command(subcommand)] - pub command: Command, - - /// Path to the config file (TOML). - #[arg(short, long, env = "TSDL_CONFIG", default_value = TSDL_CONFIG_FILE, global = true)] - pub config: PathBuf, - - /// Path to the logging file. If unspecified, it will go to `build-dir/log`. - #[arg(short, long, env = "TSDL_LOG", global = true)] - pub log: Option, - - /// Whether to emit colored logs. - #[arg(long, value_enum, default_value_t = LogColor::Auto, global = true)] - pub log_color: LogColor, - - /// Progress style. - #[arg(long, value_enum, default_value_t = ProgressStyle::Auto, global = true)] - pub progress: ProgressStyle, - - /// Verbosity level: -v, -vv, or -q, -qq. - // clap_verbosity_flag, as of now, refuses to add a serialization feature, so this will not be part of the config file. - // It's global by default, so we don't need to specify it. - #[serde(skip_serializing, skip_deserializing)] - #[command(flatten)] - pub verbose: Verbosity, + command: ConfigCommand, + }, + + /// Update tsdl to its latest version. + #[command(visible_alias = "u")] + Selfupdate { + #[arg(long, short, default_value = "false")] + force: bool, + + #[arg(long, short, default_value = "minor")] + target: String, + }, } -#[derive(clap::ValueEnum, Clone, Debug, Deserialize, Serialize)] +/// Subcommands for the `config` command. +#[derive(clap::Subcommand, Clone, Debug, Default)] +pub enum ConfigCommand { + /// Show the currently active (merged) configuration. + #[default] + Current, + /// Show the built-in default configuration. + Default, +} + +/// Whether to emit colored stderr logs (auto, on, off). +#[derive(clap::ValueEnum, Clone, Copy, Debug, Deserialize, Serialize)] pub enum LogColor { - Auto, - No, - Yes, + /// Respect terminal capabilities. + Auto, + /// Force plain text (no ANSI escapes). + No, + /// Force colored output. + Yes, } +/// A single parser override, either a bare git ref string or a full block +/// with optional build script and custom repo URL. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(untagged)] +#[serde(rename_all = "kebab-case")] +pub enum ParserConfig { + /// Full parser configuration block. + Full { + /// Custom build script (e.g. "make" or a shell command). + #[serde(alias = "cmd", alias = "script")] + build_script: Option, + + /// Custom repository URL for this parser. + from: Option, + + /// Git ref to check out (tag, branch, or commit SHA). + #[serde(rename = "ref")] + git_ref: String, + }, + /// Short form: a bare git ref string. + Ref(String), +} + +/// Terminal progress display style: auto-detect, force fancy (ratatui), or force plain. #[derive(clap::ValueEnum, Clone, Debug, Deserialize, Serialize)] pub enum ProgressStyle { - Auto, - Fancy, - Plain, + /// Respect terminal TTY detection. + Auto, + /// Force ratatui inline rendering. + Fancy, + /// Force plain line-by-line output. + Plain, } -#[allow(clippy::large_enum_variant)] -#[derive(clap::Subcommand, Clone, Debug, Deserialize, Serialize)] -pub enum Command { - /// Build one or many parsers. - #[command(visible_alias = "b")] - Build(BuildCommand), - - /// Configuration helpers. - #[serde(skip_serializing, skip_deserializing)] - #[command(visible_alias = "c")] - Config { - #[command(subcommand)] - command: ConfigCommand, - }, - - /// Update tsdl to the latest compatible version. - #[serde(skip_serializing, skip_deserializing)] - #[command(visible_alias = "u")] - Selfupdate { - /// Skip the downgrade confirmation prompt. - #[arg(long, default_value_t = false)] - force: bool, - - /// Version selector: "patch", "minor", "major", or a semver like "2.5.0". - #[arg(default_value = "minor")] - target: String, - }, +/// Build target: native shared library, WebAssembly, or both. +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Target { + /// Build a native `.so`/`.dylib`. + #[default] + Native, + /// Build a `.wasm` binary. + Wasm, + /// Build both targets. + All, } -impl Command { - #[must_use] - pub fn as_build(&self) -> Option<&BuildCommand> { - if let Command::Build(build) = self { - Some(build) - } else { - None - } - } - - #[must_use] - pub fn as_config(&self) -> Option<&ConfigCommand> { - if let Command::Config { command } = self { - Some(command) - } else { - None - } - } +#[derive(Clone, Copy, Debug, Default)] +pub enum VersionBump { + /// Update to the latest version, including breaking changes. + Major, + /// Update to the latest minor or patch version (same MAJOR). + #[default] + Minor, + /// Update to the latest patch version (same MAJOR.MINOR). + Patch, } -#[derive(clap::ValueEnum, Clone, Copy, Debug, Deserialize, Diff, PartialEq, Eq, Serialize)] -#[diff(attr( - #[derive(Debug, PartialEq)] +// ============================================================ +// Structs +// ============================================================ + +/// Top-level CLI arguments. +#[derive(Clone, Debug, clap::Parser)] +#[command(author = crate_authors!("\n"), version = TSDL_VERSION, about, styles=get_styles(), allow_external_subcommands = true)] +#[command(help_template( + "{before-help}{name} {version} +{author-with-newline}{about-with-newline} +{usage-heading} {usage} + +{all-args}{after-help}" ))] +pub struct Args { + #[command(subcommand)] + pub command: Command, + + /// Path to the config file (TOML). + #[arg(short, long, env = "TSDL_CONFIG", default_value = CONFIG_FILE, global = true)] + pub config: PathBuf, + + /// Path to the logging file. If unspecified, it will go to `build-dir/log`. + /// If the path is inside `build-dir`, it must be directly under that directory. + #[arg(short, long, env = "TSDL_LOG", global = true)] + pub log: Option, + + /// Whether to emit colored logs. + #[arg(long, value_enum, default_value_t = LogColor::Auto, global = true)] + pub log_color: LogColor, + + /// Progress style. + #[arg(long, value_enum, default_value_t = ProgressStyle::Auto, global = true)] + pub progress: ProgressStyle, + + /// Verbosity level: -v, -vv, or -q, -qq. + #[command(flatten)] + pub verbose: Verbosity, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] -#[derive(Default)] -pub enum Target { - #[default] - Native, - Wasm, - All, +pub struct BuildCommand { + /// Checkout and build directory root. + pub build_dir: PathBuf, + /// Ignore cache, force a full rebuild. + pub force: bool, + /// Clear build directory before starting. + pub fresh: bool, + /// Specific languages (positional CLI args); `None` means "all configured". + #[serde(skip_serializing)] + pub languages: Option>, + /// Max concurrent build jobs. + pub jobs: NonZeroUsize, + /// Directory for installed parser binaries. + #[serde(rename = "out-dir", alias = "out")] + pub out_dir: PathBuf, + /// Per-parser configuration overrides from config file. + pub parsers: Option>, + /// Output filename prefix for installed binaries. + pub prefix: String, + /// Print resolved config and exit. + pub show_config: bool, + /// Build output kind (native, wasm, or all). + pub target: Target, + /// Tree-sitter CLI version, platform, and repo. + pub tree_sitter: TreeSitter, + /// Seconds to wait for a stale lock to be released. + pub unlock_timeout: u64, } -impl Target { - #[must_use] - pub fn covers(&self, other: Target) -> bool { - matches!( - (self, other), - (Target::All, _) | (Target::Native, Target::Native) | (Target::Wasm, Target::Wasm) - ) - } +/// Fully optional overrides for every build configuration field. +/// +/// Every field is `Option` so that config sources (TOML file, +/// environment variables, CLI flags) can be layered: missing keys +/// remain `None` while explicitly-set fields are `Some(...)`. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct OptionalBuildCommand { + #[serde(default)] + pub build_dir: Option, + #[serde(default)] + pub force: Option, + #[serde(default)] + pub fresh: Option, + #[serde(default, alias = "out", rename = "out-dir")] + pub out_dir: Option, + #[serde(default)] + pub jobs: Option, + #[serde(default)] + pub prefix: Option, + #[serde(default)] + pub show_config: Option, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub tree_sitter: OptionalTreeSitter, + #[serde(default)] + pub unlock_timeout: Option, + #[serde(default)] + pub parsers: Option>, + #[serde(skip_deserializing)] + pub languages: Option>, +} - #[must_use] - pub fn native(&self) -> bool { - matches!(self, Self::All | Self::Native) - } +/// Fully optional overrides for [`TreeSitter`]. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct OptionalTreeSitter { + #[serde(default, alias = "git-ref", alias = "ref")] + pub version: Option, + #[serde(default)] + pub platform: Option, + #[serde(default)] + pub repo: Option, +} + +/// The tree-sitter CLI binary version, platform, and download repo. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TreeSitter { + /// Git tag (or ref) of the tree-sitter CLI release. + #[serde( + default = "default_tree_sitter_version", + alias = "git-ref", + alias = "ref" + )] + pub version: String, + + /// Target platform string used to select the correct release asset. + #[serde(default = "default_tree_sitter_platform")] + pub platform: String, + + /// GitHub repository to download the CLI from (owner/name). + #[serde(default = "default_tree_sitter_repo")] + pub repo: String, +} - #[must_use] - pub fn wasm(&self) -> bool { - matches!(self, Self::All | Self::Wasm) +// ============================================================ +// Free functions +// ============================================================ + +/// Get the build subcommand matches, if any. +#[must_use] +pub fn build_matches(matches: &ArgMatches) -> Option<&ArgMatches> { + matches.subcommand().and_then(|(name, sub)| { + if matches!(name, "build" | "b") { + Some(sub) + } else { + None } + }) } -#[derive(Clone, Copy, Debug, Default)] -pub enum VersionBump { - /// Update to the latest version, including breaking changes. - Major, - /// Update to the latest minor or patch version (same MAJOR). - #[default] - Minor, - /// Update to the latest patch version (same MAJOR.MINOR). - Patch, +/// Return the number of available CPUs as the default job count. +#[must_use] +pub fn default_jobs() -> NonZeroUsize { + NonZeroUsize::new(num_cpus::get()).unwrap_or(NonZeroUsize::MIN) } -impl fmt::Display for VersionBump { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Major => write!(f, "major"), - Self::Minor => write!(f, "minor"), - Self::Patch => write!(f, "patch"), - } - } +/// Get the default tree-sitter platform from compile-time constants. +fn default_tree_sitter_platform() -> String { + PLATFORM.to_string() } -#[allow(clippy::struct_excessive_bools)] -#[derive(clap::Args, Clone, Debug, Deserialize, Diff, PartialEq, Eq, Serialize)] -#[diff(attr( - #[derive(Debug, PartialEq)] -))] -#[serde(rename_all = "kebab-case")] -pub struct BuildCommand { - /// Build Directory. - #[serde(default)] - #[arg(short, long, env = "TSDL_BUILD_DIR", default_value = TSDL_BUILD_DIR)] - pub build_dir: PathBuf, - - /// Force clone the repository and rebuild, bypassing cache checks. Overwrites existing binaries. - #[arg(long, default_value_t = false)] - #[serde(default)] - pub force: bool, - - /// Clears the `build-dir` and starts a fresh build. - #[arg(short, long, default_value_t = TSDL_FRESH)] - #[serde(default)] - pub fresh: bool, - - /// Parsers to compile. - #[serde(skip_serializing, skip_deserializing)] - #[arg(verbatim_doc_comment)] - pub languages: Option>, - - /// Number of threads; defaults to the number of available CPUs. - #[arg(short, long, env = "TSDL_NCPUS", default_value_t = num_cpus::get())] - #[serde(default)] - pub jobs: usize, - - /// Output Directory. - #[arg(short, long, env = "TSDL_OUT_DIR", default_value = TSDL_OUT_DIR)] - #[serde(default)] - pub out_dir: PathBuf, - - /// Configured Parsers. - #[clap(skip)] - pub parsers: Option>, - - /// Prefix parser names. - #[arg(short, long, env = "TSDL_PREFIX", default_value = TSDL_PREFIX)] - #[serde(default)] - pub prefix: String, - - /// Show Config. - #[arg(long, default_value_t = TSDL_SHOW_CONFIG)] - #[serde(default)] - pub show_config: bool, - - /// Build target. - #[arg(short, long, value_enum, default_value_t = Target::default())] - pub target: Target, - - #[command(flatten)] - #[serde(default)] - pub tree_sitter: TreeSitter, - - /// Force unlock the build directory. - #[arg(long, default_value_t = false)] - #[serde(default)] - pub unlock: bool, +/// Get the default tree-sitter download repo from compile-time constants. +fn default_tree_sitter_repo() -> String { + REPO.to_string() } -impl Default for BuildCommand { - fn default() -> Self { - Self { - build_dir: PathBuf::from(TSDL_BUILD_DIR), - force: TSDL_FORCE, - fresh: TSDL_FRESH, - languages: None, - jobs: num_cpus::get(), - out_dir: PathBuf::from(TSDL_OUT_DIR), - parsers: None, - prefix: String::from(TSDL_PREFIX), - show_config: TSDL_SHOW_CONFIG, - target: Target::default(), - tree_sitter: TreeSitter::default(), - unlock: false, - } - } +/// Get the default tree-sitter version from compile-time constants. +fn default_tree_sitter_version() -> String { + VERSION.to_string() } -#[derive(Clone, Debug, Deserialize, Diff, Serialize, PartialEq, Eq)] -#[diff(attr( - #[derive(Debug, PartialEq)] -))] -#[serde(untagged)] -#[serde(rename_all = "kebab-case")] -pub enum ParserConfig { - Full { - #[serde(alias = "cmd", alias = "script")] - build_script: Option, - - #[diff(attr(#[derive(Debug, PartialEq)]))] - from: Option, - - #[serde(rename = "ref")] - #[diff(attr(#[derive(Debug, PartialEq)]))] - git_ref: String, - }, - Ref(String), +#[must_use] +/// Define the CLI colour scheme. +const fn get_styles() -> clap::builder::Styles { + clap::builder::Styles::styled() + .usage( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), + ) + .header( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), + ) + .literal(Style::new().fg_color(Some(Color::Ansi(AnsiColor::Blue)))) + .invalid( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Red))), + ) + .error( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Red))), + ) + .valid( + Style::new() + .bold() + .fg_color(Some(Color::Ansi(AnsiColor::Blue))), + ) + .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::White)))) } -#[derive(clap::Args, Clone, Debug, Diff, Deserialize, PartialEq, Eq, Serialize)] -#[diff(attr( - #[derive(Debug, PartialEq)] -))] -pub struct TreeSitter { - /// Tree-sitter version. - #[arg(short = 'V', long = "tree-sitter-version", default_value = TREE_SITTER_VERSION)] - pub version: String, +// ============================================================ +// Impls +// ============================================================ - /// Tree-sitter platform to build. Change at your own risk. - #[clap(long = "tree-sitter-platform", default_value = TREE_SITTER_PLATFORM)] - pub platform: String, +impl Command { + /// Check whether this command is a build. + #[must_use] + pub const fn is_build(&self) -> bool { + matches!(self, Command::Build) + } +} - /// Tree-sitter repo. - #[arg(short = 'R', long = "tree-sitter-repo", default_value = TREE_SITTER_REPO)] - pub repo: String, +impl Default for BuildCommand { + fn default() -> Self { + Self { + build_dir: PathBuf::from(BUILD_DIR), + force: FORCE, + fresh: FRESH, + languages: None, + jobs: default_jobs(), + out_dir: PathBuf::from(PARSER_OUT_DIR), + parsers: None, + prefix: String::from(PREFIX), + show_config: SHOW_CONFIG, + target: Target::default(), + tree_sitter: TreeSitter::default(), + unlock_timeout: UNLOCK_TIMEOUT, + } + } } impl Default for TreeSitter { - fn default() -> Self { - Self { - version: TREE_SITTER_VERSION.to_string(), - platform: TREE_SITTER_PLATFORM.to_string(), - repo: TREE_SITTER_REPO.to_string(), - } + fn default() -> Self { + Self { + version: VERSION.to_string(), + platform: PLATFORM.to_string(), + repo: REPO.to_string(), } + } } -#[derive(clap::Subcommand, Clone, Debug, Default)] -pub enum ConfigCommand { - #[default] - Current, - Default, +impl fmt::Display for ConfigCommand { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", format!("{self:?}").to_lowercase()) + } } -impl fmt::Display for ConfigCommand { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{:?}", format!("{self:?}").to_lowercase()) +impl Target { + /// Check whether `self` covers the requested target (e.g. All covers everything). + #[must_use] + pub fn covers(&self, other: Target) -> bool { + use Target::{All, Native, Wasm}; + + matches!((self, other), (All, _) | (Native, Native) | (Wasm, Wasm)) + } + + /// Combine two targets into the broadest coverage. + #[must_use] + pub fn union(self, other: Self) -> Self { + match (self, other) { + | (Self::Native, Self::Native) => Self::Native, + | (Self::Wasm, Self::Wasm) => Self::Wasm, + | (Self::All, _) + | (_, Self::All) + | (Self::Native, Self::Wasm) + | (Self::Wasm, Self::Native) => Self::All, } + } + + /// Check whether the target includes native builds. + #[must_use] + pub fn native(&self) -> bool { + matches!(self, Self::All | Self::Native) + } + + /// Check whether the target includes wasm builds. + #[must_use] + pub fn wasm(&self) -> bool { + matches!(self, Self::All | Self::Wasm) + } + + /// Return a lowercased string representation. + #[must_use] + pub fn to_lowercase(&self) -> &'static str { + match self { + | Target::Native => "native", + | Target::Wasm => "wasm", + | Target::All => "all", + } + } } -#[must_use] -const fn get_styles() -> clap::builder::Styles { - clap::builder::Styles::styled() - .usage( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), - ) - .header( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Yellow))), - ) - .literal(Style::new().fg_color(Some(Color::Ansi(AnsiColor::Blue)))) - .invalid( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Red))), - ) - .error( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Red))), - ) - .valid( - Style::new() - .bold() - .fg_color(Some(Color::Ansi(AnsiColor::Blue))), - ) - .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::White)))) +impl fmt::Display for VersionBump { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::Major => write!(f, "major"), + | Self::Minor => write!(f, "minor"), + | Self::Patch => write!(f, "patch"), + } + } } diff --git a/src/build.rs b/src/build.rs index e1d0a3c..549c308 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,328 +1,430 @@ +//! Build orchestrator: resolves languages, acquires the lock, and drives the +//! per-language build pipeline. + +use std::fmt; use std::{ - collections::{BTreeMap, HashSet}, - fs::{self, create_dir_all}, - path::PathBuf, - sync::Arc, + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + result::Result as StdResult, + sync::Arc, + time::Duration, }; use serde::{Deserialize, Serialize}; -use tokio::time; +use tracing::info; use url::Url; use crate::{ - actors::{self, CacheActor, DisplayActor, DisplayAddr}, - app::App, - args::{ParserConfig, Target, TreeSitter}, - cache::Db, - consts::TSDL_FROM, - display::{self, Progress, ProgressBar, TICK_CHARS}, - error::{self, TsdlError}, - git::GitRef, - lock::{Lock, LockStatus}, - parser::LanguageBuild, - prompt_user, SafeCanonicalize, TsdlResult, + Error, Result, ResultExt, SafeCanonicalize, absolute_normalize, actors, app, args, cache, consts, + format_duration, lock, parser, prompt_user, shutdown, }; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct BuildSpec { - pub build_script: Option, - pub git_ref: GitRef, - pub prefix: String, - pub repo: Url, - pub target: Target, - pub tree_sitter: TreeSitter, +// ============================================================ +// Structs +// ============================================================ + +/// The root build directory — anchor for all derived paths. +#[derive(Clone, Debug)] +pub struct BuildDir(PathBuf); + +/// Per-language build context flags. +#[derive(Debug, Clone, PartialEq)] +pub struct Context { + /// Whether to overwrite existing output binaries. + pub overwrite_output: bool, } +/// Paths for the checkout and output directories of a parser build. #[derive(Debug, Clone)] pub struct OutputConfig { - pub build_dir: Arc, - pub out_dir: Arc, + /// Directory where the parser source is checked out. + pub build_dir: PathBuf, + /// Directory where built binaries are installed. + pub out_dir: PathBuf, } -#[derive(Debug, Clone, PartialEq)] -pub struct BuildContext { - pub cache_hit: bool, - pub force: bool, - pub progress: Option, +/// Full build specification for a parser: source location, git ref, prefix, +/// target, and tree-sitter CLI version. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Spec { + /// Optional custom build script (e.g. "make"). + pub build_script: Option, + /// Git ref to check out (tag, branch, or commit SHA). + pub git_ref: parser::Ref, + /// Filename prefix for installed binaries. + pub prefix: String, + /// Repository URL to clone. + pub repo: Url, + /// Build output kind. + pub target: args::Target, + /// Tree-sitter CLI configuration. + pub tree_sitter: args::TreeSitter, } -impl BuildContext { - pub fn err(&self, msg: &str) { - if let Some(ref progress) = self.progress { - progress.err(msg); - } - } - - pub fn fin(&self, msg: &str) { - if let Some(ref progress) = self.progress { - progress.fin(msg); - } - } - - pub fn msg(&self, msg: &str) { - if let Some(ref progress) = self.progress { - progress.msg(msg); - } - } - - pub fn step(&self, msg: &str) { - if let Some(ref progress) = self.progress { - progress.step(msg); - } - } - - #[must_use] - pub fn is_done(&self) -> bool { - self.progress.as_ref().is_none_or(ProgressBar::is_done) - } +// ============================================================ +// Free functions +// ============================================================ + +/// Try to acquire the build lock, prompting the user if another process holds it. +fn acquire_lock(lock: &lock::Lock, unlock_timeout: Duration) -> Result { + // Loop because the lock owner may exit naturally between the prompt and + // SIGTERM, or another process may replace it. Each iteration re-checks + // the lock status and presents the current owner to the user. + loop { + use lock::Status::{Acquired, Cyclic, LockedBy, Unknown}; + + match lock.try_acquire()? { + | Acquired(guard) => return Ok(guard), + + | Cyclic => { + info!("lock::Lock already held by this process (cyclic)."); + return Err(Error::Message { + message: "1+ lock acquisition".into(), + }); + } - pub fn start(&mut self, msg: &str) { - if let Some(ref mut progress) = self.progress { - progress.step(msg); + | LockedBy(owner) => match handle_locked_by(lock, &owner, unlock_timeout) { + | Err(ref err) if err.is_retryable() => { + info!("{err}; re-checking lock status..."); + // continue to the next loop iteration } - } - - pub fn tick(&self) { - if let Some(ref progress) = self.progress { - progress.tick(); + | Err(err) => return Err(err.into()), + | Ok(guard) => return Ok(guard), + }, + + | Unknown { pid, reason } => { + if let Some(pid) = pid { + info!("Build directory is locked by PID {pid}, but tsdl could not inspect it: {reason}"); + } else { + info!("Build directory is locked, but tsdl could not identify the owner: {reason}"); } + return Err(Error::Message { + message: format!("Could not identify build lock owner: {reason}"), + }); + } } + } } -pub fn run(app: &mut App) -> TsdlResult<()> { - if app.command.show_config { - crate::config::show(&app.command)?; - } - - // Initialize the manager first with the build directory - let lock = Lock::new(&app.command.build_dir); - - if app.command.unlock { - lock.force_unlock()?; - } - - // Check lock status before clearing anything - - let _guard = match lock.try_acquire()? { - LockStatus::Acquired(lock) => lock, - - LockStatus::Cyclic => { - eprintln!("Lock already held by this process. This should not happen."); - return Err(TsdlError::message("1+ lock acquisition")); - } - - LockStatus::LockedBy { pid, exe } => { - eprintln!("Lock owned by different process: PID {pid} ({exe})"); - if prompt_user("Proceed anyway?", false)? { - // Use the manager instance to force acquire - lock.force_acquire()? - } else { - return Err(TsdlError::message("Lock acquisition cancelled by user")); - } - } - - LockStatus::Stale(pid) => { - eprintln!("Found stale lock from PID {pid} (process no longer exists)"); - if prompt_user("Take over lock?", true)? { - lock.force_acquire()? - } else { - return Err(TsdlError::message("Lock acquisition cancelled by user")); - } - } - - LockStatus::Unknown { pid, reason } => { - eprintln!("Could not verify lock owner PID {pid}: {reason}"); - if prompt_user("Take over lock?", false)? { - lock.force_acquire()? - } else { - return Err(TsdlError::message("Lock acquisition cancelled by user")); - } - } - }; - - clear(app)?; - ignite(app)?; - Ok(()) +/// Optionally clear the build directory (--fresh) and ensure it exists. +fn clear( + build_dir: &BuildDir, + guard: &lock::Guard, + fresh: bool, + log_path: Option<&Path>, +) -> Result<()> { + if fresh && build_dir.as_path().exists() { + let protected_files = log_path + .map(Path::to_path_buf) + .into_iter() + .collect::>(); + guard.clear_directory(&protected_files)?; + } + + fs::create_dir_all(build_dir.as_path())?; + + Ok(()) } -fn clear(app: &mut App) -> TsdlResult<()> { - if app.command.fresh && app.command.build_dir.exists() { - let bar = app.progress.register("Fresh Build".into(), "".into(), 1); - fs::remove_dir_all(&app.command.build_dir)?; - bar.fin(format!("Cleaned {}", app.command.build_dir.display())); - } - - fs::create_dir_all(&app.command.build_dir)?; - - Ok(()) +/// Collect language build definitions from the command arguments, partitioning +/// results to return all errors if any language fails to resolve. +fn collect_languages( + command: &args::BuildCommand, + build_dir: &BuildDir, +) -> Result> { + let results = unique_languages(command, build_dir); + let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); + + if err.is_empty() { + Ok(ok.into_iter().map(Result::unwrap).collect()) + } else { + Err(Error::LanguageCollection { + related: err.into_iter().map(Result::unwrap_err).collect(), + }) + } } -fn collect_languages(app: &App) -> Result, error::LanguageCollection> { - let results = unique_languages(app); - let (ok, err): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok); +/// Build the default parser repo URL for a language. +fn default_repo(language: &str) -> Result { + use consts::FROM; - if err.is_empty() { - Ok(ok.into_iter().map(Result::unwrap).collect()) - } else { - Err(error::LanguageCollection { - related: err.into_iter().map(Result::unwrap_err).collect(), - }) - } -} - -fn default_repo(language: &str) -> TsdlResult { - let url = format!("{TSDL_FROM}{language}"); - Url::parse(&url) - .map_err(|e| TsdlError::context(format!("Creating url {url} for {language}"), e)) + let url = format!("{FROM}{language}"); + Url::parse(&url).with_context(|| format!("Creating url {url} for {language}")) } +/// Look up the coordinates (repo URL, git ref, build script) for a language. fn get_language_coords( - language: &str, - defined_parsers: Option<&BTreeMap>, -) -> (Option, GitRef, TsdlResult) { - // Attempt to find the config; defaults to None if map or key is missing - let config = defined_parsers.and_then(|parsers| parsers.get(language)); - - match config { - Some(ParserConfig::Ref(git_ref)) => { - (None, resolve_git_ref(git_ref), default_repo(language)) - } - - Some(ParserConfig::Full { - build_script, - git_ref, - from, - }) => { - let url_result = match from { - Some(url_str) => Url::parse(url_str).map_err(|e| { - TsdlError::context(format!("Parsing {url_str} for {language}"), e) - }), - None => default_repo(language), - }; - - (build_script.clone(), resolve_git_ref(git_ref), url_result) + language: &str, + defined_parsers: Option<&BTreeMap>, +) -> Result<(Url, parser::Ref, Option)> { + use args::ParserConfig::{Full, Ref}; + + let config = defined_parsers.and_then(|parsers| parsers.get(language)); + + match config { + | None => Ok((default_repo(language)?, parser::Ref::head(), None)), + + | Some(Ref(git_ref)) => Ok(( + default_repo(language)?, + parser::Ref::parse(git_ref) + .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, + None, + )), + + | Some(Full { + build_script, + git_ref, + from, + }) => { + let repo = match from { + | Some(url_str) => { + Url::parse(url_str).with_context(|| format!("Parsing {url_str} for {language}"))? } - - None => (None, GitRef::from("HEAD"), default_repo(language)), + | None => default_repo(language)?, + }; + + Ok(( + repo, + parser::Ref::parse(git_ref) + .with_context(|| format!("Parsing git ref {git_ref:?} for {language}"))?, + build_script.clone(), + )) } + } } -fn ignite(app: &App) -> TsdlResult<()> { - create_dir_all(&app.command.out_dir)?; +/// Prompt the user to terminate the lock-holding process, then wait for release. +fn handle_locked_by( + lock: &lock::Lock, + owner: &lock::Owner, + unlock_timeout: Duration, +) -> StdResult { + info!("Build directory is locked by another process:"); + info!("{owner}"); + eprintln!( + "If you continue, tsdl will send SIGTERM to PID {} and wait up to {} \ + (--unlock-timeout {}) for the build lock to be released.", + owner.pid, + format_duration(unlock_timeout), + unlock_timeout.as_secs() + ); + + if !prompt_user("Terminate this process and continue?", false)? { + return Err( + Error::Message { + message: "lock::Lock acquisition cancelled by user".into(), + } + .into(), + ); + } + + lock.terminate_owner(owner)?; + info!( + "Sent SIGTERM to PID {}. Waiting for the build lock to be released...", + owner.pid + ); + lock.wait_for_release(owner, unlock_timeout) +} - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; +/// Set up the runtime and spawn the build pipeline. +fn ignite(command: &args::BuildCommand, app: &app::App, build_dir: &BuildDir) -> Result<()> { + fs::create_dir_all(&command.out_dir)?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + let guard = rt.enter(); + + let cache_store = cache::Store::new(build_dir); + let db = cache_store.load()?; + let languages = collect_languages(command, build_dir)?; + + let result = rt.block_on(async move { + let shutdown = shutdown::Handle::new(); + let _signals = shutdown.spawn_signal_listener()?; + let cache = actors::CacheActor::spawn(db, command.force, cache_store); + let display_build_dir = build_dir.as_path().to_path_buf(); + let display_out_dir = command.out_dir.canon()?; + let display = + actors::DisplayActor::spawn(display_build_dir, app.progress_mode, display_out_dir); + + shutdown::scope(shutdown, async move { + actors::run( + build_dir.as_path(), + cache, + display, + command.jobs, + languages, + &command.tree_sitter, + ) + .await + }) + .await?; - let guard = rt.enter(); + Ok(()) + }); - let db = Db::load(&app.command.build_dir)?; - let languages = collect_languages(app)?; + drop(guard); - let result = rt.block_on(async move { - let cache = CacheActor::spawn(db, app.command.force); - let display = DisplayActor::spawn(Progress::new(app.progress.mode)); + result +} - let display2 = display.clone(); - tokio::spawn(async { - update_screen(display2).await; - }); +/// Acquire the build lock, clear if requested, and start the build. +pub fn run(command: &args::BuildCommand, app: &app::App) -> Result<()> { + if command.show_config { + crate::config::show(command)?; + } - actors::run( - &app.command.build_dir, - cache, - display, - app.command.jobs, - languages, - &app.command.tree_sitter, - ) - .await?; + let build_dir = BuildDir::new(&command.build_dir)?; + let lock = lock::Lock::new(&build_dir); + let guard = acquire_lock(&lock, Duration::from_secs(command.unlock_timeout))?; - Ok(()) - }); + clear(&build_dir, &guard, command.fresh, app.logging.path())?; + ignite(command, app, &build_dir)?; + Ok(()) +} - drop(guard); +/// Produce a sorted, deduplicated list of language build definitions from the +/// command's requested languages or defined parsers. +fn unique_languages( + command: &args::BuildCommand, + build_dir: &BuildDir, +) -> Vec> { + let requested_languages = &command.languages; + let defined_parsers = command.parsers.as_ref(); + + let final_languages = match requested_languages { + | Some(langs) if !langs.is_empty() => langs.clone(), + | _ => defined_parsers + .map(|parsers| parsers.keys().cloned().collect()) + .unwrap_or_default(), + }; + + let unique = final_languages.into_iter().collect::>(); + let mut results = Vec::with_capacity(unique.len()); + + for language in unique { + let result = match get_language_coords(&language, defined_parsers) { + | Err(err) => Err(Error::Language { + name: language, + source: err.into(), + }), + + | Ok((repo, git_ref, build_script)) => Ok(parser::LanguageBuild::new( + Context { + overwrite_output: command.force, + }, + parser::LanguageName::from(language.clone()), + OutputConfig { + build_dir: build_dir + .checkout_dir(&language) + .canon() + .expect("Build dir canonicalization failed"), + out_dir: command + .out_dir + .canon() + .expect("Out dir canonicalization failed"), + }, + Arc::new(Spec { + build_script, + git_ref, + repo, + tree_sitter: command.tree_sitter.clone(), + prefix: command.prefix.clone(), + target: command.target, + }), + )), + }; + results.push(result); + } - result + results } -fn resolve_git_ref(git_ref: &str) -> GitRef { - let is_sha1 = git_ref.len() == 40 && git_ref.chars().all(|c| c.is_ascii_hexdigit()); +// ============================================================ +// Impls +// ============================================================ - if is_sha1 || git_ref.starts_with('v') { - return GitRef::from(git_ref); - } - - if git_ref.split('.').all(|part| part.parse::().is_ok()) { - GitRef::from(format!("v{git_ref}")) - } else { - GitRef::from(git_ref) - } +impl AsRef for BuildDir { + fn as_ref(&self) -> &Path { + &self.0 + } } -fn unique_languages(app: &App) -> Vec> { - let requested_languages = &app.command.languages; - let defined_parsers = app.command.parsers.as_ref(); +impl BuildDir { + /// The absolute, normalised path as a `&Path`. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } + + /// Path to the build cache TOML file inside this build directory. + #[must_use] + pub fn cache_file(&self) -> PathBuf { + self.0.join(consts::CACHE_FILE) + } + + /// Per-language checkout directory: `/tree-sitter-`. + #[must_use] + pub fn checkout_dir(&self, language: &str) -> PathBuf { + self.0.join(format!("tree-sitter-{language}")) + } + + /// Path to the PID lock file inside this build directory. + #[must_use] + pub fn lock_file(&self) -> PathBuf { + self.0.join(consts::LOCK_FILE) + } + + /// Path to the default log file inside this build directory. + #[must_use] + pub fn log_file(&self) -> PathBuf { + self.0.join(consts::LOG_FILE) + } + + /// Create a `BuildDir`, resolving relative paths against the current + /// working directory and normalising `.` / `..` components. + pub fn new(path: impl Into) -> Result { + let path = absolute_normalize(&path.into())?; + Ok(Self(path)) + } +} - let final_languages = match requested_languages { - Some(langs) if !langs.is_empty() => langs.clone(), - _ => defined_parsers - .map(|parsers| parsers.keys().cloned().collect()) - .unwrap_or_default(), - }; +impl fmt::Display for BuildDir { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.display()) + } +} - let unique = final_languages.into_iter().collect::>(); - let mut results = Vec::new(); - - for language in unique { - let (build_script, git_ref, url) = get_language_coords(&language, defined_parsers); - let result = match url { - Ok(repo) => Ok(LanguageBuild::new( - BuildContext { - force: app.command.force || app.command.fresh, - cache_hit: false, - progress: None, // Progress is handled by DisplayActor - }, - Arc::new(BuildSpec { - build_script, - git_ref, - repo, - tree_sitter: app.command.tree_sitter.clone(), - prefix: app.command.prefix.clone(), - target: app.command.target, - }), - language.clone().into(), - OutputConfig { - build_dir: app - .command - .build_dir - .join(format!("tree-sitter-{}", &language)) - .canon() - .expect("Build dir canonicalization failed") - .into(), - out_dir: app - .command - .out_dir - .canon() - .expect("Out dir canonicalization failed") - .into(), - }, - )), - Err(err) => Err(error::Language::new(language, err)), - }; - results.push(result); +#[cfg(test)] +mod tests { + use super::*; + use crate::args::BuildCommand; + + fn command_with_languages(languages: &[&str]) -> BuildCommand { + BuildCommand { + languages: Some( + languages + .iter() + .map(|language| (*language).to_string()) + .collect(), + ), + ..BuildCommand::default() } + } - results -} + #[test] + fn unique_languages_sorts_and_deduplicates_requested_languages() { + let command = command_with_languages(&["rust", "json", "ruby", "json", "rust"]); + let build_dir = BuildDir::new("test-build").unwrap(); -async fn update_screen(display: DisplayAddr) { - let mut interval = time::interval(time::Duration::from_millis( - 1000 / TICK_CHARS.chars().count() as u64, - )); + let languages = unique_languages(&command, &build_dir) + .into_iter() + .map(|language| language.unwrap().name.to_string()) + .collect::>(); - loop { - interval.tick().await; - display.tick().await; - } + assert_eq!(languages, vec!["json", "ruby", "rust"]); + } } diff --git a/src/cache.rs b/src/cache.rs index cefa2ad..d8598f0 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,293 +1,1122 @@ +//! Build cache: TOML-backed key-value store indexed by `{language}/{grammar}`. +//! Entries store grammar hashes and build specs to decide when rebuilds are +//! needed. + use std::{ - collections::BTreeMap, - fmt::Write, - fs, - path::{Path, PathBuf}, - sync::Arc, + collections::BTreeMap, + fmt::{self, Write as _}, + io::{self, Write as _}, + path::{Path, PathBuf}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, }; use serde::{Deserialize, Serialize}; use sha1::{Digest, Sha1}; -use tokio::io::{AsyncReadExt, ReadBuf}; +use tokio::io::AsyncWrite; use tracing::debug; -use crate::{build::BuildSpec, consts::TSDL_CACHE_FILE, error::TsdlError, TsdlResult}; +use crate::{Error, Result, ResultExt, args, build, build::BuildDir, git, parser}; + +// ============================================================ +// Enums +// ============================================================ + +/// A cache lookup result for a requested parser build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + /// Entry exists and is fully valid. + Hit, + /// Entry is missing or something changed. + Miss(Miss), +} + +/// One reason a cached parser build cannot be reused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MissReason { + /// No cache entry at all. + MissingEntry, + /// Cache was explicitly bypassed (--force). + CacheIgnored, + /// Hash of grammar.js changed. + HashChanged { + cached: GrammarHash, + current: GrammarHash, + }, + /// Parser repo URL changed. + RepoChanged { cached: String, current: String }, + /// Git ref changed (different tag or branch). + RefChanged { + cached: parser::Ref, + current: parser::Ref, + }, + /// Git ref resolved to a different commit. + RevisionChanged { cached: Revision, current: Revision }, + /// Tree-sitter CLI version changed. + TreeSitterChanged { + cached: args::TreeSitter, + current: args::TreeSitter, + }, + /// Build script changed. + BuildScriptChanged, + /// Output filename prefix changed. + PrefixChanged { cached: String, current: String }, + /// Requested output target not covered by cached entry. + OutputsMissing { + available: args::Target, + requested: args::Target, + }, + /// Expected output file is missing from disk. + ArtifactMissing { path: PathBuf }, + /// Expected output path exists but is not a regular file. + ArtifactNotFile { path: PathBuf }, + /// Expected output path exists but cannot be read. + ArtifactInaccessible { path: PathBuf, error: String }, +} + +/// Parser revision identity: stable (tag/commit) or moving (branch with +/// pinned commit SHA). +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum Revision { + /// Stable ref — no per-build commit tracking needed. + Stable, + /// Moving ref — the specific commit SHA checked out this build. + Moving { commit: git::Sha }, +} -/// The build cache stored in `build-dir/TSDL_CACHE_FILE` +// ============================================================ +// Structs +// ============================================================ + +/// The logical build cache contents. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Db { - pub parsers: BTreeMap, - pub file: PathBuf, + /// Per-key cache entries (keyed by `"{language}/{grammar}"`). + #[serde(default)] + pub parsers: BTreeMap, } -/// Cache entry for a single parser +/// Cache entry for a single parser. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { - /// Hash of the grammar.js file(s) - pub hash: Arc, - /// Complete build definition that affects parser output - pub spec: Arc, + /// Hash of the grammar.js file(s). + pub hash: GrammarHash, + /// Resolved parser revision used by the cache. Moving refs include the + /// checked-out commit. + pub revision: Revision, + /// Build specification used to produce this entry. + pub spec: Arc, + /// Parser outputs known to be available for this entry. + pub outputs: args::Target, +} + +/// SHA-1 hash of a grammar.js file. +#[derive(Clone, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GrammarHash(Arc); + +struct HashWriter<'a>(&'a mut Sha1); + +/// Cache lookup key: `"{language}/{grammar}"`. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Key(Arc); + +/// Details explaining why a cache entry cannot satisfy a requested parser build. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Miss { + /// Individual reasons for the cache miss. + pub reasons: Vec, +} + +/// File-backed storage for a [`Db`]. +#[derive(Debug, Clone)] +pub struct Store { + /// Path to the cache file on disk. + file: PathBuf, } /// Represents a "Delta" to be applied to the cache after a successful build #[derive(Debug, Clone)] pub struct Update { - pub entry: Entry, - pub name: Arc, + /// The cache entry to store. + pub entry: Entry, + /// The key to store it under. + pub name: Key, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Hash the contents of a file using SHA-1 and return the hex string. +pub async fn hash_file(path: &Path) -> Result { + let mut file = tokio::fs::File::open(path) + .await + .with_context(|| format!("Opening file for hashing: {}", path.display()))?; + + let mut hasher = Sha1::new(); + + tokio::io::copy(&mut file, &mut HashWriter(&mut hasher)) + .await + .with_context(|| format!("Reading file for hashing: {}", path.display()))?; + + let result = hasher.finalize(); + let mut hex = String::with_capacity(result.len() * 2); + for byte in result { + write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail"); + } + Ok(GrammarHash::from(hex)) +} + +/// Sync the directory metadata to disk (best-effort for atomic write safety). +fn sync_directory(path: &Path) -> std::io::Result<()> { + std::fs::File::open(path)?.sync_all() +} + +/// Verify that every path in `artifacts` exists as a regular file. +/// +/// Returns [`Decision::Hit`] when all artifacts are present, or a +/// [`Decision::Miss`] listing every missing or non-file path. +pub async fn verify_artifacts(artifacts: Vec) -> Decision { + let mut reasons = Vec::new(); + + for path in artifacts { + use MissReason::{ArtifactInaccessible, ArtifactMissing, ArtifactNotFile}; + use io::ErrorKind::NotFound; + + match tokio::fs::metadata(&path).await { + | Err(err) if err.kind() == NotFound => { + reasons.push(ArtifactMissing { path }); + } + | Err(err) => reasons.push(ArtifactInaccessible { + path, + error: err.to_string(), + }), + | Ok(metadata) if metadata.is_file() => {} + | Ok(_) => reasons.push(ArtifactNotFile { path }), + } + } + + Decision::from_reasons(reasons) +} + +/// Atomically write cache contents to a file using a temporary file + rename. +fn write_cache_file_atomically(file: &Path, contents: &str) -> Result<()> { + let parent = file.parent().ok_or_else(|| Error::Message { + message: format!( + "Cache file path has no parent directory: {}", + file.display() + ), + })?; + + std::fs::create_dir_all(parent) + .with_context(|| format!("Creating cache directory {}", parent.display()))?; + + let mut temp = tempfile::Builder::new() + .prefix(".cache.toml.") + .suffix(".tmp") + .tempfile_in(parent) + .with_context(|| format!("Creating temporary cache file in {}", parent.display()))?; + + temp + .write_all(contents.as_bytes()) + .with_context(|| format!("Writing temporary cache file {}", temp.path().display()))?; + temp + .as_file_mut() + .sync_all() + .with_context(|| format!("Syncing temporary cache file {}", temp.path().display()))?; + + temp + .persist(file) + .map_err(|err| err.error) + .with_context(|| format!("Installing cache file to {}", file.display()))?; + + if let Err(err) = sync_directory(parent) { + debug!( + "Could not sync cache directory {} after saving {}: {err}", + parent.display(), + file.display() + ); + } + + Ok(()) +} + +// ============================================================ +// Impls +// ============================================================ + +impl AsyncWrite for HashWriter<'_> { + /// Write bytes into the SHA-1 hasher. + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.0.update(buf); + Poll::Ready(Ok(buf.len())) + } + + /// Flush is a no-op for the hasher. + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + /// Shutdown is a no-op for the hasher. + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } } impl Db { - /// Clear all entries - pub fn clear(&mut self) { - self.parsers.clear(); + /// Clear all entries. + pub fn clear(&mut self) { + self.parsers.clear(); + } + + /// Get cache entry for a parser. + #[must_use] + pub fn get(&self, name: &Key) -> Option<&Entry> { + self.parsers.get(name) + } + + /// True when the cache contains at least one entry for `language` + /// that was built from the same repository and git ref. + /// + /// The tree-sitter CLI version, build script, prefix, and target are + /// intentionally excluded — they affect artefact validity (checked by + /// `needs_rebuild`) but not whether a prior checkout of the parser + /// source is still usable. + #[must_use] + pub fn has_compatible_entry_for_language( + &self, + language: &parser::LanguageName, + spec: &build::Spec, + ) -> bool { + let prefix = Key::language_prefix(language); + self.parsers.iter().any(|(key, entry)| { + key.as_str().starts_with(&prefix) + && entry.spec.repo == spec.repo + && entry.spec.git_ref == spec.git_ref + }) + } + + /// Check if a parser needs rebuilding by comparing grammar hash and build definition. + #[must_use] + pub fn needs_rebuild( + &self, + name: &Key, + hash: &GrammarHash, + revision: &Revision, + spec: &build::Spec, + ) -> bool { + self + .rebuild_decision(name, hash, revision, spec) + .needs_rebuild() + } + + /// Explain whether a parser cache entry can satisfy the requested build. + pub fn rebuild_decision( + &self, + name: &Key, + hash: &GrammarHash, + revision: &Revision, + spec: &build::Spec, + ) -> Decision { + let decision = match self.get(name) { + | None => Decision::miss(MissReason::MissingEntry), + | Some(entry) => entry.rebuild_decision(hash, revision, spec), + }; + + debug!("Cache decision for {name}: {decision}"); + decision + } + + /// Insert or update a parser cache entry. + pub fn set(&mut self, name: Key, mut entry: Entry) { + if let Some(existing) = self.parsers.get(&name) + && existing.same_subject(&entry) + { + entry.outputs = existing.outputs.union(entry.outputs); + } + + self.parsers.insert(name, entry); + } +} + +impl Decision { + /// Create a Decision from a list of miss reasons (empty list → Hit). + #[must_use] + pub fn from_reasons(reasons: Vec) -> Self { + if reasons.is_empty() { + Self::Hit + } else { + Self::Miss(Miss { reasons }) + } + } + + /// Check whether this decision indicates a cache hit. + #[must_use] + pub fn is_hit(&self) -> bool { + matches!(self, Self::Hit) + } + + /// Create a Miss decision for a single reason. + #[must_use] + pub fn miss(reason: MissReason) -> Self { + Self::Miss(Miss { + reasons: vec![reason], + }) + } + + /// Check whether the decision requires a rebuild. + #[must_use] + pub fn needs_rebuild(&self) -> bool { + !self.is_hit() + } + + /// Return a short human-readable summary of the decision. + #[must_use] + pub fn short_message(&self) -> String { + match self { + | Self::Hit => "cache hit".to_string(), + | Self::Miss(miss) => miss.short_message(), } + } +} + +impl Entry { + /// Compare the current build parameters against this entry and decide + /// whether the cached output is still valid. + #[must_use] + pub fn rebuild_decision( + &self, + hash: &GrammarHash, + revision: &Revision, + spec: &build::Spec, + ) -> Decision { + let mut reasons = Vec::new(); - /// Delete the cache file from disk - pub fn delete(build_dir: &Path) -> TsdlResult<()> { - let file = build_dir.join(TSDL_CACHE_FILE); - if file.exists() { - fs::remove_file(&file).map_err(|e| { - TsdlError::context(format!("Deleting cache file at {}", file.display()), e) - })?; - debug!("Cache file deleted"); - } - Ok(()) + if &self.hash != hash { + reasons.push(MissReason::HashChanged { + cached: self.hash.clone(), + current: hash.clone(), + }); } - /// Get cache entry for a parser - #[must_use] - pub fn get(&self, name: &str) -> Option<&Entry> { - self.parsers.get(name) + if self.spec.repo != spec.repo { + reasons.push(MissReason::RepoChanged { + cached: self.spec.repo.to_string(), + current: spec.repo.to_string(), + }); } - /// Load the cache from disk, or return the empty cache. - pub fn load(build_dir: &Path) -> TsdlResult { - let file = build_dir.join(TSDL_CACHE_FILE); - if !file.exists() { - debug!( - "Cache file not found at {}, returning empty cache", - file.display() - ); - return Ok(Db { - parsers: BTreeMap::new(), - file, - }); - } - - let contents = fs::read_to_string(&file).map_err(|e| { - TsdlError::context(format!("Reading cache file at {}", file.display()), e) - })?; - - toml::from_str(&contents) - .map_err(|e| TsdlError::context(format!("Parsing cache file at {}", file.display()), e)) + let git_ref_changed = self.spec.git_ref != spec.git_ref; + if git_ref_changed { + reasons.push(MissReason::RefChanged { + cached: self.spec.git_ref.clone(), + current: spec.git_ref.clone(), + }); } - /// Check if a parser needs rebuilding by comparing grammar hash and build definition - pub fn needs_rebuild(&self, name: &str, hash: &str, spec: &BuildSpec) -> bool { - // TODO: hash and name are plain str, I'd like strong types here. - match self.get(name) { - None => { - debug!("No cache entry for {}, rebuild needed", name); - true - } - Some(entry) => { - let hash_eq = entry.hash.as_ref() == hash; - let spec_eq = entry.spec.as_ref() == spec; - let needs_rebuild = !(hash_eq && spec_eq); - - if needs_rebuild { - debug!( - "Cache mismatch for {}: hash={} (cached={}), config_changed=true", - name, hash, entry.hash - ); - } else { - debug!("Cache hit for {}, no rebuild needed", name); - } - - needs_rebuild - } - } + if !git_ref_changed && self.revision != *revision { + reasons.push(MissReason::RevisionChanged { + cached: self.revision.clone(), + current: revision.clone(), + }); } - /// Save the cache to disk - pub fn save(&self) -> TsdlResult<()> { - let contents = toml::to_string_pretty(self) - .map_err(|e| TsdlError::context("Serializing cache to TOML", e))?; + if self.spec.tree_sitter != spec.tree_sitter { + reasons.push(MissReason::TreeSitterChanged { + cached: self.spec.tree_sitter.clone(), + current: spec.tree_sitter.clone(), + }); + } + + if self.spec.build_script != spec.build_script { + reasons.push(MissReason::BuildScriptChanged); + } - fs::write(&self.file, contents).map_err(|e| { - TsdlError::context(format!("Writing cache file to {}", self.file.display()), e) - })?; + if self.spec.prefix != spec.prefix { + reasons.push(MissReason::PrefixChanged { + cached: self.spec.prefix.clone(), + current: spec.prefix.clone(), + }); + } - debug!("Cache saved to {}", self.file.display()); - Ok(()) + if !self.outputs.covers(spec.target) { + reasons.push(MissReason::OutputsMissing { + available: self.outputs, + requested: spec.target, + }); } - /// Insert or update a parser cache entry - pub fn set(&mut self, name: String, entry: Entry) { - self.parsers.insert(name, entry); + Decision::from_reasons(reasons) + } + + /// Check whether two entries describe the same build subject (ignoring + /// output coverage). + #[must_use] + pub fn same_subject(&self, other: &Self) -> bool { + self.hash == other.hash + && self.revision == other.revision + && self.spec.build_script == other.spec.build_script + && self.spec.git_ref == other.spec.git_ref + && self.spec.prefix == other.spec.prefix + && self.spec.repo == other.spec.repo + && self.spec.tree_sitter == other.spec.tree_sitter + } +} + +impl fmt::Display for Decision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Decision::Hit => write!(f, "cache hit"), + | Decision::Miss(miss) => write!(f, "cache miss: {miss}"), } + } } -/// Hash the contents of a file using SHA-1 and return the hex string. -pub async fn hash_file(path: &Path) -> TsdlResult { - let mut file = tokio::fs::File::open(path).await.map_err(|e| { - TsdlError::context(format!("Opening file for hashing: {}", path.display()), e) - })?; +impl fmt::Display for GrammarHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} - let mut hasher = Sha1::new(); - let mut buffer = vec![0u8; 8192]; +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} - loop { - let mut read_buf = ReadBuf::new(&mut buffer); - file.read_buf(&mut read_buf).await.map_err(|e| { - TsdlError::context(format!("Reading file for hashing: {}", path.display()), e) - })?; +impl fmt::Display for Miss { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, reason) in self.reasons.iter().enumerate() { + if index > 0 { + write!(f, "; ")?; + } + write!(f, "{reason}")?; + } + Ok(()) + } +} - if read_buf.filled().is_empty() { - break; - } +impl fmt::Display for MissReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::ArtifactInaccessible { path, error } => { + write!( + f, + "artifact inaccessible path={} error={error}", + path.display() + ) + } + | Self::ArtifactMissing { path } => { + write!(f, "artifact missing path={}", path.display()) + } + | Self::ArtifactNotFile { path } => { + write!(f, "artifact is not a regular file path={}", path.display()) + } + | Self::BuildScriptChanged => write!(f, "build script changed"), + | Self::CacheIgnored => write!(f, "cache ignored"), + | Self::HashChanged { cached, current } => { + write!(f, "grammar hash changed cached={cached} current={current}") + } + | Self::MissingEntry => write!(f, "missing cache entry"), + | Self::OutputsMissing { + available, + requested, + } => { + write!( + f, + "requested output not cached available={available:?} requested={requested:?}" + ) + } + | Self::PrefixChanged { cached, current } => { + write!(f, "prefix changed cached={cached:?} current={current:?}") + } + | Self::RefChanged { cached, current } => write!( + f, + "git ref changed cached={} current={}", + cached.requested().as_str(), + current.requested().as_str() + ), + | Self::RepoChanged { cached, current } => { + write!(f, "repo changed cached={cached} current={current}") + } + | Self::RevisionChanged { cached, current } => { + write!(f, "revision changed cached={cached} current={current}") + } + | Self::TreeSitterChanged { cached, current } => write!( + f, + "tree-sitter changed cached=version:{} repo:{} platform:{} current=version:{} repo:{} platform:{}", + cached.version, + cached.repo, + cached.platform, + current.version, + current.repo, + current.platform + ), + } + } +} - hasher.update(read_buf.filled()); +impl fmt::Display for Revision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::Stable => write!(f, "stable"), + | Self::Moving { commit } => write!(f, "moving commit={}", commit.as_str()), } + } +} - let result = hasher.finalize(); - Ok(result - .iter() - .fold(String::with_capacity(result.len() * 2), |mut acc, b| { - let _ = write!(acc, "{b:02x}"); - acc - })) +impl From<&str> for GrammarHash { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +impl From<&str> for Key { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +impl From for GrammarHash { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From for Key { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl GrammarHash { + /// Return the hash as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Key { + /// Return the key as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Build the prefix for all keys belonging to a given language. + #[must_use] + pub fn language_prefix(language: &parser::LanguageName) -> String { + format!("{language}/") + } + + /// Create a cache key from a language name and grammar name. + #[must_use] + pub fn new(language: &parser::LanguageName, grammar: &parser::GrammarName) -> Self { + Self(Arc::from(format!("{language}/{grammar}"))) + } +} + +impl Miss { + /// Return a short human-readable summary of the miss reasons. + #[must_use] + pub fn short_message(&self) -> String { + match self.reasons.as_slice() { + | [] => "cache changed".to_string(), + | [reason] => reason.short_message().to_string(), + | reasons => { + let labels = reasons + .iter() + .map(MissReason::short_label) + .collect::>() + .join(", "); + format!("cache changed: {labels}") + } + } + } +} + +impl MissReason { + /// Return a compact label for this miss reason (suitable for lists). + #[must_use] + pub fn short_label(&self) -> &'static str { + match self { + | Self::ArtifactInaccessible { .. } + | Self::ArtifactMissing { .. } + | Self::ArtifactNotFile { .. } => "artifact", + | Self::BuildScriptChanged => "script", + | Self::CacheIgnored => "ignored", + | Self::HashChanged { .. } => "hash", + | Self::MissingEntry => "missing", + | Self::OutputsMissing { .. } => "outputs", + | Self::PrefixChanged { .. } => "prefix", + | Self::RefChanged { .. } => "ref", + | Self::RepoChanged { .. } => "repo", + | Self::RevisionChanged { .. } => "revision", + | Self::TreeSitterChanged { .. } => "tree-sitter", + } + } + + /// Return a one-line message describing this miss reason. + #[must_use] + pub fn short_message(&self) -> &'static str { + match self { + | Self::ArtifactInaccessible { .. } => "artifact inaccessible", + | Self::ArtifactMissing { .. } => "artifact missing", + | Self::ArtifactNotFile { .. } => "artifact invalid", + | Self::BuildScriptChanged => "build script changed", + | Self::CacheIgnored => "cache ignored", + | Self::HashChanged { .. } => "grammar changed", + | Self::MissingEntry => "not cached", + | Self::OutputsMissing { .. } => "requested output not cached", + | Self::PrefixChanged { .. } => "prefix changed", + | Self::RefChanged { .. } => "git ref changed", + | Self::RepoChanged { .. } => "repo changed", + | Self::RevisionChanged { .. } => "git ref resolved commit changed", + | Self::TreeSitterChanged { .. } => "tree-sitter changed", + } + } +} + +impl Revision { + /// Create a stable (non-moving) revision. + #[must_use] + pub const fn stable() -> Self { + Self::Stable + } + + /// Create a moving revision pinned to a specific commit. + #[must_use] + pub const fn moving(commit: git::Sha) -> Self { + Self::Moving { commit } + } +} + +impl Store { + /// Create a store backed by the build directory's cache file. + #[must_use] + pub fn new(build_dir: &BuildDir) -> Self { + Self { + file: build_dir.cache_file(), + } + } + + /// Return the path to the cache file on disk. + #[must_use] + pub fn path(&self) -> &Path { + &self.file + } + + /// Delete the cache file from disk. + pub async fn delete(&self) -> Result<()> { + match tokio::fs::metadata(&self.file).await { + | Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + | Err(err) => { + return Err(err) + .with_context(|| format!("Reading cache file metadata at {}", self.file.display())); + } + | Ok(_) => { + tokio::fs::remove_file(&self.file) + .await + .with_context(|| format!("Deleting cache file at {}", self.file.display()))?; + debug!("Cache file deleted"); + } + } + + Ok(()) + } + + /// Load the cache from disk, or return an empty cache. + pub fn load(&self) -> Result { + if !self.file.exists() { + debug!( + "Cache file not found at {}, returning empty cache", + self.file.display() + ); + return Ok(Db::default()); + } + + let contents = std::fs::read_to_string(&self.file) + .with_context(|| format!("Reading cache file at {}", self.file.display()))?; + + toml::from_str(&contents) + .with_context(|| format!("Parsing cache file at {}", self.file.display())) + } + + /// Atomically save the cache to disk. + pub async fn save(&self, db: &Db) -> Result<()> { + let contents = toml::to_string_pretty(db).context("Serializing cache to TOML")?; + let file = self.file.clone(); + let result = tokio::task::spawn_blocking(move || write_cache_file_atomically(&file, &contents)) + .await + .context("Joining cache save task")?; + result?; + + debug!("Cache saved to {}", self.file.display()); + Ok(()) + } } #[cfg(test)] mod tests { - use super::*; - use crate::args::{Target, TreeSitter}; - use crate::git::GitRef; - - #[test] - fn test_needs_rebuild_no_entry() { - let cache = Db::default(); - let test_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - assert!(cache.needs_rebuild("test-parser", "abc123", &test_definition)); + use super::*; + use crate::{consts, git, parser}; + + const SHA1: &str = "636801770eea172d140e64b691815ff11f6b556f"; + const SHA2: &str = "736801770eea172d140e64b691815ff11f6b556f"; + + fn test_spec() -> build::Spec { + build::Spec { + build_script: None, + git_ref: parser::Ref::parse("v1.0.0").unwrap(), + repo: "https://github.com/example/parser".parse().unwrap(), + tree_sitter: args::TreeSitter::default(), + prefix: String::new(), + target: args::Target::Native, } + } - #[test] - fn test_needs_rebuild_sha1_mismatch() { - let mut cache = Db::default(); - let spec = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::All, - }; - cache.set( - "test-parser".to_string(), - Entry { - hash: "abc123".into(), - spec: spec.into(), - }, - ); - - let current_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - assert!(cache.needs_rebuild("test-parser", "def456", ¤t_definition)); + fn moving_spec() -> build::Spec { + build::Spec { + git_ref: parser::Ref::parse("master").unwrap(), + ..test_spec() } + } - #[test] - fn test_needs_rebuild_git_ref_mismatch() { - let mut cache = Db::default(); - let spec = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::All, - }; - cache.set( - "test-parser".to_string(), - Entry { - hash: "abc123".into(), - spec: spec.into(), - }, - ); - - let current_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("v1.0.0"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - assert!(cache.needs_rebuild("test-parser", "abc123", ¤t_definition)); + fn stable_revision() -> Revision { + Revision::stable() + } + + fn moving_revision(spec: &build::Spec, sha: &str) -> Revision { + assert!(spec.git_ref.is_moving()); + Revision::moving(git::Sha::new(sha).unwrap()) + } + + fn key() -> Key { + Key::from("test-parser") + } + + fn grammar_hash(value: &str) -> GrammarHash { + GrammarHash::from(value) + } + + fn entry(hash: &str, spec: &build::Spec, revision: Revision) -> Entry { + Entry { + hash: grammar_hash(hash), + revision, + spec: Arc::new(spec.clone()), + outputs: spec.target, } + } + + fn cache_with_entry(hash: &str, spec: &build::Spec) -> Db { + cache_with_entry_and_revision(hash, spec, stable_revision()) + } - #[test] - fn test_needs_rebuild_target_not_covered() { - let mut cache = Db::default(); - let spec = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - cache.set( - "test-parser".to_string(), - Entry { - hash: "abc123".into(), - spec: spec.into(), - }, - ); - - let current_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Wasm, - }; - assert!(cache.needs_rebuild("test-parser", "abc123", ¤t_definition)); + fn cache_with_entry_and_revision(hash: &str, spec: &build::Spec, revision: Revision) -> Db { + let mut cache = Db::default(); + cache.set(key(), entry(hash, spec, revision)); + cache + } + + fn assert_miss(decision: Decision, expected: &[MissReason]) { + match decision { + | Decision::Hit => panic!("expected cache miss"), + | Decision::Miss(miss) => assert_eq!(miss.reasons, expected), } + } + + #[test] + fn test_rebuild_decision_no_entry() { + let cache = Db::default(); + let spec = test_spec(); + + assert_miss( + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &stable_revision(), &spec), + &[MissReason::MissingEntry], + ); + } + + #[test] + fn test_rebuild_decision_hash_mismatch() { + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + + assert_miss( + cache.rebuild_decision(&key(), &grammar_hash("def456"), &stable_revision(), &spec), + &[MissReason::HashChanged { + cached: grammar_hash("abc123"), + current: grammar_hash("def456"), + }], + ); + } + + #[test] + fn test_rebuild_decision_git_ref_mismatch() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.git_ref = parser::Ref::parse("v2.0.0").unwrap(); + let cache = cache_with_entry("abc123", &cached); + + assert_miss( + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &stable_revision(), + &requested, + ), + &[MissReason::RefChanged { + cached: parser::Ref::parse("v1.0.0").unwrap(), + current: parser::Ref::parse("v2.0.0").unwrap(), + }], + ); + } + + #[test] + fn test_rebuild_decision_outputs_must_cover_requested_target() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.target = args::Target::Wasm; + let cache = cache_with_entry("abc123", &cached); + + assert_miss( + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &stable_revision(), + &requested, + ), + &[MissReason::OutputsMissing { + available: args::Target::Native, + requested: args::Target::Wasm, + }], + ); + } + + #[test] + fn test_rebuild_decision_all_outputs_cover_narrower_targets() { + let mut cached = test_spec(); + cached.target = args::Target::All; + let cache = cache_with_entry("abc123", &cached); - #[test] - fn test_needs_rebuild_cache_hit_exact() { - let mut cache = Db::default(); - let test_definition = BuildSpec { - build_script: None, - git_ref: GitRef::from("master"), - repo: "https://github.com/example/parser".parse().unwrap(), - tree_sitter: TreeSitter::default(), - prefix: String::new(), - target: Target::Native, - }; - cache.set( - "test-parser".to_string(), - Entry { - hash: "abc123".into(), - spec: Arc::new(test_definition.clone()), - }, - ); - - assert!(!cache.needs_rebuild("test-parser", "abc123", &test_definition)); + for target in [args::Target::Native, args::Target::Wasm] { + let mut requested = cached.clone(); + requested.target = target; + assert_eq!( + cache.rebuild_decision( + &key(), + &grammar_hash("abc123"), + &stable_revision(), + &requested, + ), + Decision::Hit + ); } + } + + #[test] + fn test_rebuild_decision_cache_hit_exact() { + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + + assert_eq!( + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &stable_revision(), &spec), + Decision::Hit + ); + assert!(!cache.needs_rebuild(&key(), &grammar_hash("abc123"), &stable_revision(), &spec)); + } + + #[test] + fn test_rebuild_decision_moving_revision_commit_changed() { + let spec = moving_spec(); + let cached_revision = moving_revision(&spec, SHA1); + let current_revision = moving_revision(&spec, SHA2); + let cache = cache_with_entry_and_revision("abc123", &spec, cached_revision.clone()); + + assert_miss( + cache.rebuild_decision(&key(), &grammar_hash("abc123"), ¤t_revision, &spec), + &[MissReason::RevisionChanged { + cached: cached_revision, + current: current_revision, + }], + ); + } + + #[test] + fn test_rebuild_decision_moving_revision_commit_unchanged_hits() { + let spec = moving_spec(); + let revision = moving_revision(&spec, SHA1); + let cache = cache_with_entry_and_revision("abc123", &spec, revision.clone()); + + assert_eq!( + cache.rebuild_decision(&key(), &grammar_hash("abc123"), &revision, &spec), + Decision::Hit + ); + } + + #[test] + fn test_rebuild_decision_collects_multiple_reasons() { + let cached = test_spec(); + let mut requested = cached.clone(); + requested.build_script = Some("make".to_string()); + requested.prefix = "custom-".to_string(); + requested.target = args::Target::All; + let cache = cache_with_entry("abc123", &cached); + + assert_miss( + cache.rebuild_decision( + &key(), + &grammar_hash("def456"), + &stable_revision(), + &requested, + ), + &[ + MissReason::HashChanged { + cached: grammar_hash("abc123"), + current: grammar_hash("def456"), + }, + MissReason::BuildScriptChanged, + MissReason::PrefixChanged { + cached: String::new(), + current: "custom-".to_string(), + }, + MissReason::OutputsMissing { + available: args::Target::Native, + requested: args::Target::All, + }, + ], + ); + } + + #[test] + fn set_merges_outputs_for_same_entry_subject() { + let native = test_spec(); + let mut wasm = native.clone(); + wasm.target = args::Target::Wasm; + let mut cache = Db::default(); + + cache.set(key(), entry("abc123", &native, stable_revision())); + cache.set(key(), entry("abc123", &wasm, stable_revision())); + + assert_eq!(cache.get(&key()).unwrap().outputs, args::Target::All); + } + + #[test] + fn set_replaces_when_recipe_changes() { + let native = test_spec(); + let mut wasm = native.clone(); + wasm.target = args::Target::Wasm; + wasm.prefix = "other-".to_string(); + let mut cache = Db::default(); + + cache.set(key(), entry("abc123", &native, stable_revision())); + cache.set(key(), entry("abc123", &wasm, stable_revision())); + + let stored = cache.get(&key()).unwrap(); + assert_eq!(stored.outputs, args::Target::Wasm); + assert_eq!(stored.spec.prefix, "other-"); + } + + #[test] + fn compatible_entries_use_output_coverage() { + let mut cached = test_spec(); + cached.target = args::Target::All; + let mut requested = cached.clone(); + requested.target = args::Target::Native; + let mut cache = Db::default(); + + cache.set( + Key::from("rust/rust"), + entry("abc123", &cached, stable_revision()), + ); + + assert!( + cache.has_compatible_entry_for_language(&parser::LanguageName::from("rust"), &requested) + ); + } + + #[test] + fn store_loads_cache_without_runtime_file_path() { + let temp = tempfile::TempDir::new().unwrap(); + let build_dir = BuildDir::new(temp.path()).unwrap(); + let store = Store::new(&build_dir); + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + let contents = toml::to_string_pretty(&cache).unwrap(); + + std::fs::write(store.path(), contents).unwrap(); + + let loaded = store.load().unwrap(); + assert!(loaded.get(&key()).is_some()); + } + + #[tokio::test] + async fn store_ignores_legacy_file_field_and_saves_to_current_path() { + let current = tempfile::TempDir::new().unwrap(); + let stale = tempfile::TempDir::new().unwrap(); + let build_dir = BuildDir::new(current.path()).unwrap(); + let store = Store::new(&build_dir); + let stale_file = stale.path().join(consts::CACHE_FILE); + let spec = test_spec(); + let cache = cache_with_entry("abc123", &spec); + let contents = toml::to_string_pretty(&cache).unwrap(); + let legacy_contents = format!( + "file = {:?}\n{contents}", + stale_file.to_string_lossy().as_ref() + ); + + std::fs::write(store.path(), legacy_contents).unwrap(); + + let mut loaded = store.load().unwrap(); + assert!(loaded.get(&key()).is_some()); + loaded.clear(); + + store.save(&loaded).await.unwrap(); + + assert!( + !stale_file.exists(), + "cache save should not use a legacy serialized file path" + ); + let saved = std::fs::read_to_string(store.path()).unwrap(); + assert!( + !saved.lines().any(|line| line.starts_with("file")), + "cache TOML should not serialize runtime storage path: {saved}" + ); + assert!(saved.contains("parsers")); + } + + #[tokio::test] + async fn verify_artifacts_hits_when_all_paths_are_files() { + let temp = tempfile::TempDir::new().unwrap(); + let artifact = temp.path().join("parser.so"); + tokio::fs::write(&artifact, b"parser").await.unwrap(); + + assert_eq!(verify_artifacts(vec![artifact]).await, Decision::Hit); + } + + #[tokio::test] + async fn verify_artifacts_reports_missing_and_non_file_paths() { + let temp = tempfile::TempDir::new().unwrap(); + let missing = temp.path().join("missing.so"); + let directory = temp.path().join("parser.so"); + tokio::fs::create_dir(&directory).await.unwrap(); + + assert_eq!( + verify_artifacts(vec![missing.clone(), directory.clone()]).await, + Decision::Miss(Miss { + reasons: vec![ + MissReason::ArtifactMissing { path: missing }, + MissReason::ArtifactNotFile { path: directory }, + ], + }) + ); + } } diff --git a/src/columns.rs b/src/columns.rs new file mode 100644 index 0000000..9e7f4fc --- /dev/null +++ b/src/columns.rs @@ -0,0 +1,410 @@ +//! Column-layout algorithm for terminal output (multi-column, dense, plain). + +/// Layout direction for [`format()`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + /// Fill down each column before moving right. + Column, + /// Fill across each row before moving down. + Row, + /// Print one item per line. + Plain, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DenseLayout { + rows: usize, + cols: usize, + widths: Vec, +} + +/// Options controlling [`format()`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Options<'a> { + /// Layout direction. + pub layout: Layout, + /// Use per-column widths and try to reduce row count, like `git column --mode=...,dense`. + pub dense: bool, + /// Maximum output width, including indentation. + pub width: usize, + /// Minimum spaces between columns. + pub padding: usize, + /// Prefix printed before each output line. + pub indent: &'a str, + /// Suffix printed after each output line. + pub line_ending: &'a str, +} + +/// Compute the maximum width of each column from the item widths. +fn compute_column_widths( + item_widths: &[usize], + cols: usize, + layout: Layout, + rows: usize, +) -> Vec { + let mut widths = vec![0; cols]; + + for (x, column_width) in widths.iter_mut().enumerate() { + for y in 0..rows { + let item_index = linear_index(cols, layout, rows, x, y); + if let Some(width) = item_widths.get(item_index) { + *column_width = (*column_width).max(*width); + } + } + } + + widths +} + +/// Return the visible width of a string (accounting for CJK, emoji, etc.). +fn display_width(value: &str) -> usize { + console::measure_text_width(value) +} + +/// Format a list of cells using the same row/column rules as `git column`. +/// +/// This intentionally mirrors Git's small column formatter instead of shelling +/// out to `git column`: non-dense mode uses a single global cell width, while +/// dense mode shrinks the row count using per-column widths. +#[must_use] +pub fn format>(items: &[S], options: Options<'_>) -> String { + if items.is_empty() { + return String::new(); + } + + if options.layout == Layout::Plain { + return format_plain(items, options); + } + + let item_widths = items + .iter() + .map(|item| display_width(item.as_ref())) + .collect::>(); + let max_item_width = item_widths.iter().copied().max().unwrap_or(0); + let initial_width = max_item_width + options.padding; + let indent_width = display_width(options.indent); + let available_width = options.width.saturating_sub(indent_width); + + let mut cols = available_width + .checked_div(initial_width) + .unwrap_or(items.len()) + .max(1) + .min(items.len()); + let mut rows = items.len().div_ceil(cols); + let mut column_widths = None; + + if options.dense { + let dense = shrink_columns(&item_widths, options, rows, cols); + rows = dense.rows; + cols = dense.cols; + column_widths = Some(dense.widths); + } + + format_table( + items, + &item_widths, + options, + rows, + cols, + initial_width, + column_widths.as_deref(), + ) +} + +/// Format with options matching the current `git column` call site. +#[must_use] +pub fn format_git>(items: &[S], indent: &str, width: usize) -> String { + format(items, Options::git(indent, width)) +} + +/// Format items one per line (indented). +fn format_plain>(items: &[S], options: Options<'_>) -> String { + let mut output = String::new(); + + for item in items { + output.push_str(options.indent); + output.push_str(item.as_ref()); + output.push_str(options.line_ending); + } + + output +} + +/// Render items into a row/column grid with optional per-column widths. +fn format_table>( + items: &[S], + item_widths: &[usize], + options: Options<'_>, + rows: usize, + cols: usize, + initial_width: usize, + column_widths: Option<&[usize]>, +) -> String { + let mut output = String::new(); + + for y in 0..rows { + for x in 0..cols { + let item_index = linear_index(cols, options.layout, rows, x, y); + if item_index >= items.len() { + break; + } + + if x == 0 { + output.push_str(options.indent); + } + + output.push_str(items[item_index].as_ref()); + + if is_last_cell_in_row(options.layout, item_index, items.len(), rows, cols, x) { + output.push_str(options.line_ending); + } else { + let target_width = column_widths.map_or(initial_width, |widths| { + widths.get(x).copied().unwrap_or(0) + options.padding + }); + push_spaces( + &mut output, + target_width.saturating_sub(item_widths[item_index]), + ); + } + } + } + + output +} + +/// Check whether `item_index` is the last cell in its row. +const fn is_last_cell_in_row( + layout: Layout, + item_index: usize, + item_count: usize, + rows: usize, + cols: usize, + x: usize, +) -> bool { + match layout { + | Layout::Column => item_index + rows >= item_count, + | Layout::Plain => unreachable!(), + | Layout::Row => x == cols - 1 || item_index == item_count - 1, + } +} + +/// Convert grid coordinates to a linear index based on the layout direction. +const fn linear_index(cols: usize, layout: Layout, rows: usize, x: usize, y: usize) -> usize { + match layout { + | Layout::Column => x * rows + y, + | Layout::Plain => unreachable!(), + | Layout::Row => y * cols + x, + } +} + +/// Append `count` space characters to the output string. +fn push_spaces(output: &mut String, count: usize) { + output.extend(std::iter::repeat_n(' ', count)); +} + +/// Try to reduce row count while keeping total width within the limit. +fn shrink_columns( + item_widths: &[usize], + options: Options<'_>, + mut rows: usize, + mut cols: usize, +) -> DenseLayout { + let indent_width = display_width(options.indent); + + while rows > 1 { + let previous_rows = rows; + let previous_cols = cols; + + rows -= 1; + cols = item_widths + .len() + .div_ceil(rows) + .max(1) + .min(item_widths.len()); + + let candidate_widths = compute_column_widths(item_widths, cols, options.layout, rows); + let total_width = + indent_width + candidate_widths.iter().sum::() + options.padding.saturating_mul(cols); + + if total_width > options.width { + rows = previous_rows; + cols = previous_cols; + break; + } + } + + DenseLayout { + rows, + cols, + widths: compute_column_widths(item_widths, cols, options.layout, rows), + } +} + +impl<'a> Options<'a> { + /// Options matching `git column --mode=always --indent= --width=`. + #[must_use] + pub const fn git(indent: &'a str, width: usize) -> Self { + Self { + dense: false, + indent, + layout: Layout::Column, + line_ending: "\n", + padding: 1, + width, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ITEMS: &[&str] = &[ + "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", + ]; + + const fn options(layout: Layout, width: usize) -> Options<'static> { + Options { + dense: false, + indent: "", + layout, + line_ending: "\n", + padding: 1, + width, + } + } + + #[test] + fn empty_list_is_empty() { + assert_eq!(format::<&str>(&[], Options::git(" ", 80)), ""); + } + + #[test] + fn plain_layout_prints_one_indented_item_per_line() { + let opts = Options { + dense: false, + indent: "Z", + layout: Layout::Plain, + line_ending: "\n", + padding: 1, + width: 80, + }; + + assert_eq!( + format(ITEMS, opts), + "Zone\nZtwo\nZthree\nZfour\nZfive\nZsix\nZseven\nZeight\nZnine\nZten\nZeleven\n" + ); + } + + #[test] + fn column_layout_fits_eighty_columns_like_git() { + assert_eq!( + format(ITEMS, options(Layout::Column, 80)), + "one two three four five six seven eight nine ten eleven\n" + ); + } + + #[test] + fn column_layout_width_one_falls_back_to_one_per_line() { + assert_eq!( + format(ITEMS, options(Layout::Column, 1)), + "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\n" + ); + } + + #[test] + fn column_layout_width_twenty_matches_git() { + assert_eq!( + format(ITEMS, options(Layout::Column, 20)), + "one seven\ntwo eight\nthree nine\nfour ten\nfive eleven\nsix\n" + ); + } + + #[test] + fn column_layout_width_twenty_padding_two_matches_git() { + let opts = Options { + padding: 2, + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one seven\ntwo eight\nthree nine\nfour ten\nfive eleven\nsix\n" + ); + } + + #[test] + fn column_layout_width_twenty_indented_matches_git() { + let opts = Options { + indent: " ", + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + " one seven\n two eight\n three nine\n four ten\n five eleven\n six\n" + ); + } + + #[test] + fn dense_column_layout_width_twenty_matches_git() { + let opts = Options { + dense: true, + ..options(Layout::Column, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one five nine\ntwo six ten\nthree seven eleven\nfour eight\n" + ); + } + + #[test] + fn row_layout_width_twenty_matches_git() { + assert_eq!( + format(ITEMS, options(Layout::Row, 20)), + "one two\nthree four\nfive six\nseven eight\nnine ten\neleven\n" + ); + } + + #[test] + fn dense_row_layout_width_twenty_matches_git() { + let opts = Options { + dense: true, + ..options(Layout::Row, 20) + }; + + assert_eq!( + format(ITEMS, opts), + "one two three\nfour five six\nseven eight nine\nten eleven\n" + ); + } + + #[test] + fn zero_padding_keeps_adjacent_columns_adjacent() { + let opts = Options { + padding: 0, + ..options(Layout::Column, 10) + }; + + assert_eq!(format(&["a", "b"], opts), "ab\n"); + } + + #[test] + fn long_items_are_not_truncated() { + assert_eq!( + format(&["abcdef", "g"], options(Layout::Column, 3)), + "abcdef\ng\n" + ); + } + + #[test] + fn format_git_matches_current_call_with_actual_cells() { + let languages = ["rust", "ruby", "json", "typescript", "python"]; + + assert_eq!( + format_git(&languages, " ", 80), + " rust ruby json typescript python\n" + ); + } +} diff --git a/src/config.rs b/src/config.rs index 8305a84..9f5dfd2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,96 +1,629 @@ -use std::path::Path; +//! Configuration merging: CLI flags ⊕ `parsers.toml` ⊕ defaults, resolved via +//! figment and diff-struct. -use diff::Diff; -use figment::{ - providers::{Format, Serialized, Toml}, - Figment, +use std::{ + ffi::OsString, + fs, + num::NonZeroUsize, + path::{Path, PathBuf}, + result::Result as StdResult, }; + +use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, parser::ValueSource}; +use serde::Serialize; use tracing::debug; -use crate::{ - app::App, - args::{BuildCommand, ConfigCommand}, - error::TsdlError, - git, TsdlResult, -}; +use crate::{Result, ResultExt, args, columns}; -pub fn current(config: &Path, command: Option<&BuildCommand>) -> TsdlResult { - let from_default = BuildCommand::default(); - let mut from_file: BuildCommand = Figment::new() - .merge(Serialized::defaults(from_default.clone())) - .merge(Toml::file(config)) - .extract() - .map_err(|e| TsdlError::context("Merging default and config file", e))?; - match command { - Some(from_command) => { - debug!("Merging cli args + config files"); - let diff = from_default.diff(from_command); - debug!("diff default command = {:?}", diff); - from_file.apply(&diff); - } - None => { - debug!("Skipping cli args + config file merger."); - } - } - debug!("from_both = {:?}", from_file); - // Figment is screwing with me, and it's overriding config coming - // from Env::prefixed("TSDL_"). - // The scary thing is that I might have to write my own config - // joiner, where I need to track provenance of the config, and also - // whether it was explicitly set or taken from default … Figment - // has many features I don't care about. - Ok(from_file) +// ============================================================ +// Constants +// ============================================================ + +// clap derive uses the Rust field name as the arg ID, not the long flag +// name. These constants match the field names so `ArgMatches::value_source` +// can look them up. + +const ARG_BUILD_DIR: &str = "build_dir"; +const ARG_FORCE: &str = "force"; +const ARG_NO_FORCE: &str = "no_force"; +const ARG_FRESH: &str = "fresh"; +const ARG_NO_FRESH: &str = "no_fresh"; +const ARG_LANGUAGES: &str = "languages"; +const ARG_JOBS: &str = "jobs"; +const ARG_OUT_DIR: &str = "out_dir"; +const ARG_PREFIX: &str = "prefix"; +const ARG_SHOW_CONFIG: &str = "show_config"; +const ARG_NO_SHOW_CONFIG: &str = "no_show_config"; +const ARG_TARGET: &str = "target"; +const ARG_TS_VERSION: &str = "version"; +const ARG_TS_PLATFORM: &str = "platform"; +const ARG_TS_REPO: &str = "repo"; +const ARG_UNLOCK_TIMEOUT: &str = "unlock_timeout"; + +// ============================================================ +// Enums +// ============================================================ + +/// Provenance source for a single configuration value. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Source { + /// Compiled-in default. + #[default] + BuiltInDefault, + /// From the parser TOML config file. + ConfigFile, + /// From an environment variable. + Environment, + /// From a CLI flag. + CommandLine, +} + +// ============================================================ +// Structs +// ============================================================ + +/// CLI arguments for the `build` subcommand, defined via derive so the arg +/// definitions and value extraction stay in sync. +#[derive(clap::Args, Clone, Debug, Default)] +pub struct BuildArgs { + /// Override build directory (`--build-dir`, `-b`). + #[arg(long = "build-dir", short = 'b', env = "BUILD_DIR")] + pub build_dir: Option, + + /// Force rebuild (`--force`). + #[arg( + long = "force", + env = "FORCE", + num_args = 0..=1, + require_equals = true, + default_missing_value = "true" + )] + pub force: Option, + /// Negate --force (`--no-force`). + #[arg(long = "no-force")] + pub no_force: bool, + + /// Fresh build, clear build directory (`--fresh`, `-f`). + #[arg( + long = "fresh", + short = 'f', + env = "FRESH", + num_args = 0..=1, + require_equals = true, + default_missing_value = "true" + )] + pub fresh: Option, + /// Negate --fresh (`--no-fresh`). + #[arg(long = "no-fresh")] + pub no_fresh: bool, + + /// Languages to build (positional args, zero or more). + #[arg(num_args = 0..)] + pub languages: Vec, + + /// Max concurrent jobs (`--jobs`, `-j`). + #[arg(long = "jobs", short = 'j', env = "TSDL_NCPUS")] + pub jobs: Option, + + /// Output directory for installed binaries (`--out-dir`, `-o`). + #[arg(long = "out-dir", short = 'o', env = "PARSER_OUT_DIR")] + pub out_dir: Option, + + /// Output filename prefix (`--prefix`, `-p`). + #[arg(long = "prefix", short = 'p', env = "PREFIX")] + pub prefix: Option, + + /// Print resolved config and exit (`--show-config`). + #[arg( + long = "show-config", + env = "SHOW_CONFIG", + num_args = 0..=1, + require_equals = true, + default_missing_value = "true" + )] + pub show_config: Option, + /// Negate --show-config (`--no-show-config`). + #[arg(long = "no-show-config")] + pub no_show_config: bool, + + /// Build target (`--target`, `-t`). + #[arg(long = "target", short = 't', env = "TSDL_TARGET", value_enum)] + pub target: Option, + + #[command(flatten)] + pub tree_sitter: TreeSitterArgs, + + /// Lock wait timeout in seconds (`--unlock-timeout`). + #[arg( + long = "unlock-timeout", + env = "UNLOCK_TIMEOUT", + value_parser = clap::value_parser!(u64).range(1..) + )] + pub unlock_timeout: Option, +} + +/// Tracks the source provenance for every field in [`args::BuildCommand`]. +/// +/// Each field records whether the corresponding [`args::BuildCommand`] +/// value came from the built-in default, the config file, an env var, or +/// the CLI. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct BuildProvenance { + pub build_dir: Source, + pub force: Source, + pub fresh: Source, + pub languages: Source, + pub jobs: Source, + pub out_dir: Source, + pub parsers: Source, + pub prefix: Source, + pub show_config: Source, + pub target: Source, + pub tree_sitter: TreeSitterProvenance, + pub unlock_timeout: Source, +} + +/// Nested CLI arguments for tree-sitter configuration. +#[derive(clap::Args, Clone, Debug, Default)] +pub struct TreeSitterArgs { + /// CLI flag: `--tree-sitter-version` / `-V`. + #[arg(long = "tree-sitter-version", short = 'V', env = "TSDL_VERSION")] + pub version: Option, + /// CLI flag: `--tree-sitter-platform`. + #[arg(long = "tree-sitter-platform", env = "TSDL_PLATFORM")] + pub platform: Option, + /// CLI flag: `--tree-sitter-repo` / `-R`. + #[arg(long = "tree-sitter-repo", short = 'R', env = "TSDL_REPO")] + pub repo: Option, } +/// Tracks the source provenance for every field in [`args::TreeSitter`]. +/// +/// Each field records whether the corresponding [`args::TreeSitter`] +/// value came from the built-in default, the config file, an env var, or +/// the CLI. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TreeSitterProvenance { + pub version: Source, + pub platform: Source, + pub repo: Source, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Apply an Option value to a mutable field if present. +fn apply_opt(field: &mut T, value: Option) { + if let Some(v) = value { + *field = v; + } +} + +/// Resolve the current merged configuration from defaults, config file, and CLI. +pub fn current(config: &Path, matches: Option<&ArgMatches>) -> Result { + let (cmd, _provenance) = current_with_provenance(config, matches)?; + Ok(cmd) +} + +/// Resolve the current merged configuration with full provenance tracking. +pub fn current_with_provenance( + config: &Path, + matches: Option<&ArgMatches>, +) -> Result<(args::BuildCommand, BuildProvenance)> { + let defaults = args::BuildCommand::default(); + let file_overrides = read_file_overrides(config)?; + let file_provenance = file_provenance_from(&file_overrides); + + let (cli_overrides, cli_provenance) = if let Some(matches) = matches { + extract_overrides(matches) + } else { + ( + args::OptionalBuildCommand::default(), + BuildProvenance::default(), + ) + }; + + let command = merge(defaults, file_overrides, cli_overrides); + let provenance = merge_provenance(&file_provenance, &cli_provenance); + + debug!(?provenance, ?command, "Resolved build configuration"); + + Ok((command, provenance)) +} + +/// Extract CLI overrides from parsed `ArgMatches` into `OptionalBuildCommand` +/// with provenance tracking. +#[must_use] +pub fn extract_overrides(matches: &ArgMatches) -> (args::OptionalBuildCommand, BuildProvenance) { + let cli = BuildArgs::from_arg_matches(matches).unwrap_or_else(|err| err.exit()); + overrides_from_build_args(cli, matches) +} + +/// Build provenance for each config file override. +fn file_provenance_from(overrides: &args::OptionalBuildCommand) -> BuildProvenance { + let mut p = BuildProvenance::default(); + if overrides.build_dir.is_some() { + p.build_dir = Source::ConfigFile; + } + if overrides.force.is_some() { + p.force = Source::ConfigFile; + } + if overrides.fresh.is_some() { + p.fresh = Source::ConfigFile; + } + if overrides.jobs.is_some() { + p.jobs = Source::ConfigFile; + } + if overrides.out_dir.is_some() { + p.out_dir = Source::ConfigFile; + } + if overrides.parsers.is_some() { + p.parsers = Source::ConfigFile; + } + if overrides.prefix.is_some() { + p.prefix = Source::ConfigFile; + } + if overrides.show_config.is_some() { + p.show_config = Source::ConfigFile; + } + if overrides.target.is_some() { + p.target = Source::ConfigFile; + } + if overrides.unlock_timeout.is_some() { + p.unlock_timeout = Source::ConfigFile; + } + let ts = &overrides.tree_sitter; + if ts.version.is_some() { + p.tree_sitter.version = Source::ConfigFile; + } + if ts.platform.is_some() { + p.tree_sitter.platform = Source::ConfigFile; + } + if ts.repo.is_some() { + p.tree_sitter.repo = Source::ConfigFile; + } + p +} + +/// Merge defaults + file overrides + CLI overrides into a final `BuildCommand`. +fn merge( + defaults: args::BuildCommand, + file: args::OptionalBuildCommand, + cli: args::OptionalBuildCommand, +) -> args::BuildCommand { + let mut cmd = defaults; + apply_opt(&mut cmd.build_dir, file.build_dir); + apply_opt(&mut cmd.build_dir, cli.build_dir); + apply_opt(&mut cmd.force, file.force); + apply_opt(&mut cmd.force, cli.force); + apply_opt(&mut cmd.fresh, file.fresh); + apply_opt(&mut cmd.fresh, cli.fresh); + apply_opt(&mut cmd.jobs, file.jobs); + apply_opt(&mut cmd.jobs, cli.jobs); + apply_opt(&mut cmd.out_dir, file.out_dir); + apply_opt(&mut cmd.out_dir, cli.out_dir); + if let Some(parsers) = file.parsers { + cmd.parsers = Some(parsers); + } + apply_opt(&mut cmd.prefix, file.prefix); + apply_opt(&mut cmd.prefix, cli.prefix); + apply_opt(&mut cmd.show_config, file.show_config); + apply_opt(&mut cmd.show_config, cli.show_config); + apply_opt(&mut cmd.target, file.target); + apply_opt(&mut cmd.target, cli.target); + apply_opt(&mut cmd.unlock_timeout, file.unlock_timeout); + apply_opt(&mut cmd.unlock_timeout, cli.unlock_timeout); + + apply_opt(&mut cmd.tree_sitter.version, file.tree_sitter.version); + apply_opt(&mut cmd.tree_sitter.version, cli.tree_sitter.version); + apply_opt(&mut cmd.tree_sitter.platform, file.tree_sitter.platform); + apply_opt(&mut cmd.tree_sitter.platform, cli.tree_sitter.platform); + apply_opt(&mut cmd.tree_sitter.repo, file.tree_sitter.repo); + apply_opt(&mut cmd.tree_sitter.repo, cli.tree_sitter.repo); + + if let Some(langs) = cli.languages { + cmd.languages = Some(langs); + } + + cmd +} + +/// Merge file and CLI provenance into a single resolved provenance. +fn merge_provenance(file: &BuildProvenance, cli: &BuildProvenance) -> BuildProvenance { + BuildProvenance { + build_dir: merge_source(file.build_dir, cli.build_dir), + force: merge_source(file.force, cli.force), + fresh: merge_source(file.fresh, cli.fresh), + languages: merge_source(file.languages, cli.languages), + jobs: merge_source(file.jobs, cli.jobs), + out_dir: merge_source(file.out_dir, cli.out_dir), + parsers: merge_source(file.parsers, cli.parsers), + prefix: merge_source(file.prefix, cli.prefix), + show_config: merge_source(file.show_config, cli.show_config), + target: merge_source(file.target, cli.target), + tree_sitter: TreeSitterProvenance { + platform: merge_source(file.tree_sitter.platform, cli.tree_sitter.platform), + repo: merge_source(file.tree_sitter.repo, cli.tree_sitter.repo), + version: merge_source(file.tree_sitter.version, cli.tree_sitter.version), + }, + unlock_timeout: merge_source(file.unlock_timeout, cli.unlock_timeout), + } +} + +/// Pick the CLI source if set, otherwise fall back to file source. +fn merge_source(file: Source, cli: Source) -> Source { + if cli == Source::default() { file } else { cli } +} + +/// Convert CLI `BuildArgs` into `OptionalBuildCommand` + `BuildProvenance`. +fn overrides_from_build_args( + cli: BuildArgs, + matches: &ArgMatches, +) -> (args::OptionalBuildCommand, BuildProvenance) { + let mut o = args::OptionalBuildCommand::default(); + let mut p = BuildProvenance::default(); + + set_simple( + matches, + ARG_BUILD_DIR, + cli.build_dir, + &mut o.build_dir, + &mut p.build_dir, + ); + resolve_bool( + matches, + cli.force, + ARG_FORCE, + ARG_NO_FORCE, + &mut o.force, + &mut p.force, + ); + resolve_bool( + matches, + cli.fresh, + ARG_FRESH, + ARG_NO_FRESH, + &mut o.fresh, + &mut p.fresh, + ); + + if !cli.languages.is_empty() + && let Some(source) = source_for(matches, ARG_LANGUAGES) + { + o.languages = Some(cli.languages); + p.languages = source; + } + + if let Some(n) = cli.jobs.and_then(NonZeroUsize::new) + && let Some(source) = source_for(matches, ARG_JOBS) + { + o.jobs = Some(n); + p.jobs = source; + } + + set_simple( + matches, + ARG_OUT_DIR, + cli.out_dir, + &mut o.out_dir, + &mut p.out_dir, + ); + set_simple( + matches, + ARG_PREFIX, + cli.prefix, + &mut o.prefix, + &mut p.prefix, + ); + + resolve_bool( + matches, + cli.show_config, + ARG_SHOW_CONFIG, + ARG_NO_SHOW_CONFIG, + &mut o.show_config, + &mut p.show_config, + ); + + set_simple( + matches, + ARG_TARGET, + cli.target, + &mut o.target, + &mut p.target, + ); + + set_simple( + matches, + ARG_TS_VERSION, + cli.tree_sitter.version, + &mut o.tree_sitter.version, + &mut p.tree_sitter.version, + ); + set_simple( + matches, + ARG_TS_PLATFORM, + cli.tree_sitter.platform, + &mut o.tree_sitter.platform, + &mut p.tree_sitter.platform, + ); + set_simple( + matches, + ARG_TS_REPO, + cli.tree_sitter.repo, + &mut o.tree_sitter.repo, + &mut p.tree_sitter.repo, + ); + + set_simple( + matches, + ARG_UNLOCK_TIMEOUT, + cli.unlock_timeout, + &mut o.unlock_timeout, + &mut p.unlock_timeout, + ); + + (o, p) +} + +/// Parse CLI args using the augmented command definitions. +#[must_use] +pub fn parse_with_matches() -> (args::Args, ArgMatches) { + let mut cmd = args::Args::command(); + if let Some(build_sub) = cmd.find_subcommand_mut("build") { + *build_sub = BuildArgs::augment_args(build_sub.clone()); + } + let matches = cmd.get_matches(); + let args = args::Args::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()); + (args, matches) +} + +/// Print the current merged configuration as TOML. +pub fn print_current(command: &args::BuildCommand) -> Result<()> { + println!( + "{}", + toml::to_string(command).context("Generating current TOML config")? + ); + Ok(()) +} + +/// Print the built-in default configuration as TOML. +pub fn print_default() -> Result<()> { + println!( + "{}", + toml::to_string(&args::BuildCommand::default()).context("Generating default TOML config")? + ); + Ok(()) +} + +/// Print a string with a per-line indent prefix. pub fn print_indent(s: &str, indent: &str) { - s.lines().for_each(|line| println!("{indent}{line}")); -} - -pub fn run(app: &App, command: &ConfigCommand) -> TsdlResult<()> { - match command { - ConfigCommand::Current => { - let config: BuildCommand = current(&app.config_path, None)?; - println!( - "{}", - toml::to_string(&config) - .map_err(|e| { TsdlError::context("Generating default TOML config", e) })? - ); - } - ConfigCommand::Default => println!( - "{}", - toml::to_string(&BuildCommand::default()) - .map_err(|e| { TsdlError::context("Generating default TOML config", e) })? - ), - } - Ok(()) -} - -pub fn show(command: &BuildCommand) -> TsdlResult<()> { - if let Some(langs) = &command.languages { - println!("Building the following languages:"); - println!(); - println!( - "{}", - String::from_utf8( - git::column(&langs.join(" "), " ", 80) - .map_err(|e| TsdlError::context("Printing requested languages", e))? - .stdout - ) - .map_err(|e| TsdlError::context( - "Converting column-formatted languages to a string for printing", - e - ))? - ); - } else { - println!("Building all languages."); - println!(); - } - println!("Running with the following configuration:"); + s.lines().for_each(|line| println!("{indent}{line}")); +} + +/// Read parser config file overrides from the TOML file at `config`. +fn read_file_overrides(config: &Path) -> Result { + if !config.exists() { + return Ok(args::OptionalBuildCommand::default()); + } + + let contents = fs::read_to_string(config) + .with_context(|| format!("Reading config file {}", config.display()))?; + + if contents.trim().is_empty() { + return Ok(args::OptionalBuildCommand::default()); + } + + toml::from_str(&contents).with_context(|| format!("Parsing config file {}", config.display())) +} + +/// Resolve a boolean field that has both a positive (`--force`) and negative +/// (`--no-force`) flag. The negative form only takes effect when passed +/// explicitly on the command line; env-var / config values flow through the +/// positive arg. +fn resolve_bool( + matches: &ArgMatches, + positive: Option, + pos_id: &str, + neg_id: &str, + field: &mut Option, + provenance: &mut Source, +) { + // --no-* on the command line always wins. + if matches!(source_for(matches, neg_id), Some(Source::CommandLine)) { + *field = Some(false); + *provenance = Source::CommandLine; + return; + } + + if let Some(source) = source_for(matches, pos_id) + && let Some(val) = positive + { + *field = Some(val); + *provenance = source; + } +} + +/// Run a config command (current or default). +pub fn run(config_path: &Path, command: &args::ConfigCommand) -> Result<()> { + use args::ConfigCommand::{Current, Default}; + + match command { + | Current => print_current(¤t(config_path, None)?), + | Default => print_default(), + } +} + +/// Set a simple `Option` override when the arg's `ValueSource` indicates +/// the user supplied it (CLI or env). +fn set_simple( + matches: &ArgMatches, + id: &str, + value: Option, + field: &mut Option, + provenance: &mut Source, +) { + if let Some(source) = source_for(matches, id) + && let Some(val) = value + { + *field = Some(val); + *provenance = source; + } +} + +/// Print a human-readable summary of the resolved configuration. +pub fn show(command: &args::BuildCommand) -> Result<()> { + if let Some(langs) = &command.languages { + println!("Building the following languages:"); + println!(); + print!("{}", columns::format_git(langs, " ", 80)); println!(); - print_indent( - &toml::to_string(&command).map_err(|e| TsdlError::context("Showing config", e))?, - " ", - ); + } else { + println!("Building all languages."); println!(); - Ok(()) + } + println!("Running with the following configuration:"); + println!(); + print_indent(&toml::to_string(&command).context("Showing config")?, " "); + println!(); + Ok(()) +} + +/// Look up the `ValueSource` for a given arg ID and convert it to a Source. +fn source_for(matches: &ArgMatches, id: &str) -> Option { + matches.value_source(id).and_then(Source::from_value_source) +} + +/// Parse CLI args from an iterator (used in tests). +pub fn try_parse_from_with_matches(itr: I) -> StdResult<(args::Args, ArgMatches), clap::Error> +where + I: IntoIterator, + T: Into + Clone, +{ + let mut cmd = args::Args::command(); + if let Some(build_sub) = cmd.find_subcommand_mut("build") { + *build_sub = BuildArgs::augment_args(build_sub.clone()); + } + let matches = cmd.try_get_matches_from(itr)?; + let args = args::Args::from_arg_matches(&matches)?; + Ok((args, matches)) +} + +// ============================================================ +// Impls +// ============================================================ + +impl Source { + /// Convert a clap `ValueSource` to a config Source. + fn from_value_source(source: ValueSource) -> Option { + match source { + | ValueSource::CommandLine => Some(Self::CommandLine), + | ValueSource::EnvVariable => Some(Self::Environment), + | _ => None, + } + } } diff --git a/src/consts.rs b/src/consts.rs index 8fc0fd3..97376dc 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,3 +1,5 @@ +//! Build-time constants generated from `Cargo.toml` metadata. + // Include the generated constants. // See build.rs . include!(concat!(env!("OUT_DIR"), "/tree_sitter_consts.rs")); diff --git a/src/display.rs b/src/display.rs index ace4dfa..e19a427 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,333 +1,920 @@ -use std::{ - sync::atomic::Ordering, - sync::{atomic::AtomicU64, Arc}, - time, -}; +//! Display state model: repos, grammars, grid cache, layout computation, and +//! summary counts. + +use std::collections::{HashMap, HashSet}; +use std::num::NonZeroU64; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; use clap_verbosity_flag::{InfoLevel, Verbosity}; -use console::style; use log::Level; -use tokio::sync::OnceCell; - -use crate::{args::ProgressStyle, error::TsdlError, format_duration, git::GitRef, TsdlResult}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +use crate::args::ProgressStyle; +use crate::git; + +// ============================================================ +// Constants +// ============================================================ + +/// Width of the icon column (●, ✓, ✗ are all single-width). +const ICON_COL_WIDTH: usize = 1; +/// Minimum width for the message column. +const MIN_MSG_WIDTH: usize = 1; +/// Minimum width of the name column (left-aligned). +const MIN_NAME_WIDTH: usize = 4; +/// Minimum width of the git-ref column (left-aligned). +const MIN_REF_WIDTH: usize = 4; +/// Minimum width of the step column (right-aligned, e.g. "[1/4]"). +const MIN_STEP_WIDTH: usize = 5; +const MSG_STYLE: Style = Style::new(); +const REF_STYLE: Style = Style::new(); +/// Width of a spacer between columns (a single space). +const SPACER_WIDTH: usize = 1; +/// Fixed width of the time column (right-aligned, " 0.00s" … "59:59"). +const TIME_COL_WIDTH: usize = 6; +const TIME_STYLE: Style = Style::new(); + +// ============================================================ +// Enums +// ============================================================ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Column { + /// Icon (status indicator). + Icon, + /// Message text. + Msg, + /// Parser/grammar name. + Name, + /// Git ref string. + Ref, + /// Step counter (e.g. "[1/4]"). + Step, + /// Elapsed time. + Time, +} -#[derive(Debug, Clone, Copy)] -pub enum UpdateKind { - Msg, - Step, - Fin, - Err, +pub(crate) enum ItemInfo<'a> { + /// References a grammar-level entry. + Grammar(&'a GrammarEntry), + /// References a repo-level entry. + Repo(&'a RepoEntry), } -/// Spinning sprite. -pub const TICK_CHARS: &str = "⠷⠯⠟⠻⠽⠾⠿"; +/// Complete lifecycle state for a display row. +/// +/// This intentionally keeps terminality and success classification in one +/// field, so a row cannot be both "cancelled" and "still building". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemState { + /// Just registered, not yet started. + New, + /// In progress. The optional outcome is known after cache classification: + /// cached rows stay live while they are re-installed, and built rows stay + /// live while they are generated/built/installed. + InProgress(Option), + /// Successfully completed. + Done(SuccessOutcome), + /// Cancelled by shutdown. + Cancelled, + /// Failed. + Failed, +} +/// The selected display backend. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Mode { - Fancy, - Plain, + /// Ratatui inline terminal rendering. + Fancy, + /// Deterministic line-by-line output. + Plain, } +// ============================================================ +// Structs +// ============================================================ + +/// The kind of rows: collapsed single-grammar repos and multi-grammar repos. #[derive(Debug, Clone)] -pub struct Progress { - multi: indicatif::MultiProgress, - pub mode: Mode, - // We store handles to ensure they aren't dropped prematurely if needed, - // mimicking the original `handles` vectors. - handles: Vec, -} - -impl Progress { - #[must_use] - pub fn new(mode: Mode) -> Self { - Self { - multi: indicatif::MultiProgress::new(), - mode, - handles: Vec::new(), - } - } +pub(crate) enum RowKind { + /// A grammar-level progress row. + Grammar, + /// A repo-level progress row. + Repo, +} - pub fn clear(&self) -> TsdlResult<()> { - if self.mode == Mode::Fancy { - self.multi - .clear() - .map_err(|e| TsdlError::context("Clearing the multi-progress bar", e))?; - } - Ok(()) - } +/// Whether a completed build needed actual work or was served from cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SuccessOutcome { + /// Not cached; work was needed. + Built, + /// Cache hit. + Cached, +} - pub fn is_done(&self) -> bool { - self.handles.iter().all(ProgressBar::is_done) - } +// ============================================================ +// Structs +// ============================================================ - pub fn prinltn(&self, msg: impl AsRef) { - println!("{}", msg.as_ref()); - } +#[derive(Debug, Clone)] +pub(crate) struct CachedLayout { + /// Width of the time column. + pub time: usize, + /// Width of the git-ref column. + pub ref_: usize, + /// Width of the step column. + pub step: usize, + /// Width of the name column. + pub name: usize, + /// Total width consumed by non-message columns: time + sp + ref + sp + + /// step + sp + icon(1) + sp + name + sp. + pub fixed_width: usize, +} - /// # Panics - /// - /// Will panic indicatif errs. - pub fn register(&mut self, name: Arc, git_ref: GitRef, num_tasks: usize) -> ProgressBar { - let bar = match self.mode { - Mode::Fancy => { - let bar = indicatif::ProgressBar::new(num_tasks as u64); - let bar = self.multi.add(bar); - let style = indicatif::ProgressStyle::with_template( - "{prefix:.bold.dim} {spinner} {wide_msg}", - ) - .unwrap_or_else(|_| { - panic!("cannot create spinner [?/{num_tasks}] {name} @ {git_ref}") - }) - .tick_chars(TICK_CHARS); - bar.set_style(style); - bar.set_prefix(format!("[?/{num_tasks}]")); - Some(bar) - } - Mode::Plain => None, - }; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct CellKey { + item_id: ItemId, + column: Column, +} - let handle = ProgressBar { - bar, - name, - git_ref, - num_tasks, - t_start: OnceCell::new(), - mode: self.mode, - current_step: Arc::new(AtomicU64::new(0)), - }; +#[derive(Debug, Clone)] +pub(crate) struct GrammarEntry { + /// Parent repo name. + pub repo: Arc, + /// Parent repo's item ID (for batch updates). + pub repo_id: Option, + /// Grammar name within the repo. + pub name: Arc, + /// Git ref for display. + pub git_ref: git::Ref, + /// Current lifecycle state. + pub state: ItemState, + /// Current status message. + pub msg: Arc, + /// Current step index (0-based). + pub step: usize, + /// Total number of steps. + pub total: usize, + /// Creation time for the whole item. This is not reset between steps. + pub started_at: Instant, + /// Frozen whole-item elapsed time for terminal states. + pub frozen_elapsed: Option, +} - self.handles.push(handle.clone()); - handle - } +pub(crate) struct GridCache { + /// Cached rendered cells, keyed by (`item_id`, column). + cells: HashMap>, + /// Item IDs whose cells need recomputation. + pub dirty_items: HashSet, + /// Current column layout (widths). + pub layout: CachedLayout, +} - pub fn tick(&self) { - // Only necessary for fancy bars in some terminals/configs, plain bars do nothing - if self.mode == Mode::Fancy { - for handle in &self.handles { - handle.tick(); - } - } +/// Unique identifier for a display row (repo or grammar). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ItemId(NonZeroU64); + +#[derive(Debug, Clone)] +pub(crate) struct RepoEntry { + /// Repo name. + pub name: Arc, + /// Git ref for display. + pub git_ref: git::Ref, + /// Current lifecycle state. + pub state: ItemState, + /// Current status message. + pub msg: Arc, + /// Current step index (0-based). + pub step: usize, + /// Total number of steps. + pub total: usize, + /// Creation time for the whole item. This is not reset between steps. + pub started_at: Instant, + /// Frozen whole-item elapsed time for terminal states. + pub frozen_elapsed: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct RowSpec { + /// Unique row identifier. + pub id: ItemId, + /// Whether this row is a repo or a grammar. + pub kind: RowKind, + /// The text to display in the name column (already includes indent). + pub display_name: Arc, + /// Indentation prefix for the name column (applied before padding). + pub indent: &'static str, +} + +/// Tallies of terminal row states for the summary footer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct BuildSummary { + /// Rows still building. + pub building: usize, + /// Rows that were built (not cached). + pub built: usize, + /// Rows served from cache. + pub cached: usize, + /// Rows cancelled by shutdown. + pub cancelled: usize, + /// Rows that failed. + pub failed: usize, +} + +pub(crate) struct State { + /// Build directory root path. + pub build_dir: PathBuf, + footer_build: Line<'static>, + footer_out: Line<'static>, + /// Grammar-level entries keyed by item ID. + pub grammars: HashMap, + /// Display mode. + pub mode: Mode, + /// Output directory path. + pub out_dir: PathBuf, + /// Repo-level entries keyed by item ID. + pub repos: HashMap, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Render the status icon cell (●, ✓, ✗) with indicator color. +pub(crate) fn compute_icon_cell(info: &ItemInfo<'_>) -> Span<'static> { + let state = info.state(); + Span::styled( + state.icon(), + Style::default() + .fg(state.indicator_color()) + .add_modifier(Modifier::BOLD), + ) +} + +/// Render the message cell, truncating to fit the available width. +pub(crate) fn compute_msg_cell( + info: &ItemInfo<'_>, + layout: &CachedLayout, + term_width: usize, +) -> Span<'static> { + let msg_width = term_width + .saturating_sub(layout.fixed_width) + .max(MIN_MSG_WIDTH); + Span::styled(truncate_str(info.msg(), msg_width), MSG_STYLE) +} + +/// Render the name cell with indentation and outcome-based colour. +pub(crate) fn compute_name_cell( + display_name: &str, + indent: &str, + info: &ItemInfo<'_>, + layout: &CachedLayout, +) -> Span<'static> { + let full = format!("{indent}{display_name}"); + let padded = format!("{:, layout: &CachedLayout) -> Span<'static> { + let ref_str = info.git_ref().short(); + let padded = format!("{:, layout: &CachedLayout) -> Span<'static> { + let step_str = if info.total() > 0 { + format!("[{}/{}]", info.step().min(info.total()), info.total()) + } else { + String::new() + }; + // Right-aligned + let padded = format!("{:>width$}", step_str, width = layout.step); + Span::styled(padded, REF_STYLE) +} + +/// Render the elapsed time cell, right-aligned. +pub(crate) fn compute_time_cell(info: &ItemInfo<'_>, layout: &CachedLayout) -> Span<'static> { + let time = format_elapsed_duration(info.elapsed()); + // Right-aligned + let padded = format!("{:>width$}", time, width = layout.time); + Span::styled(padded, TIME_STYLE) +} + +/// Accumulate a single item state into the summary counters. +/// Accumulate a single item state into the summary counters. +fn count_item_state( + state: ItemState, + cached: &mut usize, + built: &mut usize, + building: &mut usize, + failed: &mut usize, + cancelled: &mut usize, +) { + use ItemState::{Cancelled, Done, Failed, InProgress, New}; + use SuccessOutcome::{Built, Cached}; + + match state { + | Cancelled => *cancelled += 1, + | Done(Built) => *built += 1, + | Done(Cached) => *cached += 1, + | Failed => *failed += 1, + | InProgress(_) | New => *building += 1, + } +} + +/// Pre-computed dimmed footer lines (static; never change). +/// Build a dimmed footer line. +fn dim_line(text: String) -> Line<'static> { + Line::from(Span::styled( + text, + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + )) +} + +/// Pre-compute the fixed (non-message) portion of a row line. +/// +/// Columns in order: Time _ Ref _ Step _ Icon _ Name _ Msg +/// (5 spacers between 6 columns). +/// Compute the total width consumed by all non-message columns. +const fn fixed_width(time: usize, rf: usize, step: usize, name: usize) -> usize { + time + + SPACER_WIDTH + + rf + + SPACER_WIDTH + + step + + SPACER_WIDTH + + ICON_COL_WIDTH + + SPACER_WIDTH + + name + + SPACER_WIDTH +} + +/// Format a duration as " 0.00s" or " 3:45". +/// Format a duration as " 0.00s" or " 3:45". +fn format_elapsed_duration(dur: Duration) -> String { + let secs = dur.as_secs_f64(); + if secs < 60.0 { + format!("{secs:>5.2}s") + } else { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let mins = secs as u64 / 60; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let remaining = secs as u64 % 60; + format!("{mins:>3}:{remaining:02}") + } +} + +/// Determine the display mode from CLI flags and environment. +#[must_use] +pub fn mode_from_args(progress: &ProgressStyle, verbose: &Verbosity) -> Mode { + let mut mode = match progress { + | ProgressStyle::Auto => { + if atty::is(atty::Stream::Stdout) { + Mode::Fancy + } else { + Mode::Plain + } } + | ProgressStyle::Fancy => Mode::Fancy, + | ProgressStyle::Plain => Mode::Plain, + }; + + if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { + mode = Mode::Plain; + } + + mode } -// Ensure bars are finished on drop -impl Drop for Progress { - fn drop(&mut self) { - for handle in &self.handles { - if !handle.is_done() { - if let Some(bar) = &handle.bar { - bar.finish(); - } - } - } +/// Append a "building" counter span to the summary line. +fn push_summary_building(spans: &mut Vec>, count: usize) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{count} building"), + Style::default().fg(if count > 0 { + Color::White + } else { + Color::DarkGray + }), + )); +} + +/// Append a "cancelled" counter span to the summary line. +fn push_summary_cancelled(spans: &mut Vec>, count: usize, _leading: bool) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗ {count} cancelled"), + Style::default().fg(if count > 0 { + Color::Yellow + } else { + Color::DarkGray + }), + )); +} + +/// Append a "failed" counter span to the summary line. +fn push_summary_failed(spans: &mut Vec>, count: usize) { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("✗ {count} failed"), + Style::default().fg(if count > 0 { + Color::Red + } else { + Color::DarkGray + }), + )); +} + +/// Append a "cached"/"built" success counter span to the summary line. +fn push_summary_success( + spans: &mut Vec>, + count: usize, + label: &'static str, + label_color: Color, + leading: bool, +) { + if !leading { + spans.push(Span::raw(" ")); + } + + if count > 0 { + spans.push(Span::styled( + format!("✓ {count} "), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + label, + Style::default() + .fg(label_color) + .add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!("✓ {count} {label}"), + Style::default().fg(Color::DarkGray), + )); + } +} + +/// Truncate a string with an ellipsis if it exceeds `max` characters. +fn truncate_str(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else if max <= 1 { + "…".to_string() + } else { + let mut truncated: String = s.chars().take(max - 1).collect(); + truncated.push('…'); + truncated + } +} + +// ============================================================ +// Impls +// ============================================================ + +impl Default for CachedLayout { + fn default() -> Self { + Self { + time: TIME_COL_WIDTH, + ref_: MIN_REF_WIDTH, + step: MIN_STEP_WIDTH, + name: MIN_NAME_WIDTH, + fixed_width: fixed_width( + TIME_COL_WIDTH, + MIN_REF_WIDTH, + MIN_STEP_WIDTH, + MIN_NAME_WIDTH, + ), } + } } -#[derive(Debug, Clone)] -pub struct ProgressBar { - bar: Option, - pub name: Arc, - git_ref: GitRef, - num_tasks: usize, - t_start: OnceCell, - mode: Mode, - current_step: Arc, -} - -impl PartialEq for ProgressBar { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - && self.git_ref == other.git_ref - && self.num_tasks == other.num_tasks +impl CachedLayout { + /// Apply minimum width constraints and recompute `fixed_width`. + fn finalize(&mut self) { + self.time = TIME_COL_WIDTH; + self.ref_ = self.ref_.max(MIN_REF_WIDTH); + self.step = self.step.max(MIN_STEP_WIDTH); + self.name = self.name.max(MIN_NAME_WIDTH); + self.fixed_width = fixed_width(self.time, self.ref_, self.step, self.name); + } +} + +impl GrammarEntry { + /// Return the elapsed time (frozen or live). + pub(crate) fn elapsed(&self) -> Duration { + self + .frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } +} + +impl GridCache { + /// Create an empty grid cache. + pub fn new() -> Self { + Self { + cells: HashMap::new(), + dirty_items: HashSet::new(), + layout: CachedLayout::default(), } + } + + /// Get a cell for the given item and column. If `stale` is true (or the + /// cell is not cached), `compute` is called, the result is cached, and + /// returned. Otherwise the cached cell is cloned. + pub fn cell( + &mut self, + item_id: ItemId, + column: Column, + compute: impl FnOnce() -> Span<'static>, + stale: bool, + ) -> Span<'static> { + let key = CellKey { item_id, column }; + if stale { + let span = compute(); + self.cells.insert(key, span.clone()); + span + } else if let Some(cached) = self.cells.get(&key) { + cached.clone() + } else { + let span = compute(); + self.cells.insert(key, span.clone()); + span + } + } + + /// Mark all cells for an item as needing recomputation. + pub fn mark_dirty(&mut self, item_id: ItemId) { + self.dirty_items.insert(item_id); + } + + /// Clear the dirty set after recomputation. + pub fn clear_dirty(&mut self) { + self.dirty_items.clear(); + } + + /// Invalidate all cached cells for a given column (e.g. when layout width + /// for that column changes). + pub fn invalidate_column(&mut self, column: Column) { + self.cells.retain(|key, _| key.column != column); + } +} + +impl ItemId { + /// Create an `ItemId` from a non-zero u64. + #[must_use] + pub(crate) fn new(value: NonZeroU64) -> Self { + Self(value) + } + + /// Return the next sequential `ItemId`. + #[must_use] + pub(crate) fn next_after(self) -> Self { + let next = self + .0 + .get() + .checked_add(1) + .and_then(NonZeroU64::new) + .expect("display item id exhausted"); + Self(next) + } } -impl ProgressBar { - fn format_elapsed(&self) -> String { - self.t_start - .get() - .map(|start| { - let dur = format_duration(time::Instant::now().duration_since(*start)); - if self.mode == Mode::Fancy { - format!(" in {}", style(dur).yellow()) - } else { - format!(" in {dur}") - } - }) - .unwrap_or_default() +impl ItemInfo<'_> { + /// Get the current item state. + pub(crate) fn state(&self) -> ItemState { + match self { + | ItemInfo::Grammar(g) => g.state, + | ItemInfo::Repo(r) => r.state, } + } - fn name_with_version(&self) -> String { - if self.git_ref.is_empty() { - self.name.to_string() - } else { - format!("{} {}", self.name, style(&self.git_ref).blue()) - } + /// Get the current message string. + pub(crate) fn msg(&self) -> &str { + match self { + | ItemInfo::Grammar(g) => &g.msg, + | ItemInfo::Repo(r) => &r.msg, } + } - /// Helper to print log lines in Plain mode (using bar.println to coordinate with `MultiProgress`) - pub fn println(&self, msg: String) { - match &self.bar { - Some(bar) => bar.println(msg), - None => println!("{msg}"), - } + /// Get the current step index (0-based). + pub(crate) fn step(&self) -> usize { + match self { + | ItemInfo::Grammar(g) => g.step, + | ItemInfo::Repo(r) => r.step, + } + } + + /// Get the total number of steps. + pub(crate) fn total(&self) -> usize { + match self { + | ItemInfo::Grammar(g) => g.total, + | ItemInfo::Repo(r) => r.total, + } + } + + /// Get the git ref for this item. + /// Get the git ref for this item. + pub(crate) fn git_ref(&self) -> &git::Ref { + match self { + | ItemInfo::Grammar(g) => &g.git_ref, + | ItemInfo::Repo(r) => &r.git_ref, + } + } + + /// Get the elapsed time for this item. + pub(crate) fn elapsed(&self) -> Duration { + match self { + | ItemInfo::Grammar(g) => g.elapsed(), + | ItemInfo::Repo(r) => r.elapsed(), } + } } -impl ProgressBar { - pub fn err(&self, msg: impl AsRef) { - if let Some(bar) = &self.bar { - bar.abandon_with_message(format!( - "{} {} {}{}", - self.name_with_version(), - style(msg.as_ref()).blue(), - style("failed").red(), - self.format_elapsed() - )); - } else { - let cur = self.current_step.load(Ordering::SeqCst); - self.println(format!( - "[{}/{}] {} {} {}{}", - cur, - self.num_tasks, - self.name_with_version(), - msg.as_ref(), - style("failed").red(), - self.format_elapsed() - )); - } +impl ItemState { + /// Check whether the item is still live (new or in progress). + #[must_use] + pub fn is_live(self) -> bool { + matches!(self, Self::New | Self::InProgress(_)) + } + + /// Get the success outcome, if the item has reached a terminal outcome state. + #[must_use] + pub fn success_outcome(self) -> Option { + match self { + | Self::Cancelled | Self::Failed | Self::New => None, + | Self::Done(outcome) => Some(outcome), + | Self::InProgress(outcome) => outcome, + } + } + + /// Return the status icon character. + fn icon(self) -> &'static str { + match self { + | Self::Cancelled | Self::Failed => "✗", + | Self::Done(_) => "✓", + | Self::New | Self::InProgress(_) => "●", } + } + + /// Return the colour for the name text. + fn name_color(self) -> Color { + match self { + | Self::Cancelled => Color::Yellow, + | Self::Done(outcome) | Self::InProgress(Some(outcome)) => outcome.color(), + | Self::Failed => Color::Red, + | Self::New | Self::InProgress(None) => Color::DarkGray, + } + } + + /// Return the colour for the status indicator. + fn indicator_color(self) -> Color { + match self { + | Self::Done(_) => Color::Green, + | Self::Cancelled => Color::Yellow, + | Self::Failed => Color::Red, + | Self::New | Self::InProgress(_) => Color::DarkGray, + } + } +} - pub fn fin(&self, msg: impl AsRef) { - if let Some(bar) = &self.bar { - bar.inc(1); - } else { - self.current_step.fetch_add(1, Ordering::SeqCst); - } +impl RepoEntry { + /// Return the elapsed time (frozen or live). + pub(crate) fn elapsed(&self) -> Duration { + self + .frozen_elapsed + .unwrap_or_else(|| self.started_at.elapsed()) + } +} - if let Some(bar) = &self.bar { - let position = usize::try_from(bar.position()) - .unwrap_or(self.num_tasks) - .min(self.num_tasks); - bar.set_prefix(format!("[{}/{}]", position, self.num_tasks)); - - let message = if msg.as_ref().is_empty() { - format!( - "{} {}{}", - self.name_with_version(), - style("done").green(), - self.format_elapsed() - ) - } else { - format!( - "{} {} {}{}", - self.name_with_version(), - msg.as_ref(), - style("done").green(), - self.format_elapsed() - ) - }; - bar.finish_with_message(message); - } else { - let cur = self.current_step.load(Ordering::SeqCst); - if msg.as_ref().is_empty() { - self.println(format!( - "[{}/{}] {} {}{}", - cur, - self.num_tasks, - self.name_with_version(), - style("done").green(), - self.format_elapsed() - )); - } else { - self.println(format!( - "[{}/{}] {} {} {}{}", - cur, - self.num_tasks, - self.name_with_version(), - style(msg.as_ref()).blue(), - style("done").green(), - self.format_elapsed() - )); - } - } +impl State { + /// Create a new display state with the given build dir, mode, and output dir. + pub fn new(build_dir: PathBuf, mode: Mode, out_dir: PathBuf) -> Self { + let footer_build = dim_line(format!("build: {}", build_dir.display())); + let footer_out = dim_line(format!("out: {}", out_dir.display())); + Self { + build_dir, + footer_build, + footer_out, + grammars: HashMap::new(), + mode, + out_dir, + repos: HashMap::new(), } + } + + // ── Footer lines ────────────────────────────────────────────── + + /// Return the pre-computed "build:" footer line. + pub fn footer_build_line(&self) -> Line<'static> { + self.footer_build.clone() + } + + /// Return the pre-computed "out:" footer line. + pub fn footer_out_line(&self) -> Line<'static> { + self.footer_out.clone() + } - pub fn is_done(&self) -> bool { - self.bar - .as_ref() - .is_some_and(indicatif::ProgressBar::is_finished) + /// Build the summary counts footer line. + pub fn format_footer_counts(&self) -> Line<'static> { + let s = self.summary(); + let mut spans: Vec> = Vec::new(); + + push_summary_success(&mut spans, s.cached, "cached", Color::Yellow, true); + push_summary_success(&mut spans, s.built, "built", Color::Blue, false); + if s.cancelled > 0 { + push_summary_cancelled(&mut spans, s.cancelled, false); + } + if s.building > 0 { + push_summary_building(&mut spans, s.building); + } + push_summary_failed(&mut spans, s.failed); + + Line::from(spans) + } + + // ── Row order ───────────────────────────────────────────────── + + /// Compute the sorted row order. Repos are sorted alphabetically by name; + /// grammars within each repo are sorted alphabetically by name. + /// Collapses single-grammar repos where the grammar name equals the repo + /// name into a single grammar row (no separate repo row). + pub fn compute_row_order(&self) -> Vec { + let mut rows = Vec::new(); + + // Sort repos alphabetically by name + let mut sorted_repos: Vec<(ItemId, &RepoEntry)> = + self.repos.iter().map(|(id, r)| (*id, r)).collect(); + sorted_repos.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); + + // Gather grammar (id, entry) pairs grouped by repo, sorted by name + let mut grammars_by_repo: HashMap> = HashMap::new(); + for (gid, g) in &self.grammars { + if let Some(repo_id) = g.repo_id { + grammars_by_repo.entry(repo_id).or_default().push((*gid, g)); + } + } + for list in grammars_by_repo.values_mut() { + list.sort_by(|(_, a), (_, b)| a.name.cmp(&b.name)); } - pub fn msg(&self, msg: impl AsRef) { - if let Some(bar) = &self.bar { - let position = usize::try_from(bar.position()) - .unwrap_or(self.num_tasks) - .min(self.num_tasks); - bar.set_prefix(format!("[{}/{}]", position, self.num_tasks)); - bar.set_message(format!("{} {}", self.name_with_version(), msg.as_ref())); + for (repo_id, repo) in &sorted_repos { + let repo_grammars = grammars_by_repo.remove(repo_id).unwrap_or_default(); + + if repo_grammars.is_empty() { + rows.push(RowSpec { + id: *repo_id, + kind: RowKind::Repo, + display_name: repo.name.clone(), + indent: "", + }); + } else if repo_grammars.len() == 1 { + let (gid, grammar) = repo_grammars[0]; + let display_name: Arc = if grammar.name == repo.name { + repo.name.clone() } else { - let cur = self.current_step.load(Ordering::SeqCst); - self.println(format!( - "[{}/{}] {}: {}", - cur, - self.num_tasks, - self.name_with_version(), - msg.as_ref() - )); + format!("{}/{}", repo.name, grammar.name).into() + }; + rows.push(RowSpec { + id: gid, + kind: RowKind::Grammar, + display_name, + indent: "", + }); + } else { + rows.push(RowSpec { + id: *repo_id, + kind: RowKind::Repo, + display_name: repo.name.clone(), + indent: "", + }); + for (gid, grammar) in repo_grammars { + rows.push(RowSpec { + id: gid, + kind: RowKind::Grammar, + display_name: grammar.name.clone(), + indent: " ", + }); } + } } - pub fn step(&self, msg: impl AsRef) { - let _ = self.t_start.set(time::Instant::now()); - if let Some(bar) = &self.bar { - bar.inc(1); - } else { - self.current_step.fetch_add(1, Ordering::SeqCst); - } + rows + } + + // ── Item access ─────────────────────────────────────────────── + + /// Get the item info for a row spec. + pub fn get_item_info(&self, spec: &RowSpec) -> ItemInfo<'_> { + match spec.kind { + | RowKind::Grammar => ItemInfo::Grammar( + self + .grammars + .get(&spec.id) + .expect("grammar not found for row spec"), + ), + | RowKind::Repo => ItemInfo::Repo( + self + .repos + .get(&spec.id) + .expect("repo not found for row spec"), + ), + } + } + + // ── Layout ──────────────────────────────────────────────────── + + /// Compute column layout from the current row specs and state. + pub fn compute_layout(&self, row_specs: &[RowSpec]) -> CachedLayout { + let mut layout = CachedLayout::default(); + for spec in row_specs { + let info = self.get_item_info(spec); + let ref_str = info.git_ref().short(); + let step_str = if info.total() > 0 { + format!("[{}/{}]", info.step().min(info.total()), info.total()) + } else { + String::new() + }; + let name_full = format!("{}{}", spec.indent, spec.display_name); + layout.ref_ = layout.ref_.max(ref_str.chars().count()); + layout.step = layout.step.max(step_str.chars().count()); + layout.name = layout.name.max(name_full.chars().count()); + } + layout.finalize(); + layout + } + + // ── Summary counts ──────────────────────────────────────────── + + /// Compute build summary counts from all item states. + pub(crate) fn summary(&self) -> BuildSummary { + let mut cached = 0; + let mut built = 0; + let mut building = 0; + let mut failed = 0; + let mut cancelled = 0; + + let mut repos_with_grammars: HashSet = HashSet::new(); + for grammar in self.grammars.values() { + if let Some(repo_id) = grammar.repo_id { + repos_with_grammars.insert(repo_id); + } + count_item_state( + grammar.state, + &mut cached, + &mut built, + &mut building, + &mut failed, + &mut cancelled, + ); + } - if let Some(bar) = &self.bar { - let position = usize::try_from(bar.position()) - .unwrap_or(self.num_tasks) - .min(self.num_tasks); - bar.set_prefix(format!("[{}/{}]", position, self.num_tasks)); - bar.set_message(format!("{}: {}", self.name_with_version(), msg.as_ref())); - } else { - let cur = self.current_step.load(Ordering::SeqCst); - self.println(format!( - "[{}/{}] {} {}", - cur, - self.num_tasks, - self.name_with_version(), - msg.as_ref() - )); - } + // Repos without grammars are visible terminal rows too (for example, + // tree-sitter-cli or a language that failed before grammar discovery). + for (repo_id, repo) in &self.repos { + if !repos_with_grammars.contains(repo_id) { + count_item_state( + repo.state, + &mut cached, + &mut built, + &mut building, + &mut failed, + &mut cancelled, + ); + } } - pub fn tick(&self) { - if let Some(bar) = &self.bar { - bar.tick(); - } + BuildSummary { + building, + built, + cached, + cancelled, + failed, } + } } -#[must_use] -pub fn current(progress: &ProgressStyle, verbose: &Verbosity) -> Progress { - let mut mode = match progress { - ProgressStyle::Auto => { - if atty::is(atty::Stream::Stdout) { - Mode::Fancy - } else { - Mode::Plain - } - } - ProgressStyle::Fancy => Mode::Fancy, - ProgressStyle::Plain => Mode::Plain, - }; - - if matches!(verbose.log_level(), Some(Level::Debug | Level::Trace)) { - mode = Mode::Plain; +impl SuccessOutcome { + /// Return the display colour for entries with this outcome. + fn color(self) -> Color { + match self { + | SuccessOutcome::Built => Color::Blue, + | SuccessOutcome::Cached => Color::Yellow, } - - Progress::new(mode) + } } diff --git a/src/error.rs b/src/error.rs index caf63f1..00b22d7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,578 +1,443 @@ +//! Error type hierarchy: `TsdlError` with nested `Step`, `Parser`, +//! `Language`, `Command`, and `Build` variants. + use std::fmt; use std::path::PathBuf; use std::sync::Arc; use derive_more::derive::Display; -/// Macro for creating Step errors with common patterns -#[macro_export] -macro_rules! step_error { - ($name:expr, $kind:expr, $source:expr) => { - error::Step::new($name.to_string(), $kind, $source) - }; -} - -/// Represents a single layer in the context chain -#[derive(Debug)] -pub struct ContextKind { - /// The wrapped error - pub error: TsdlError, - /// The context message - pub message: String, -} - -impl fmt::Display for ContextKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}: {}", self.message, self.error) - } -} - -#[derive(Debug)] -pub struct Command { - pub msg: String, - pub stderr: String, - pub stdout: String, -} - -impl std::error::Error for Command {} - -impl fmt::Display for Command { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) - } -} - -impl Command { - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!(w, "{}$ {}", prefix, self.msg)?; - - let has_stdout = !self.stdout.is_empty(); - let has_stderr = !self.stderr.is_empty(); - - if has_stdout && has_stderr { - let mut write_section = |header: &str, content: &str| -> fmt::Result { - writeln!(w, "\n{prefix} {header}:")?; - - let mut lines = content.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix} {first}")?; - for line in lines { - write!(w, "\n{prefix} {line}")?; - } - } - Ok(()) - }; - - write_section("stdout", &self.stdout)?; - write_section("stderr", &self.stderr)?; - } else if has_stderr { - writeln!(w)?; - let mut lines = self.stderr.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix}{first}")?; - for line in lines { - write!(w, "\n{prefix}{line}")?; - } - } - } else if has_stdout { - writeln!(w)?; - let mut lines = self.stdout.lines(); - if let Some(first) = lines.next() { - write!(w, "{prefix}{first}")?; - for line in lines { - write!(w, "\n{prefix}{line}")?; - } - } - } - - Ok(()) - } - - #[must_use] - pub fn format_with_indent(&self, indent: usize) -> String { - let mut s = String::new(); - let _ = self.format(&mut s, indent); - s - } -} - -#[derive(Debug)] -pub struct LanguageCollection { - pub related: Vec, -} - -impl fmt::Display for LanguageCollection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Could not figure out all languages:")?; - format_languages_inner(f, &self.related) - } -} - -impl std::error::Error for LanguageCollection {} - -#[derive(Debug)] -pub struct Language { - pub name: String, - pub source: Box, -} +use crate::shutdown::Signal; -impl fmt::Display for Language { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) - } -} +pub type Result = std::result::Result; -impl Language { - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!( - w, - "{}{}\n{}", - prefix, - self.name, - self.source.format_indent(indent + 2) - ) - } +// ============================================================ +// Traits +// ============================================================ - /// Format with indentation - /// - /// # Panics - /// - /// This function will panic if writing to the string fails, which should never happen - /// since we're writing to a String which doesn't fail. - #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - self.format(&mut s, indent) - .expect("Failed to format with indent"); - s - } -} +pub trait ResultExt { + /// Wrap the error value with a context message. + fn context(self, message: impl Into) -> Result; -impl Language { - pub fn new(name: String, source: impl Into) -> Language { - Language { - name, - source: Box::new(source.into()), - } - } + /// Wrap the error value with a lazily-evaluated context message. + fn with_context(self, message: impl FnOnce() -> String) -> Result; } -impl std::error::Error for Language { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(self.source.as_ref()) - } -} +// ============================================================ +// Enums +// ============================================================ +/// Main error type for tsdl operations #[derive(Debug)] -pub struct Parser { - pub related: Vec, -} - -impl fmt::Display for Parser { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) - } -} +pub enum Error { + /// Build errors + Build { errors: Vec }, -// TODO: review formatting; duplicates? -impl Parser { - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!(w, "{prefix}Could not build all parsers.")?; + /// Command execution failed + Command { + msg: String, + stderr: String, + stdout: String, + }, - for err in &self.related { - write!(w, "\n\n{}", err.format_indent(indent + 2))?; - } + /// Configuration error + Config { message: String }, - Ok(()) - } + /// Context chain (linked list of context layers) + Context { message: String, source: Cause }, - /// Format with indentation - /// - /// # Panics - /// - /// This function will panic if writing to the string fails, which should never happen - /// since we're writing to a String which doesn't fail. - #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - self.format(&mut s, indent).unwrap(); - s - } -} + /// Generic IO error + Io { source: std::io::Error }, -impl std::error::Error for Parser {} + /// Build was interrupted by a Unix signal. + Interrupted { signal: Signal }, -#[derive(Debug)] -pub struct Step { - pub name: Arc, - pub kind: ParserOp, - pub source: Box, -} + /// Simple error message + Message { message: String }, -impl fmt::Display for Step { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.format(f, 0) - } -} + /// Language collection failed + LanguageCollection { related: Vec }, -impl Step { - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - write!( - w, - "{}{}: {}.\n{}", - prefix, - self.name, - self.kind, - self.source.format_indent(indent + 4) - ) - } + /// Individual language failed + Language { name: String, source: Cause }, - /// Format with indentation - /// - /// # Panics - /// - /// This function will panic if writing to the string fails, which should never happen - /// since we're writing to a String which doesn't fail. - #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - self.format(&mut s, indent).unwrap(); - s - } -} - -impl Step { - pub fn new(name: Arc, kind: ParserOp, source: impl Into) -> Step { - Step { - name, - kind, - source: Box::new(source.into()), - } - } -} - -impl std::error::Error for Step { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(self.source.as_ref()) - } + /// Specific step failed + Step { + name: Arc, + kind: ParserOp, + source: Cause, + }, } +/// The specific parser operation that failed. #[derive(Debug, Display)] pub enum ParserOp { - #[display("Could not build in {}", dir.display())] - Build { dir: PathBuf }, - #[display("Could not clone to {}", dir.display())] - Clone { dir: PathBuf }, - #[display("Could not copy {} to {}", src.display(), dst.display())] - Copy { src: PathBuf, dst: PathBuf }, - #[display("Could not generate in {}", dir.display())] - Generate { dir: PathBuf }, -} - -fn format_languages_inner(w: &mut impl fmt::Write, langs: &[Language]) -> fmt::Result { - for (i, lang) in langs.iter().enumerate() { - if i > 0 { - write!(w, ", ")?; - } - write!(w, "{}", lang.name)?; - } - Ok(()) -} - -/// Main error type for tsdl operations + /// Compilation step inside the checked-out grammar directory. + #[display("Could not build in {}", dir.display())] + Build { dir: PathBuf }, + /// Git clone into the checkout directory. + #[display("Could not clone to {}", dir.display())] + Clone { dir: PathBuf }, + /// Scanning the checkout for grammar.js files. + #[display("Could not discover grammars in {}", dir.display())] + Discover { dir: PathBuf }, + /// Copying or hard-linking the built artifact. + #[display("Could not copy {} to {}", src.display(), dst.display())] + Copy { src: PathBuf, dst: PathBuf }, + /// Running tree-sitter generate. + #[display("Could not generate in {}", dir.display())] + Generate { dir: PathBuf }, +} + +// ============================================================ +// Structs +// ============================================================ + +/// Boxed wrapper around [`Error`] to break the recursive type. #[derive(Debug)] -pub enum TsdlError { - /// Build errors - Build(Vec), - - /// Command execution failed - Command(Command), - - /// Configuration error - Config(String), - - /// Context chain (linked list of context layers) - Context(Box), - - /// Generic IO error - Io(std::io::Error), - - /// Simple error message - Message(String), - - /// Language collection failed - LanguageCollection(LanguageCollection), - - /// Individual language failed - Language(Language), - - /// Parser building failed - Parser(Parser), - - /// Specific step failed - Step(Step), -} - -impl fmt::Display for TsdlError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - TsdlError::Build(errs) => { - write!(f, "Could not build all parsers.")?; - for e in errs { - write!(f, "\n\n{}", e.format_indent(2))?; - } - Ok(()) - } - TsdlError::Command(e) => write!(f, "{e}"), - TsdlError::Config(msg) => write!(f, "Configuration error: {msg}"), - TsdlError::Context(kind) => write!(f, "{kind}"), - TsdlError::Io(e) => write!(f, "IO error: {e}"), - TsdlError::Language(e) => write!(f, "{e}"), - TsdlError::LanguageCollection(e) => write!(f, "{e}"), - TsdlError::Message(msg) => write!(f, "{msg}"), - TsdlError::Parser(e) => write!(f, "{e}"), - TsdlError::Step(e) => write!(f, "{e}"), - } - } -} - -impl std::error::Error for TsdlError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - TsdlError::Build(_) | TsdlError::Config(_) | TsdlError::Message(_) => None, - TsdlError::Command(e) => Some(e), - TsdlError::Context(kind) => Some(&kind.error), - TsdlError::Io(e) => Some(e), - TsdlError::Language(e) => Some(e), - TsdlError::LanguageCollection(e) => Some(e), - TsdlError::Parser(e) => Some(e), - TsdlError::Step(e) => Some(e), +pub struct Cause(Box); + +// ============================================================ +// Free functions +// ============================================================ + +/// Format a build error list with indentation. +fn format_build_errors(w: &mut impl fmt::Write, errors: &[Error], indent: usize) -> fmt::Result { + let prefix = " ".repeat(indent); + write!(w, "{prefix}Could not build all parsers.")?; + + for error in errors { + write!(w, "\n\n")?; + error.format(w, indent + 2)?; + } + + Ok(()) +} + +/// Format a command execution error with stdout/stderr and indentation. +fn format_command( + w: &mut impl fmt::Write, + indent: usize, + msg: &str, + stdout: &str, + stderr: &str, +) -> fmt::Result { + let prefix = " ".repeat(indent); + write!(w, "{prefix}$ {msg}")?; + + let has_stdout = !stdout.is_empty(); + let has_stderr = !stderr.is_empty(); + + if has_stdout && has_stderr { + let mut write_section = |header: &str, content: &str| -> fmt::Result { + writeln!(w, "\n{prefix} {header}:")?; + + let mut lines = content.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix} {first}")?; + for line in lines { + write!(w, "\n{prefix} {line}")?; } - } -} - -// From trait implementations to preserve #[from] functionality -impl From for TsdlError { - fn from(e: Command) -> Self { - TsdlError::Command(e) - } -} + } + Ok(()) + }; -impl From for TsdlError { - fn from(e: LanguageCollection) -> Self { - TsdlError::LanguageCollection(e) - } -} + write_section("stdout", stdout)?; + write_section("stderr", stderr)?; + } else if has_stderr { + writeln!(w)?; + let mut lines = stderr.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix}{first}")?; + for line in lines { + write!(w, "\n{prefix}{line}")?; + } + } + } else if has_stdout { + writeln!(w)?; + let mut lines = stdout.lines(); + if let Some(first) = lines.next() { + write!(w, "{prefix}{first}")?; + for line in lines { + write!(w, "\n{prefix}{line}")?; + } + } + } + + Ok(()) +} + +/// Format language collection errors as a comma-separated list. +fn format_language_collection( + w: &mut impl fmt::Write, + related: &[Error], + indent: usize, +) -> fmt::Result { + let prefix = " ".repeat(indent); + writeln!(w, "{prefix}Could not figure out all languages:")?; + + for (i, error) in related.iter().enumerate() { + if i > 0 { + write!(w, ", ")?; + } + match error { + | Error::Language { name, .. } => write!(w, "{name}")?, + | _ => error.format(w, 0)?, + } + } + + Ok(()) +} + +// ============================================================ +// Impls +// ============================================================ + +impl Cause { + /// Wrap an error source into a boxed Cause. + #[must_use] + pub fn new(source: impl Into) -> Self { + Self(Box::new(source.into())) + } + + /// Borrow the inner error. + #[must_use] + pub fn as_error(&self) -> &Error { + &self.0 + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.format(f, 0) + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + | Error::Build { .. } + | Error::Command { .. } + | Error::Config { .. } + | Error::Interrupted { .. } + | Error::LanguageCollection { .. } + | Error::Message { .. } => None, + | Error::Context { source, .. } + | Error::Language { source, .. } + | Error::Step { source, .. } => Some(source.as_error()), + | Error::Io { source } => Some(source), + } + } +} + +impl Error { + /// Format the error with a given indentation level, returning a String. + #[must_use] + pub fn format_indent(&self, indent: usize) -> String { + let mut s = String::new(); + let _ = self.format(&mut s, indent); + s + } + + /// Recursively format the error tree with per-level indentation. + fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { + use Error::{ + Build, Command, Config, Context, Interrupted, Io, Language, LanguageCollection, Message, Step, + }; -impl From for TsdlError { - fn from(e: Language) -> Self { - TsdlError::Language(e) + let prefix = " ".repeat(indent); + + match self { + | Build { errors } => format_build_errors(w, errors, indent), + | Command { + msg, + stderr, + stdout, + } => format_command(w, indent, msg, stdout, stderr), + | Config { message } => write!(w, "{prefix}Configuration error: {message}"), + | Context { message, source } => { + write!( + w, + "{}{}\n{}", + prefix, + message, + source.as_error().format_indent(indent + 2) + ) + } + | Io { source } => write!(w, "{prefix}IO error: {source}"), + | Interrupted { signal } => write!(w, "{prefix}Interrupted by {signal}"), + | Language { name, source } => { + write!( + w, + "{}{}\n{}", + prefix, + name, + source.as_error().format_indent(indent + 2) + ) + } + | LanguageCollection { related } => format_language_collection(w, related, indent), + | Message { message } => write!(w, "{prefix}{message}"), + | Step { name, kind, source } => { + write!( + w, + "{}{}: {}.\n{}", + prefix, + name, + kind, + source.as_error().format_indent(indent + 4) + ) + } } + } } -impl From for TsdlError { - fn from(e: Parser) -> Self { - TsdlError::Parser(e) - } +impl From for Cause +where + E: Into, +{ + fn from(source: E) -> Self { + Self::new(source) + } } -impl From for TsdlError { - fn from(e: Step) -> Self { - TsdlError::Step(e) +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Error::Message { + message: format!("HTTP request error: {error}"), } + } } -impl From for TsdlError { - fn from(e: std::io::Error) -> Self { - TsdlError::Io(e) +impl From for Error { + fn from(error: reqwest::header::InvalidHeaderValue) -> Self { + Error::Message { + message: format!("Invalid header value: {error}"), } + } } -impl From for TsdlError { - fn from(e: std::fmt::Error) -> Self { - TsdlError::Message(format!("formatting error: {e}")) +impl From for Error { + fn from(error: self_update::errors::Error) -> Self { + Error::Message { + message: format!("Self-update error: {error}"), } + } } -impl From for TsdlError { - fn from(e: std::string::FromUtf8Error) -> Self { - TsdlError::Message(format!("UTF-8 conversion error: {e}")) +impl From for Error { + fn from(error: semver::Error) -> Self { + Error::Message { + message: format!("Semver error: {error}"), } + } } -impl From for TsdlError { - fn from(e: reqwest::Error) -> Self { - TsdlError::Message(format!("HTTP request error: {e}")) +impl From for Error { + fn from(error: std::fmt::Error) -> Self { + Error::Message { + message: format!("formatting error: {error}"), } + } } -impl From for TsdlError { - fn from(e: url::ParseError) -> Self { - TsdlError::Message(format!("URL parse error: {e}")) - } +impl From for Error { + fn from(source: std::io::Error) -> Self { + Error::Io { source } + } } -impl From for TsdlError { - fn from(e: toml::ser::Error) -> Self { - TsdlError::Message(format!("TOML serialization error: {e}")) +impl From for Error { + fn from(error: std::string::FromUtf8Error) -> Self { + Error::Message { + message: format!("UTF-8 conversion error: {error}"), } + } } -impl From for TsdlError { - fn from(e: toml::de::Error) -> Self { - TsdlError::Message(format!("TOML deserialization error: {e}")) +impl From for Error { + fn from(error: tokio::task::JoinError) -> Self { + Error::Message { + message: format!("Task join error: {error}"), } + } } -impl From for TsdlError { - fn from(e: figment::Error) -> Self { - TsdlError::Message(format!("Configuration error: {e}")) +impl From for Error { + fn from(error: toml::de::Error) -> Self { + Error::Message { + message: format!("TOML deserialization error: {error}"), } + } } -impl From for TsdlError { - fn from(e: semver::Error) -> Self { - TsdlError::Message(format!("Semver error: {e}")) +impl From for Error { + fn from(error: toml::ser::Error) -> Self { + Error::Message { + message: format!("TOML serialization error: {error}"), } + } } -impl From for TsdlError { - fn from(e: self_update::errors::Error) -> Self { - TsdlError::Message(format!("Self-update error: {e}")) +impl From for Error { + fn from(error: url::ParseError) -> Self { + Error::Message { + message: format!("URL parse error: {error}"), } + } } -impl From for TsdlError { - fn from(e: reqwest::header::InvalidHeaderValue) -> Self { - TsdlError::Message(format!("Invalid header value: {e}")) - } -} +impl ResultExt for std::result::Result +where + E: Into, +{ + fn context(self, message: impl Into) -> Result { + self.map_err(|source| Error::Context { + message: message.into(), + source: Cause::new(source), + }) + } -impl From for TsdlError { - fn from(e: tokio::task::JoinError) -> Self { - TsdlError::Message(format!("Task join error: {e}")) - } + fn with_context(self, message: impl FnOnce() -> String) -> Result { + self.map_err(|source| Error::Context { + message: message(), + source: Cause::new(source), + }) + } } -impl TsdlError { - /// Wrap a `TsdlError` with additional context message - /// The error parameter must be convertible to `TsdlError` - pub fn context(context: C, error: E) -> Self - where - C: Into, - E: Into, - { - let message = context.into(); - let tsdl_err = error.into(); - - // Create a context wrapper linking the message to the error - TsdlError::Context(Box::new(ContextKind { - message, - error: tsdl_err, - })) - } - - /// Create a simple error message - pub fn message(message: M) -> Self - where - M: Into, - { - TsdlError::Message(message.into()) - } - - /// Format the error with indentation support - /// Format the error with indentation support - /// - /// # Panics - /// - /// This function will panic if writing to the string fails, which should never happen - /// since we're writing to a String which doesn't fail. - #[must_use] - pub fn format_indent(&self, indent: usize) -> String { - let mut s = String::new(); - self.format(&mut s, indent).unwrap(); - s - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_formatting_with_indentation() { + let stderr = "remote: Repository not found.\nfatal: repository 'https://github.com/tree-sitter/tree-sitter-jsonxxx/' not found"; + let command_error = Error::Command { + msg: "git fetch origin --depth 1 HEAD failed with exit status 128.".to_string(), + stderr: stderr.to_string(), + stdout: String::new(), + }; - fn format(&self, w: &mut impl fmt::Write, indent: usize) -> fmt::Result { - let prefix = " ".repeat(indent); - match self { - TsdlError::Build(errs) => { - for (i, e) in errs.iter().enumerate() { - e.format(w, indent)?; - if i < errs.len() - 1 { - writeln!(w)?; - } - } - Ok(()) - } - TsdlError::Command(e) => e.format(w, indent), - TsdlError::Config(msg) => write!(w, "{prefix}Configuration error: {msg}"), - TsdlError::Context(kind) => { - write!( - w, - "{}{}\n{}", - prefix, - kind.message, - TsdlError::format_context_error(&kind.error, indent + 2) - ) - } - TsdlError::Io(e) => write!(w, "{prefix}IO error: {e}"), - TsdlError::Language(e) => e.format(w, indent), - TsdlError::LanguageCollection(e) => write!(w, "{prefix}{e}"), - TsdlError::Message(msg) => write!(w, "{prefix}{msg}"), - TsdlError::Parser(e) => e.format(w, indent), - TsdlError::Step(e) => e.format(w, indent), - } - } + let step_error = Error::Step { + name: "jsonxxx".into(), + kind: ParserOp::Clone { + dir: PathBuf::from("/home/firas/src/github.com/stackmystack/tsdl/tmp/tree-sitter-jsonxxx"), + }, + source: command_error.into(), + }; - fn format_context_error(err: &TsdlError, indent: usize) -> String { - err.format_indent(indent) - } -} + let err = Error::Build { + errors: vec![step_error], + }; + let formatted = err.format_indent(0); -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_formatting_with_indentation() { - // Simulate the jsonxxx error structure - let stderr = "remote: Repository not found.\nfatal: repository 'https://github.com/tree-sitter/tree-sitter-jsonxxx/' not found"; - let command_error = Command { - msg: "git fetch origin --depth 1 HEAD failed with exit status 128.".to_string(), - stderr: stderr.to_string(), - stdout: String::new(), - }; - - let step_error = Step { - name: "jsonxxx".into(), - kind: ParserOp::Clone { - dir: PathBuf::from( - "/home/firas/src/github.com/stackmystack/tsdl/tmp/tree-sitter-jsonxxx", - ), - }, - source: Box::new(command_error.into()), - }; - - let parser_error = Parser { - related: vec![TsdlError::Step(step_error)], - }; - - let tsdl_error = TsdlError::Parser(parser_error); - let formatted = tsdl_error.format_indent(0); - - let expected = r"Could not build all parsers. + let expected = r"Could not build all parsers. jsonxxx: Could not clone to /home/firas/src/github.com/stackmystack/tsdl/tmp/tree-sitter-jsonxxx. $ git fetch origin --depth 1 HEAD failed with exit status 128. remote: Repository not found. fatal: repository 'https://github.com/tree-sitter/tree-sitter-jsonxxx/' not found"; - assert_eq!(formatted, expected); - } + assert_eq!(formatted, expected); + } } diff --git a/src/git.rs b/src/git.rs index a623d13..279963c 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,330 +1,707 @@ +//! Git operations: clone, fetch, checkout, ls-files, tag resolution. + use std::{ - ffi::OsStr, - fmt, - io::Write, - path::{Component, Path, PathBuf}, - process::{Output, Stdio}, + ffi::OsStr, + fmt, + path::{Component, Path, PathBuf}, + result::Result as StdResult, + sync::Arc, }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use tokio::{fs, process::Command}; -use crate::{error::TsdlError, sh::Exec, TsdlResult}; -use derive_more::{AsRef, Deref}; +use crate::{Error, Result, ResultExt, sh::Exec}; + +type RefResult = StdResult; + +// ============================================================ +// Enums +// ============================================================ + +/// Error type for git ref and SHA validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefError { + /// Ref string is empty. + EmptyRef, + /// Ref contains an invalid character. + InvalidRefCharacter { index: usize, character: char }, + /// Ref has invalid syntax. + InvalidRefSyntax { reason: &'static str }, + /// SHA string is not 40 characters long. + InvalidShaLength { actual: usize }, + /// SHA string contains non-hex characters. + InvalidShaHex { index: usize, character: char }, +} -use std::sync::Arc; +/// A resolved git ref after tag lookup or branch checkout. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum ResolvedRef { + /// A tag with its resolved commit SHA. + Tag { label: String, sha: Sha }, + /// A bare git ref (no tag resolution). + Ref(Ref), +} -#[derive(AsRef, Clone, Deref, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct GitRef(pub Arc); +// ============================================================ +// Structs +// ============================================================ -impl From for GitRef { - fn from(s: String) -> Self { - Self(s.into()) - } +/// The commit SHA checked out for a given git ref. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Checkout { + /// The checked-out commit hash. + pub commit: Sha, } -impl From<&str> for GitRef { - fn from(s: &str) -> Self { - Self(s.into()) - } +/// A validated git ref string (branch name, tag, or commit SHA). +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Ref(Arc); + +/// A validated 40-character hex SHA-1 commit hash. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Sha(Arc); + +// ============================================================ +// Free functions +// ============================================================ + +/// Check out a git ref in the given directory (with force flag to handle +/// remote URL changes). +pub async fn checkout(repo: &str, cwd: &Path, git_ref: &Ref) -> Result { + checkout_with_force(repo, cwd, git_ref, false).await } -impl std::str::FromStr for GitRef { - type Err = std::convert::Infallible; +/// Like [`checkout`], but optionally force-clean the working directory even +/// when the remote URL matches. +pub async fn checkout_with_force( + repo: &str, + cwd: &Path, + git_ref: &Ref, + force: bool, +) -> Result { + if force || !is_same_remote(cwd, repo).await { + clean_anyway(cwd).await?; + } + if is_valid_git_dir(cwd).await { + reset_head_hard(cwd, git_ref).await?; + } else { + init_fetch_and_checkout(cwd, repo, git_ref).await?; + } + let commit = get_head_sha(cwd) + .await + .with_context(|| format!("Resolving checked out commit for {}", cwd.display()))?; + Ok(Checkout { commit }) +} - fn from_str(s: &str) -> Result { - Ok(Self(s.into())) - } +// TODO: get rid of async fs completely. +/// Remove a directory or file at `cwd` if it exists. +async fn clean_anyway(cwd: &Path) -> Result<()> { + if cwd.exists() { + if cwd.is_dir() { + fs::remove_dir_all(cwd).await + } else { + fs::remove_file(cwd).await + }?; + } + Ok(()) } -impl GitRef { - /// Create a new `GitRef` from a string slice - #[must_use] - pub fn new(s: &str) -> Self { - Self(s.into()) - } +/// Clone a repository and pull if it already exists. +pub async fn clone(repo: &str, cwd: &Path) -> Result<()> { + if cwd.exists() { + Command::new("git") + .current_dir(cwd) + .args(["pull"]) + .exec() + .await?; + } else { + Command::new("git") + .args(["clone", repo, &format!("{}", cwd.display())]) + .exec() + .await?; + } + Ok(()) +} - /// Get as string slice - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } +/// Fetch from origin and reset to `FETCH_HEAD`. +async fn fetch_and_checkout(cwd: &Path, git_ref: &Ref) -> Result<()> { + Command::new("git") + .env("GIT_TERMINAL_PROMPT", "0") + .current_dir(cwd) + .args(["fetch", "origin", "--depth", "1", git_ref.as_str()]) + .exec() + .await?; + Command::new("git") + .current_dir(cwd) + .args(["reset", "--hard", "FETCH_HEAD"]) + .exec() + .await?; + Ok(()) } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub enum Tag { - Exact { label: String, sha1: GitRef }, - Ref(GitRef), +/// Get the full SHA of HEAD in the given repo. +async fn get_head_sha(cwd: &Path) -> Result { + let value = get_head_sha1(cwd).await?; + Sha::new(value.trim()).context("Parsing HEAD commit") } -impl Tag { - #[must_use] - pub fn git_ref(&self) -> &GitRef { - match self { - Tag::Exact { sha1, .. } => sha1, - Tag::Ref(r) => r, - } - } +/// Get the raw SHA-1 string of HEAD. +async fn get_head_sha1(cwd: &Path) -> Result { + String::from_utf8( + Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "HEAD"]) + .exec() + .await? + .stdout, + ) + .context("rev-parse HEAD is not a valid utf-8") } -impl fmt::Display for GitRef { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let git_ref = if self.0.len() == 40 && self.0.chars().all(|c| c.is_ascii_hexdigit()) { - &self.0[..7] - } else { - &self.0 - }; - write!(f, "{git_ref}") - } +/// Get the remote URL for origin. +async fn get_remote_url(cwd: &Path) -> Result { + String::from_utf8( + Command::new("git") + .current_dir(cwd) + .args(["remote", "get-url", "origin"]) + .exec() + .await? + .stdout, + ) + .context("remote get-url origin did not return a valid utf-8") } -impl fmt::Display for Tag { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Tag::Exact { label, .. } => write!(f, "{label}"), - Tag::Ref(ref_) => write!(f, "{ref_}"), - } - } +/// Initialise a new repo, add a remote, and fetch+checkout. +async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &Ref) -> Result<()> { + clean_anyway(cwd).await?; + fs::create_dir_all(cwd).await?; + + Command::new("git") + .current_dir(cwd) + .arg("init") + .exec() + .await?; + + Command::new("git") + .current_dir(cwd) + .args(["remote", "add", "origin", repo]) + .exec() + .await?; + + fetch_and_checkout(cwd, git_ref).await?; + + Ok(()) } -// TODO: get rid of async fs completely. -async fn clean_anyway(cwd: &Path) -> TsdlResult<()> { - if cwd.exists() { - if cwd.is_dir() { - fs::remove_dir_all(cwd).await +/// Check whether the checkout directory contains a usable git repo with the +/// expected remote URL. +pub async fn is_checkout_usable(repo: &str, cwd: &Path) -> bool { + is_valid_git_dir(cwd).await && is_same_remote(cwd, repo).await +} + +/// Check whether a directory has the same origin remote URL as `remote`. +async fn is_same_remote(cwd: &Path, remote: &str) -> bool { + remote == get_remote_url(cwd).await.unwrap_or_default().trim() +} + +/// Check whether `cwd` is inside a valid git work tree with a parseable HEAD. +async fn is_valid_git_dir(cwd: &Path) -> bool { + let is_inside_work_tree = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "--is-inside-work-tree"]) + .exec() + .await + .is_ok(); + let can_parse_head = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", "HEAD"]) + .exec() + .await + .is_ok(); + + is_inside_work_tree && can_parse_head +} + +/// List all grammar.js files tracked by git (excluding common non-source +/// directories). +pub async fn list_grammar_files(cwd: &Path) -> Result> { + let output = Command::new("git") + .current_dir(cwd) + .args(["ls-files", "--cached", "--others", "--exclude-standard"]) + .exec() + .await?; + + let stdout = + String::from_utf8(output.stdout).context("git ls-files output is not valid utf-8")?; + + let exclude = [ + ".github", "bindings", "doc", "docs", "examples", "queries", "script", "scripts", "test", + "tests", + ]; + + let result: Vec = stdout + .lines() + .filter_map(|line| { + if line.is_empty() { + return None; + } + + let path = Path::new(line); + + // Check if filename is exactly "grammar.js" + if path.file_name() != Some(OsStr::new("grammar.js")) { + return None; + } + + // Check if any path component is in excluded dirs + let has_excluded = path.components().any(|comp| { + if let Component::Normal(name) = comp { + exclude.contains(&name.to_string_lossy().as_ref()) } else { - fs::remove_file(cwd).await - }?; - } - Ok(()) + false + } + }); + + if has_excluded { + return None; + } + + Some(PathBuf::from(line)) + }) + .collect(); + + Ok(result) } -pub async fn clone(repo: &str, cwd: &Path) -> TsdlResult<()> { - if cwd.exists() { - Command::new("git") - .current_dir(cwd) - .args(["pull"]) - .exec() - .await?; - } else { - Command::new("git") - .args(["clone", repo, &format!("{}", cwd.display())]) - .exec() - .await?; - } - Ok(()) +/// Reset HEAD hard to the given ref, fetching first if needed. +async fn reset_head_hard(cwd: &Path, git_ref: &Ref) -> Result<()> { + if git_ref.as_str() != get_head_sha1(cwd).await?.trim() { + Command::new("git") + .current_dir(cwd) + .args(["reset", "--hard", "HEAD"]) + .exec() + .await?; + fetch_and_checkout(cwd, git_ref).await?; + } + Ok(()) } -pub async fn clone_fast(repo: &str, git_ref: &str, cwd: &Path) -> TsdlResult<()> { - clone_fast_with_force(repo, git_ref, cwd, false).await +/// Resolve a git ref to a tag name or fall back to commit SHA. +pub async fn tag_for_ref(cwd: &Path, git_ref: &Ref) -> Result { + // Try to find a tag for this ref + let tag = Command::new("git") + .current_dir(cwd) + .args(["describe", "--abbrev=0", "--tags", git_ref.as_str()]) + .exec() + .await; + + if let Ok(output) = tag { + // Found a tag, use it + String::from_utf8(output.stdout) + .context("Failed to parse git tag output as UTF-8") + .map(|s| s.trim().to_string()) + } else { + // No tag found (e.g., ref is a branch), fall back to commit SHA1 + let sha1 = Command::new("git") + .current_dir(cwd) + .args(["rev-parse", git_ref.as_str()]) + .exec() + .await?; + String::from_utf8(sha1.stdout) + .context("Failed to parse git rev-parse output as UTF-8") + .map(|s| s.trim().to_string()) + } } -pub async fn clone_fast_with_force( - repo: &str, - git_ref: &str, - cwd: &Path, - force: bool, -) -> TsdlResult<()> { - if force || !is_same_remote(cwd, repo).await { - clean_anyway(cwd).await?; +/// Validate a git ref string against Git's refname rules. +fn validate_git_ref(value: &str) -> RefResult<()> { + if value.is_empty() { + return Err(RefError::EmptyRef); + } + + if value == "@" { + return Err(RefError::InvalidRefSyntax { + reason: "single @ is not a ref", + }); + } + + if value.starts_with('/') || value.ends_with('/') { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot start or end with /", + }); + } + + if value.ends_with('.') { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot end with .", + }); + } + + if value.contains("..") { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot contain ..", + }); + } + + if value.contains("@{") { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot contain @{", + }); + } + + if let Some((index, character)) = value.char_indices().find(|(_, c)| { + c.is_ascii_control() + || c.is_ascii_whitespace() + || matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\') + }) { + return Err(RefError::InvalidRefCharacter { index, character }); + } + + for component in value.split('/') { + if component.is_empty() { + return Err(RefError::InvalidRefSyntax { + reason: "refs cannot contain empty path components", + }); } - if is_valid_git_dir(cwd).await { - reset_head_hard(cwd, git_ref).await?; - } else { - init_fetch_and_checkout(cwd, repo, git_ref).await?; + + if component.starts_with('.') { + return Err(RefError::InvalidRefSyntax { + reason: "ref path components cannot start with .", + }); + } + + if component.strip_suffix(".lock").is_some() { + return Err(RefError::InvalidRefSyntax { + reason: "ref path components cannot end with .lock", + }); } - Ok(()) + } + + Ok(()) } -pub fn column(input: &str, indent: &str, width: usize) -> TsdlResult { - let mut child = std::process::Command::new("git") - .arg("column") - .arg("--mode=always") - .arg(format!("--indent={indent}")) - .arg(format!("--width={width}")) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn()?; +/// Validate a 40-character hex SHA-1 string. +fn validate_git_sha(value: &str) -> RefResult<()> { + if value.len() != 40 { + return Err(RefError::InvalidShaLength { + actual: value.len(), + }); + } - let Some(mut stdin) = child.stdin.take() else { - return child - .wait_with_output() - .map_err(|e| TsdlError::context("git column did not finish normally", e)); - }; + if let Some((index, character)) = value.char_indices().find(|(_, c)| !c.is_ascii_hexdigit()) { + return Err(RefError::InvalidShaHex { index, character }); + } - stdin - .write_all(input.as_bytes()) - .map_err(|e| TsdlError::context("Failed to write to git column stdin", e))?; + Ok(()) +} + +// ============================================================ +// Impls +// ============================================================ - child - .wait_with_output() - .map_err(|e| TsdlError::context("git column did not finish normally", e)) +impl AsRef for Ref { + fn as_ref(&self) -> &str { + self.as_str() + } } -async fn fetch_and_checkout(cwd: &Path, git_ref: &str) -> TsdlResult<()> { - Command::new("git") - .env("GIT_TERMINAL_PROMPT", "0") - .current_dir(cwd) - .args(["fetch", "origin", "--depth", "1", git_ref]) - .exec() - .await?; - Command::new("git") - .current_dir(cwd) - .args(["reset", "--hard", "FETCH_HEAD"]) - .exec() - .await?; - Ok(()) -} - -async fn get_head_sha1(cwd: &Path) -> TsdlResult { - String::from_utf8( - Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "HEAD"]) - .exec() - .await? - .stdout, - ) - .map_err(|e| TsdlError::context("rev-parse HEAD is not a valid utf-8", e)) -} - -async fn get_remote_url(cwd: &Path) -> TsdlResult { - String::from_utf8( - Command::new("git") - .current_dir(cwd) - .args(["remote", "get-url", "origin"]) - .exec() - .await? - .stdout, - ) - .map_err(|e| TsdlError::context("remote get-url origin did not return a valid utf-8", e)) -} - -async fn init_fetch_and_checkout(cwd: &Path, repo: &str, git_ref: &str) -> TsdlResult<()> { - clean_anyway(cwd).await?; - fs::create_dir_all(cwd).await?; +impl AsRef for Sha { + fn as_ref(&self) -> &str { + self.as_str() + } +} - Command::new("git") - .current_dir(cwd) - .arg("init") - .exec() - .await?; +impl<'de> Deserialize<'de> for Ref { + fn deserialize(deserializer: D) -> StdResult + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} - Command::new("git") - .current_dir(cwd) - .args(["remote", "add", "origin", repo]) - .exec() - .await?; +impl<'de> Deserialize<'de> for Sha { + fn deserialize(deserializer: D) -> StdResult + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} - fetch_and_checkout(cwd, git_ref).await?; +impl fmt::Display for Ref { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) + } +} - Ok(()) +impl fmt::Display for RefError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::EmptyRef => write!(f, "git ref cannot be empty"), + | Self::InvalidRefCharacter { index, character } => { + write!( + f, + "git ref contains invalid character at byte {index}: {character:?}" + ) + } + | Self::InvalidShaHex { index, character } => { + write!( + f, + "git SHA contains non-hex character at byte {index}: {character:?}" + ) + } + | Self::InvalidShaLength { actual } => { + write!(f, "git SHA must be exactly 40 hex characters, got {actual}") + } + | Self::InvalidRefSyntax { reason } => write!(f, "git ref is invalid: {reason}"), + } + } } -async fn is_same_remote(cwd: &Path, remote: &str) -> bool { - remote == get_remote_url(cwd).await.unwrap_or_default().trim() +impl fmt::Display for ResolvedRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::Ref(git_ref) => write!(f, "{git_ref}"), + | Self::Tag { label, .. } => write!(f, "{label}"), + } + } } -async fn is_valid_git_dir(cwd: &Path) -> bool { - let is_inside_work_tree = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "--is-inside-work-tree"]) - .exec() - .await - .is_ok(); - let can_parse_head = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", "HEAD"]) - .exec() - .await - .is_ok(); - - is_inside_work_tree && can_parse_head -} - -pub async fn list_grammar_files(cwd: &Path) -> TsdlResult> { - let output = Command::new("git") - .current_dir(cwd) - .args(["ls-files", "--cached", "--others", "--exclude-standard"]) - .exec() - .await?; - - let stdout = String::from_utf8(output.stdout) - .map_err(|e| TsdlError::context("git ls-files output is not valid utf-8", e))?; - - let exclude = [ - ".github", "bindings", "doc", "docs", "examples", "queries", "script", "scripts", "test", - "tests", - ]; - - let result: Vec = stdout - .lines() - .filter_map(|line| { - if line.is_empty() { - return None; - } - - let path = Path::new(line); - - // Check if filename is exactly "grammar.js" - if path.file_name() != Some(OsStr::new("grammar.js")) { - return None; - } - - // Check if any path component is in excluded dirs - let has_excluded = path.components().any(|comp| { - if let Component::Normal(name) = comp { - exclude.contains(&name.to_string_lossy().as_ref()) - } else { - false - } - }); - - if has_excluded { - return None; - } - - Some(PathBuf::from(line)) - }) - .collect(); - - Ok(result) -} - -async fn reset_head_hard(cwd: &Path, git_ref: &str) -> TsdlResult<()> { - if git_ref != get_head_sha1(cwd).await?.trim() { - Command::new("git") - .current_dir(cwd) - .args(["reset", "--hard", "HEAD"]) - .exec() - .await?; - fetch_and_checkout(cwd, git_ref).await?; +impl fmt::Display for Sha { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) + } +} + +impl From for Error { + fn from(error: RefError) -> Self { + Error::Message { + message: error.to_string(), } - Ok(()) -} - -pub async fn tag_for_ref(cwd: &Path, git_ref: &str) -> TsdlResult { - // Try to find a tag for this ref - let tag = Command::new("git") - .current_dir(cwd) - .args(["describe", "--abbrev=0", "--tags", git_ref]) - .exec() - .await; - - if let Ok(output) = tag { - // Found a tag, use it - String::from_utf8(output.stdout) - .map_err(|e| TsdlError::context("Failed to parse git tag output as UTF-8", e)) - .map(|s| s.trim().to_string()) + } +} + +impl From for Ref { + fn from(sha: Sha) -> Self { + Self(sha.0) + } +} + +impl std::str::FromStr for Ref { + type Err = RefError; + + fn from_str(value: &str) -> StdResult { + Self::new(value) + } +} + +impl std::str::FromStr for Sha { + type Err = RefError; + + fn from_str(value: &str) -> StdResult { + Self::new(value) + } +} + +impl Ref { + /// Create a validated git ref. + pub fn new(value: impl Into>) -> RefResult { + let value = value.into(); + validate_git_ref(&value)?; + Ok(Self(value)) + } + + /// The default ref used for unpinned parser builds. + #[must_use] + pub fn head() -> Self { + Self(Arc::from("HEAD")) + } + + /// Get the exact git ref string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get a human-oriented representation, shortening full commit SHAs. + #[must_use] + pub fn short(&self) -> &str { + if Sha::is_full_sha(self.as_str()) { + &self.0[..7] } else { - // No tag found (e.g., ref is a branch), fall back to commit SHA1 - let sha1 = Command::new("git") - .current_dir(cwd) - .args(["rev-parse", git_ref]) - .exec() - .await?; - String::from_utf8(sha1.stdout) - .map_err(|e| TsdlError::context("Failed to parse git rev-parse output as UTF-8", e)) - .map(|s| s.trim().to_string()) + &self.0 } + } + + /// Check whether the ref is an exact 40-character SHA. + #[must_use] + pub fn is_exact_sha(&self) -> bool { + Sha::is_full_sha(self.as_str()) + } +} + +impl Serialize for Ref { + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl Serialize for Sha { + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl Sha { + /// Create a validated full 40-character git SHA-1. + pub fn new(value: impl Into>) -> RefResult { + let value = value.into(); + validate_git_sha(&value)?; + Ok(Sha(value)) + } + + /// Return the SHA as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Return the first 7 hex characters of the SHA. + #[must_use] + pub fn short(&self) -> &str { + &self.0[..7] + } + + /// Check whether a string looks like a full 40-character hex SHA. + #[must_use] + pub fn is_full_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) + } +} + +impl std::error::Error for RefError {} + +impl TryFrom<&str> for Ref { + type Error = RefError; + + fn try_from(value: &str) -> StdResult { + Self::new(value) + } +} + +impl TryFrom<&str> for Sha { + type Error = RefError; + + fn try_from(value: &str) -> StdResult { + Self::new(value) + } +} + +impl TryFrom for Ref { + type Error = RefError; + + fn try_from(value: String) -> StdResult { + Self::new(value) + } +} + +impl TryFrom for Sha { + type Error = RefError; + + fn try_from(value: String) -> StdResult { + Self::new(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FULL_SHA: &str = "636801770eea172d140e64b691815ff11f6b556f"; + + #[test] + fn git_ref_rejects_empty_refs() { + assert_eq!(Ref::new(""), Err(RefError::EmptyRef)); + } + + #[test] + fn git_ref_rejects_whitespace() { + assert_eq!( + Ref::new("feature branch"), + Err(RefError::InvalidRefCharacter { + index: 7, + character: ' ' + }) + ); + } + + #[test] + fn git_ref_rejects_invalid_git_ref_syntax() { + assert_eq!( + Ref::new("feature..branch"), + Err(RefError::InvalidRefSyntax { + reason: "refs cannot contain .." + }) + ); + assert_eq!( + Ref::new("feature.lock"), + Err(RefError::InvalidRefSyntax { + reason: "ref path components cannot end with .lock", + }) + ); + assert_eq!( + Ref::new("refs/heads/main"), + Ok(Ref::new("refs/heads/main").unwrap()) + ); + } + + #[test] + fn git_ref_preserves_branches_and_full_shas() { + assert_eq!(Ref::new("master").unwrap().as_str(), "master"); + assert_eq!(Ref::new(FULL_SHA).unwrap().as_str(), FULL_SHA); + } + + #[test] + fn git_ref_display_is_short_but_as_str_is_exact() { + let git_ref = Ref::new(FULL_SHA).unwrap(); + + assert_eq!(git_ref.as_str(), FULL_SHA); + assert_eq!(git_ref.short(), "6368017"); + assert_eq!(git_ref.to_string(), "6368017"); + } + + #[test] + fn git_sha_requires_full_hex_sha() { + assert_eq!(Sha::new(FULL_SHA).unwrap().as_str(), FULL_SHA); + assert_eq!(Sha::new(FULL_SHA).unwrap().short(), "6368017"); + assert_eq!( + Sha::new("6368017"), + Err(RefError::InvalidShaLength { actual: 7 }) + ); + assert_eq!( + Sha::new("636801770eea172d140e64b691815ff11f6b556x"), + Err(RefError::InvalidShaHex { + index: 39, + character: 'x' + }) + ); + } + + #[test] + fn serde_rejects_invalid_git_refs() { + let err = + toml::from_str::>(r#"git_ref = """#).unwrap_err(); + + assert!(err.to_string().contains("git ref cannot be empty")); + } } diff --git a/src/lib.rs b/src/lib.rs index 3b69fa6..008e4da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,14 +50,12 @@ //! example configuration. use std::{ - env, - io::{self, Write}, - path::{Path, PathBuf}, - time::Duration, + env, + io::{self, Write}, + path::{Component, Path, PathBuf}, + time::Duration, }; -use crate::error::TsdlError; - extern crate log; pub mod actors; @@ -65,10 +63,12 @@ pub mod app; pub mod args; pub mod build; pub mod cache; +pub mod columns; pub mod config; pub mod consts; pub mod display; pub mod error; +pub use error::{Cause, Error, Result, ResultExt}; pub mod git; pub mod lock; pub mod logging; @@ -76,98 +76,144 @@ pub mod parser; #[macro_use] pub mod sh; pub mod selfupdate; +pub mod shutdown; pub mod tree_sitter; pub mod walk; -pub trait SafeCanonicalize { - fn canon(&self) -> TsdlResult; -} +// ============================================================ +// Traits +// ============================================================ -impl SafeCanonicalize for Path { - fn canon(&self) -> TsdlResult { - if self.is_absolute() { - Ok(self.to_path_buf()) - } else { - let current_dir = env::current_dir() - .map_err(|e| TsdlError::context("Failed to get current directory", e))?; - Ok(current_dir.join(self)) - } - } +pub trait SafeCanonicalize { + fn canon(&self) -> Result; } -impl SafeCanonicalize for PathBuf { - fn canon(&self) -> TsdlResult { - self.as_path().canon() - } +// ============================================================ +// Free functions +// ============================================================ + +/// Convert a path to an absolute, lexically-normalized path without requiring +/// the final path to exist. +pub fn absolute_normalize(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .context("Failed to get current directory")? + .join(path) + }; + + Ok(normalize_components(&absolute)) } +/// Convert a duration to a human-readable string (e.g. "0.50s", "1mn 30s"). #[must_use] pub fn format_duration(duration: Duration) -> String { - let total_seconds = duration.as_secs(); - let millis = duration.subsec_millis(); + let total_seconds = duration.as_secs(); + let millis = duration.subsec_millis(); - // Base case: sub-minute gets full precision - if total_seconds < 60 { - return format!("{total_seconds}.{millis:02}s"); - } + // Base case: sub-minute gets full precision + if total_seconds < 60 { + return format!("{total_seconds}.{millis:02}s"); + } - let seconds = total_seconds % 60; - let minutes = (total_seconds / 60) % 60; - let hours = total_seconds / 3600; + let seconds = total_seconds % 60; + let minutes = (total_seconds / 60) % 60; + let hours = total_seconds / 3600; - let mut parts = Vec::new(); + let mut parts = Vec::new(); - if hours > 0 { - parts.push(format!("{hours}h")); - } + if hours > 0 { + parts.push(format!("{hours}h")); + } - if minutes > 0 { - parts.push(format!("{minutes}mn")); - } + if minutes > 0 { + parts.push(format!("{minutes}mn")); + } - if seconds > 0 || millis > 0 { - if millis > 0 { - parts.push(format!("{seconds}.{millis:03}s")); - } else { - parts.push(format!("{seconds}s")); - } + if seconds > 0 || millis > 0 { + if millis > 0 { + parts.push(format!("{seconds}.{millis:03}s")); + } else { + parts.push(format!("{seconds}s")); } + } - parts.join(" ") + parts.join(" ") } -pub fn relative_to_cwd(dir: &Path) -> PathBuf { - let canon = dir.canon().unwrap_or_else(|_| dir.to_path_buf()); - let cwd = env::current_dir().unwrap_or_else(|_| dir.to_path_buf()); - - if canon != cwd && canon.starts_with(&cwd) { - dir.strip_prefix(cwd).map_or(canon, Path::to_path_buf) - } else { - canon +/// Resolve `.` and `..` components without requiring the path to exist. +fn normalize_components(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + + for component in path.components() { + use Component::{CurDir, Normal, ParentDir, Prefix, RootDir}; + + match component { + | CurDir => {} + | Normal(part) => normalized.push(part), + | ParentDir => { + normalized.pop(); + } + | Prefix(prefix) => normalized.push(prefix.as_os_str()), + | RootDir => normalized.push(component.as_os_str()), } -} + } -/// Result type for tsdl operations -pub type TsdlResult = Result; + normalized +} /// Prompt user for confirmation with default behavior -pub fn prompt_user(question: &str, default_yes: bool) -> TsdlResult { - let options = if default_yes { "[Y/n]" } else { "[y/N]" }; +pub fn prompt_user(question: &str, default_yes: bool) -> Result { + let options = if default_yes { "[Y/n]" } else { "[y/N]" }; + + eprint!("{question} {options}: "); + + let _ = io::stderr().flush(); + let mut input = String::new(); + + io::stdin() + .read_line(&mut input) + .context("Reading user input")?; + + let input = input.trim().to_lowercase(); - eprint!("{question} {options}: "); + if input.is_empty() { + return Ok(default_yes); + } - let _ = io::stderr().flush(); - let mut input = String::new(); + Ok(input == "y") +} - io::stdin() - .read_line(&mut input) - .map_err(|e| TsdlError::context("Reading user input", e))?; +/// Strip the current working directory prefix from a path when possible. +pub fn relative_to_cwd(dir: &Path) -> PathBuf { + let canon = dir.canon().unwrap_or_else(|_| dir.to_path_buf()); + let cwd = env::current_dir().unwrap_or_else(|_| dir.to_path_buf()); + + if canon != cwd && canon.starts_with(&cwd) { + dir.strip_prefix(cwd).map_or(canon, Path::to_path_buf) + } else { + canon + } +} - let input = input.trim().to_lowercase(); +// ============================================================ +// Impls +// ============================================================ - if input.is_empty() { - return Ok(default_yes); +impl SafeCanonicalize for Path { + fn canon(&self) -> Result { + if self.is_absolute() { + Ok(self.to_path_buf()) + } else { + let current_dir = env::current_dir().context("Failed to get current directory")?; + Ok(current_dir.join(self)) } + } +} - Ok(input == "y") +impl SafeCanonicalize for PathBuf { + fn canon(&self) -> Result { + self.as_path().canon() + } } diff --git a/src/lock.rs b/src/lock.rs index cee6393..09b3d59 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -1,171 +1,620 @@ +//! PID-based filesystem lock to prevent concurrent builds. + use std::{ - fs, - path::{Path, PathBuf}, - process, + collections::HashSet, + ffi::OsString, + fmt, + fs::{self, File, OpenOptions}, + io::{self, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + process, + result::Result as StdResult, + thread, + time::{Duration, Instant}, }; -use sysinfo::{Pid, ProcessesToUpdate, System}; +use fs2::FileExt; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System, UpdateKind}; use tracing::info; -use crate::{consts::TSDL_LOCK_FILE, error::TsdlError, TsdlResult}; +use crate::{ + Error, Result, ResultExt, absolute_normalize, build::BuildDir, consts, format_duration, +}; + +// ============================================================ +// Enums +// ============================================================ -/// Result of checking lock status +/// Last observed state while waiting for a lock takeover to complete. +#[derive(Debug, Clone)] +pub enum Observation { + /// The lock is still held by an identifiable owner. + LockedBy(Box), + /// The lock is still held, but owner metadata could not be resolved. + Unknown { pid: Option, reason: String }, +} + +/// Result of checking lock status. #[derive(Debug)] -pub enum LockStatus { - /// Lock acquired successfully - Acquired(LockGuard), - /// Acquired lock is cyclic (same process) - Cyclic, - /// Lock exists from a different process - LockedBy { pid: Pid, exe: String }, - /// Lock exists from a stale (dead) process - Stale(Pid), - /// Not enough privileges to check process status - Unknown { pid: Pid, reason: String }, -} - -/// A guard that holds an exclusive lock on the build directory. -/// The lock is automatically released when this guard is dropped. +pub enum Status { + /// Lock acquired successfully. + Acquired(Guard), + /// Acquired lock is cyclic (same process). + Cyclic, + /// Lock is held by a different process. + LockedBy(Owner), + /// Lock is held, but the owner could not be identified. + Unknown { pid: Option, reason: String }, +} + +/// Error returned while terminating a lock owner or waiting for its lock to release. #[derive(Debug)] -pub struct LockGuard { - lock: PathBuf, +pub enum TakeoverError { + /// The previously observed owner exited before it could be signalled. + OwnerDisappeared { previous: Box }, + /// The lock owner changed while takeover was in progress. + OwnerChanged { + previous: Box, + current: Box, + }, + /// Lock status became cyclic while waiting. + Cyclic, + /// The lock remained held by an unidentifiable owner. + Unknown { pid: Option, reason: String }, + /// Sending SIGTERM to the owner failed. + SignalFailed { owner: Box }, + /// SIGTERM is unavailable on this platform. + SignalUnsupported { owner: Box }, + /// The timeout elapsed before the lock could be acquired. + Timeout { + previous: Box, + timeout: Duration, + last_observation: Box, + }, + /// An underlying tsdl error occurred while taking over the lock. + Source(Error), } -impl Drop for LockGuard { - fn drop(&mut self) { - let _ = fs::remove_file(&self.lock); - } +// ============================================================ +// Structs +// ============================================================ + +/// A guard that holds an exclusive OS lock on the build directory lock file. +/// +/// The lock is released when this guard is dropped. The lock file itself is +/// intentionally left on disk because OS locks are tied to open file handles, +/// not to path existence. +#[derive(Debug)] +pub struct Guard { + file: File, + lock_path: PathBuf, } /// Manages lock configuration and acquisition. pub struct Lock { - current_pid: Pid, - lock_path: PathBuf, + current_pid: Pid, + lock_path: PathBuf, } -impl Lock { - #[must_use] - pub fn new(build_dir: &Path) -> Self { - Self { - lock_path: build_dir.join(TSDL_LOCK_FILE), - current_pid: Pid::from(process::id() as usize), +/// Information about the process currently holding the build lock. +#[derive(Debug, Clone)] +pub struct Owner { + pub pid: Pid, + pub name: String, + pub command: Option, + pub exe: Option, + pub cwd: Option, + pub status: String, + pub run_time: u64, + pub start_time: u64, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Join OS command arguments into a single display string. +fn command_line(cmd: &[std::ffi::OsString]) -> Option { + if cmd.is_empty() { + return None; + } + + Some( + cmd + .iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "), + ) +} + +/// Check if an IO error is caused by lock contention (`WouldBlock`). +fn is_lock_contention(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::WouldBlock +} + +/// Check if two owner records refer to the same process instance (same PID and start time). +fn same_owner(current: &Owner, previous: &Owner) -> bool { + current.pid == previous.pid && current.start_time == previous.start_time +} + +// ============================================================ +// Impls +// ============================================================ + +impl fmt::Display for Observation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::LockedBy(owner) => { + write!(f, "lock is held by PID {} ({})", owner.pid, owner.name) + } + | Self::Unknown { pid, reason } => { + if let Some(pid) = pid { + write!( + f, + "lock is held by PID {pid}, but owner is unknown: {reason}" + ) + } else { + write!(f, "lock is held by an unknown owner: {reason}") } + } } + } +} + +impl fmt::Display for Owner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, " pid: {}", self.pid)?; + writeln!(f, " process: {}", self.name)?; + writeln!( + f, + " command: {}", + self.command.as_deref().unwrap_or("") + )?; + writeln!( + f, + " exe: {}", + self + .exe + .as_ref() + .map_or_else(|| "".to_string(), |p| p.display().to_string()) + )?; + writeln!( + f, + " cwd: {}", + self + .cwd + .as_ref() + .map_or_else(|| "".to_string(), |p| p.display().to_string()) + )?; + writeln!( + f, + " runtime: {}", + format_duration(Duration::from_secs(self.run_time)) + )?; + write!(f, " status: {}", self.status) + } +} - /// Acquire a new lock by creating the lock file with current PID. - fn acquire(&self) -> TsdlResult { - if let Some(parent) = self.lock_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - TsdlError::context(format!("Creating build directory {}", parent.display()), e) - })?; +impl fmt::Display for TakeoverError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::OwnerDisappeared { previous } => write!( + f, + "Lock owner PID {} ({}) is no longer running; retry lock acquisition", + previous.pid, previous.name + ), + | Self::OwnerChanged { previous, current } => write!( + f, + "Lock owner changed while taking over: previous PID {} ({}), current PID {} ({})", + previous.pid, previous.name, current.pid, current.name + ), + | Self::Cyclic => write!(f, "Lock became cyclic while waiting for release"), + | Self::Unknown { pid, reason } => { + if let Some(pid) = pid { + write!(f, "Could not identify build lock owner PID {pid}: {reason}") + } else { + write!(f, "Could not identify build lock owner: {reason}") } + } + | Self::SignalFailed { owner } => { + write!( + f, + "Failed to send SIGTERM to lock owner PID {} ({})", + owner.pid, owner.name + ) + } + | Self::SignalUnsupported { .. } => { + write!( + f, + "SIGTERM is not supported on this platform; cannot terminate lock owner" + ) + } + | Self::Timeout { + previous, + timeout, + last_observation, + } => write!( + f, + "Timed out after {} waiting for PID {} ({}) to release the build lock; last observation: {last_observation}", + format_duration(*timeout), + previous.pid, + previous.name + ), + | Self::Source(err) => write!(f, "{err}"), + } + } +} + +impl Drop for Guard { + fn drop(&mut self) { + let _ = self.file.unlock(); + } +} - self.write()?; +impl From for TakeoverError { + fn from(err: Error) -> Self { + Self::Source(err) + } +} - info!("Acquired lock on build directory"); - Ok(LockGuard { - lock: self.lock_path.clone(), - }) +impl From for Error { + fn from(err: TakeoverError) -> Self { + Error::Message { + message: err.to_string(), } + } +} + +impl Guard { + /// Delete every entry in the build directory except the lock file itself + /// and explicitly protected root-level files, such as the active log file. + /// + /// The build directory itself is preserved; only its children are removed. + /// This is used by `--fresh` to clean the build directory without + /// invalidating OS locks or unlinking files held open by the current process. + pub fn clear_directory(&self, protected_files: &[PathBuf]) -> Result<()> { + let build_dir = self.lock_path.parent().ok_or_else(|| Error::Message { + message: format!( + "Lock path has no parent directory: {}", + self.lock_path.display() + ), + })?; + let build_dir_abs = absolute_normalize(build_dir)?; + let mut protected_names = HashSet::::new(); - /// Force acquire a lock, overwriting any existing lock. - /// - /// This will replace any existing lock file. - pub fn force_acquire(&self) -> TsdlResult { - self.force_unlock()?; - self.acquire() + let lock_name = self + .lock_path + .file_name() + .map_or_else(|| OsString::from(consts::LOCK_FILE), OsString::from); + protected_names.insert(lock_name); + + for protected in protected_files { + let protected_abs = absolute_normalize(protected)?; + if protected_abs.parent() == Some(build_dir_abs.as_path()) + && let Some(name) = protected_abs.file_name() + { + protected_names.insert(name.to_os_string()); + } } - /// Force unlock the build directory by removing the lock file. - /// - /// This does not verify ownership. - pub fn force_unlock(&self) -> TsdlResult<()> { - if self.lock_path.exists() { - fs::remove_file(&self.lock_path).map_err(|e| { - TsdlError::context( - format!("Removing lock file {}", self.lock_path.display()), - e, - ) - })?; - info!("Lock removed from build directory"); - } else { - info!("No lock file found"); - } + for entry in fs::read_dir(build_dir) + .with_context(|| format!("Reading build directory {}", build_dir.display()))? + { + let entry = + entry.with_context(|| format!("Reading directory entry in {}", build_dir.display()))?; - Ok(()) + let name = entry.file_name(); + + if protected_names.contains(&name) { + continue; + } + + let path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("Statting {}", path.display()))?; + + if file_type.is_dir() { + fs::remove_dir_all(&path) + .with_context(|| format!("Removing directory {}", path.display()))?; + } else { + fs::remove_file(&path).with_context(|| format!("Removing file {}", path.display()))?; + } } - /// Helper for checking process status and determining lock conflicts - fn lock_status(&self) -> TsdlResult { - let lock_pid = self.read()?; + info!("Cleaned {}", build_dir.display()); + Ok(()) + } +} - if lock_pid == self.current_pid { - return Ok(LockStatus::Cyclic); - } +impl Lock { + /// Create a lock manager for the given build directory. + #[must_use] + pub fn new(build_dir: &BuildDir) -> Self { + Self { + lock_path: build_dir.lock_file(), + current_pid: Pid::from(process::id() as usize), + } + } - // Refresh only the PIDs we care about - let mut system = System::new(); - system.refresh_processes(ProcessesToUpdate::Some(&[self.current_pid, lock_pid]), true); - - match (system.process(lock_pid), system.process(self.current_pid)) { - (Some(lock_process), Some(current_process)) => { - match (lock_process.exe(), current_process.exe()) { - (Some(lock), Some(current)) if lock == current => Err(TsdlError::message( - format!("Build already in progress (PID {})", lock_process.pid()), - )), - (Some(lock), _) => Ok(LockStatus::LockedBy { - pid: lock_process.pid(), - exe: lock.to_string_lossy().to_string(), - }), - (None, _) => Ok(LockStatus::Unknown { - pid: lock_process.pid(), - reason: "Insufficient privileges to read process.exe".to_string(), - }), - } - } - (None, _) => Ok(LockStatus::Stale(lock_pid)), - (_, None) => Ok(LockStatus::Unknown { - pid: self.current_pid, - reason: "Insufficient privileges to read process information".to_string(), - }), - } + /// Check lock status and acquire the OS lock if available. + pub fn try_acquire(&self) -> Result { + let file = self.open_lock_file()?; + + match file.try_lock_exclusive() { + | Ok(()) => self.activate(file).map(Status::Acquired), + | Err(err) if is_lock_contention(&err) => Ok(self.lock_status()), + | Err(err) => Err(Error::Context { + message: format!("Acquiring build lock {}", self.lock_path.display()), + source: err.into(), + }), } + } - fn read(&self) -> TsdlResult { - let content = fs::read_to_string(&self.lock_path).map_err(|e| { - TsdlError::context(format!("Reading lock file {}", self.lock_path.display()), e) - })?; + /// Send SIGTERM to the process that held the lock when `owner` was captured. + pub fn terminate_owner(&self, owner: &Owner) -> StdResult<(), TakeoverError> { + info!( + "Sending SIGTERM to lock owner PID {} ({})", + owner.pid, owner.name + ); + let system = Self::system_for_pid(owner.pid); + let process = system + .process(owner.pid) + .ok_or_else(|| TakeoverError::OwnerDisappeared { + previous: Box::new(owner.clone()), + })?; - let pid: usize = content.trim().parse().map_err(|_| { - TsdlError::message(format!( - "Invalid PID '{}' in lock file {}", - content.trim(), - self.lock_path.display() - )) + if process.start_time() != owner.start_time { + let current = + Self::owner_for_pid(owner.pid).ok_or_else(|| TakeoverError::OwnerDisappeared { + previous: Box::new(owner.clone()), })?; + return Err(TakeoverError::OwnerChanged { + previous: Box::new(owner.clone()), + current: Box::new(current), + }); + } - Ok(Pid::from(pid)) + match process.kill_with(Signal::Term) { + | Some(true) => { + info!("Sent SIGTERM to lock owner PID {}", owner.pid); + Ok(()) + } + | Some(false) => Err(TakeoverError::SignalFailed { + owner: Box::new(owner.clone()), + }), + | None => Err(TakeoverError::SignalUnsupported { + owner: Box::new(owner.clone()), + }), } + } + + /// Wait for the build lock to become available after the lock owner was terminated. + /// + /// The OS lock remains the source of truth. Each poll tries to acquire it first; + /// if it is still held, owner metadata is re-read so races with a new lock owner + /// are reported explicitly instead of waiting on stale information. + pub fn wait_for_release( + &self, + owner: &Owner, + timeout: Duration, + ) -> StdResult { + use Status::{Acquired, Cyclic, LockedBy, Unknown}; - /// Check lock status and acquire if available. - pub fn try_acquire(&self) -> TsdlResult { - if !self.lock_path.exists() { - return self.acquire().map(LockStatus::Acquired); + info!( + "Waiting up to {} for lock release from PID {}", + crate::format_duration(timeout), + owner.pid + ); + + let deadline = Instant::now() + timeout; + let mut delay = Duration::from_millis(50); + let mut last_observation = Observation::LockedBy(Box::new(owner.clone())); + + loop { + let now = Instant::now(); + if now >= deadline { + return Err(TakeoverError::Timeout { + previous: Box::new(owner.clone()), + timeout, + last_observation: Box::new(last_observation), + }); + } + + match self.try_acquire()? { + | Acquired(guard) => return Ok(guard), + + | Cyclic => return Err(TakeoverError::Cyclic), + + | LockedBy(current) => { + if same_owner(¤t, owner) { + last_observation = Observation::LockedBy(Box::new(current)); + } else { + return Err(TakeoverError::OwnerChanged { + previous: Box::new(owner.clone()), + current: Box::new(current), + }); + } } - self.lock_status() + | Unknown { pid, reason } => { + // If metadata now points at a different PID that we cannot inspect, + // stop waiting on the previous owner. Otherwise keep polling until + // timeout so transient metadata reads or inherited lock handles have + // time to settle. + if pid.is_some_and(|pid| pid != owner.pid) { + return Err(TakeoverError::Unknown { pid, reason }); + } + last_observation = Observation::Unknown { pid, reason }; + } + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + thread::sleep(delay.min(remaining)); + delay = delay.saturating_mul(2).min(Duration::from_millis(500)); + } + } + /// Write PID metadata and construct a Guard after acquiring the OS lock. + fn activate(&self, mut file: File) -> Result { + self.write_metadata(&mut file)?; + info!("Acquired lock on build directory"); + Ok(Guard { + file, + lock_path: self.lock_path.clone(), + }) + } + + /// Open (or create) the lock file for exclusive access. + fn open_lock_file(&self) -> Result { + if let Some(parent) = self.lock_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Creating build directory {}", parent.display()))?; + } + + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&self.lock_path) + .with_context(|| format!("Opening lock file {}", self.lock_path.display())) + } + + /// Helper for checking process status and determining lock conflicts. + fn lock_status(&self) -> Status { + let lock_pid = match self.read_pid() { + | Err(err) => { + return Status::Unknown { + pid: None, + reason: format!("lock is held, but owner metadata could not be read: {err}"), + }; + } + | Ok(pid) => pid, + }; + + if lock_pid == self.current_pid { + return Status::Cyclic; } - fn write(&self) -> TsdlResult<()> { - fs::write(&self.lock_path, self.current_pid.as_u32().to_string()).map_err(|e| { - TsdlError::context( - format!( - "Writing lock file {} with PID {}", - self.lock_path.display(), - self.current_pid - ), - e, - ) - }) + match Self::owner_for_pid(lock_pid) { + | None => Status::Unknown { + pid: Some(lock_pid), + reason: "lock is held, but the metadata PID is not running or cannot be inspected" + .to_string(), + }, + | Some(owner) => Status::LockedBy(owner), } + } + + /// Read process metadata for a given PID (name, command, cwd, etc.). + fn owner_for_pid(pid: Pid) -> Option { + let system = Self::system_for_pid(pid); + let process = system.process(pid)?; + + Some(Owner { + pid: process.pid(), + name: process.name().to_string_lossy().to_string(), + command: command_line(process.cmd()), + exe: process.exe().map(Path::to_path_buf), + cwd: process.cwd().map(Path::to_path_buf), + status: format!("{:?}", process.status()), + run_time: process.run_time(), + start_time: process.start_time(), + }) + } + + /// Read the PID value stored in the lock file. + fn read_pid(&self) -> Result { + let content = fs::read_to_string(&self.lock_path) + .with_context(|| format!("Reading lock file {}", self.lock_path.display()))?; + + let pid: usize = content.trim().parse().map_err(|_| Error::Message { + message: format!( + "Invalid PID '{}' in lock file {}", + content.trim(), + self.lock_path.display() + ), + })?; + + Ok(Pid::from(pid)) + } + + /// Create a System snapshot with process info for a single PID. + fn system_for_pid(pid: Pid) -> System { + let mut system = System::new(); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + true, + ProcessRefreshKind::nothing() + .with_cmd(UpdateKind::Always) + .with_cwd(UpdateKind::Always) + .with_exe(UpdateKind::Always) + .with_user(UpdateKind::Always), + ); + system + } + + /// Write the current PID into the lock file. + fn write_metadata(&self, file: &mut File) -> Result<()> { + file + .set_len(0) + .with_context(|| format!("Truncating lock file {}", self.lock_path.display()))?; + file + .seek(SeekFrom::Start(0)) + .with_context(|| format!("Seeking lock file {}", self.lock_path.display()))?; + write!(file, "{}", self.current_pid.as_u32()).with_context(|| { + format!( + "Writing lock file {} with PID {}", + self.lock_path.display(), + self.current_pid + ) + })?; + file + .sync_all() + .with_context(|| format!("Syncing lock file {}", self.lock_path.display())) + } +} + +impl std::error::Error for TakeoverError {} + +impl TakeoverError { + /// Whether the caller should re-check lock status and retry the outer takeover loop. + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::OwnerDisappeared { .. } | Self::OwnerChanged { .. } + ) + } +} + +// ── Tests ─────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn unused_pid() -> usize { + let system = System::new_all(); + (1_000_000..10_000_000) + .find(|pid| system.process(Pid::from(*pid)).is_none()) + .expect("could not find an unused PID for lock test") + } + + #[test] + fn stale_lock_metadata_does_not_prevent_acquiring_free_os_lock() { + let temp = tempfile::tempdir().unwrap(); + let lock_file = temp.path().join(consts::LOCK_FILE); + fs::write(&lock_file, unused_pid().to_string()).unwrap(); + + let build_dir = BuildDir::new(temp.path()).unwrap(); + let lock = Lock::new(&build_dir); + let status = lock.try_acquire().unwrap(); + + assert!( + matches!(status, Status::Acquired(_)), + "stale lock metadata should not block acquiring a free OS lock" + ); + } } diff --git a/src/logging.rs b/src/logging.rs index 2cd1711..7cf2efa 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,81 +1,258 @@ +//! Logging/tracing setup: dual stderr+file output, configurable levels. + use std::{ - fs::{self, File}, - path::{Path, PathBuf}, + ffi::OsStr, + fs::{self, File}, + path::{Path, PathBuf}, }; use tracing::level_filters::LevelFilter; use tracing_appender::non_blocking::WorkerGuard; use tracing_log::AsTrace; -use tracing_subscriber::{layer::SubscriberExt, Layer}; - -use crate::{ - args::{Args, LogColor}, - config::current, - consts::TSDL_BUILD_DIR, - error::TsdlError, - TsdlResult, -}; +use tracing_subscriber::{Layer, layer::SubscriberExt}; -pub fn init(args: &Args) -> TsdlResult { - let color = match args.log_color { - LogColor::Auto => atty::is(atty::Stream::Stdout), - LogColor::No => false, - LogColor::Yes => true, - }; - console::set_colors_enabled(color); - let filter = args.verbose.log_level_filter().as_trace(); - let file = init_log_file(args)?; - Ok(init_tracing(file, color, filter)) +use crate::{Error, Result, ResultExt, absolute_normalize, args, consts}; + +// ============================================================ +// Enums +// ============================================================ + +/// Describes how the log path is determined: explicitly via `--log` or +/// implicitly derived from the build directory. +pub enum Implicit<'a> { + /// Derive from build directory. + BuildDir { dir: &'a Path }, + /// No implicit path — log is disabled unless `--log` is given. + None, +} + +// ============================================================ +// Structs +// ============================================================ + +/// Wrapper around the non-blocking writer guard to keep the log writer alive. +#[allow(dead_code)] +pub struct Guard(WorkerGuard); + +/// Log path policy: an explicitly provided path or a fallback to the build +/// directory. +pub struct Policy<'a> { + /// Path provided via `--log` flag. + pub explicit: Option<&'a Path>, + /// Fallback policy when no explicit path is given. + pub implicit: Implicit<'a>, } -fn init_tracing(file: File, color: bool, filter: LevelFilter) -> WorkerGuard { - let (writer, guard) = tracing_appender::non_blocking(file); - let stdout_layer = tracing_subscriber::fmt::layer() - .compact() - .with_ansi(color) - .with_file(true) - .with_level(true) - .with_line_number(true) - .with_target(true) - .with_thread_ids(false) - .with_writer(std::io::stderr) - .without_time() - .with_filter(filter); - let file_layer = tracing_subscriber::fmt::layer() - .compact() - .with_ansi(color) - .with_file(true) - .with_level(true) - .with_line_number(true) - .with_target(true) - .with_thread_ids(true) - .with_writer(writer) - .with_filter(filter); - if filter == LevelFilter::DEBUG || filter == LevelFilter::TRACE { - let subscriber = tracing_subscriber::registry() - .with(file_layer) - .with(stdout_layer); - tracing::subscriber::set_global_default(subscriber).unwrap(); - } else { - let subscriber = tracing_subscriber::registry().with(file_layer); - tracing::subscriber::set_global_default(subscriber).unwrap(); +/// Active logging session: owns the resolved log path and the writer guard. +pub struct Session { + /// Resolved log file path on disk. + path: Option, + /// Non-blocking writer guard (keeps writer alive). + _guard: Option, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Build a tracing layer that writes formatted log output to a writer. +fn file_layer( + writer: tracing_appender::non_blocking::NonBlocking, + color: bool, + filter: LevelFilter, +) -> Box + Send + Sync> { + tracing_subscriber::fmt::layer() + .compact() + .with_ansi(color) + .with_file(true) + .with_level(true) + .with_line_number(true) + .with_target(true) + .with_thread_ids(true) + .with_writer(writer) + .with_filter(filter) + .boxed() +} + +/// Initialize the logging system from a policy, color setting, and verbosity. +pub fn init( + policy: Policy<'_>, + log_color: args::LogColor, + verbose: clap_verbosity_flag::Verbosity, +) -> Result { + use args::LogColor::{Auto, No, Yes}; + + let color = match log_color { + | Auto => atty::is(atty::Stream::Stdout), + | No => false, + | Yes => true, + }; + console::set_colors_enabled(color); + + let filter = verbose.log_level_filter().as_trace(); + let path = resolve_log_path(policy)?; + let (writer, guard) = match path.as_ref() { + | None => (None, None), + | Some(path) => { + let file = open_log_file(path)?; + let (writer, guard) = tracing_appender::non_blocking(file); + (Some(writer), Some(Guard(guard))) } - guard + }; + + init_tracing(writer, color, filter); + Ok(Session { + path, + _guard: guard, + }) } -fn init_log_file(args: &Args) -> TsdlResult { - let log = args.log.as_ref().map_or_else( - || { - current(&args.config, args.command.as_build()).map_or_else( - |_| PathBuf::from(TSDL_BUILD_DIR).join("log"), - |c| c.build_dir.clone().join("log"), - ) - }, - std::clone::Clone::clone, - ); - let parent = log.parent().unwrap_or(Path::new(".")); - if !parent.exists() { - fs::create_dir_all(parent).map_err(|e| TsdlError::context("Preparing log directory", e))?; +/// Register the global tracing subscriber with optional file and stderr layers. +fn init_tracing( + writer: Option, + color: bool, + filter: LevelFilter, +) { + let mut layers: Vec + Send + Sync>> = Vec::new(); + + if let Some(writer) = writer { + layers.push(file_layer(writer, color, filter)); + } + + if filter == LevelFilter::DEBUG || filter == LevelFilter::TRACE { + layers.push(stderr_layer(color, filter)); + } + + let subscriber = tracing_subscriber::registry().with(layers); + tracing::subscriber::set_global_default(subscriber).unwrap(); +} + +/// Open or create the log file. +fn open_log_file(log: &Path) -> Result { + let parent = log.parent().unwrap_or(Path::new(".")); + if !parent.exists() { + fs::create_dir_all(parent).context("Preparing log directory")?; + } + File::create(log).context("Creating log file") +} + +/// Resolve the log path from a policy (explicit + implicit fallback). +fn resolve_log_path(policy: Policy<'_>) -> Result> { + match (policy.explicit, policy.implicit) { + | (Some(log), Implicit::BuildDir { dir }) => validate_log_path(dir, log).map(Some), + | (Some(log), Implicit::None) => validate_standalone_log_path(log).map(Some), + | (None, Implicit::BuildDir { dir }) => { + validate_log_path(dir, &dir.join(consts::LOG_FILE)).map(Some) + } + | (None, Implicit::None) => Ok(None), + } +} + +/// Build a tracing layer for stderr output (used at DEBUG/TRACE levels). +fn stderr_layer( + color: bool, + filter: LevelFilter, +) -> Box + Send + Sync> { + tracing_subscriber::fmt::layer() + .compact() + .with_ansi(color) + .with_file(true) + .with_level(true) + .with_line_number(true) + .with_target(true) + .with_thread_ids(false) + .with_writer(std::io::stderr) + .without_time() + .with_filter(filter) + .boxed() +} + +/// Validate that a log path relative to the build directory is acceptable. +fn validate_log_path(build_dir: &Path, log: &Path) -> Result { + let build_dir = absolute_normalize(build_dir)?; + let log = absolute_normalize(log)?; + + if log == build_dir { + return Err(Error::Message { + message: format!( + "--log must be a file path, not the build directory {}", + build_dir.display() + ), + }); + } + + if log.is_dir() { + return Err(Error::Message { + message: format!( + "--log must be a file path, not a directory: {}", + log.display() + ), + }); + } + + if log.starts_with(&build_dir) { + let relative = log.strip_prefix(&build_dir).map_err(|e| Error::Message { + message: format!( + "Could not compare log path {} with build directory {}: {e}", + log.display(), + build_dir.display() + ), + })?; + let component_count = relative.components().count(); + + if component_count != 1 { + return Err(Error::Message { + message: format!( + "--log path {} is nested inside --build-dir {}. Put logs directly under the build directory root or outside it.", + log.display(), + build_dir.display() + ), + }); + } + + let Some(name) = relative.file_name() else { + return Err(Error::Message { + message: format!("--log must be a file path: {}", log.display()), + }); + }; + + if name == OsStr::new(consts::LOCK_FILE) || name == OsStr::new(consts::CACHE_FILE) { + return Err(Error::Message { + message: format!( + "--log path {} conflicts with a tsdl runtime/build file", + log.display() + ), + }); } - File::create(&log).map_err(|e| TsdlError::context("Creating log file", e)) + } + + Ok(log) +} + +/// Validate a standalone log path (no build directory context). +fn validate_standalone_log_path(log: &Path) -> Result { + let log = absolute_normalize(log)?; + + if log.is_dir() { + return Err(Error::Message { + message: format!( + "--log must be a file path, not a directory: {}", + log.display() + ), + }); + } + + Ok(log) +} + +// ============================================================ +// Impls +// ============================================================ + +impl Session { + /// Return the resolved log file path, if any. + #[must_use] + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } } diff --git a/src/main.rs b/src/main.rs index cc4d6cf..9592890 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,72 +1,81 @@ use std::{process::ExitCode, time::Instant}; -use clap::Parser; +use console::style; use tracing::{error, info}; - -use tsdl::{app::App, args, logging, TsdlResult}; +use tsdl::{Error, Result, app}; fn main() -> ExitCode { - set_panic_hook(); - let args = args::Args::parse(); + set_panic_hook(); + let app = match app::setup() { + | Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + | Ok(app) => app, + }; - if let Err(e) = logging::init(&args) { - eprintln!("Could not initialize logging: {e}"); - ExitCode::FAILURE - } else { - info!("Starting"); - match App::new(&args).and_then(|mut app| run(&mut app, &args)) { - Err(e) => { - eprintln!("{e}"); - ExitCode::FAILURE - } - Ok(()) => ExitCode::SUCCESS, - } + info!("Starting"); + match run(&app) { + | Err(Error::Interrupted { signal }) => ExitCode::from(signal.shell_exit_code()), + | Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE } + | Ok(()) => ExitCode::SUCCESS, + } } -fn run(app: &mut App, args: &args::Args) -> TsdlResult<()> { - match &args.command { - args::Command::Build(_) => { - let (result, duration) = time(|| tsdl::build::run(app)); - println!("Done in {duration}"); - result - } - args::Command::Config { command } => tsdl::config::run(app, command), - args::Command::Selfupdate { force, target } => tsdl::selfupdate::run(app, *force, target), +fn run(app: &app::App) -> Result<()> { + use app::ResolvedCommand::{Build, ConfigCurrent, ConfigDefault, Selfupdate}; + + match &app.command { + | Build(build) => { + let (result, duration) = time(|| tsdl::build::run(&build.command, app)); + match &result { + | Ok(()) => println!("{}", style(format!("Done in {duration}")).green()), + | Err(Error::Interrupted { signal }) => println!( + "{}", + style(format!("Interrupted by {signal} after {duration}")).yellow() + ), + | Err(_) => println!("{}", style(format!("Done in {duration}")).red()), + } + result } + | ConfigCurrent(build) => tsdl::config::print_current(&build.command), + | ConfigDefault => tsdl::config::print_default(), + | Selfupdate { force, target } => tsdl::selfupdate::run(*force, target.as_str()), + } } pub fn set_panic_hook() { - std::panic::set_hook(Box::new(move |info| { - #[cfg(not(debug_assertions))] - { - use human_panic::{handle_dump, print_msg, Metadata}; - let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) - .authors(env!("CARGO_PKG_AUTHORS").replace(':', ", ")) - .homepage(env!("CARGO_PKG_HOMEPAGE")); - - let file_path = handle_dump(&meta, info); - print_msg(file_path, &meta) - .expect("human-panic: printing error message to console failed"); - } - #[cfg(debug_assertions)] - { - better_panic::Settings::auto() - .most_recent_first(false) - .lineno_suffix(true) - .verbosity(better_panic::Verbosity::Full) - .create_panic_handler()(info); - } - error!("{}", info); - std::process::exit(1); - })); + std::panic::set_hook(Box::new(move |info| { + #[cfg(not(debug_assertions))] + { + use human_panic::{Metadata, handle_dump, print_msg}; + let meta = Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) + .authors(env!("CARGO_PKG_AUTHORS").replace(':', ", ")) + .homepage(env!("CARGO_PKG_HOMEPAGE")); + let file_path = handle_dump(&meta, info); + print_msg(file_path, &meta).expect("human-panic: printing error message to console failed"); + } + #[cfg(debug_assertions)] + { + better_panic::Settings::auto() + .most_recent_first(false) + .lineno_suffix(true) + .verbosity(better_panic::Verbosity::Full) + .create_panic_handler()(info); + } + error!("{}", info); + std::process::exit(1); + })); } fn time(f: F) -> (T, String) where - F: FnOnce() -> T, + F: FnOnce() -> T, { - let start = Instant::now(); - let result = f(); - (result, tsdl::format_duration(start.elapsed())) + let start = Instant::now(); + let result = f(); + (result, tsdl::format_duration(start.elapsed())) } diff --git a/src/parser.rs b/src/parser.rs index ff0e235..2f1cccc 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,519 +1,1386 @@ +//! Per-language and per-grammar build pipeline: clone, discover grammars, +//! build, install. + use std::{ - env::consts::DLL_EXTENSION, - os::unix::fs::MetadataExt, - path::{Path, PathBuf}, - sync::Arc, + env::consts::DLL_EXTENSION, + fmt, + fs::Metadata, + io, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, + process, + result::Result as StdResult, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, }; use tokio::{fs, process::Command}; -use tracing::warn; +use tracing::{debug, warn}; + +use crate::args::TreeSitter; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use crate::{ - actors::ProgressAddr, - build::{BuildContext, BuildSpec, OutputConfig}, - cache::{Entry, Update}, - error::{self, TsdlError}, - git::clone_fast, - sh::{Exec, Script}, - walk::collect_grammar_paths, - TsdlResult, + Error, Result, ResultExt, actors, build, cache, error, git, + sh::{Exec, Script}, + shutdown, + walk::collect_grammar_paths, }; -pub const NUM_STEPS: usize = 3; +// ============================================================ +// Constants +// ============================================================ + pub const WASM_EXTENSION: &str = "wasm"; -/// Result message from a grammar build -#[derive(Debug, Clone)] -pub enum GrammarMessage { - Completed(Update), - Failed(String), +// ============================================================ +// Enums +// ============================================================ + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +enum ArtifactKind { + Native, + Wasm, } +/// A parser git ref: either a stable tag/commit or a moving branch whose +/// checked-out commit gets resolved and pinned at clone time. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub enum Ref { + /// Stable ref (tag or commit SHA): revision is always `Stable`. + Stable(git::Ref), + /// Moving ref (branch): revision changes on each build. + Moving(git::Ref), +} + +// ============================================================ +// Structs +// ============================================================ + /// A grammar ready to be built, combining definition and cache state #[derive(Clone, Debug)] pub struct GrammarBuild { - pub context: BuildContext, - pub dir: Arc, - pub entry: Option, - pub hash: Arc, - pub language: Arc, // Required for error reporting and cache keys; set from parent LanguageBuild - pub name: Arc, - pub output: OutputConfig, - pub progress: ProgressAddr, // Use language's handle - pub spec: Arc, - pub ts_cli: Arc, + /// Overwrite and other per-build flags. + pub context: build::Context, + /// Cache lookup result for this grammar. + pub cache_decision: cache::Decision, + /// Directory containing the checked-out grammar source. + pub dir: PathBuf, + /// SHA-1 hash of the grammar.js source. + pub hash: cache::GrammarHash, + /// Parent language name (for cache keys and error reporting). + pub language: LanguageName, + /// Grammar name within the parser repo. + pub name: GrammarName, + /// Build and output directory paths. + pub output: build::OutputConfig, + /// Handle for updating the progress display. + pub progress: actors::ProgressAddr, + /// Resolved parser revision (commit SHA for moving refs). + pub revision: cache::Revision, + /// Full build spec (source, ref, tree-sitter version, etc.). + pub spec: Arc, + /// Path to the tree-sitter CLI binary. + pub ts_cli: PathBuf, } -impl GrammarBuild { - /// Build this grammar, returning a cache update if it was built. - /// Uses the language's progress handle for progress reporting. - pub async fn build(&self) -> TsdlResult> { - self.progress.step("checking cache"); - let key = format!("{}/{}", self.language, self.name); - - // Check cache: if cached and definitions match, skip build but still install - let hit = !self.context.force && !self.needs_rebuild(&key); - - if hit { - // Install the binary from the build directory - if let Err(e) = self.install().await { - self.progress.err("install"); - return Err(e); - } - - self.progress.fin("cached"); - return Ok(None); - } +/// A validated grammar name string, used for cache keys and display. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GrammarName(Arc); - // Use the grammar directory path provided - if !self.dir.exists() { - let err = TsdlError::message(format!( - "Grammar directory not found: {}", - self.dir.display() - )); - self.progress.err(format!("{err}")); - return Err(err); - } +/// A language ready for grammar discovery: has a name, context flags, output +/// paths, and a resolved build spec. +#[derive(Clone, Debug)] +pub struct LanguageBuild { + /// Overwrite and other per-build flags. + pub context: build::Context, + /// Language name. + pub name: LanguageName, + /// Build and output directory paths. + pub output: build::OutputConfig, + /// Full build spec (source, ref, tree-sitter version, etc.). + pub spec: Arc, +} - // Build the grammar - if let Err(e) = self.build_grammar().await { - self.progress.err("build"); - return Err(e); - } +/// A validated language name string, used for cache keys, display, and error +/// reporting. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct LanguageName(Arc); + +// ============================================================ +// Free functions +// ============================================================ + +/// Derive an artifact subdirectory name from the tree-sitter CLI filename. +fn artifact_dir_name_from_tree_sitter_cli(ts_cli: &Path) -> Result { + let file_name = ts_cli + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| Error::Message { + message: format!( + "Could not derive artifact id from tree-sitter CLI path {}", + ts_cli.display() + ), + })?; + let id = file_name.strip_prefix("tree-sitter-").unwrap_or(file_name); + Ok(format!("tsdl-{}", sanitize_path_component(id))) +} - // Return cache update for this grammar - let update = Update { - name: key.into(), - entry: Entry { - hash: self.hash.clone(), - spec: self.spec.clone(), - }, - }; +/// Compute the path to a built artifact for a grammar. +fn artifact_path_for( + grammar_name: &GrammarName, + build_dir: &Path, + kind: ArtifactKind, + spec: &build::Spec, + ts_cli: &Path, +) -> Result { + Ok( + build_dir + .join(artifact_dir_name_from_tree_sitter_cli(ts_cli)?) + .join(parser_name_and_ext(grammar_name, kind, &spec.prefix)), + ) +} - self.progress.fin("build"); +/// Create the parent directory for a path if it doesn't exist. +async fn ensure_parent_dir(path: &Path) -> Result<()> { + let parent = path.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not determine parent directory for {}", + path.display() + ), + })?; + + fs::create_dir_all(parent) + .await + .with_context(|| format!("Creating {}", parent.display())) +} - Ok(Some(update)) - } +/// Get the last path component as a string. +fn extract_dir_name(dir: &Path) -> Result { + dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .ok_or_else(|| Error::Message { + message: format!("Could not get dir name for {}", dir.display()), + }) +} - fn build_command(&self, ext: &str, output_name: &str) -> Command { - if let Some(script) = &self.spec.build_script { - return Command::from_str(script); - } +/// Extract grammar name from directory (strips "tree-sitter-" prefix if present) +fn extract_grammar_name(dir: &Path) -> Result { + let dir_name = extract_dir_name(dir)?; + let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); + Ok(GrammarName::from(name)) +} - let mut cmd = Command::new(self.ts_cli.as_os_str()); - cmd.arg("build"); +/// Check whether a string looks like a dotted-numeric version (e.g. "0.21.0"). +fn is_dotted_numeric_version(value: &str) -> bool { + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} - if ext == WASM_EXTENSION { - cmd.arg("--wasm"); - } +/// Determine whether a source ref is "stable" (tag, SHA, version number). +fn is_stable_source_ref(input: &str, git_ref: &git::Ref) -> bool { + git_ref.is_exact_sha() + || is_dotted_numeric_version(input) + || is_v_dotted_numeric_version(input) + || git_ref.as_str().starts_with("refs/tags/") +} + +/// Check if a string matches "v" + dotted numeric version. +fn is_v_dotted_numeric_version(value: &str) -> bool { + value + .strip_prefix('v') + .is_some_and(is_dotted_numeric_version) +} - cmd.args(["--output", output_name]); - cmd +/// Normalize a user-provided source ref: adds "v" prefix to bare version numbers. +fn normalize_source_ref(value: &str) -> String { + if git::Sha::is_full_sha(value) || value.starts_with('v') { + value.to_string() + } else if is_dotted_numeric_version(value) { + format!("v{value}") + } else { + value.to_string() + } +} + +/// Build the output filename for a grammar artifact. +fn parser_name_and_ext(grammar_name: &GrammarName, kind: ArtifactKind, prefix: &str) -> String { + format!("{prefix}{grammar_name}.{}", kind.extension()) +} + +/// Check whether two metadata structs refer to the same file (same device + inode). +fn same_file_identity(a: &Metadata, b: &Metadata) -> bool { + a.dev() == b.dev() && a.ino() == b.ino() +} + +/// Check if two files have identical contents (via SHA-1 hash). +async fn same_regular_file_contents( + dst: &Path, + dst_metadata: &Metadata, + src: &Path, + src_metadata: &Metadata, +) -> Result { + if src_metadata.len() != dst_metadata.len() { + return Ok(false); + } + + let src_hash = cache::hash_file(src).await?; + let dst_hash = cache::hash_file(dst).await?; + Ok(src_hash == dst_hash) +} + +/// Sanitise a string for use as a filesystem path component. +fn sanitize_path_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '-' + } + }) + .collect::(); + + if sanitized.is_empty() { + "unknown".to_string() + } else { + sanitized + } +} + +/// Create a temporary path next to the destination for atomic install. +fn temp_install_path(dst: &Path) -> Result { + let file_name = dst.file_name().ok_or_else(|| Error::Message { + message: format!( + "Could not create temporary install path for {}", + dst.display() + ), + })?; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| Error::Message { + message: format!( + "System clock is before UNIX epoch while creating temporary install path: {e}" + ), + })? + .as_nanos(); + let mut tmp_name = file_name.to_os_string(); + tmp_name.push(format!(".tsdl-{}-{nanos}", process::id())); + + Ok(dst.with_file_name(tmp_name)) +} + +/// Check that a path exists and is a regular file. +async fn verify_artifact(path: &Path) -> Result<()> { + let metadata = fs::metadata(path) + .await + .with_context(|| format!("Reading built artifact {}", path.display()))?; + + if metadata.is_file() { + Ok(()) + } else { + Err(Error::Message { + message: format!("Built artifact is not a regular file: {}", path.display()), + }) + } +} + +// ============================================================ +// Impls +// ============================================================ + +impl ArtifactKind { + /// Get the file extension for this artifact kind (e.g. "so", "dylib", "wasm"). + #[must_use] + fn extension(self) -> &'static str { + match self { + | Self::Native => DLL_EXTENSION, + | Self::Wasm => WASM_EXTENSION, } + } - async fn build_grammar(&self) -> TsdlResult<()> { - // Generate parser if no custom build script - self.progress.step("generating"); - if self.spec.build_script.is_none() { - self.generate().await?; - } else { - warn!("Custom build scripts not supported for generate step (TypeScript limitation)"); - } + /// Check whether this is a wasm target. + #[must_use] + const fn is_wasm(self) -> bool { + matches!(self, Self::Wasm) + } +} - // Build native and/or wasm targets - self.progress.step("building"); - self.build_targets().await?; +impl<'de> Deserialize<'de> for Ref { + fn deserialize(deserializer: D) -> StdResult + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } +} - // Install built parsers - self.progress.step("installing"); - self.install().await?; +impl fmt::Display for GrammarName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl fmt::Display for LanguageName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl fmt::Display for Ref { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.requested()) + } +} + +impl From<&str> for GrammarName { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +impl From<&str> for LanguageName { + fn from(value: &str) -> Self { + Self(Arc::from(value)) + } +} + +impl From> for GrammarName { + fn from(value: Arc) -> Self { + Self(value) + } +} + +impl From> for LanguageName { + fn from(value: Arc) -> Self { + Self(value) + } +} + +impl From for GrammarName { + fn from(value: String) -> Self { + Self(value.into()) + } +} - Ok(()) +impl From for LanguageName { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl GrammarBuild { + /// Build this grammar, returning a cache update if it was built. + /// Uses the language's progress handle for progress reporting. + pub async fn build(&self) -> Result> { + shutdown::test_delay().await; + shutdown::check()?; + debug!( + "[grammar:build] start: lang={} grammar={}", + self.language, self.name + ); + + self.progress.step("checking cache"); + let key = cache::Key::new(&self.language, &self.name); + + // Cache decisions are computed centrally by the cache actor, including + // verification that all required managed artifacts exist. + debug!("[grammar:cache] {key}: {}", self.cache_decision); + + if self.cache_decision.is_hit() { + self.progress.set_outcome_cached().await; + // Install the binary from the build directory + if let Err(e) = self.install().await { + self.progress.err("install failed").await; + return Err(e); + } + + self.progress.cached("done").await; + return Ok(None); } - async fn build_target(&self, ext: &str) -> TsdlResult<()> { - let output_name = self.parser_name_and_ext(ext); - let mut cmd = self.build_command(ext, &output_name); + self.progress.set_outcome_built().await; + self.progress.msg(self.cache_decision.short_message()); - cmd.current_dir(self.dir.as_ref()) - .exec() - .await - .map_err(|err| { - error::TsdlError::Step(error::Step::new( - self.language.clone(), - error::ParserOp::Build { - dir: self.dir.to_path_buf(), - }, - err, - )) - })?; + // Use the grammar directory path provided + if !self.dir.exists() { + let err = Error::Message { + message: format!("Grammar directory not found: {}", self.dir.display()), + }; + self.progress.err("missing grammar directory").await; + return Err(err); + } - Ok(()) + // Build the grammar + if let Err(e) = self.build_grammar().await { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + self.progress.cancel().await; + } else { + self.progress.err("build failed").await; + } + return Err(e); } - async fn build_targets(&self) -> TsdlResult<()> { - if self.spec.target.native() { - self.build_target(DLL_EXTENSION).await?; - } + // Return cache update for this grammar + let update = cache::Update { + name: key, + entry: cache::Entry { + hash: self.hash.clone(), + revision: self.revision.clone(), + spec: self.spec.clone(), + outputs: self.spec.target, + }, + }; + + self.progress.fin("done").await; + + Ok(Some(update)) + } + + /// Build the tree-sitter CLI command line for a given target. + fn builtin_build_command(&self, kind: ArtifactKind, output_path: &Path) -> Command { + let mut cmd = Command::new(self.ts_cli.as_os_str()); + cmd.arg("build"); + + if kind.is_wasm() { + cmd.arg("--wasm"); + } - if self.spec.target.wasm() { - self.build_target(WASM_EXTENSION).await?; - } + cmd.arg("--output").arg(output_path); + cmd + } + + /// Run the full build pipeline for this grammar (generate, build, install). + async fn build_grammar(&self) -> Result<()> { + shutdown::test_delay().await; + shutdown::check()?; + + // Generate parser if no custom build script + self.progress.step("generating"); + if self.spec.build_script.is_none() { + self.generate().await?; + } else { + warn!("Custom build scripts not supported for generate step (TypeScript limitation)"); + } - Ok(()) + // Build native and/or wasm targets + self.progress.step("building"); + self.build_targets().await?; + + // Install built parsers + self.progress.step("installing"); + self.install().await?; + + Ok(()) + } + + /// Build a single target (native or wasm) and return the artifact path. + async fn build_target(&self, kind: ArtifactKind) -> Result { + shutdown::test_delay().await; + shutdown::check()?; + let ext = kind.extension(); + debug!( + "[grammar:build_target] lang={} grammar={} ext={ext}", + self.language, self.name + ); + + if let Some(script) = &self.spec.build_script { + return self.build_custom_target(kind, script).await; } - async fn create_hardlink(&self, src: &Path, dst: &Path) -> TsdlResult<()> { - fs::hard_link(src, dst).await.map_err(|e| { - TsdlError::context(format!("Linking {} -> {}", src.display(), dst.display()), e) - }) + self.build_builtin_target(kind).await + } + + /// Build a target using the built-in tree-sitter CLI. + async fn build_builtin_target(&self, kind: ArtifactKind) -> Result { + let artifact = self.artifact_path(kind)?; + ensure_parent_dir(&artifact).await?; + + let mut cmd = self.builtin_build_command(kind, &artifact); + cmd + .current_dir(self.dir.as_path()) + .exec() + .await + .map_err(|err| self.build_step_error(err))?; + + verify_artifact(&artifact).await?; + Ok(artifact) + } + + /// Build a target using a custom build script. + async fn build_custom_target(&self, kind: ArtifactKind, script: &str) -> Result { + let mut cmd = Command::from_str(script); + cmd + .current_dir(self.dir.as_path()) + .exec() + .await + .map_err(|err| self.build_step_error(err))?; + + let discovered = self.brute_force_discover(kind).await?; + let artifact = self.artifact_path(kind)?; + self.stage_artifact(&discovered, &artifact).await?; + verify_artifact(&artifact).await?; + Ok(artifact) + } + + /// Create a Step error indicating a build failure. + fn build_step_error(&self, err: Error) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Build { + dir: self.dir.clone(), + }, + source: err.into(), } + } - async fn find_parser_binary(&self, ext: &str) -> TsdlResult { - let expected_name = self.parser_name_and_ext(ext); - let mut files = fs::read_dir(self.dir.as_ref()).await.map_err(|e| { - TsdlError::context( - format!("Failed to read directory {}", self.dir.display()), - e, - ) - })?; + /// Build every target (native and/or wasm) requested by the spec. + async fn build_targets(&self) -> Result<()> { + if self.spec.target.native() { + self.build_target(ArtifactKind::Native).await?; + } - let mut exact_match = None; - let mut candidates = Vec::new(); + if self.spec.target.wasm() { + self.build_target(ArtifactKind::Wasm).await?; + } - while let Ok(Some(entry)) = files.next_entry().await { - if !entry.file_type().await.unwrap().is_file() { - continue; - } + Ok(()) + } + + /// Create a hardlink from src to dst. + async fn create_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { + fs::hard_link(src, dst).await.with_context(|| { + format!( + "Could not hardlink {} to {}. build-dir and out-dir must be on the same filesystem", + src.display(), + dst.display() + ) + }) + } + + /// Brute-force discover a built artifact by searching the grammar directory. + async fn brute_force_discover(&self, kind: ArtifactKind) -> Result { + let ext = kind.extension(); + let expected_name = self.parser_name_and_ext(kind); + let mut files = fs::read_dir(self.dir.as_path()) + .await + .with_context(|| format!("Failed to read directory {}", self.dir.display()))?; + + let mut exact_match = None; + let mut candidates = Vec::new(); + + loop { + let Some(entry) = files + .next_entry() + .await + .with_context(|| format!("Failed to read directory entry in {}", self.dir.display()))? + else { + break; + }; + + let path = entry.path(); + let file_type = entry + .file_type() + .await + .with_context(|| format!("Failed to read file type for {}", path.display()))?; - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); + if !file_type.is_file() { + continue; + } - if name == expected_name { - exact_match = Some(self.dir.join(&file_name)); - break; - } + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); - if Path::new(&file_name).extension().and_then(|e| e.to_str()) == Some(ext) { - candidates.push(self.dir.join(&file_name)); - } - } + if name == expected_name { + exact_match = Some(path); + break; + } - match (exact_match, candidates.len()) { - (Some(path), _) => Ok(path), - (None, 0) => Err(self.missing_parser_error(ext)), - (None, 1) => Ok(candidates.into_iter().next().unwrap()), - (None, _) => Err(self.multiple_parsers_error(ext, &candidates)), - } + if Path::new(&file_name).extension().and_then(|e| e.to_str()) == Some(ext) { + candidates.push(path); + } } - async fn generate(&self) -> TsdlResult<()> { - Command::new(self.ts_cli.as_os_str()) - .current_dir(self.dir.as_path()) - .arg("generate") - .exec() - .await - .map(|_| ()) - .map_err(|err| { - error::TsdlError::Step(error::Step::new( - self.language.clone(), - error::ParserOp::Generate { - dir: self.dir.to_path_buf(), - }, - err, - )) - }) - } - - async fn install(&self) -> TsdlResult<()> { - // Find and install parser binary for each extension - if self.spec.target.native() { - self.install_binary(DLL_EXTENSION).await?; - } + match (exact_match, candidates.len()) { + | (None, 0) => Err(self.missing_parser_error(ext)), + | (None, 1) => Ok(candidates.into_iter().next().unwrap()), + | (None, _) => Err(self.multiple_parsers_error(ext, &candidates)), + | (Some(path), _) => Ok(path), + } + } + + /// Run tree-sitter generate in the grammar directory. + async fn generate(&self) -> Result<()> { + Command::new(self.ts_cli.as_os_str()) + .current_dir(self.dir.as_path()) + .arg("generate") + .exec() + .await + .map(|_| ()) + .map_err(|err| Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Generate { + dir: self.dir.clone(), + }, + source: err.into(), + }) + } + + /// Install built binary artifacts to the output directory. + async fn install(&self) -> Result<()> { + // Find and install parser binary for each extension + if self.spec.target.native() { + self.install_binary(ArtifactKind::Native).await?; + } - if self.spec.target.wasm() { - self.install_binary(WASM_EXTENSION).await?; - } + if self.spec.target.wasm() { + self.install_binary(ArtifactKind::Wasm).await?; + } - Ok(()) - } - - async fn install_binary(&self, ext: &str) -> TsdlResult<()> { - let src = self.find_parser_binary(ext).await?; - let dst = self.output.out_dir.join(self.parser_name_and_ext(ext)); - - // Check if different file exists - if dst.exists() { - let src_metadata = fs::metadata(&src) - .await - .map_err(|e| TsdlError::context(format!("Reading {}", src.display()), e))?; - let dst_metadata = fs::metadata(&dst) - .await - .map_err(|e| TsdlError::context(format!("Reading {}", dst.display()), e))?; - - let src_size = src_metadata.size(); - let dst_size = dst_metadata.size(); - let src_inode = src_metadata.ino(); - let dst_inode = dst_metadata.ino(); - - // Check if hardlink is broken (inodes don't match when they should) - let hardlink_broken = src_inode != dst_inode; - - if src_size != dst_size || hardlink_broken { - if src_size != dst_size && !self.context.force { - return Err(TsdlError::message(format!( - "Binary differs at {}. Use --force to overwrite", - dst.display() - ))); - } - - fs::remove_file(&dst) - .await - .map_err(|e| TsdlError::context(format!("Removing {}", dst.display()), e))?; - - // Report reinstallation when fixing broken hardlink - if hardlink_broken { - if let Some(hnd) = self.context.progress.as_ref() { - hnd.msg("Reinstalled"); - } - } - - // Create the hardlink after removing the old one - self.create_hardlink(&src, &dst).await?; - } else { - // Inodes match and sizes match - hardlink is already correct, skip - } - } else { - // Destination doesn't exist, create the hardlink - self.create_hardlink(&src, &dst).await?; - } + Ok(()) + } + + /// Install a single binary artifact to the output directory. + async fn install_binary(&self, kind: ArtifactKind) -> Result<()> { + let src = self.artifact_path(kind)?; + let dst = self.output.out_dir.join(self.parser_name_and_ext(kind)); + let src_metadata = fs::metadata(&src) + .await + .with_context(|| format!("Reading {}", src.display()))?; + + let dst_link_metadata = match fs::symlink_metadata(&dst).await { + | Err(err) if err.kind() == io::ErrorKind::NotFound => { + self.create_hardlink(&src, &dst).await?; + return Ok(()); + } + | Err(err) => { + return Err(Error::Context { + message: format!("Reading {}", dst.display()), + source: err.into(), + }); + } + | Ok(metadata) => metadata, + }; + + self + .install_over_existing(&src, &dst, &dst_link_metadata, &src_metadata) + .await + } + + /// Handle the case where a destination output file already exists. + async fn install_over_existing( + &self, + src: &Path, + dst: &Path, + dst_link_metadata: &Metadata, + src_metadata: &Metadata, + ) -> Result<()> { + let dst_file_type = dst_link_metadata.file_type(); + + if dst_file_type.is_dir() { + return Err(Error::Message { + message: format!( + "Output path is a directory and cannot be replaced: {}", + dst.display() + ), + }); + } - Ok(()) - } - fn missing_parser_error(&self, ext: &str) -> TsdlError { - error::TsdlError::Step(error::Step::new( - self.language.clone(), - error::ParserOp::Copy { - src: self.output.out_dir.to_path_buf(), - dst: self.output.build_dir.to_path_buf(), - }, - TsdlError::message(format!("Couldn't find any {ext} file")), - )) - } - - fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> TsdlError { - error::TsdlError::Step(error::Step::new( - self.language.clone(), - error::ParserOp::Copy { - src: self.output.out_dir.to_path_buf(), - dst: self.output.build_dir.to_path_buf(), - }, - TsdlError::message(format!("Found multiple {ext} files: {candidates:?}")), - )) - } - - /// Check if this grammar needs rebuilding based on cache - fn needs_rebuild(&self, _cache_key: &str) -> bool { - match &self.entry { - None => true, // No cache entry - rebuild needed - Some(entry) => { - // Check if hash or definition changed - let hash_eq = entry.hash == self.hash; - let def_eq = entry.spec == self.spec; - !(hash_eq && def_eq) - } - } + if dst_file_type.is_symlink() { + if !self.context.overwrite_output { + return Err(Error::Message { + message: format!( + "Output path is a symlink and will not be replaced without --force: {}", + dst.display() + ), + }); + } + + self.replace_with_hardlink(src, dst).await?; + self.progress.msg("Reinstalled"); + return Ok(()); } - fn parser_name_and_ext(&self, ext: &str) -> String { - format!("{}{}.{}", self.spec.prefix, self.name, ext) + if !dst_file_type.is_file() { + return Err(Error::Message { + message: format!( + "Output path is not a regular file and cannot be replaced: {}", + dst.display() + ), + }); } -} -#[derive(Clone, Debug)] -pub struct LanguageBuild { - pub context: BuildContext, - pub spec: Arc, - pub name: Arc, - pub output: OutputConfig, -} + let dst_metadata = fs::metadata(dst) + .await + .with_context(|| format!("Reading {}", dst.display()))?; -impl LanguageBuild { - #[must_use] - pub fn new( - context: BuildContext, - spec: Arc, - name: Arc, - output: OutputConfig, - ) -> Self { - Self { - context, - spec, - name, - output, - } + if same_file_identity(src_metadata, &dst_metadata) { + return Ok(()); } - pub async fn discover_grammars(&self) -> TsdlResult> { - let file_results = collect_grammar_paths(self.output.build_dir.clone()).await?; - let mut grammars = Vec::new(); - - for (grammar_path, hash) in file_results { - let grammar_dir = grammar_path.parent().ok_or_else(|| { - TsdlError::Message(format!( - "Could not get parent directory for {}", - grammar_path.display() - )) - })?; - let grammar_name = extract_grammar_name(grammar_dir)?; - grammars.push((grammar_name, grammar_dir.to_path_buf(), hash)); - } + let same_contents = same_regular_file_contents(dst, &dst_metadata, src, src_metadata).await?; - Ok(grammars) + if !same_contents && !self.context.overwrite_output { + return Err(Error::Message { + message: format!( + "Output already exists and differs from the built parser: {}. Use --force to replace it.", + dst.display() + ), + }); } - pub async fn clone(&self) -> TsdlResult<()> { - clone_fast( - self.spec.repo.as_str(), - &self.spec.git_ref, - &self.output.build_dir, - ) - .await - .map_err(|err| { - error::TsdlError::Step(error::Step::new( - self.name.clone(), - error::ParserOp::Clone { - dir: self.output.build_dir.to_path_buf(), - }, - err, - )) - }) + self.replace_with_hardlink(src, dst).await?; + self.progress.msg("reinstalled"); + Ok(()) + } + + /// Copy a custom-built artifact to its canonical build-dir location. + async fn stage_artifact(&self, src: &Path, dst: &Path) -> Result<()> { + ensure_parent_dir(dst).await?; + let src_metadata = fs::metadata(src) + .await + .with_context(|| format!("Reading {}", src.display()))?; + + if !src_metadata.is_file() { + return Err(Error::Message { + message: format!( + "Discovered parser artifact is not a regular file: {}", + src.display() + ), + }); } -} -fn extract_dir_name(dir: &Path) -> TsdlResult { - dir.file_name() - .map(|n| n.to_string_lossy().to_string()) - .ok_or_else(|| TsdlError::Message(format!("Could not get dir name for {}", dir.display()))) -} + match fs::metadata(dst).await { + | Err(err) if err.kind() == io::ErrorKind::NotFound => {} + | Err(err) => { + return Err(Error::Context { + message: format!("Reading {}", dst.display()), + source: err.into(), + }); + } + | Ok(dst_metadata) if same_file_identity(&src_metadata, &dst_metadata) => return Ok(()), + | Ok(_) => {} + } -/// Extract grammar name from directory (strips "tree-sitter-" prefix if present) -fn extract_grammar_name(dir: &Path) -> TsdlResult { - let dir_name = extract_dir_name(dir)?; - let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); - Ok(name.to_string()) -} + self.replace_with_hardlink(src, dst).await + } + + /// Return the list of artifact paths that must exist on disk for a cache hit. + pub fn required_artifacts_for( + grammar_name: &GrammarName, + build_dir: &Path, + spec: &build::Spec, + ts_cli: &Path, + ) -> Result> { + let mut artifacts = Vec::new(); + + if spec.target.native() { + artifacts.push(artifact_path_for( + grammar_name, + build_dir, + ArtifactKind::Native, + spec, + ts_cli, + )?); + } -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - /// Extract directory name from a path - fn extract_dir_name(dir: &Path) -> TsdlResult { - dir.file_name() - .ok_or_else(|| TsdlError::message("Could not extract directory name")) - .map(|name| name.to_string_lossy().to_string()) - } - - /// Extract grammar name from directory (strips "tree-sitter-" prefix if present) - fn extract_grammar_name(dir: &Path) -> TsdlResult { - let dir_name = extract_dir_name(dir)?; - let name = dir_name.strip_prefix("tree-sitter-").unwrap_or(&dir_name); - Ok(name.to_string()) - } - - /// Generate cache key for a grammar: `language_name/grammar_name` - fn make_cache_key(language_name: &str, grammar_path: &Path) -> TsdlResult { - let grammar_dir = grammar_path.parent().ok_or_else(|| { - TsdlError::Message(format!( - "Could not get parent directory for {}", - grammar_path.display() - )) - })?; - - let grammar_name = extract_grammar_name(grammar_dir)?; - Ok(format!("{language_name}/{grammar_name}")) - } - - /// Parse parser name and extension - fn parser_name_and_ext(grammar_name: &str, prefix: &str, ext: &str) -> String { - if prefix.is_empty() { - format!("{grammar_name}.{ext}") - } else { - format!("{prefix}{grammar_name}.{ext}") - } + if spec.target.wasm() { + artifacts.push(artifact_path_for( + grammar_name, + build_dir, + ArtifactKind::Wasm, + spec, + ts_cli, + )?); } - #[test] - fn test_make_cache_key() { - let path = PathBuf::from("/tmp/build/tree-sitter-typescript/grammar.js"); - let key = make_cache_key("typescript", &path).unwrap(); - assert_eq!(key, "typescript/typescript"); + Ok(artifacts) + } + + /// Compute the artifact path for a given target kind. + fn artifact_path(&self, kind: ArtifactKind) -> Result { + artifact_path_for( + &self.name, + &self.output.build_dir, + kind, + &self.spec, + &self.ts_cli, + ) + } + + /// Atomically replace dst with a hardlink to src (via a temp file + rename). + async fn replace_with_hardlink(&self, src: &Path, dst: &Path) -> Result<()> { + let tmp = temp_install_path(dst)?; + self.create_hardlink(src, &tmp).await?; + + if let Err(err) = fs::rename(&tmp, dst).await { + let _ = fs::remove_file(&tmp).await; + return Err(Error::Context { + message: format!("Installing {} to {}", src.display(), dst.display()), + source: err.into(), + }); + } - let path = PathBuf::from("/tmp/build/tree-sitter-tsx/grammar.js"); - let key = make_cache_key("typescript", &path).unwrap(); - assert_eq!(key, "typescript/tsx"); + Ok(()) + } + + /// Create an error reporting a missing parser binary. + fn missing_parser_error(&self, ext: &str) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Copy { + src: self.output.out_dir.clone(), + dst: self.output.build_dir.clone(), + }, + source: Error::Message { + message: format!("Couldn't find any {ext} file"), + } + .into(), } + } + + /// Create an error reporting multiple candidate parser binaries. + fn multiple_parsers_error(&self, ext: &str, candidates: &[PathBuf]) -> Error { + Error::Step { + name: self.language.as_arc(), + kind: error::ParserOp::Copy { + src: self.output.out_dir.clone(), + dst: self.output.build_dir.clone(), + }, + source: Error::Message { + message: format!("Found multiple {ext} files: {candidates:?}"), + } + .into(), + } + } - #[test] - fn test_extract_grammar_name() { - let dir = Path::new("/tmp/build/tree-sitter-typescript"); - let name = extract_grammar_name(dir).unwrap(); - assert_eq!(name, "typescript"); + /// Build the output filename for a grammar artifact (with prefix). + fn parser_name_and_ext(&self, kind: ArtifactKind) -> String { + parser_name_and_ext(&self.name, kind, &self.spec.prefix) + } +} - let dir = Path::new("/tmp/build/custom-parser"); - let name = extract_grammar_name(dir).unwrap(); - assert_eq!(name, "custom-parser"); - } +impl GrammarName { + /// Return the grammar name as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Return the grammar name as a cloned `Arc`. + #[must_use] + pub fn as_arc(&self) -> Arc { + self.0.clone() + } +} - #[test] - fn test_extract_grammar_name_strips_prefix() { - let name_with_prefix = "tree-sitter-typescript"; - let stripped = name_with_prefix - .strip_prefix("tree-sitter-") - .unwrap_or(name_with_prefix); - assert_eq!(stripped, "typescript"); +impl LanguageBuild { + /// Create a new `LanguageBuild`. + #[must_use] + pub fn new( + context: build::Context, + name: LanguageName, + output: build::OutputConfig, + spec: Arc, + ) -> Self { + Self { + context, + name, + output, + spec, + } + } + + /// Scan the checkout directory for grammar.js files and return their + /// names, hashes, and parent directories. + pub async fn discover_grammars(&self) -> Result> { + let file_results = collect_grammar_paths(&self.output.build_dir).await?; + let mut grammars = Vec::new(); + + for (grammar_path, hash) in file_results { + let grammar_dir = grammar_path.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not get parent directory for {}", + grammar_path.display() + ), + })?; + let grammar_name = extract_grammar_name(grammar_dir)?; + grammars.push((grammar_name, hash, grammar_dir.to_path_buf())); + } - let name_without_prefix = "custom-parser"; - let not_stripped = name_without_prefix - .strip_prefix("tree-sitter-") - .unwrap_or(name_without_prefix); - assert_eq!(not_stripped, "custom-parser"); + if grammars.is_empty() { + return Err(Error::Step { + name: self.name.as_arc(), + kind: error::ParserOp::Discover { + dir: self.output.build_dir.clone(), + }, + source: Error::Message { + message: format!( + "No grammar.js files found for parser {} (repo: {}, ref: {})", + self.name, + self.spec.repo, + self.spec.git_ref.requested().as_str() + ), + } + .into(), + }); } - #[test] - fn test_parser_name_and_ext() { - let name = parser_name_and_ext("typescript", "", "so"); - assert_eq!(name, "typescript.so"); + Ok(grammars) + } + + /// Return a new `LanguageBuild` with the tree-sitter version replaced + /// (once the CLI has been resolved). + #[must_use] + pub fn with_tree_sitter(mut self, tree_sitter: TreeSitter) -> Self { + let mut spec = self.spec.as_ref().clone(); + spec.tree_sitter = tree_sitter; + self.spec = Arc::new(spec); + self + } + + /// Clone/checkout the parser repository at the configured ref. + pub async fn checkout(&self) -> Result { + git::checkout( + self.spec.repo.as_str(), + &self.output.build_dir, + self.spec.git_ref.requested(), + ) + .await + .map_err(|err| Error::Step { + name: self.name.as_arc(), + kind: error::ParserOp::Clone { + dir: self.output.build_dir.clone(), + }, + source: err.into(), + }) + } + + /// Check whether the existing checkout directory is usable for this language. + pub async fn is_checkout_usable(&self) -> bool { + git::is_checkout_usable(self.spec.repo.as_str(), &self.output.build_dir).await + } +} - let name = parser_name_and_ext("typescript", "", "wasm"); - assert_eq!(name, "typescript.wasm"); - } +impl LanguageName { + /// Return the language name as a string slice. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Return the language name as a cloned `Arc`. + #[must_use] + pub fn as_arc(&self) -> Arc { + self.0.clone() + } +} - #[test] - fn test_parser_name_with_prefix() { - let name = parser_name_and_ext("typescript", "lib", "so"); - assert_eq!(name, "libtypescript.so"); +impl Ref { + /// The default source ref used for unpinned parser builds. + #[must_use] + pub fn head() -> Self { + Self::Moving(git::Ref::head()) + } + + /// Create a parser source ref from user input, preserving parser-version + /// normalization while classifying refs for cache semantics. + pub fn parse(value: &str) -> StdResult { + let normalized = normalize_source_ref(value); + let git_ref = git::Ref::new(normalized)?; + if is_stable_source_ref(value, &git_ref) { + Ok(Self::Stable(git_ref)) + } else { + Ok(Self::Moving(git_ref)) + } + } + + /// Check whether this is a moving ref (branch). + #[must_use] + pub const fn is_moving(&self) -> bool { + matches!(self, Self::Moving(_)) + } + + /// Check whether this is a stable ref (tag or SHA). + #[must_use] + pub const fn is_stable(&self) -> bool { + matches!(self, Self::Stable(_)) + } + + /// Return the underlying git ref, regardless of moving/stable classification. + #[must_use] + pub fn requested(&self) -> &git::Ref { + match self { + | Self::Stable(git_ref) | Self::Moving(git_ref) => git_ref, } + } +} - #[tokio::test] - async fn test_per_grammar_cache_key_format() { - // Test that cache keys follow the "language/grammar" format - let temp_dir = TempDir::new().unwrap(); - let grammar_dir = temp_dir.path().join("tree-sitter-tsx"); - tokio::fs::create_dir(&grammar_dir).await.unwrap(); - let grammar_file = grammar_dir.join("grammar.js"); - tokio::fs::write(&grammar_file, "module.exports = {};") - .await - .unwrap(); +impl Serialize for Ref { + fn serialize(&self, serializer: S) -> StdResult + where + S: Serializer, + { + serializer.serialize_str(self.requested().as_str()) + } +} - let cache_key = make_cache_key("typescript", &grammar_file).unwrap(); +#[cfg(test)] +mod tests { + use super::*; + use std::{os::unix::fs::symlink, process::Command as StdCommand}; + + use crate::{ + actors::{DisplayActor, DisplayAddr}, + args::{Target, TreeSitter}, + display::Mode, + git, + }; + use tempfile::TempDir; + + const FULL_SHA: &str = "636801770eea172d140e64b691815ff11f6b556f"; + + #[test] + fn ref_classifies_stable_refs() { + assert!(Ref::parse("0.21.0").unwrap().is_stable()); + assert!(Ref::parse("v0.21.0").unwrap().is_stable()); + assert!(Ref::parse("refs/tags/release").unwrap().is_stable()); + assert!(Ref::parse(FULL_SHA).unwrap().is_stable()); + } + + #[test] + fn ref_classifies_moving_refs() { + assert!(Ref::head().is_moving()); + assert!(Ref::parse("master").unwrap().is_moving()); + assert!(Ref::parse("refs/heads/main").unwrap().is_moving()); + assert!(Ref::parse("vnext").unwrap().is_moving()); + } + + #[test] + fn ref_serializes_as_requested_ref() { + #[derive(serde::Serialize)] + struct Wrapper { + git_ref: Ref, + } - assert_eq!(cache_key, "typescript/tsx"); - assert!( - cache_key.contains('/'), - "Cache key should use language/grammar format" - ); + let source = Ref::parse("0.21.0").unwrap(); + + assert_eq!( + toml::to_string(&Wrapper { git_ref: source }).unwrap(), + "git_ref = \"v0.21.0\"\n" + ); + } + + fn test_language_build(build_dir: PathBuf, out_dir: PathBuf) -> LanguageBuild { + LanguageBuild::new( + build::Context { + overwrite_output: false, + }, + LanguageName::from("empty"), + build::OutputConfig { build_dir, out_dir }, + Arc::new(build::Spec { + build_script: None, + git_ref: Ref::parse("v1.0.0").unwrap(), + prefix: String::new(), + repo: "https://example.com/tree-sitter-empty".parse().unwrap(), + target: Target::Native, + tree_sitter: TreeSitter::default(), + }), + ) + } + + fn init_git_repo(path: &Path) { + std::fs::create_dir_all(path).unwrap(); + let output = StdCommand::new("git") + .arg("init") + .arg("--quiet") + .current_dir(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + async fn test_grammar_build( + grammar_dir: PathBuf, + out_dir: PathBuf, + overwrite_output: bool, + ) -> (GrammarBuild, DisplayAddr) { + let display = DisplayActor::spawn(grammar_dir.clone(), Mode::Plain, out_dir.clone()); + let progress = display + .add_grammar("rust", "rust", git::Ref::head(), 1) + .await; + let source_ref = Ref::parse("v1.0.0").unwrap(); + let build = GrammarBuild { + context: build::Context { overwrite_output }, + cache_decision: cache::Decision::miss(cache::MissReason::MissingEntry), + dir: grammar_dir.clone(), + hash: cache::GrammarHash::from("test"), + language: "rust".into(), + name: "rust".into(), + output: build::OutputConfig { + build_dir: grammar_dir, + out_dir, + }, + progress, + revision: cache::Revision::stable(), + spec: Arc::new(build::Spec { + build_script: None, + git_ref: source_ref, + prefix: String::new(), + repo: "https://example.com/tree-sitter-rust".parse().unwrap(), + target: Target::Native, + tree_sitter: TreeSitter::default(), + }), + ts_cli: PathBuf::from("tree-sitter-macos-arm64-v0.26.5"), + }; + + (build, display) + } + + async fn write_artifact(build: &GrammarBuild, kind: ArtifactKind, contents: &[u8]) -> PathBuf { + let path = build.artifact_path(kind).unwrap(); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, contents).await.unwrap(); + path + } + + fn same_identity(a: &Path, b: &Path) -> bool { + let a = std::fs::metadata(a).unwrap(); + let b = std::fs::metadata(b).unwrap(); + same_file_identity(&a, &b) + } + + #[tokio::test] + async fn discover_grammars_errors_when_no_grammar_js_files_are_found() { + let temp_dir = TempDir::new().unwrap(); + let build_dir = temp_dir.path().join("tree-sitter-empty"); + let out_dir = temp_dir.path().join("out"); + init_git_repo(&build_dir); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let language = test_language_build(build_dir.clone(), out_dir); + let err = language.discover_grammars().await.unwrap_err(); + + match err { + | Error::Step { name, kind, source } => { + assert_eq!(name.as_ref(), "empty"); + assert!(matches!(kind, error::ParserOp::Discover { dir } if dir == build_dir)); + let message = source.as_error().to_string(); + assert!(message.contains("No grammar.js files found")); + assert!(message.contains("https://example.com/tree-sitter-empty")); + assert!(message.contains("v1.0.0")); + } + | other => panic!("expected discovery step error, got {other:?}"), } + } + + #[test] + fn test_cache_key_format() { + let key = cache::Key::new( + &LanguageName::from("typescript"), + &GrammarName::from("typescript"), + ); + assert_eq!(key.as_str(), "typescript/typescript"); + + let key = cache::Key::new(&LanguageName::from("typescript"), &GrammarName::from("tsx")); + assert_eq!(key.as_str(), "typescript/tsx"); + } + + #[test] + fn test_extract_grammar_name() { + let dir = Path::new("/tmp/build/tree-sitter-typescript"); + let name = extract_grammar_name(dir).unwrap(); + assert_eq!(name.as_str(), "typescript"); + + let dir = Path::new("/tmp/build/custom-parser"); + let name = extract_grammar_name(dir).unwrap(); + assert_eq!(name.as_str(), "custom-parser"); + } + + #[test] + fn test_extract_grammar_name_strips_prefix() { + let name_with_prefix = "tree-sitter-typescript"; + let stripped = name_with_prefix + .strip_prefix("tree-sitter-") + .unwrap_or(name_with_prefix); + assert_eq!(stripped, "typescript"); + + let name_without_prefix = "custom-parser"; + let not_stripped = name_without_prefix + .strip_prefix("tree-sitter-") + .unwrap_or(name_without_prefix); + assert_eq!(not_stripped, "custom-parser"); + } + + #[test] + fn test_artifact_dir_name_from_tree_sitter_cli() { + assert_eq!( + artifact_dir_name_from_tree_sitter_cli(Path::new( + "/tmp/tsdl/tree-sitter-macos-arm64-v0.26.5" + )) + .unwrap(), + "tsdl-macos-arm64-v0.26.5" + ); + } + + #[test] + fn test_parser_name_and_ext() { + let name = parser_name_and_ext(&GrammarName::from("typescript"), ArtifactKind::Native, ""); + assert_eq!(name, format!("typescript.{DLL_EXTENSION}")); + } + + #[test] + fn test_parser_name_with_prefix() { + let name = parser_name_and_ext( + &GrammarName::from("typescript"), + ArtifactKind::Native, + "lib", + ); + assert_eq!(name, format!("libtypescript.{DLL_EXTENSION}")); + } + + #[tokio::test] + async fn install_missing_destination_creates_hardlink() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, ArtifactKind::Native, b"parser").await; + build.install_binary(ArtifactKind::Native).await.unwrap(); + display.shutdown(false).await; + + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_content_relinks_without_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, ArtifactKind::Native, b"parser").await; + tokio::fs::write(&dst, b"parser").await.unwrap(); + assert!(!same_identity(&src, &dst)); + + build.install_binary(ArtifactKind::Native).await.unwrap(); + display.shutdown(false).await; + + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_size_different_content_requires_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let src = write_artifact(&build, ArtifactKind::Native, b"new").await; + tokio::fs::write(&dst, b"old").await.unwrap(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("differs from the built parser")); + assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"old"); + assert!(!same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_same_size_different_content_with_force_replaces() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; + let src = write_artifact(&build, ArtifactKind::Native, b"new").await; + tokio::fs::write(&dst, b"old").await.unwrap(); + build.install_binary(ArtifactKind::Native).await.unwrap(); + display.shutdown(false).await; + + assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"new"); + assert!(same_identity(&src, &dst)); + } + + #[tokio::test] + async fn install_symlink_requires_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + let target = out_dir.join(format!("target.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, false).await; + let _src = write_artifact(&build, ArtifactKind::Native, b"new").await; + tokio::fs::write(&target, b"old").await.unwrap(); + symlink(&target, &dst).unwrap(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("symlink")); + assert_eq!(std::fs::read_link(&dst).unwrap(), target); + assert_eq!(tokio::fs::read(&target).await.unwrap(), b"old"); + } + + #[tokio::test] + async fn install_directory_destination_errors_even_with_force() { + let temp_dir = TempDir::new().unwrap(); + let grammar_dir = temp_dir.path().join("grammar"); + let out_dir = temp_dir.path().join("out"); + tokio::fs::create_dir_all(&grammar_dir).await.unwrap(); + tokio::fs::create_dir_all(&out_dir).await.unwrap(); + + let dst = out_dir.join(format!("rust.{DLL_EXTENSION}")); + + let (build, display) = test_grammar_build(grammar_dir, out_dir, true).await; + let _src = write_artifact(&build, ArtifactKind::Native, b"new").await; + tokio::fs::create_dir(&dst).await.unwrap(); + let err = build + .install_binary(ArtifactKind::Native) + .await + .unwrap_err(); + display.shutdown(false).await; + + assert!(err.to_string().contains("directory")); + assert!(dst.is_dir()); + } } diff --git a/src/selfupdate.rs b/src/selfupdate.rs index cdab518..3a548b9 100644 --- a/src/selfupdate.rs +++ b/src/selfupdate.rs @@ -3,170 +3,160 @@ use std::{fs, path::PathBuf}; use self_update::self_replace; use semver::Version; -use crate::{ - app::App, args::VersionBump, consts::TREE_SITTER_PLATFORM, error::TsdlError, prompt_user, - TsdlResult, -}; +use crate::{Error, Result, ResultExt, args::VersionBump, consts::PLATFORM, prompt_user}; enum UpdateTarget { - Exact(Version), - Relative(VersionBump), + Exact(Version), + Relative(VersionBump), } -fn download_and_replace( - asset_name: &str, - download_url: &str, - handle: &crate::display::ProgressBar, - version: &Version, -) -> TsdlResult<()> { - let tsdl = env!("CARGO_PKG_NAME"); - let tmp_dir = tempfile::tempdir() - .map_err(|e| TsdlError::context("Failed to create temporary directory", e))?; - let tmp_gz_path = tmp_dir.path().join(asset_name); - let tmp_gz = fs::File::create_new(&tmp_gz_path) - .map_err(|e| TsdlError::context("Failed to create temporary file", e))?; - - handle.step(format!("downloading {version}")); - self_update::Download::from_url(download_url) - .set_header( - reqwest::header::ACCEPT, - "application/octet-stream" - .parse() - .map_err(|e| TsdlError::context("Failed to parse accept header", e))?, - ) - .download_to(&tmp_gz) - .map_err(|e| TsdlError::context("Failed to download release asset", e))?; - - handle.step(format!("extracting {version}")); - let tsdl_bin = PathBuf::from(tsdl); - self_update::Extract::from_source(&tmp_gz_path) - .archive(self_update::ArchiveKind::Plain(Some( - self_update::Compression::Gz, - ))) - .extract_file(tmp_dir.path(), &tsdl_bin) - .map_err(|e| TsdlError::context("Failed to extract release asset", e))?; - - let new_exe = tmp_dir.path().join(tsdl_bin); - self_replace::self_replace(new_exe) - .map_err(|e| TsdlError::context("Failed to replace current executable", e))?; - - handle.fin(format!("{version}")); - Ok(()) +fn download_and_replace(asset_name: &str, download_url: &str, version: &Version) -> Result<()> { + let tsdl = env!("CARGO_PKG_NAME"); + let tmp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; + let tmp_gz_path = tmp_dir.path().join(asset_name); + let tmp_gz = fs::File::create_new(&tmp_gz_path).context("Failed to create temporary file")?; + + eprintln!("downloading {version}"); + self_update::Download::from_url(download_url) + .set_header( + reqwest::header::ACCEPT, + "application/octet-stream" + .parse() + .context("Failed to parse accept header")?, + ) + .download_to(&tmp_gz) + .context("Failed to download release asset")?; + + eprintln!("extracting {version}"); + let tsdl_bin = PathBuf::from(tsdl); + self_update::Extract::from_source(&tmp_gz_path) + .archive(self_update::ArchiveKind::Plain(Some( + self_update::Compression::Gz, + ))) + .extract_file(tmp_dir.path(), &tsdl_bin) + .with_context(|| "Failed to extract release asset".to_string())?; + + let new_exe = tmp_dir.path().join(tsdl_bin); + self_replace::self_replace(new_exe) + .with_context(|| "Failed to replace current executable".to_string())?; + + eprintln!("{version}"); + Ok(()) } -fn parse_target(raw: &str) -> Result { - match raw { - "major" => Ok(UpdateTarget::Relative(VersionBump::Major)), - "minor" => Ok(UpdateTarget::Relative(VersionBump::Minor)), - "patch" => Ok(UpdateTarget::Relative(VersionBump::Patch)), - other => Version::parse(other).map(UpdateTarget::Exact).map_err(|e| { - format!("expected 'patch', 'minor', 'major', or a semver like '2.5.0': {e}") - }), - } +fn parse_target(raw: &str) -> Result { + match raw { + | "major" => Ok(UpdateTarget::Relative(VersionBump::Major)), + | "minor" => Ok(UpdateTarget::Relative(VersionBump::Minor)), + | "patch" => Ok(UpdateTarget::Relative(VersionBump::Patch)), + | other => Version::parse(other) + .map(UpdateTarget::Exact) + .context("expected 'patch', 'minor', 'major', or a semver like '2.5.0'"), + } } -pub fn run(app: &mut App, force: bool, target: &str) -> TsdlResult<()> { - let update_target = parse_target(target).map_err(TsdlError::message)?; - - let tsdl = env!("CARGO_PKG_NAME"); - let current_version = Version::parse(env!("CARGO_PKG_VERSION")) - .map_err(|e| TsdlError::context("Failed to parse current version", e))?; - let handle = app.progress.register("selfupdate".into(), "".into(), 4); - - handle.step("fetching releases"); - let releases = self_update::backends::github::ReleaseList::configure() - .repo_owner("stackmystack") - .repo_name(tsdl) - .build() - .map_err(|e| TsdlError::context("Failed to build release list configuration", e))? - .fetch() - .map_err(|e| TsdlError::context("Failed to fetch releases", e))?; - - if releases.is_empty() { - return Err(TsdlError::message("No releases found")); +pub fn run(force: bool, target: &str) -> Result<()> { + let update_target = parse_target(target)?; + + let tsdl = env!("CARGO_PKG_NAME"); + let current_version = Version::parse(env!("CARGO_PKG_VERSION")) + .with_context(|| "Failed to parse current version".to_string())?; + + eprintln!("fetching releases"); + let releases = self_update::backends::github::ReleaseList::configure() + .repo_owner("stackmystack") + .repo_name(tsdl) + .build() + .with_context(|| "Failed to build release list configuration".to_string())? + .fetch() + .with_context(|| "Failed to fetch releases".to_string())?; + + if releases.is_empty() { + return Err(Error::Message { + message: "No releases found".to_string(), + }); + } + + let (release, version) = match update_target { + | UpdateTarget::Exact(target_version) => { + let Some(found_release) = releases + .iter() + .find(|r| Version::parse(&r.version).is_ok_and(|v| v == target_version)) + else { + return Err(Error::Message { + message: format!("version {target_version} not found in releases"), + }); + }; + + if target_version == current_version { + eprintln!("already at {target_version}"); + return Ok(()); + } + + let is_downgrade = target_version < current_version; + if is_downgrade + && !force + && !prompt_user( + &format!("Downgrade from {current_version} to {target_version}?"), + false, + )? + { + eprintln!("downgrade cancelled"); + return Ok(()); + } + + (found_release.clone(), target_version) } - let (release, version) = match update_target { - UpdateTarget::Exact(target_version) => { - let Some(found_release) = releases - .iter() - .find(|r| Version::parse(&r.version).is_ok_and(|v| v == target_version)) - else { - return Err(TsdlError::message(format!( - "version {target_version} not found in releases" - ))); - }; - - if target_version == current_version { - handle.msg(format!("already at {target_version}")); - return Ok(()); - } - - let is_downgrade = target_version < current_version; - if is_downgrade - && !force - && !prompt_user( - &format!("Downgrade from {current_version} to {target_version}?"), - false, - )? - { - handle.msg("downgrade cancelled"); - return Ok(()); + | UpdateTarget::Relative(bump) => { + let compatible: Vec<_> = releases + .iter() + .filter(|r| { + let Ok(rel_ver) = Version::parse(&r.version) else { + return false; + }; + if rel_ver <= current_version { + return false; + } + match bump { + | VersionBump::Major => true, + | VersionBump::Minor => rel_ver.major == current_version.major, + | VersionBump::Patch => { + rel_ver.major == current_version.major && rel_ver.minor == current_version.minor } - - (found_release.clone(), target_version) + } + }) + .collect(); + + if compatible.is_empty() { + let overall_latest = Version::parse(&releases[0].version).ok(); + if overall_latest + .as_ref() + .is_some_and(|v| v > ¤t_version) + { + eprintln!( + "no compatible {bump} update (latest is {}; use `tsdl selfupdate major` to install it)", + releases[0].version, + ); + } else { + eprintln!("already at the latest version"); } + return Ok(()); + } - UpdateTarget::Relative(bump) => { - let compatible: Vec<_> = releases - .iter() - .filter(|r| { - let Ok(rel_ver) = Version::parse(&r.version) else { - return false; - }; - if rel_ver <= current_version { - return false; - } - match bump { - VersionBump::Major => true, - VersionBump::Minor => rel_ver.major == current_version.major, - VersionBump::Patch => { - rel_ver.major == current_version.major - && rel_ver.minor == current_version.minor - } - } - }) - .collect(); - - if compatible.is_empty() { - let overall_latest = Version::parse(&releases[0].version).ok(); - if overall_latest - .as_ref() - .is_some_and(|v| v > ¤t_version) - { - handle.msg(format!( - "no compatible {bump} update (latest is {}; use `tsdl selfupdate major` to install it)", - releases[0].version, - )); - } else { - handle.msg("already at the latest version"); - } - return Ok(()); - } - - let latest_release = compatible[0]; - let latest_version = Version::parse(&latest_release.version) - .map_err(|e| TsdlError::context("Failed to parse latest version", e))?; - (latest_release.clone(), latest_version) - } - }; + let latest_release = compatible[0]; + let latest_version = + Version::parse(&latest_release.version).context("Failed to parse latest version")?; + (latest_release.clone(), latest_version) + } + }; - let asset_name = format!("{tsdl}-{TREE_SITTER_PLATFORM}.gz"); - let Some(asset) = release.assets.iter().find(|a| a.name == asset_name) else { - return Err(TsdlError::message( - "Could not find a suitable release for your platform", - )); - }; + let asset_name = format!("{tsdl}-{PLATFORM}.gz"); + let Some(asset) = release.assets.iter().find(|a| a.name == asset_name) else { + return Err(Error::Message { + message: "Could not find a suitable release for your platform".to_string(), + }); + }; - download_and_replace(&asset.name, &asset.download_url, &handle, &version) + download_and_replace(&asset.name, &asset.download_url, &version) } diff --git a/src/sh.rs b/src/sh.rs index b410263..5f3b202 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -1,92 +1,213 @@ -use std::{env, fmt::Write, os::unix::process::ExitStatusExt, process::Output}; +//! Command execution trait (`Exec`) with error formatting. -use tokio::process::Command; -use tracing::{error, trace}; +use std::os::unix::process::CommandExt as _; +use std::{env, fmt::Write, os::unix::process::ExitStatusExt, process::Output, time::Duration}; -use crate::{error, TsdlResult}; +use tokio::{process::Command, time}; +use tracing::{debug, error, info, trace, warn}; + +use crate::{ + Error, Result, ResultExt, + shutdown::{self, PgId}, +}; + +// ============================================================ +// Traits +// ============================================================ pub trait Exec { - fn display(&self) -> TsdlResult; - fn display_full(&self) -> TsdlResult; - fn exec(&mut self) -> impl std::future::Future>; + fn display(&self) -> Result; + fn display_full(&self) -> Result; + fn exec(&mut self) -> impl std::future::Future>; } pub trait Script { - fn from_str(script: &str) -> Command; + fn from_str(script: &str) -> Command; } -impl Exec for Command { - fn display(&self) -> TsdlResult { - let program = self.as_std().get_program().to_string_lossy(); - let args = self.as_std().get_args(); - let mut res = String::new(); - - write!(res, "{program} ").map_err(|e| { - error::TsdlError::context("Failed to write program to display string", e) - })?; - - for arg in args { - write!(res, "{} ", arg.to_string_lossy()).map_err(|e| { - error::TsdlError::context("Failed to write argument to display string", e) - })?; - } +// ============================================================ +// Free functions +// ============================================================ + +/// Look up the human-readable name of a Unix signal number. +fn signal_display(number: i32) -> Option { + let ptr = unsafe { libc::strsignal(number) }; + if ptr.is_null() { + None + } else { + Some( + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) + } +} - Ok(res.trim_end().to_string()) - } +// ============================================================ +// Impls +// ============================================================ - fn display_full(&self) -> TsdlResult { - let cwd = self.as_std().get_current_dir(); - let base = self.display()?; +impl Exec for Command { + /// Format the command as a one-line string (program + args). + fn display(&self) -> Result { + let program = self.as_std().get_program().to_string_lossy(); + let args = self.as_std().get_args(); + let mut res = String::new(); - match cwd { - Some(path) => Ok(format!("[{}] {}", path.display(), base)), - None => Ok(base), - } + write!(res, "{program} ").context("Failed to write program to display string")?; + + for arg in args { + write!(res, "{} ", arg.to_string_lossy()) + .context("Failed to write argument to display string")?; } - #[tracing::instrument(skip(self))] - async fn exec(&mut self) -> TsdlResult { - let cmd_full = self.display_full()?; - trace!("{}", cmd_full); + Ok(res.trim_end().to_string()) + } - let cmd = self.display()?; - let output = self - .output() - .await - .map_err(|e| error::TsdlError::context("Failed to execute command", e))?; + /// Format the command including the working directory prefix. + fn display_full(&self) -> Result { + let cwd = self.as_std().get_current_dir(); + let base = self.display()?; - if output.status.success() { - return Ok(output); + match cwd { + | None => Ok(base), + | Some(path) => Ok(format!("[{}] {}", path.display(), base)), + } + } + + /// Execute the command, handle shutdown signals, and return the output. + #[tracing::instrument(skip(self))] + async fn exec(&mut self) -> Result { + let cmd_full = self.display_full()?; + let cmd_short = self.display()?; + trace!("{cmd_full}"); + + // Check for shutdown before spawning + shutdown::check()?; + + // Capture stdout/stderr (mimics `self.output()` which we can't use + // because we need to spawn+race rather than spawn+wait). + self + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + // Put child in its own process group so signals can target the whole + // sub-tree (e.g. `tree-sitter build` → `node` → `cc`). + self.as_std_mut().process_group(0); + + let child = self.spawn().context("Failed to execute command")?; + + let child_pid = child.id(); + let pgid = child_pid + .map(PgId::try_from) + .transpose() + .map_err(|e| Error::Message { + message: format!("Invalid child process group id: {e}"), + })?; + debug!("spawned pid={child_pid:?} cmd={cmd_short}"); + + let pgid_guard = match (pgid, shutdown::current()) { + | (Some(pgid), Some(shutdown)) => { + debug!("registered pgid={pgid}"); + Some(shutdown.register_pgid(pgid)) + } + | _ => None, + }; + + // Test hook: pause while the child is running so an external + // shutdown signal has a guaranteed window to arrive mid-execution. + shutdown::test_delay().await; + + // Race: child completion vs. shutdown signal. The wait future is kept + // alive after cancellation so the child can be reaped before returning. + let wait = child.wait_with_output(); + tokio::pin!(wait); + + let output = tokio::select! { + output = &mut wait => { + debug!("child exited pid={child_pid:?}"); + output } - - let program = self.as_std().get_program().to_string_lossy(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let msg = match output.status.code() { - Some(code) => format!("{cmd} failed with exit status {code}."), - None => format!( - "{} interrupted by signal {}.", - program, - output.status.signal().unwrap() - ), - }; - - error!("{msg}\nStdOut:\n{stdout}\nStdErr\n{stderr}"); - - Err(error::Command { - msg, - stderr, - stdout, + signal = shutdown::cancelled() => { + let grace = Duration::from_secs(5); + info!("Command interrupted by {signal}; waiting up to {grace:?} for pid={child_pid:?}: {cmd_short}"); + match time::timeout(grace, &mut wait).await { + Ok(Ok(_)) => { + debug!("child exited after {signal} pid={child_pid:?}"); + } + Ok(Err(err)) => { + debug!("child wait after {signal} failed pid={child_pid:?}: {err}"); + } + Err(_) => { + if let (Some(pgid), Some(shutdown)) = (pgid, shutdown::current()) { + shutdown.kill_pgid(pgid); + + let kill_grace = Duration::from_secs(2); + match time::timeout(kill_grace, &mut wait).await { + Ok(Ok(_)) => { + debug!("child reaped after SIGKILL pid={child_pid:?}"); + } + Ok(Err(err)) => { + debug!("child wait after SIGKILL failed pid={child_pid:?}: {err}"); + } + Err(_) => { + warn!( + "Timed out waiting {kill_grace:?} to reap pid={child_pid:?} after SIGKILL; returning interruption" + ); + } + } + } else { + warn!("Timed out waiting for pid={child_pid:?}, but no process group was registered; returning interruption"); + } + } + } + + // If a second signal arrives before we drop the pgid_guard, + // `kill_children()` could target a stale PGID. PGID reuse in such a + // tiny window is rare, but this is exactly the kind of edge case + // process-group code tries to avoid. + drop(pgid_guard); + return Err(Error::Interrupted { signal }); } - .into()) + }; + + drop(pgid_guard); + + let output = output.context("Failed to execute command")?; + + if output.status.success() { + return Ok(output); } + + let program = self.as_std().get_program().to_string_lossy(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + let msg = if let Some(code) = output.status.code() { + format!("{cmd_short} failed with exit status {code}.") + } else { + let sig = signal_display(output.status.signal().expect("a proper signal code")) + .unwrap_or_else(|| "UNKNOWN".to_string()); + format!("{program} interrupted by signal {sig}.") + }; + + error!("{msg}\nStdOut:\n{stdout}\nStdErr\n{stderr}"); + + Err(Error::Command { + msg, + stderr, + stdout, + }) + } } impl Script for Command { - fn from_str(script: &str) -> Command { - let shell = env::var("SHELL").unwrap_or_else(|_| String::from("sh")); - let mut cmd = Command::new(shell); - cmd.args(["-c", script]); - cmd - } + /// Create a shell command to run a script string. + fn from_str(script: &str) -> Command { + let shell = env::var("SHELL").unwrap_or_else(|_| String::from("sh")); + let mut cmd = Command::new(shell); + cmd.args(["-c", script]); + cmd + } } diff --git a/src/shutdown.rs b/src/shutdown.rs new file mode 100644 index 0000000..2ed30b8 --- /dev/null +++ b/src/shutdown.rs @@ -0,0 +1,446 @@ +//! Graceful shutdown: signal handling, process group management, and test +//! delay hooks. + +use std::{ + collections::HashSet, + fmt, + future::Future, + num::NonZeroU32, + result::Result as StdResult, + sync::{Arc, Mutex}, +}; + +use tokio::sync::watch; +use tracing::{debug, info}; + +use crate::{Error, Result, ResultExt}; + +tokio::task_local! { + static CURRENT_SHUTDOWN: Handle; +} + +// ============================================================ +// Enums +// ============================================================ + +/// Error returned when constructing a [`PgId`] from a raw value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PgIdError { + /// Process group ID zero would target the current process group. + Zero, + /// The value cannot be represented by the platform's `pid_t` type. + OutOfRange(u32), +} + +// ============================================================ +// Structs +// ============================================================ + +/// Cooperative shutdown signal shared across build tasks. +#[derive(Clone, Debug)] +pub struct Handle { + tx: watch::Sender>, + rx: watch::Receiver>, + /// Active child process group IDs. Build commands are put in their own + /// process groups so a signal can target each command's whole subprocess tree. + active_pgids: Arc>>, +} + +/// A positive Unix process group ID. +/// +/// `PgId` rejects zero and values that do not fit in `libc::pid_t`, so callers +/// cannot accidentally use `killpg(0, ...)`/`kill(-0, ...)` semantics and signal +/// the current process group. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct PgId(NonZeroU32); + +/// Registration guard for an active child process group. +/// +/// Keep this guard alive while the child process group may still contain live +/// processes. Dropping it removes the [`PgId`] from shutdown's active set, so a +/// later second signal will no longer send `SIGKILL` to that process group. +/// Drop it promptly after the child has exited and been reaped; this narrows the +/// window where a stale, OS-reused process group ID could be signalled by a +/// later shutdown escalation. +#[derive(Debug)] +pub struct PgIdGuard { + shutdown: Handle, + pgid: PgId, +} + +/// A Unix signal that requested shutdown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Signal { + pub number: i32, + pub name: &'static str, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Wait for the shutdown signal from the current task-local handle. +pub async fn cancelled() -> Signal { + match current() { + | None => std::future::pending::().await, + | Some(shutdown) => shutdown.cancelled().await, + } +} + +/// Check if shutdown was requested by the current task-local handle. +pub fn check() -> Result<()> { + current().map_or(Ok(()), |shutdown| shutdown.check()) +} + +/// Get the shutdown handle scoped to the current task, if any. +#[must_use] +pub fn current() -> Option { + CURRENT_SHUTDOWN.try_with(Clone::clone).ok() +} + +/// Get the current task-local handle or a no-op default. +#[must_use] +pub fn current_or_default() -> Handle { + current().unwrap_or_default() +} + +/// Install a shutdown handle for the duration of the given future. +pub async fn scope(shutdown: Handle, future: F) -> F::Output +where + F: Future, +{ + CURRENT_SHUTDOWN.scope(shutdown, future).await +} + +/// Send a Unix signal to an entire process group via `killpg`. +fn signal_process_group(pgid: PgId, signal: Signal) { + let rc = unsafe { libc::killpg(pgid.as_pid_t(), signal.number) }; + if rc != 0 { + debug!( + "failed to send {} to pgid={pgid}: {}", + signal.name, + std::io::Error::last_os_error() + ); + } +} + +/// Test helper: when `TSDL_TEST_DELAY_MS` is set, inserts a sleep at every step +/// boundary so signal delivery can be observed deterministically. +/// +/// Only active in debug/test builds; a noop in release. +#[cfg(debug_assertions)] +pub async fn test_delay() { + if let Ok(ms) = std::env::var("TSDL_TEST_DELAY_MS") + && let Ok(ms) = ms.parse::() + { + let d = std::time::Duration::from_millis(ms); + debug!("[tsdl:test_delay] sleeping {ms}ms"); + tokio::time::sleep(d).await; + } +} + +/// Test helper that inserts a sleep at every step boundary when +/// `TSDL_TEST_DELAY_MS` is set. This is a noop in release builds. +#[cfg(not(debug_assertions))] +pub async fn test_delay() { + // noop in release +} + +// ============================================================ +// Impls +// ============================================================ + +impl Default for Handle { + fn default() -> Self { + Self::new() + } +} + +impl Drop for PgIdGuard { + fn drop(&mut self) { + self.shutdown.unregister_pgid(self.pgid); + } +} + +impl fmt::Display for PgId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.get()) + } +} + +impl fmt::Display for PgIdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + | Self::OutOfRange(value) => { + write!(f, "process group id {value} does not fit in libc::pid_t") + } + | Self::Zero => write!(f, "process group id cannot be zero"), + } + } +} + +impl fmt::Display for Signal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Handle { + /// Create a new shutdown handle with no signal set. + #[must_use] + pub fn new() -> Self { + let (tx, rx) = watch::channel(None); + Self { + tx, + rx, + active_pgids: Arc::new(Mutex::new(HashSet::new())), + } + } + + /// Record shutdown and forward the received signal to all active children. + pub fn cancel_with_signal(&self, signal: Signal) { + if self.reason().is_none() { + let _ = self.tx.send(Some(signal)); + } + self.signal_children(signal); + } + + /// Register an active child process group. + /// + /// If shutdown was already requested, the process group is immediately sent + /// the recorded shutdown signal so late-spawned children cannot escape it. + #[must_use] + pub fn register_pgid(&self, pgid: PgId) -> PgIdGuard { + { + let mut active = self + .active_pgids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + active.insert(pgid); + } + + if let Some(signal) = self.reason() { + self.signal_pgid(pgid, signal); + } + + PgIdGuard { + shutdown: self.clone(), + pgid, + } + } + + fn unregister_pgid(&self, pgid: PgId) { + let mut active = self + .active_pgids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + active.remove(&pgid); + } + + /// Check whether a shutdown signal has been recorded. + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.reason().is_some() + } + + /// Return the signal that triggered shutdown, if any. + #[must_use] + pub fn reason(&self) -> Option { + *self.rx.borrow() + } + + /// Wait until a shutdown signal is set, then return it. + pub async fn cancelled(&self) -> Signal { + if let Some(signal) = self.reason() { + return signal; + } + + let mut rx = self.rx.clone(); + loop { + if rx.changed().await.is_err() { + return std::future::pending::().await; + } + if let Some(signal) = *rx.borrow() { + return signal; + } + } + } + + /// Return `Err(Interrupted)` if shutdown was signalled, or `Ok(())` otherwise. + pub fn check(&self) -> Result<()> { + if let Some(signal) = self.reason() { + Err(Error::Interrupted { signal }) + } else { + Ok(()) + } + } + + /// Send a signal to every active child process group. + pub fn signal_children(&self, signal: Signal) { + for pgid in self.active_pgids() { + self.signal_pgid(pgid, signal); + } + } + + /// Send a signal to one child process group. + pub fn signal_pgid(&self, pgid: PgId, signal: Signal) { + info!("SHUTDOWN sending {} to pgid={pgid}", signal.name); + signal_process_group(pgid, signal); + } + + /// Send SIGKILL to every active child process group. + pub fn kill_children(&self) { + for pgid in self.active_pgids() { + self.kill_pgid(pgid); + } + } + + /// Send SIGKILL to one child process group. + pub fn kill_pgid(&self, pgid: PgId) { + info!("SHUTDOWN killing pgid={pgid}"); + signal_process_group(pgid, Signal::KILL); + } + + /// Collect all currently registered child process group IDs. + fn active_pgids(&self) -> Vec { + self + .active_pgids + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .copied() + .collect() + } + + /// Spawn a background task that listens for OS signals and triggers graceful then forceful shutdown. + #[cfg(unix)] + pub fn spawn_signal_listener(&self) -> Result> { + use std::process; + + use tokio::signal::unix::{SignalKind, signal}; + + let mut sighup = + signal(SignalKind::from_raw(Signal::HUP.number)).context("Installing SIGHUP handler")?; + let mut sigint = + signal(SignalKind::from_raw(Signal::INT.number)).context("Installing SIGINT handler")?; + let mut sigquit = + signal(SignalKind::from_raw(Signal::QUIT.number)).context("Installing SIGQUIT handler")?; + let mut sigterm = + signal(SignalKind::from_raw(Signal::TERM.number)).context("Installing SIGTERM handler")?; + let shutdown = self.clone(); + + Ok(tokio::spawn(async move { + let mut graceful = true; + + loop { + let signal = tokio::select! { + s = sighup.recv() => s.map(|()| Signal::HUP), + s = sigint.recv() => s.map(|()| Signal::INT), + s = sigquit.recv() => s.map(|()| Signal::QUIT), + s = sigterm.recv() => s.map(|()| Signal::TERM), + }; + + let Some(signal) = signal else { + // Signal stream ended unexpectedly; stop listening. + return; + }; + + if graceful { + graceful = false; + info!("Received {signal}; stopping running build commands..."); + shutdown.cancel_with_signal(signal); + } else { + // Second signal: escalate. Kill every known child process group, + // then force-exit with the conventional signal status. + info!("Received second {signal}; forcing exit."); + shutdown.kill_children(); + process::exit(signal.shell_exit_code().into()); + } + } + })) + } +} + +impl PgId { + /// Return the raw positive process group ID value. + #[must_use] + pub fn get(self) -> u32 { + self.0.get() + } + + /// Convert to the platform `pid_t` type (panics if the invariant is violated). + fn as_pid_t(self) -> libc::pid_t { + ::try_from(self.0.get()).expect("PgId invariant: value fits in libc::pid_t") + } +} + +impl Signal { + pub const HUP: Self = Self { + number: libc::SIGHUP, + name: "SIGHUP", + }; + pub const INT: Self = Self { + number: libc::SIGINT, + name: "SIGINT", + }; + pub const QUIT: Self = Self { + number: libc::SIGQUIT, + name: "SIGQUIT", + }; + pub const TERM: Self = Self { + number: libc::SIGTERM, + name: "SIGTERM", + }; + pub const KILL: Self = Self { + number: libc::SIGKILL, + name: "SIGKILL", + }; + + /// Conventional shell exit status for commands terminated by signal N. + #[must_use] + pub fn shell_exit_code(self) -> u8 { + u8::try_from(128 + self.number).unwrap_or(255) + } +} + +impl std::error::Error for PgIdError {} + +impl TryFrom for PgId { + type Error = PgIdError; + + fn try_from(value: u32) -> StdResult { + let value = NonZeroU32::new(value).ok_or(PgIdError::Zero)?; + ::try_from(value.get()).map_err(|_| PgIdError::OutOfRange(value.get()))?; + Ok(Self(value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pgid_rejects_zero() { + assert_eq!(PgId::try_from(0), Err(PgIdError::Zero)); + } + + #[test] + fn pgid_accepts_positive_value() { + let pgid = PgId::try_from(1).unwrap(); + assert_eq!(pgid.get(), 1); + } + + #[test] + fn pgid_rejects_values_that_do_not_fit_pid_t() { + let too_large = u32::try_from(::MAX) + .unwrap_or(u32::MAX) + .saturating_add(1); + + if ::try_from(too_large).is_err() { + assert_eq!( + PgId::try_from(too_large), + Err(PgIdError::OutOfRange(too_large)) + ); + } + } +} diff --git a/src/tree_sitter.rs b/src/tree_sitter.rs index 00aa363..c72fce3 100644 --- a/src/tree_sitter.rs +++ b/src/tree_sitter.rs @@ -1,227 +1,606 @@ +//! Download, prepare, and cache the tree-sitter CLI binary. + use std::borrow::Cow; use std::collections::HashMap; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use std::str::FromStr; +use std::result::Result as StdResult; use async_compression::tokio::bufread::GzipDecoder; +use tempfile::TempPath; use tokio::{fs, io, process::Command}; -use tracing::trace; +use tracing::{debug, info, trace, warn}; use url::Url; -use crate::actors::{DisplayAddr, ProgressAddr}; -use crate::args::TreeSitter; -use crate::git::{self, GitRef}; -use crate::SafeCanonicalize; -use crate::{error::TsdlError, TsdlResult}; -use crate::{git::Tag, sh::Exec}; +use crate::actors; +use crate::args; +use crate::git; +use crate::sh::Exec; +use crate::shutdown; +use crate::{Error, Result, ResultExt, SafeCanonicalize}; + +// ============================================================ +// Enums +// ============================================================ + +#[derive(Debug, PartialEq, Eq)] +enum CliCacheStatus { + Hit, + Missing, + Invalid(String), +} + +// ============================================================ +// Structs +// ============================================================ + +/// A downloaded and cached tree-sitter CLI binary, ready to use. +#[derive(Debug, Clone)] +pub struct PreparedCli { + /// Path to the CLI binary on disk. + pub path: PathBuf, + /// The tree-sitter version, platform, and repo used to fetch it. + pub tree_sitter: args::TreeSitter, +} + +// ============================================================ +// Free functions +// ============================================================ + +/// Check whether a cached tree-sitter CLI binary is still valid. +async fn check_cached_cli(path: &Path, tag: &str) -> Result { + let metadata = match fs::symlink_metadata(path).await { + | Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(CliCacheStatus::Missing); + } + | Err(err) => { + return Err(err) + .with_context(|| format!("Inspecting cached tree-sitter CLI {}", path.display())); + } + | Ok(metadata) => metadata, + }; + + let file_type = metadata.file_type(); + if file_type.is_dir() { + return Err(Error::Message { + message: format!( + "Cached tree-sitter CLI path is a directory and cannot be replaced: {}", + path.display() + ), + }); + } -async fn chmod_x(prog: &Path) -> TsdlResult<()> { - let metadata = fs::metadata(prog) - .await - .map_err(|e| TsdlError::context(format!("getting metadata for {}", prog.display()), e))?; - let mut permissions = metadata.permissions(); - permissions.set_mode(permissions.mode() | 0o111); - fs::set_permissions(prog, permissions) - .await - .map_err(|e| TsdlError::context(format!("chmod +x {}", prog.display()), e)) + if file_type.is_symlink() { + return Ok(CliCacheStatus::Invalid( + "cached path is a symbolic link".to_string(), + )); + } + + if !file_type.is_file() { + return Ok(CliCacheStatus::Invalid( + "cached path is not a regular file".to_string(), + )); + } + + if metadata.permissions().mode() & 0o111 == 0 { + return Ok(CliCacheStatus::Invalid( + "cached file is not executable".to_string(), + )); + } + + match verify_cli(path, tag).await { + | Err(Error::Interrupted { signal }) => Err(Error::Interrupted { signal }), + | Err(err) => Ok(CliCacheStatus::Invalid(first_line(&err.to_string()))), + | Ok(()) => Ok(CliCacheStatus::Hit), + } +} + +/// Make a binary executable (chmod +x). +async fn chmod_x(prog: &Path) -> Result<()> { + let metadata = fs::metadata(prog) + .await + .with_context(|| format!("getting metadata for {}", prog.display()))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(permissions.mode() | 0o111); + fs::set_permissions(prog, permissions) + .await + .with_context(|| format!("chmod +x {}", prog.display())) } +/// Download and/or cache the tree-sitter CLI binary for the given tag. async fn cli( - build_dir: &PathBuf, - handle: &ProgressAddr, - platform: &str, - repo: &str, - tag: &Tag, -) -> TsdlResult { - let tag = match tag { - Tag::Exact { label, .. } => Cow::Borrowed(label), - Tag::Ref(git_ref) => { - handle.msg(format!("Figuring out the exact tag for {tag}")); - let tree_sitter = PathBuf::new().join(build_dir).join("tree-sitter"); - git::clone(repo, &tree_sitter).await?; - Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) - } - }; - let cli = format!("tree-sitter-{platform}"); - let res = PathBuf::new() - .join(build_dir) - .join(format!("{cli}-{tag}")) - .canon()?; - - if !res.exists() { - handle.msg(format!("Downloading {tag}")); - let gz_basename = format!("{cli}.gz"); - let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); - let gz = PathBuf::new().join(build_dir).join(gz_basename); - - download_and_extract(&gz, &url, &res).await?; + build_dir: &Path, + handle: &actors::ProgressAddr, + platform: &str, + repo: &str, + tag: &str, +) -> Result { + let cli = format!("tree-sitter-{platform}"); + let res = build_dir.join(format!("{cli}-{tag}")).canon()?; + + let gz_basename = format!("{cli}.gz"); + let url = format!("{repo}/releases/download/{tag}/{gz_basename}"); + + match check_cached_cli(&res, tag).await? { + | CliCacheStatus::Hit => { + info!("Using cached tree-sitter CLI at {}", res.display()); + handle.set_outcome_cached().await; + handle.step("cached"); + } + | CliCacheStatus::Missing => { + info!("Tree-sitter CLI cache miss for {tag}; downloading from {url}"); + handle.set_outcome_built().await; + handle.step("downloading"); + download_and_install(&url, &res, tag).await?; + } + | CliCacheStatus::Invalid(reason) => { + warn!( + "Cached tree-sitter CLI at {} is invalid ({reason}); re-downloading from {url}", + res.display() + ); + handle.msg("cached CLI invalid; re-downloading"); + handle.set_outcome_built().await; + handle.step("downloading"); + download_and_install(&url, &res, tag).await?; } + } - Ok(res) + Ok(res) } -async fn download(gz: &Path, url: &str) -> TsdlResult<()> { - fs::write( - gz, - reqwest::get(url) - .await - .map_err(|e| TsdlError::context("fetch", e))? - .bytes() - .await - .map_err(|e| TsdlError::context("fetching bytes", e))?, +/// Wrap a result, adding context unless it's an Interrupted error. +fn context_unless_interrupted(result: Result, message: impl FnOnce() -> String) -> Result { + match result { + | Err(err @ Error::Interrupted { .. }) => Err(err), + | Err(err) => Err(Error::Context { + message: message(), + source: err.into(), + }), + | Ok(value) => Ok(value), + } +} + +/// Format a tree-sitter version string as a git ref for display purposes. +pub(crate) fn display_tree_sitter_ref(version: &str) -> StdResult { + git::Ref::new(normalize_release_ref(version)) +} + +/// Download a file via HTTP GET. +async fn download(url: &str, gz: &Path) -> Result<()> { + let response = reqwest::get(url) + .await + .with_context(|| format!("Fetching tree-sitter CLI from {url}"))? + .error_for_status() + .with_context(|| format!("Downloading tree-sitter CLI from {url}"))?; + + let bytes = response + .bytes() + .await + .with_context(|| format!("Reading tree-sitter CLI response body from {url}"))?; + + fs::write(gz, bytes) + .await + .with_context(|| format!("Writing tree-sitter CLI archive to {}", gz.display())) +} + +/// Download a gzipped CLI binary and install it to the final path. +async fn download_and_install(url: &str, res: &Path, tag: &str) -> Result<()> { + let gz = temp_path_for(res, ".gz")?; + let cli = temp_path_for(res, ".bin")?; + + info!("Downloading tree-sitter CLI from {url}"); + download(url, gz.as_ref()).await?; + install_downloaded_cli(gz.as_ref(), cli.as_ref(), res, tag).await?; + info!("Installed tree-sitter CLI at {}", res.display()); + Ok(()) +} + +/// Get the expected CLI version string from a tag (strips "v" prefix). +fn expected_cli_version(tag: &str) -> Option<&str> { + let version = tag.strip_prefix('v').unwrap_or(tag); + is_dotted_numeric_version(version).then_some(version) +} + +/// Find a release tag matching the requested version string. +fn find_tag( + refs: &HashMap, + version: &str, +) -> StdResult { + refs + .get_key_value(&format!("v{version}")) + .or_else(|| refs.get_key_value(version)) + .map_or_else( + || git::Ref::new(normalize_release_ref(version)).map(git::ResolvedRef::Ref), + |(k, v)| { + trace!("Found! {k} -> {v}"); + Ok(git::ResolvedRef::Tag { + sha: git::Sha::new(v.as_str())?, + label: k.clone(), + }) + }, ) +} + +/// Get the first line of a multi-line string (for error summaries). +fn first_line(message: &str) -> String { + message + .lines() + .next() + .unwrap_or("unknown verification failure") + .to_string() +} + +/// Decompress a gzip file to the target path. +async fn gunzip(gz: &Path, to: &Path) -> Result<()> { + let file = fs::File::open(gz) + .await + .with_context(|| format!("opening {}", gz.display()))?; + let mut decompressor = GzipDecoder::new(tokio::io::BufReader::new(file)); + + let mut file = tokio::fs::File::create(to) + .await + .with_context(|| format!("creating {}", to.display()))?; + + io::copy(&mut decompressor, &mut file) .await - .map_err(|e| TsdlError::context(format!("downloading {url} to {}", gz.display()), e)) -} - -async fn download_and_extract(gz: &Path, url: &str, res: &Path) -> TsdlResult<()> { - download(gz, url).await?; - gunzip(gz, res).await?; - chmod_x(res).await?; - fs::remove_file(gz) - .await - .map_err(|e| TsdlError::context(format!("removing {}", gz.display()), e))?; - Ok(()) -} - -fn find_tag(refs: &HashMap, version: &str) -> Tag { - refs.get_key_value(&format!("v{version}")) - .or_else(|| refs.get_key_value(version)) - .map_or_else( - || Tag::Ref(GitRef::from_str(version).unwrap()), - |(k, v)| { - trace!("Found! {k} -> {v}"); - Tag::Exact { - sha1: GitRef::from_str(v).unwrap(), - label: k.clone(), - } - }, - ) -} - -async fn gunzip(gz: &Path, to: &Path) -> TsdlResult<()> { - let file = fs::File::open(gz) - .await - .map_err(|e| TsdlError::context(format!("opening {}", gz.display()), e))?; - let mut decompressor = GzipDecoder::new(tokio::io::BufReader::new(file)); - // let path = gz.with_extension(""); - - let mut file = tokio::fs::File::create(to) - .await - .map_err(|e| TsdlError::context(format!("creating {}", to.display()), e))?; - - io::copy(&mut decompressor, &mut file) - .await - .and(Ok(())) - .map_err(|e| TsdlError::context(format!("decompressing {}", gz.display()), e)) + .map(|_| ()) + .with_context(|| format!("decompressing {}", gz.display()))?; + file + .sync_all() + .await + .with_context(|| format!("syncing extracted tree-sitter CLI {}", to.display())) +} + +/// Decompress, verify, and install a downloaded CLI binary. +async fn install_downloaded_cli(gz: &Path, tmp_cli: &Path, res: &Path, tag: &str) -> Result<()> { + gunzip(gz, tmp_cli).await?; + chmod_x(tmp_cli).await?; + context_unless_interrupted(verify_cli(tmp_cli, tag).await, || { + format!("Verifying downloaded tree-sitter CLI {}", tmp_cli.display()) + })?; + promote_cli(tmp_cli, res).await +} + +/// Check if a string looks like a dotted-numeric version. +fn is_dotted_numeric_version(value: &str) -> bool { + !value.is_empty() + && value + .split('.') + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} + +/// Normalize a release ref by adding "v" prefix to bare semver. +fn normalize_release_ref(value: &str) -> String { + if git::Sha::is_full_sha(value) || value.starts_with('v') { + value.to_string() + } else if is_dotted_numeric_version(value) { + format!("v{value}") + } else { + value.to_string() + } } +/// Parse `git ls-remote --refs --tags` output into a tag→sha map. fn parse_refs(stdout: &str) -> HashMap { - let mut refs = HashMap::new(); + let mut refs = HashMap::new(); - for line in stdout.lines() { - let ref_line = line.split('\t').map(str::trim).collect::>(); - let (sha1, full_ref) = (ref_line[0], ref_line[1]); - let Some(tag) = full_ref.split('/').next_back() else { - continue; - }; - trace!("insert {tag} -> {sha1}"); - refs.insert(tag.to_string(), sha1.to_string()); - } + for line in stdout.lines() { + let ref_line = line.split('\t').map(str::trim).collect::>(); + let (sha1, full_ref) = (ref_line[0], ref_line[1]); + let Some(tag) = full_ref.strip_prefix("refs/tags/") else { + continue; + }; + trace!("insert {tag} -> {sha1}"); + refs.insert(tag.to_string(), sha1.to_string()); + } - refs + refs } +/// Download and prepare the tree-sitter CLI binary for the requested version. +/// Returns the path to the prepared CLI binary and metadata. pub async fn prepare( - build_dir: &PathBuf, - display: DisplayAddr, - tree_sitter: &TreeSitter, -) -> TsdlResult { - let progress = display - .add_language( - "Preparing tree-sitter-cli".into(), - format!("v{}", tree_sitter.version), - 3, - ) - .await; - - let repo = Url::parse(&tree_sitter.repo) - .map_err(|e| TsdlError::context("Parsing the tree-sitter URL", e))?; - let git_ref = &tree_sitter.version; - - progress.step(format!("Figuring out tag from ref {git_ref}")); - let tag = tag(repo.as_str(), git_ref).await?; - - progress.step(format!("Fetching {tag}")); - let cli = cli( - build_dir, - &progress, - &tree_sitter.platform, - &tree_sitter.repo, - &tag, + build_dir: &Path, + display: actors::DisplayAddr, + tree_sitter: &args::TreeSitter, +) -> Result { + shutdown::test_delay().await; + shutdown::check()?; + debug!("[prepare] tree-sitter-cli version={}", tree_sitter.version); + + let progress = display + .add_language( + "tree-sitter-cli", + display_tree_sitter_ref(&tree_sitter.version)?, + 2, ) - .await?; - progress.fin(format!("{tag}")); + .await; - Ok(cli) + let repo = Url::parse(&tree_sitter.repo).context("Parsing the tree-sitter URL")?; + let git_ref = &tree_sitter.version; + + progress.step(format!("resolving {git_ref}")); + let tag = match tag(repo.as_str(), git_ref).await { + | Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("resolve failed").await; + } + return Err(e); + } + | Ok(tag) => tag, + }; + let release_tag = match resolve_release_tag(build_dir, &progress, &tree_sitter.repo, &tag).await { + | Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("resolve failed").await; + } + return Err(e); + } + | Ok(tag) => tag, + }; + info!("Resolved tree-sitter CLI ref {git_ref:?} to release tag {release_tag:?}"); + + let cli = match context_unless_interrupted( + cli( + build_dir, + &progress, + &tree_sitter.platform, + &tree_sitter.repo, + &release_tag, + ) + .await, + || { + format!( + "Preparing tree-sitter CLI {release_tag} for platform {}", + tree_sitter.platform + ) + }, + ) { + | Err(e) => { + if shutdown::current().is_some_and(|s| s.is_cancelled()) { + progress.cancel().await; + } else { + progress.err("download failed").await; + } + return Err(e); + } + | Ok(cli) => cli, + }; + progress.fin("done").await; + + Ok(PreparedCli { + path: cli, + tree_sitter: args::TreeSitter { + version: release_tag, + platform: tree_sitter.platform.clone(), + repo: tree_sitter.repo.clone(), + }, + }) +} + +/// Move a temp file to its final destination. +async fn promote_cli(tmp_cli: &Path, res: &Path) -> Result<()> { + if let Err(err) = fs::rename(tmp_cli, res).await { + let _ = fs::remove_file(tmp_cli).await; + return Err(err).with_context(|| { + format!( + "Installing tree-sitter CLI {} to {}", + tmp_cli.display(), + res.display() + ) + }); + } + + Ok(()) +} + +/// Resolve a user-requested tree-sitter ref into a concrete release tag. +async fn resolve_release_tag( + build_dir: &Path, + handle: &actors::ProgressAddr, + repo: &str, + resolved_ref: &git::ResolvedRef, +) -> Result { + let tag = match resolved_ref { + | git::ResolvedRef::Ref(git_ref) => { + handle.msg(format!("resolving exact tag for {resolved_ref}")); + let tree_sitter = build_dir.join("tree-sitter"); + git::clone(repo, &tree_sitter).await?; + Cow::Owned(git::tag_for_ref(&tree_sitter, git_ref).await?) + } + | git::ResolvedRef::Tag { label, .. } => Cow::Borrowed(label.as_str()), + }; + Ok(tag.into_owned()) } #[allow(clippy::missing_panics_doc)] -pub async fn tag(repo: &str, version: &str) -> TsdlResult { - let output = Command::new("git") - .args(["ls-remote", "--refs", "--tags", repo]) - .exec() - .await?; - let stdout = String::from_utf8_lossy(&output.stdout); - let refs = parse_refs(&stdout); - Ok(find_tag(&refs, version)) +/// Look up the git ref for a tree-sitter release tag. +pub async fn tag(repo: &str, version: &str) -> Result { + let output = Command::new("git") + .args(["ls-remote", "--refs", "--tags", repo]) + .exec() + .await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let refs = parse_refs(&stdout); + find_tag(&refs, version).with_context(|| format!("Parsing tree-sitter git ref {version:?}")) +} + +/// Create a temporary file path adjacent to the final path. +fn temp_path_for(res: &Path, suffix: &str) -> Result { + let parent = res.parent().ok_or_else(|| Error::Message { + message: format!( + "Could not determine parent directory for tree-sitter CLI path {}", + res.display() + ), + })?; + let name = res.file_name().map_or_else( + || Cow::Borrowed("tree-sitter"), + |name| name.to_string_lossy(), + ); + + tempfile::Builder::new() + .prefix(&format!(".{name}.")) + .suffix(suffix) + .tempfile_in(parent) + .map(tempfile::NamedTempFile::into_temp_path) + .with_context(|| { + format!( + "Creating temporary tree-sitter CLI file in {}", + parent.display() + ) + }) +} + +/// Verify that a tree-sitter CLI binary works and matches the expected version. +async fn verify_cli(path: &Path, tag: &str) -> Result<()> { + let output = + context_unless_interrupted(Command::new(path).arg("--version").exec().await, || { + format!("Running {} --version", path.display()) + })?; + + let output = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + if let Some(expected) = expected_cli_version(tag) + && !output.contains(expected) + { + return Err(Error::Message { + message: format!( + "tree-sitter CLI version output did not contain expected version {expected:?}: {}", + output.trim() + ), + }); + } + + Ok(()) } #[cfg(test)] mod tests { - use super::*; + use super::*; + use std::fs as std_fs; + + fn write_script(path: &Path, body: &str) { + std_fs::write(path, body).unwrap(); + let mut permissions = std_fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + std_fs::set_permissions(path, permissions).unwrap(); + } + + #[tokio::test] + async fn cached_cli_missing_is_miss() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); - #[test] - fn test_parse_refs_empty() { - let stdout = ""; - let refs = parse_refs(stdout); - assert!(refs.is_empty()); + assert_eq!( + check_cached_cli(&path, "v1.2.3").await.unwrap(), + CliCacheStatus::Missing + ); + } + + #[tokio::test] + async fn cached_cli_non_executable_is_invalid() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + std_fs::write(&path, b"tree-sitter 1.2.3\n").unwrap(); + + match check_cached_cli(&path, "v1.2.3").await.unwrap() { + | CliCacheStatus::Invalid(reason) => assert!(reason.contains("not executable")), + | status => panic!("expected invalid cache entry, got {status:?}"), } + } + + #[tokio::test] + async fn cached_cli_valid_version_hits() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + write_script(&path, "#!/bin/sh\necho 'tree-sitter 1.2.3'\n"); - #[test] - fn test_parse_refs() { - let stdout = - "abc123\trefs/tags/v1.0.0\nuwu456\trefs/tags/release\nxyz789\trefs/tags/v2.0.0"; - let refs = parse_refs(stdout); - assert_eq!(refs.get("v1.0.0"), Some(&"abc123".to_string())); - assert_eq!(refs.get("release"), Some(&"uwu456".to_string())); - assert_eq!(refs.get("v2.0.0"), Some(&"xyz789".to_string())); + assert_eq!( + check_cached_cli(&path, "v1.2.3").await.unwrap(), + CliCacheStatus::Hit + ); + } + + #[tokio::test] + async fn cached_cli_wrong_version_is_invalid() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tree-sitter-test-v1.2.3"); + write_script(&path, "#!/bin/sh\necho 'tree-sitter 9.9.9'\n"); + + match check_cached_cli(&path, "v1.2.3").await.unwrap() { + | CliCacheStatus::Invalid(reason) => assert!(reason.contains("expected version")), + | status => panic!("expected invalid cache entry, got {status:?}"), } + } + + #[tokio::test] + async fn failed_extraction_does_not_create_final_cli_path() { + let temp = tempfile::tempdir().unwrap(); + let gz = temp.path().join("tree-sitter-test.gz"); + let tmp_cli = temp.path().join("tree-sitter-test.tmp"); + let final_cli = temp.path().join("tree-sitter-test"); + std_fs::write(&gz, b"not a gzip archive").unwrap(); - #[test] - fn test_find_tag_exact() { - let mut refs = HashMap::new(); - refs.insert("v1.0.0".to_string(), "abc123".to_string()); - let tag = find_tag(&refs, "1.0.0"); - match tag { - Tag::Exact { sha1, label } => { - assert_eq!(sha1.as_str(), "abc123"); - assert_eq!(label, "v1.0.0"); - } - Tag::Ref(_) => panic!("Expected Tag::Exact"), - } + let err = install_downloaded_cli(&gz, &tmp_cli, &final_cli, "v1.2.3") + .await + .unwrap_err(); + + assert!(err.to_string().contains("decompressing")); + assert!(!final_cli.exists()); + } + + #[test] + fn test_parse_refs_empty() { + let stdout = ""; + let refs = parse_refs(stdout); + assert!(refs.is_empty()); + } + + #[test] + fn test_parse_refs() { + let stdout = "abc123\trefs/tags/v1.0.0\nuwu456\trefs/tags/release\nxyz789\trefs/tags/v2.0.0\nbranch\trefs/heads/main\nslash\trefs/tags/channel/release"; + let refs = parse_refs(stdout); + assert_eq!(refs.get("v1.0.0"), Some(&"abc123".to_string())); + assert_eq!(refs.get("release"), Some(&"uwu456".to_string())); + assert_eq!(refs.get("v2.0.0"), Some(&"xyz789".to_string())); + assert_eq!(refs.get("channel/release"), Some(&"slash".to_string())); + assert_eq!(refs.get("main"), None); + } + + #[test] + fn test_find_tag_exact() { + let mut refs = HashMap::new(); + refs.insert( + "v1.0.0".to_string(), + "636801770eea172d140e64b691815ff11f6b556f".to_string(), + ); + let tag = find_tag(&refs, "1.0.0").unwrap(); + match tag { + | git::ResolvedRef::Ref(_) => panic!("Expected git::ResolvedRef::Tag"), + | git::ResolvedRef::Tag { sha, label } => { + assert_eq!(sha.as_str(), "636801770eea172d140e64b691815ff11f6b556f"); + assert_eq!(label, "v1.0.0"); + } } + } - #[test] - fn test_find_tag_ref() { - let refs = HashMap::new(); - let tag = find_tag(&refs, "1.0.0"); - match tag { - Tag::Ref(git_ref) => { - assert_eq!(git_ref.as_str(), "1.0.0"); - } - Tag::Exact { .. } => panic!("Expected Tag::Ref"), - } + #[test] + fn test_find_tag_ref() { + let refs = HashMap::new(); + let tag = find_tag(&refs, "1.0.0").unwrap(); + match tag { + | git::ResolvedRef::Ref(git_ref) => { + assert_eq!(git_ref.as_str(), "v1.0.0"); + } + | git::ResolvedRef::Tag { .. } => panic!("Expected git::ResolvedRef::Ref"), } + } } diff --git a/src/walk.rs b/src/walk.rs index 922581d..34b73c9 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -1,158 +1,25 @@ -use async_stream::try_stream; -use futures::{Stream, StreamExt}; -use ignore::{ - gitignore::{Gitignore, GitignoreBuilder}, - overrides::Override, - types::Types, -}; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::fs; - -use crate::cache; - -/// Holds the immutable rules for the traversal. -struct FilterContext { - types: Types, - overrides: Override, -} - -impl FilterContext { - fn new(root: impl AsRef) -> Self { - use ignore::{overrides::OverrideBuilder, types::TypesBuilder}; - - let mut types_builder = TypesBuilder::new(); - types_builder.add_def("js:*.js").unwrap(); - let types = types_builder.select("js").build().unwrap(); - - let mut overrides_builder = OverrideBuilder::new(root); - overrides_builder.case_insensitive(true).unwrap(); - overrides_builder - .add("!(.github|bindings|doc|docs|examples|queries|script|scripts|test|tests)/**") - .unwrap(); - let overrides = overrides_builder.build().unwrap(); - - Self { types, overrides } - } - - fn is_ignored(&self, path: &Path, is_dir: bool, gitignore: &Gitignore) -> bool { - if gitignore.matched(path, is_dir).is_ignore() - && !self.overrides.matched(path, is_dir).is_whitelist() - { - return true; - } - false - } -} - -/// Recursive async generator -fn scan_directory( - dir: PathBuf, - ctx: Arc, - parent_ignore: Arc, -) -> impl Stream> { - try_stream! { - // 1. Check for a local .gitignore in this folder - // If it exists, we must create a new matcher for this scope. - // If not, we reuse the parent's matcher (cheap pointer copy). - let local_ignore_path = dir.join(".gitignore"); - let active_ignore = if fs::try_exists(&local_ignore_path).await.unwrap_or(false) { - let mut builder = GitignoreBuilder::new(&dir); - builder.add(local_ignore_path); - // Note: In a production 'ignore' replacement, you would chain - // the parent_ignore here. For simplicity, we just build local. - Arc::new(builder.build().unwrap()) - } else { - parent_ignore - }; - - // 2. Open the directory stream - let mut read_dir = fs::read_dir(&dir).await?; +//! File-system walker for discovering `grammar.js` files (also used as a git +//! ls-files path). - // 3. Iterate over entries - while let Some(entry) = read_dir.next_entry().await? { - let path = entry.path(); - let metadata = entry.metadata().await?; - let is_dir = metadata.is_dir(); - let is_file = metadata.is_file(); - - // 4. Check Filters - if ctx.is_ignored(&path, is_dir, &active_ignore) { - continue; - } - - if is_dir { - // RECURSION: - // We recursively call this function and yield results from the sub-stream - let mut sub_stream = Box::pin(scan_directory( - path, - ctx.clone(), - active_ignore.clone() - )); - - while let Some(result) = sub_stream.next().await { - yield result?; - } - } else if is_file - && is_grammar_file(&path, &ctx.types) { - yield path; - } - } - } -} - -/// Check if filename is "grammar.js" AND the path is not ignored by types -fn is_grammar_file(path: &Path, types: &Types) -> bool { - path.file_name() == Some("grammar.js".as_ref()) && !types.matched(path, false).is_ignore() -} - -/// Collect grammar.js paths and compute their hashes in a single stream. -/// Returns a stream of (path, hash) tuples. -/// -/// # Panics -/// -/// -pub fn collect_grammar_paths_with_hash( - root: PathBuf, -) -> impl Stream> { - let ctx = Arc::new(FilterContext::new(root.clone())); +use std::path::{Path, PathBuf}; - let mut builder = GitignoreBuilder::new(&root); - builder.add(root.join(".gitignore")); - let root_ignore = Arc::new( - builder - .build() - .unwrap_or_else(|_| panic!("gitignore builder failed")), - ); +use crate::{Result, cache, git, shutdown}; - try_stream! { - let mut stream = Box::pin(scan_directory(root, ctx, root_ignore)); - while let Some(path_result) = stream.next().await { - let path = path_result?; - let hash = cache::hash_file(&path).await.map_err(|e| { - io::Error::other(format!("Failed to hash {}: {}", path.display(), e)) - })?; - yield (path, hash); - } - } -} +// ============================================================ +// Free functions +// ============================================================ /// Collect grammar.js paths via git ls-files and compute their hashes. -/// Uses git for file enumeration (truly async, avoids blocking thread pool). -pub async fn collect_grammar_paths( - root: Arc, -) -> crate::TsdlResult> { - use crate::git; - - let files = git::list_grammar_files(&root).await?; - let mut results = Vec::new(); - - for file in files { - let full_path = root.join(&file); - let hash = cache::hash_file(&full_path).await?; - results.push((full_path, hash)); - } - - Ok(results) +pub async fn collect_grammar_paths(root: &Path) -> Result> { + let files = git::list_grammar_files(root).await?; + let mut results = Vec::with_capacity(files.len()); + + for file in files { + shutdown::check()?; + let full_path = root.join(&file); + let hash = cache::hash_file(&full_path).await?; + results.push((full_path, hash)); + } + + Ok(results) } diff --git a/tests/cli.rs b/tests/cli.rs index 9ac0688..e043732 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -6,16 +6,16 @@ use cmd::Sandbox; #[test] fn empty_dir_no_command_shows_help() { - let mut sandbox = Sandbox::new(); - sandbox - .cmd - .assert() - .failure() - .stderr( - p::str::contains(env!("CARGO_PKG_DESCRIPTION")).and(p::str::contains(format!( - "Usage: {} [OPTIONS] ", - env!("CARGO_PKG_NAME") - ))), - ); - assert!(sandbox.is_empty()); + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .assert() + .failure() + .stderr( + p::str::contains(env!("CARGO_PKG_DESCRIPTION")).and(p::str::contains(format!( + "Usage: {} [OPTIONS] ", + env!("CARGO_PKG_NAME") + ))), + ); + assert!(sandbox.is_empty()); } diff --git a/tests/cmd/build.rs b/tests/cmd/build.rs index aada81f..62643cd 100644 --- a/tests/cmd/build.rs +++ b/tests/cmd/build.rs @@ -6,180 +6,13 @@ use indoc::{formatdoc, indoc}; use predicates::{self as p}; use rstest::*; -use tsdl::consts::{ - TREE_SITTER_PLATFORM, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_CONFIG_FILE, TSDL_OUT_DIR, - TSDL_PREFIX, -}; +use tsdl::consts::{BUILD_DIR, CONFIG_FILE, PARSER_OUT_DIR, PLATFORM, PREFIX, VERSION}; #[cfg(enable_wasm_cases)] use tsdl::parser::WASM_EXTENSION; use crate::cmd::Sandbox; -#[rstest] -fn no_args_should_download_tree_sitter_cli() { - let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build"); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(format!( - "tree-sitter-cli v{TREE_SITTER_VERSION}" - ))); - assert!(!sandbox.is_empty()); - let tree_sitter_cli = sandbox.tmp.child(TSDL_BUILD_DIR).child(format!( - "tree-sitter-{TREE_SITTER_PLATFORM}-v{TREE_SITTER_VERSION}" - )); - - tree_sitter_cli - .assert(p::path::exists()) - .assert(p::path::is_file()); - - let tree_sitter_cli = tree_sitter_cli.to_path_buf(); - assert!(tree_sitter_cli.metadata().unwrap().permissions().mode() & 0o111 != 0); - - let gz = tree_sitter_cli.with_extension("gz"); - assert!(!gz.exists()); -} - -#[rstest] -#[case::no_leading_v("0.25.6", "v0.25.6", "0.25.6")] -#[case::leading_v("v0.25.6", "v0.25.6", "0.25.6")] -#[case::sha1("636801770eea172d140e64b691815ff11f6b556f", "6368017", "0.22.6")] -fn no_args_should_build_tree_sitter_with_specific_version( - #[case] requested: &str, - #[case] version: &str, - #[case] cli_version: &str, -) { - let mut sandbox = Sandbox::new(); - sandbox - .cmd - .args(["build", "--tree-sitter-version", requested]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(format!("tree-sitter-cli {version}"))); - let mut tree_sitter_cli = Command::new( - sandbox - .tmp - .child(TSDL_BUILD_DIR) - .child(format!("tree-sitter-{TREE_SITTER_PLATFORM}-v{cli_version}")) - .to_path_buf(), - ); - tree_sitter_cli.arg("--version"); - tree_sitter_cli - .assert() - .success() - .stdout(p::str::contains(format!("tree-sitter {cli_version}"))); -} - -#[rstest] -#[case::gringo(vec!["gringo"])] -#[case::gringo_bringo(vec!["gringo", "bringo"])] -fn unknown_parser_should_fail(#[case] languages: Vec<&str>) { - let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build").args(&languages); - let mut assert = sandbox.cmd.assert().failure(); - for lang in &languages { - assert = assert.stdout(p::str::contains(format!("{lang} HEAD cloning"))); - } - for lang in languages { - sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{lang}.{DLL_EXTENSION}")) - .assert(p::path::missing()); - } -} - -#[rstest] -fn test_real_parser_error_formatting() { - let mut sandbox = Sandbox::new(); - let output = sandbox.cmd.arg("build").args(["jsonxxx"]).output().unwrap(); - - // Should fail - assert!(!output.status.success()); - - let stderr = String::from_utf8_lossy(&output.stderr); - - // Extract just the error part (after the progress messages) - let error_part = stderr - .lines() - .skip_while(|line| !line.contains("Could not build all parsers")) - .collect::>() - .join("\n"); - - // MacOS needs the canonicalize because tmp by default doesn't have /private as root. - let build_dir = std::fs::canonicalize( - sandbox - .tmp - .path() - .join(TSDL_BUILD_DIR) - .join("tree-sitter-jsonxxx"), - ) - .unwrap(); - - // Define the exact expected error format using multi-line string literal - let expected = format!( - "\ -Could not build all parsers. - - jsonxxx: Could not clone to {}. - $ git fetch origin --depth 1 HEAD failed with exit status 128. - fatal: could not read Username for 'https://github.com': terminal prompts disabled\ -", - build_dir.display() - ); - - // Cursor for sequential searching because some shells might output noise. - let mut remaining_output = error_part.as_str(); - - for line in expected.lines() { - if line.trim().is_empty() { - continue; - } - - // Find exact line (w/ indentation) within the remaining slice - if let Some(idx) = remaining_output.find(line) { - // Move cursor past the found line to ensure order - remaining_output = &remaining_output[idx + line.len()..]; - } else { - panic!( - "Output mismatch.\n\ - Could not find expected line (or it is out of order):\n\ - {line:?}\n\ - \n\ - Inside remaining output:\n\ - {remaining_output:?}\n\ - \n\ - Original full output:\n\ - {error_part}" - ); - } - } -} - -#[rstest] -#[case::json(vec!["json"])] -#[case::json_rust(vec!["json", "rust"])] -fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { - let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build").args(&languages); - let mut assert = sandbox.cmd.assert().success(); - for lang in &languages { - assert = assert.stdout(p::str::contains(format!("{lang} HEAD cloning"))); - } - for lang in &languages { - let dylib = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{lang}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } -} - #[rstest] #[case::pinned_hash_and_from_cobol("cobol", "6a46906")] #[case::pinned_leading_v_java("java", "v0.21.0")] @@ -188,8 +21,8 @@ fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { #[case::unpinned_rust("rust", "HEAD")] #[case::pinned::cmd::typescript("typescript", "v0.21.0")] fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: &str) { - let config = indoc! { - r#" + let config = indoc! { + r#" [parsers] java = "v0.21.0" json = "0.21.0" @@ -197,39 +30,37 @@ fn build_explicit_pinned_and_unpinned(#[case] language: &str, #[case] version: & typescript = { ref = "0.21.0", cmd = "make" } cobol = { ref = "6a469068cacb5e3955bb16ad8dfff0dd792883c9", from = "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } "# - }; - let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(TSDL_CONFIG_FILE) - .write_str(config) - .unwrap(); - sandbox - .cmd - .args(["build", language]) - .assert() - .success() - .stdout(p::str::contains(format!( - "{language}/{language} {version} build done" - ))); - let dylib = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); + }; + let mut sandbox = Sandbox::new(); + sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); + sandbox + .cmd + .args(["build", language]) + .assert() + .success() + .stdout(p::str::contains(format!("{language:<16} @ {version}"))) + .stdout(p::str::contains(format!( + "{:<16} [4/4] built", + format!("{language}/{language}") + ))); + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); } #[rstest] fn build_implicit_pinned_and_unpinned() { - let parsers = [ - ("cobol", "6a46906"), - ("java", "v0.21.0"), - ("python", "master"), - ("json", "v0.21.0"), - ("typescript", "v0.21.0"), - ]; - let config = indoc! { - r#" + let parsers = [ + ("cobol", "6a46906"), + ("java", "v0.21.0"), + ("python", "master"), + ("json", "v0.21.0"), + ("typescript", "v0.21.0"), + ]; + let config = indoc! { + r#" [parsers] java = "v0.21.0" json = "0.21.0" @@ -237,75 +68,74 @@ fn build_implicit_pinned_and_unpinned() { typescript = { ref = "0.21.0", cmd = "make" } cobol = { ref = "6a469068cacb5e3955bb16ad8dfff0dd792883c9", from = "https://github.com/yutaro-sakamoto/tree-sitter-cobol" } "# - }; - let mut sandbox = Sandbox::new(); - sandbox - .tmp - .child(TSDL_CONFIG_FILE) - .write_str(config) - .unwrap(); - let mut out = sandbox.cmd.arg("build").assert().success(); - for (language, version) in parsers { - out = out.stdout(p::str::contains(format!( - "{language}/{language} {version} build done" - ))); - } - for (language, _version) in parsers { - let dylib = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } + }; + let mut sandbox = Sandbox::new(); + sandbox.tmp.child(CONFIG_FILE).write_str(config).unwrap(); + let mut out = sandbox.cmd.arg("build").assert().success(); + for (language, _version) in parsers { + out = out + .stdout(p::str::contains(format!("{language}/{language}"))) + .stdout(p::str::contains("[4/4] built")); + } + for (language, _version) in parsers { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } } #[rstest] -fn multi_parsers_no_cmd() { - let java = "java"; - let version = "HEAD"; - let languages = [java]; - let mut sandbox = Sandbox::new(); - let mut assert = sandbox.cmd.args(["build", java]).assert().success(); - for language in languages { - assert = assert.stdout(p::str::contains(format!("{language} {version} cloning"))); - } - for language in languages { - let dylib = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } +fn build_plain_progress_numbered_correctly() { + let mut sandbox = Sandbox::new(); + let output = sandbox + .cmd + .args(["build", "json", "--progress=plain"]) + .output() + .unwrap(); + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Verify that steps are numbered starting from 1, not 0 + assert!(stdout.contains("[1/"), "stdout should contain [1/"); + assert!(stdout.contains("[2/"), "stdout should contain [2/"); + assert!(stdout.contains("[3/"), "stdout should contain [3/"); + + // Verify no [0/ appears (which was the bug) + assert!( + !stdout.contains("[0/"), + "stdout should not contain [0/ (step numbering started at 0)" + ); + + // Verify the output artifact was created + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); } #[rstest] -fn multi_parsers_cmd() { - let typescript = "typescript"; - let version = "0.21.0"; - let languages = [typescript, "tsx"]; - let mut sandbox = Sandbox::new(); - let config = formatdoc! { - r#" - [parsers] - typescript = {{ ref = "{version}", cmd = "make" }} - "# - }; - sandbox - .tmp - .child(TSDL_CONFIG_FILE) - .write_str(&config) - .unwrap(); - let assert = sandbox.cmd.args(["build", typescript]).assert().success(); - // Check for version in cloning step - // TODO: dig for changes in this test and revert. - _ = assert.stdout(p::str::contains(format!("{typescript} v{version} cloning"))); - for language in languages { - let dylib = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{language}.{DLL_EXTENSION}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } +fn build_rejects_malformed_config_file() { + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); + + sandbox + .cmd + .arg("build") + .assert() + .failure() + .stderr(p::str::contains( + "Resolving build configuration for `build`", + )) + .stderr(p::str::contains("Parsing config file")); } #[rstest] @@ -314,63 +144,240 @@ fn multi_parsers_cmd() { #[case::native(Some("native"), &[DLL_EXTENSION])] #[cfg_attr(enable_wasm_cases, case::wasm(Some("wasm"), &[WASM_EXTENSION]))] fn build_target(#[case] target: Option<&str>, #[case] exts: &[&str]) { - use std::fmt::Write as _; - - let languages = [("json", "0.21.0")]; - let mut config = String::new(); - writeln!(config, "[parsers]").unwrap(); - for (lang, ver) in languages { - writeln!(config, " {lang} = \"{ver}\"").unwrap(); - } - if let Some(target) = target { - config = format!("target = \"{target}\"\n{config}"); - } - let mut sandbox = Sandbox::new(); - sandbox + use std::fmt::Write as _; + + let languages = [("json", "0.21.0")]; + let mut config = String::new(); + writeln!(config, "[parsers]").unwrap(); + for (lang, ver) in languages { + writeln!(config, " {lang} = \"{ver}\"").unwrap(); + } + if let Some(target) = target { + config = format!("target = \"{target}\"\n{config}"); + } + let mut sandbox = Sandbox::new(); + sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); + sandbox.cmd.args(["build"]).assert().success(); + for (lang, _) in languages { + for ext in exts { + let dylib = sandbox .tmp - .child(TSDL_CONFIG_FILE) - .write_str(&config) - .unwrap(); - sandbox.cmd.args(["build"]).assert().success(); - for (lang, _) in languages { - for ext in exts { - let dylib = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}{lang}.{ext}")); - dylib.assert(p::path::exists()).assert(p::path::is_file()); - } + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{lang}.{ext}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); } + } } #[rstest] -fn build_plain_progress_numbered_correctly() { - let mut sandbox = Sandbox::new(); - let output = sandbox - .cmd - .args(["build", "json", "--progress=plain"]) - .output() - .unwrap(); - - assert!(output.status.success()); +fn multi_parsers_cmd() { + let typescript = "typescript"; + let version = "0.21.0"; + let languages = [typescript, "tsx"]; + let mut sandbox = Sandbox::new(); + let config = formatdoc! { + r#" + [parsers] + typescript = {{ ref = "{version}", cmd = "make" }} + "# + }; + sandbox.tmp.child(CONFIG_FILE).write_str(&config).unwrap(); + let assert = sandbox.cmd.args(["build", typescript]).assert().success(); + // Check for version in cloning step + // TODO: dig for changes in this test and revert. + _ = assert.stdout(p::str::contains(format!("{typescript:<16} [1/2] cloning"))); + for language in languages { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } +} - let stdout = String::from_utf8_lossy(&output.stdout); +#[rstest] +fn multi_parsers_no_cmd() { + let java = "java"; + let version = "HEAD"; + let languages = [java]; + let mut sandbox = Sandbox::new(); + let mut assert = sandbox + .cmd + .args(["build", java]) + .assert() + .success() + .stdout(p::str::contains(format!("{java:<16} @ {version}"))); + for language in languages { + assert = assert.stdout(p::str::contains(format!("{language:<16} [1/2] cloning"))); + } + for language in languages { + let dylib = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{language}.{DLL_EXTENSION}")); + dylib.assert(p::path::exists()).assert(p::path::is_file()); + } +} - // Verify that steps are numbered starting from 1, not 0 - assert!(stdout.contains("[1/"), "stdout should contain [1/"); - assert!(stdout.contains("[2/"), "stdout should contain [2/"); - assert!(stdout.contains("[3/"), "stdout should contain [3/"); +#[rstest] +#[case::no_leading_v("0.25.6", "v0.25.6", "0.25.6")] +#[case::leading_v("v0.25.6", "v0.25.6", "0.25.6")] +#[case::sha1("636801770eea172d140e64b691815ff11f6b556f", "6368017", "0.22.6")] +fn no_args_should_build_tree_sitter_with_specific_version( + #[case] requested: &str, + #[case] version: &str, + #[case] cli_version: &str, +) { + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["build", "--tree-sitter-version", requested]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(format!("tree-sitter-cli @ {version}"))); + let mut tree_sitter_cli = Command::new( + sandbox + .tmp + .child(BUILD_DIR) + .child(format!("tree-sitter-{PLATFORM}-v{cli_version}")) + .to_path_buf(), + ); + tree_sitter_cli.arg("--version"); + tree_sitter_cli + .assert() + .success() + .stdout(p::str::contains(format!("tree-sitter {cli_version}"))); +} - // Verify no [0/ appears (which was the bug) - assert!( - !stdout.contains("[0/"), - "stdout should not contain [0/ (step numbering started at 0)" - ); +#[rstest] +fn no_args_should_download_tree_sitter_cli() { + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build"); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(format!("tree-sitter-cli @ v{VERSION}"))); + assert!(!sandbox.is_empty()); + let tree_sitter_cli = sandbox + .tmp + .child(BUILD_DIR) + .child(format!("tree-sitter-{PLATFORM}-v{VERSION}")); + + tree_sitter_cli + .assert(p::path::exists()) + .assert(p::path::is_file()); + + let tree_sitter_cli = tree_sitter_cli.to_path_buf(); + assert!(tree_sitter_cli.metadata().unwrap().permissions().mode() & 0o111 != 0); + + let gz = tree_sitter_cli.with_extension("gz"); + assert!(!gz.exists()); +} - // Verify the output artifact was created +#[rstest] +#[case::json(vec!["json"])] +#[case::json_rust(vec!["json", "rust"])] +fn no_config_should_build_valid_parser_from_head(#[case] languages: Vec<&str>) { + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build").args(&languages); + let mut assert = sandbox.cmd.assert().success(); + for lang in &languages { + assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); + } + for lang in &languages { let dylib = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}{lang}.{DLL_EXTENSION}")); dylib.assert(p::path::exists()).assert(p::path::is_file()); + } +} + +#[rstest] +fn test_real_parser_error_formatting() { + let mut sandbox = Sandbox::new(); + let output = sandbox.cmd.arg("build").args(["jsonxxx"]).output().unwrap(); + + // Should fail + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + + // Extract just the error part (after the progress messages) + let error_part = stderr + .lines() + .skip_while(|line| !line.contains("Could not build all parsers")) + .collect::>() + .join("\n"); + + // MacOS needs the canonicalize because tmp by default doesn't have /private as root. + let build_dir = std::fs::canonicalize( + sandbox + .tmp + .path() + .join(BUILD_DIR) + .join("tree-sitter-jsonxxx"), + ) + .unwrap(); + + // Define the exact expected error format using multi-line string literal + let expected = format!( + "\ +Could not build all parsers. + + jsonxxx: Could not clone to {}. + $ git fetch origin --depth 1 HEAD failed with exit status 128. + fatal: could not read Username for 'https://github.com': terminal prompts disabled\ +", + build_dir.display() + ); + + // Cursor for sequential searching because some shells might output noise. + let mut remaining_output = error_part.as_str(); + + for line in expected.lines() { + if line.trim().is_empty() { + continue; + } + + // Find exact line (w/ indentation) within the remaining slice + if let Some(idx) = remaining_output.find(line) { + // Move cursor past the found line to ensure order + remaining_output = &remaining_output[idx + line.len()..]; + } else { + panic!( + "Output mismatch.\n\ + Could not find expected line (or it is out of order):\n\ + {line:?}\n\ + \n\ + Inside remaining output:\n\ + {remaining_output:?}\n\ + \n\ + Original full output:\n\ + {error_part}" + ); + } + } +} + +#[rstest] +#[case::gringo(vec!["gringo"])] +#[case::gringo_bringo(vec!["gringo", "bringo"])] +fn unknown_parser_should_fail(#[case] languages: Vec<&str>) { + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build").args(&languages); + let mut assert = sandbox.cmd.assert().failure(); + for lang in &languages { + assert = assert.stdout(p::str::contains(format!("{lang:<16} [1/2] cloning"))); + } + for lang in languages { + sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{lang}.{DLL_EXTENSION}")) + .assert(p::path::missing()); + } } diff --git a/tests/cmd/cache.rs b/tests/cmd/cache.rs index 99ffca7..c002830 100644 --- a/tests/cmd/cache.rs +++ b/tests/cmd/cache.rs @@ -5,251 +5,281 @@ use assert_fs::prelude::*; use predicates::{self as p, prelude::*}; use rstest::*; -use tsdl::consts::{TSDL_BUILD_DIR, TSDL_OUT_DIR, TSDL_PREFIX}; +use tsdl::consts::{BUILD_DIR, CONFIG_FILE, PARSER_OUT_DIR, PLATFORM, PREFIX, VERSION}; use crate::cmd::Sandbox; +#[rstest] +fn cache_file_structure() { + let mut sandbox = Sandbox::new(); + + // Build two parsers + sandbox + .cmd + .arg("build") + .args(["json", "python"]) + .assert() + .success(); + + // Read and validate cache file + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); + cache_file.assert(p::path::exists()); + + let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); + + // Verify TOML structure contains expected entries + assert!( + cache_content.contains("[parsers.\"json/json\"]"), + "Cache should have json entry" + ); + assert!( + cache_content.contains("[parsers.\"python/python\"]"), + "Cache should have python entry" + ); + assert!( + cache_content.contains("hash"), + "Cache should have hash field" + ); + assert!( + cache_content.contains("git_ref"), + "Cache should have git_ref field" + ); + assert!( + cache_content.contains("revision"), + "Cache should have revision identity field" + ); + assert!( + cache_content.contains("spec"), + "Cache should have build spec field" + ); + assert!( + cache_content.contains("outputs"), + "Cache should have outputs field" + ); + assert!( + !cache_content.lines().any(|line| line.starts_with("file")), + "Cache should not serialize its runtime storage path" + ); +} + #[rstest] fn cache_hit_skips_build() { - let mut sandbox = Sandbox::new(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - let binary = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); - binary.assert(p::path::exists()).assert(p::path::is_file()); - - // Cache file should exist - let cache_file = sandbox.tmp.child(TSDL_BUILD_DIR).child("cache.toml"); - cache_file.assert(p::path::exists()); - - let first_inode = binary.metadata().unwrap().ino(); - - // Second build in same sandbox should hit cache - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.arg("build") - .arg("json") - .assert() - .success() - .stdout(p::str::contains("cached done")) - .stdout(p::str::contains("cloning").not()); - - let second_inode = binary.metadata().unwrap().ino(); - assert_eq!( - first_inode, second_inode, - "Inode should remain the same on cache hit" - ); + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("[parsers]\njson = \"0.21.0\"\n") + .unwrap(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + let binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + binary.assert(p::path::exists()).assert(p::path::is_file()); + + // Cache file should exist + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); + cache_file.assert(p::path::exists()); + + let first_inode = binary.metadata().unwrap().ino(); + + // Second build in same sandbox should hit cache + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .arg("build") + .arg("json") + .assert() + .success() + .stdout(p::str::contains("json/json [4/4] cached")) + .stdout(p::str::contains("cloning").not()); + + let second_inode = binary.metadata().unwrap().ino(); + assert_eq!( + first_inode, second_inode, + "Inode should remain the same on cache hit" + ); } #[rstest] fn cache_miss_on_grammar_modification() { - let mut sandbox = Sandbox::new(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - // Modify grammar file - let grammar = sandbox - .tmp - .child(TSDL_BUILD_DIR) - .child("tree-sitter-json") - .child("grammar.js"); - let mut content = std::fs::read_to_string(grammar.path()).unwrap(); - content.push_str("\n// test modification\n"); - grammar.write_str(&content).unwrap(); - - // Second build should miss cache (use --force because binary already exists with different inode) - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--force", "json"]) - .assert() - .success() - .stdout(p::str::contains("HEAD cloning")) - .stdout(p::str::contains("(cached)").not()); + let mut sandbox = Sandbox::new(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + // Modify grammar file + let grammar = sandbox + .tmp + .child(BUILD_DIR) + .child("tree-sitter-json") + .child("grammar.js"); + let mut content = std::fs::read_to_string(grammar.path()).unwrap(); + content.push_str("\n// test modification\n"); + grammar.write_str(&content).unwrap(); + + // Second build should miss cache (use --force because binary already exists with different inode) + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .args(["build", "--force", "json"]) + .assert() + .success() + .stdout(p::str::contains("json [1/2] cloning")) + .stdout(p::str::contains("json/json [3/4] building")); } #[rstest] -fn fresh_flag_clears_build_dir() { - let mut sandbox = Sandbox::new(); +fn force_flag_bypasses_cache() { + let mut sandbox = Sandbox::new(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + let binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + let first_inode = binary.metadata().unwrap().ino(); + + // Second build with --force + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .args(["build", "--force", "json"]) + .assert() + .success() + .stdout(p::str::contains("json [1/2] cloning")) + .stdout(p::str::contains("json/json [3/4] building")); + + let second_inode = binary.metadata().unwrap().ino(); + assert_ne!( + first_inode, second_inode, + "--force should create new binary with different inode" + ); +} - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); +#[rstest] +fn force_flag_reinstalls_hardlink() { + let mut sandbox = Sandbox::new(); + + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); + + let binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + let build_binary = sandbox + .tmp + .child(BUILD_DIR) + .child("tree-sitter-json") + .child(format!("tsdl-{PLATFORM}-v{VERSION}")) + .child(format!("libtree-sitter-json.{DLL_EXTENSION}")); + + let first_inode_out = binary.metadata().unwrap().ino(); + let first_inode_build = build_binary.metadata().unwrap().ino(); + assert_eq!( + first_inode_out, first_inode_build, + "Hard-link should have same inode" + ); + + // Replace output binary with a copy (different inode) + let content = std::fs::read(binary.path()).unwrap(); + std::fs::remove_file(binary.path()).unwrap(); + std::fs::write(binary.path(), &content).unwrap(); + + let broken_inode_out = binary.metadata().unwrap().ino(); + assert_ne!( + broken_inode_out, first_inode_build, + "Replaced binary should have different inode" + ); + + // Second build with --force should fix the hard-link + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .args(["build", "--force", "json"]) + .assert() + .success() + .stdout(p::str::contains("json/json [4/4] installing")); + + let final_inode_out = binary.metadata().unwrap().ino(); + let final_inode_build = build_binary.metadata().unwrap().ino(); + assert_eq!( + final_inode_out, final_inode_build, + "After --force, hard-link should be restored" + ); +} - let build_dir = sandbox.tmp.child(TSDL_BUILD_DIR); - build_dir.assert(p::path::exists()); +#[rstest] +fn fresh_flag_clears_build_dir() { + let mut sandbox = Sandbox::new(); - let cache_file = build_dir.child("cache.toml"); - cache_file.assert(p::path::exists()); + // First build + sandbox.cmd.arg("build").arg("json").assert().success(); - let first_binary = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); - let first_inode = first_binary.metadata().unwrap().ino(); + let build_dir = sandbox.tmp.child(BUILD_DIR); + build_dir.assert(p::path::exists()); - // Second build with --fresh (need --force to overwrite existing binary) - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--fresh", "--force", "json"]) - .assert() - .success(); + let cache_file = build_dir.child("cache.toml"); + cache_file.assert(p::path::exists()); - // Cache file should be gone and recreated - cache_file.assert(p::path::exists()); + let first_binary = sandbox + .tmp + .child(PARSER_OUT_DIR) + .child(format!("{PREFIX}json.{DLL_EXTENSION}")); + let first_inode = first_binary.metadata().unwrap().ino(); - let second_inode = first_binary.metadata().unwrap().ino(); + // Second build with --fresh (need --force to overwrite existing binary) + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + cmd + .args(["build", "--fresh", "--force", "json"]) + .assert() + .success(); - assert_ne!( - first_inode, second_inode, - "Fresh build should create new binary with different inode" - ); -} + // Cache file should be gone and recreated + cache_file.assert(p::path::exists()); -#[rstest] -fn force_flag_bypasses_cache() { - let mut sandbox = Sandbox::new(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - let binary = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); - let first_inode = binary.metadata().unwrap().ino(); - - // Second build with --force - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--force", "json"]) - .assert() - .success() - .stdout(p::str::contains("HEAD cloning")) - .stdout(p::str::contains("(cached)").not()); - - let second_inode = binary.metadata().unwrap().ino(); - assert_ne!( - first_inode, second_inode, - "--force should create new binary with different inode" - ); -} + let second_inode = first_binary.metadata().unwrap().ino(); -#[rstest] -fn force_flag_reinstalls_hardlink() { - let mut sandbox = Sandbox::new(); - - // First build - sandbox.cmd.arg("build").arg("json").assert().success(); - - let binary = sandbox - .tmp - .child(TSDL_OUT_DIR) - .child(format!("{TSDL_PREFIX}json.{DLL_EXTENSION}")); - let build_binary = sandbox - .tmp - .child(TSDL_BUILD_DIR) - .child("tree-sitter-json") - .child(format!("libtree-sitter-json.{DLL_EXTENSION}")); - - let first_inode_out = binary.metadata().unwrap().ino(); - let first_inode_build = build_binary.metadata().unwrap().ino(); - assert_eq!( - first_inode_out, first_inode_build, - "Hard-link should have same inode" - ); - - // Replace output binary with a copy (different inode) - let content = std::fs::read(binary.path()).unwrap(); - std::fs::remove_file(binary.path()).unwrap(); - std::fs::write(binary.path(), &content).unwrap(); - - let broken_inode_out = binary.metadata().unwrap().ino(); - assert_ne!( - broken_inode_out, first_inode_build, - "Replaced binary should have different inode" - ); - - // Second build with --force should fix the hard-link - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - cmd.args(["build", "--force", "json"]) - .assert() - .success() - .stdout(p::str::contains("json/json HEAD installing")); - - let final_inode_out = binary.metadata().unwrap().ino(); - let final_inode_build = build_binary.metadata().unwrap().ino(); - assert_eq!( - final_inode_out, final_inode_build, - "After --force, hard-link should be restored" - ); + assert_ne!( + first_inode, second_inode, + "Fresh build should create new binary with different inode" + ); } #[rstest] #[case::json_and_python(vec!["json", "python"])] fn multi_parser_independent_cache(#[case] languages: Vec<&str>) { - let mut sandbox = Sandbox::new(); - - // First build all parsers - sandbox.cmd.arg("build").args(&languages).assert().success(); - - // Verify cache contains both entries - let cache_file = sandbox.tmp.child(TSDL_BUILD_DIR).child("cache.toml"); - let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); - for lang in &languages { - assert!( - cache_content.contains(&format!("[parsers.\"{lang}/{lang}\"]")), - "Cache should contain entry for {lang}", - ); - } - - // Second build without modification should hit cache for both - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(sandbox.tmp.path()); - let mut output = cmd.arg("build").args(&languages).assert().success(); - - for lang in &languages { - output = output.stdout(p::str::contains(format!("{lang} HEAD cached done"))); - } -} + let mut sandbox = Sandbox::new(); -#[rstest] -fn cache_file_structure() { - let mut sandbox = Sandbox::new(); + // First build all parsers + sandbox.cmd.arg("build").args(&languages).assert().success(); - // Build two parsers - sandbox - .cmd - .arg("build") - .args(["json", "python"]) - .assert() - .success(); - - // Read and validate cache file - let cache_file = sandbox.tmp.child(TSDL_BUILD_DIR).child("cache.toml"); - cache_file.assert(p::path::exists()); - - let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); - - // Verify TOML structure contains expected entries - assert!( - cache_content.contains("[parsers.\"json/json\"]"), - "Cache should have json entry" - ); - assert!( - cache_content.contains("[parsers.\"python/python\"]"), - "Cache should have python entry" - ); - assert!( - cache_content.contains("hash"), - "Cache should have hash field" - ); + // Verify cache contains both entries + let cache_file = sandbox.tmp.child(BUILD_DIR).child("cache.toml"); + let cache_content = std::fs::read_to_string(cache_file.path()).unwrap(); + for lang in &languages { assert!( - cache_content.contains("git_ref"), - "Cache should have git_ref field" + cache_content.contains(&format!("[parsers.\"{lang}/{lang}\"]")), + "Cache should contain entry for {lang}", ); + } + + // Second build without modification should hit cache for both + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(sandbox.tmp.path()); + let mut output = cmd.arg("build").args(&languages).assert().success(); + + for lang in &languages { + output = output.stdout(p::str::contains(format!( + "{:<16} [4/4] cached", + format!("{lang}/{lang}") + ))); + } } diff --git a/tests/cmd/config.rs b/tests/cmd/config.rs index 95e5769..57d78c6 100644 --- a/tests/cmd/config.rs +++ b/tests/cmd/config.rs @@ -2,83 +2,138 @@ use assert_fs::prelude::*; use indoc::formatdoc; use predicates::{self as p}; -use tsdl::{args::BuildCommand, consts::TSDL_BUILD_DIR}; +use tsdl::{ + args::BuildCommand, + consts::{BUILD_DIR, CONFIG_FILE}, +}; use crate::cmd::Sandbox; #[test] -fn no_args_shows_help() { - let mut sandbox = Sandbox::new(); - sandbox - .cmd - .args(["config"]) - .assert() - .failure() - .stderr(p::str::starts_with("Configuration helpers")) - .stderr(p::str::contains(format!( - "Usage: {} config [OPTIONS] ", - env!("CARGO_PKG_NAME") - ))); - assert!(sandbox.is_empty()); +fn current_rejects_malformed_config_file() { + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); + + sandbox + .cmd + .args(["config", "current"]) + .assert() + .failure() + .stderr(p::str::contains( + "Resolving build configuration for `config current`", + )) + .stderr(p::str::contains("Parsing config file")); } #[test] -fn default_is_default_toml() { - let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["config", "default"]); - sandbox.cmd.assert().success().stdout(p::str::contains( - toml::to_string(&BuildCommand::default()).unwrap(), - )); - assert!(!sandbox.is_empty()); - sandbox - .tmp - .child(TSDL_BUILD_DIR) - .child("log") - .assert(p::path::exists()) - .assert(p::path::is_file()); +fn current_uses_config_file() { + let build_dir = "build-dir"; + let out_dir = "out-dir"; + let config = formatdoc! { + r#" + build-dir = "{build_dir}" + out = "{out_dir}" + "# + }; + let mut sandbox = Sandbox::new(); + sandbox.config(&config); + sandbox.cmd.args(["config", "current"]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); + assert!(!sandbox.is_empty()); + sandbox.tmp.child(build_dir).assert(p::path::missing()); } #[test] fn current_uses_default() { - let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["config", "current"]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); - assert!(!sandbox.is_empty()); - sandbox - .tmp - .child(TSDL_BUILD_DIR) - .child("log") - .assert(p::path::exists()) - .assert(p::path::is_file()); + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["config", "current"]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); + assert!(sandbox.is_empty()); } #[test] -fn current_uses_config_file() { - let build_dir = "build-dir"; - let out_dir = "out-dir"; - let config = formatdoc! { - r#" - build-dir = "{build_dir}" - out = "{out_dir}" - "# - }; - let mut sandbox = Sandbox::new(); - sandbox.config(&config); - sandbox.cmd.args(["config", "current"]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(toml::to_string(&sandbox.build).unwrap())); - assert!(!sandbox.is_empty()); - sandbox - .tmp - .child(build_dir) - .child("log") - .assert(p::path::exists()) - .assert(p::path::is_file()); +fn current_uses_explicit_log_file() { + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["--log", "config-current.log", "config", "current"]); + sandbox.cmd.assert().success(); + sandbox + .tmp + .child("config-current.log") + .assert(p::path::exists()) + .assert(p::path::is_file()); + sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); +} + +#[test] +fn default_ignores_malformed_config_file() { + let mut sandbox = Sandbox::new(); + sandbox + .tmp + .child(CONFIG_FILE) + .write_str("not valid toml =") + .unwrap(); + + sandbox + .cmd + .args(["config", "default"]) + .assert() + .success() + .stdout(p::str::contains( + toml::to_string(&BuildCommand::default()).unwrap(), + )); +} + +#[test] +fn default_is_default_toml() { + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["config", "default"]); + sandbox.cmd.assert().success().stdout(p::str::contains( + toml::to_string(&BuildCommand::default()).unwrap(), + )); + assert!(sandbox.is_empty()); +} + +#[test] +fn default_uses_explicit_log_file() { + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["--log", "config-default.log", "config", "default"]); + sandbox.cmd.assert().success(); + sandbox + .tmp + .child("config-default.log") + .assert(p::path::exists()) + .assert(p::path::is_file()); + sandbox.tmp.child(BUILD_DIR).assert(p::path::missing()); +} + +#[test] +fn no_args_shows_help() { + let mut sandbox = Sandbox::new(); + sandbox + .cmd + .args(["config"]) + .assert() + .failure() + .stderr(p::str::starts_with("Configuration helpers")) + .stderr(p::str::contains(format!( + "Usage: {} config [OPTIONS] ", + env!("CARGO_PKG_NAME") + ))); + assert!(sandbox.is_empty()); } diff --git a/tests/cmd/log.rs b/tests/cmd/log.rs index 91b4c94..f6bf68e 100644 --- a/tests/cmd/log.rs +++ b/tests/cmd/log.rs @@ -2,48 +2,82 @@ use rstest::*; use assert_fs::prelude::*; use predicates::{self as p}; -use tsdl::consts::{TREE_SITTER_VERSION, TSDL_BUILD_DIR}; +use tsdl::consts::BUILD_DIR; use crate::cmd::Sandbox; #[rstest] fn build_no_args_should_log_to_default_path() { - let mut sandbox = Sandbox::new(); - sandbox.cmd.arg("build"); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(format!( - "tree-sitter-cli v{TREE_SITTER_VERSION} done" - ))); - assert!(!sandbox.is_empty()); - sandbox - .tmp - .child(TSDL_BUILD_DIR) - .child("log") - .assert(p::path::exists()) - .assert(p::path::is_file()); + let mut sandbox = Sandbox::new(); + sandbox.cmd.arg("build"); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains("tree-sitter-cli [2/2] done")); + assert!(!sandbox.is_empty()); + sandbox + .tmp + .child(BUILD_DIR) + .child("log") + .assert(p::path::exists()) + .assert(p::path::is_file()); +} + +#[rstest] +#[case::nested_in_build_dir("tmp/logs/tsdl.log", "nested inside --build-dir")] +#[case::lock_file("tmp/tsdl.lock", "conflicts with a tsdl runtime/build file")] +#[case::cache_file("tmp/cache.toml", "conflicts with a tsdl runtime/build file")] +fn build_rejects_log_paths_that_conflict_with_fresh_cleanup( + #[case] log: &str, + #[case] expected: &str, +) { + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["build", "--log", log]); + sandbox + .cmd + .assert() + .failure() + .stderr(p::str::contains(expected)); } #[rstest] #[case::cwd("tsdl.log")] #[case::child_dir("here/log")] +#[case::build_dir_root("tmp/custom.log")] #[case::absolute("/tmp/tsdl.log")] #[case::parent("../tsdl.log")] fn build_w_specific_log_path(#[case] log: &str) { - let mut sandbox = Sandbox::new(); - sandbox.cmd.args(["build", "--log", log]); - sandbox - .cmd - .assert() - .success() - .stdout(p::str::contains(format!( - "tree-sitter-cli v{TREE_SITTER_VERSION} done" - ))); - sandbox - .tmp - .child(log) - .assert(p::path::exists()) - .assert(p::path::is_file()); + let mut sandbox = Sandbox::new(); + sandbox.cmd.args(["build", "--log", log]); + sandbox + .cmd + .assert() + .success() + .stdout(p::str::contains("tree-sitter-cli [2/2] done")); + sandbox + .tmp + .child(log) + .assert(p::path::exists()) + .assert(p::path::is_file()); +} + +#[rstest] +fn fresh_preserves_root_level_custom_log_and_removes_build_entries() { + let mut sandbox = Sandbox::new(); + let stale = sandbox.tmp.child(BUILD_DIR).child("stale.txt"); + stale.write_str("stale").unwrap(); + + sandbox + .cmd + .args(["build", "--fresh", "--log", "tmp/custom.log"]); + sandbox.cmd.assert().success(); + + sandbox + .tmp + .child(BUILD_DIR) + .child("custom.log") + .assert(p::path::exists()) + .assert(p::path::is_file()); + stale.assert(p::path::missing()); } diff --git a/tests/cmd/mod.rs b/tests/cmd/mod.rs index 2c9131e..16fe26c 100644 --- a/tests/cmd/mod.rs +++ b/tests/cmd/mod.rs @@ -6,51 +6,43 @@ mod cache; mod config; #[cfg(test)] mod log; - +#[cfg(test)] use std::{env, fs, path::Path}; -use assert_cmd::{cargo::cargo_bin_cmd, Command}; +use assert_cmd::{Command, cargo::cargo_bin_cmd}; use assert_fs::TempDir; -use figment::{ - providers::{Format, Serialized, Toml}, - Figment, -}; -use tsdl::{args::BuildCommand, consts::TSDL_CONFIG_FILE}; +use tsdl::{args::BuildCommand, config as tsdl_config, consts::CONFIG_FILE}; pub struct Sandbox { - pub build: BuildCommand, - pub cmd: Command, - pub tmp: TempDir, + pub build: BuildCommand, + pub cmd: Command, + pub tmp: TempDir, } impl Sandbox { - pub fn new() -> Self { - let tmp = TempDir::new().unwrap(); - let mut cmd = cargo_bin_cmd!(); - cmd.current_dir(tmp.path()); - Sandbox { - build: BuildCommand::default(), - cmd, - tmp, - } + pub fn new() -> Self { + let tmp = TempDir::new().unwrap(); + let mut cmd = cargo_bin_cmd!(); + cmd.current_dir(tmp.path()); + Sandbox { + build: BuildCommand::default(), + cmd, + tmp, } + } - pub fn config(&mut self, config: &str) -> &mut Self { - self.config_at(config, &self.tmp.path().join(TSDL_CONFIG_FILE)) - } + pub fn config(&mut self, config: &str) -> &mut Self { + self.config_at(config, &self.tmp.path().join(CONFIG_FILE)) + } - pub fn config_at(&mut self, config: &str, dst: &Path) -> &mut Self { - self.build = Figment::new() - .merge(Serialized::defaults(BuildCommand::default())) - .merge(Toml::string(config)) - .extract() - .unwrap(); - fs::write(dst, config).unwrap(); - self - } + pub fn config_at(&mut self, config_contents: &str, dst: &Path) -> &mut Self { + fs::write(dst, config_contents).unwrap(); + self.build = tsdl_config::current(dst, None).unwrap(); + self + } - pub fn is_empty(&self) -> bool { - fs::read_dir(&self.tmp).is_ok_and(|mut dir| dir.next().is_none()) - } + pub fn is_empty(&self) -> bool { + fs::read_dir(&self.tmp).is_ok_and(|mut dir| dir.next().is_none()) + } } diff --git a/tests/config.rs b/tests/config.rs index 8998813..4bb42f7 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,3 +1,5 @@ +use std::{env, ffi::OsString, path::PathBuf, sync::Mutex}; + use anyhow::Result; use assert_fs::prelude::*; use indoc::{formatdoc, indoc}; @@ -5,58 +7,141 @@ use indoc::{formatdoc, indoc}; use pretty_assertions::{assert_eq, assert_ne}; use tsdl::{ - args::BuildCommand, - config, - consts::{ - TREE_SITTER_PLATFORM, TREE_SITTER_REPO, TREE_SITTER_VERSION, TSDL_BUILD_DIR, TSDL_FRESH, - TSDL_OUT_DIR, TSDL_SHOW_CONFIG, - }, + args::{self, BuildCommand, Target}, + config::{self, Source}, + consts::{BUILD_DIR, FRESH, PARSER_OUT_DIR, PLATFORM, PREFIX, REPO, SHOW_CONFIG, VERSION}, }; +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: The caller holds ENV_LOCK, serializing all env access + // within these tests. + match &self.previous { + | None => unsafe { env::remove_var(self.key) }, + | Some(value) => unsafe { env::set_var(self.key, value) }, + } + } +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var_os(key); + // SAFETY: The caller holds ENV_LOCK, serializing all env access + // within these tests. + unsafe { env::set_var(key, value) }; + Self { key, previous } + } +} + +fn current_with_cli(config: &assert_fs::fixture::ChildPath, argv: &[&str]) -> BuildCommand { + let _lock = ENV_LOCK.lock().unwrap(); + let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); + let build_matches = args::build_matches(&matches); + config::current(config.path(), build_matches).unwrap() +} + +fn current_with_cli_provenance( + config: &assert_fs::fixture::ChildPath, + argv: &[&str], +) -> (BuildCommand, config::BuildProvenance) { + let _lock = ENV_LOCK.lock().unwrap(); + let (_parsed_args, matches) = config::try_parse_from_with_matches(argv).unwrap(); + let build_matches = args::build_matches(&matches); + config::current_with_provenance(config.path(), build_matches).unwrap() +} + #[test] -fn current_from_generated_default() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.write_str(&toml::to_string(&def)?)?; - assert_eq!(def, config::current(&generated, None).unwrap()); - Ok(()) +fn boolean_env_can_override_config_file() -> Result<()> { + let _lock = ENV_LOCK.lock().unwrap(); + let _force = EnvVarGuard::set("FORCE", "false"); + + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("force = true\n")?; + + let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + + assert!(!cmd.force); + assert_eq!(prov.force, Source::Environment); + Ok(()) } #[test] -fn current_from_empty() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.touch()?; - assert_eq!(def, config::current(&generated, None).unwrap()); - Ok(()) +fn cli_can_override_config_to_builtin_default_value() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("target = \"wasm\"\n")?; + + let resolved = current_with_cli(&generated, &["tsdl", "build", "--target", "native"]); + + assert_eq!(resolved.target, Target::Native); + Ok(()) +} + +#[test] +fn cli_can_override_tree_sitter_version_to_builtin_default_value() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("[tree-sitter]\nversion = \"0.25.0\"\n")?; + + let resolved = current_with_cli( + &generated, + &["tsdl", "build", "--tree-sitter-version", VERSION], + ); + + assert_eq!(resolved.tree_sitter.version, VERSION); + Ok(()) } #[test] -fn current_preserve_languages() -> Result<()> { - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let mut def = BuildCommand::default(); - generated.touch()?; - def.languages = None; - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); - def.languages = Some(vec![]); - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); - def.languages = Some(vec!["rust".to_string()]); - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); - def.languages = Some(vec!["rust".to_string(), "ruby".to_string()]); - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); - Ok(()) +fn cli_explicit_default_value_overrides_config_file() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("build-dir = \"/custom\"\n")?; + + let (cmd, prov) = + current_with_cli_provenance(&generated, &["tsdl", "build", "--build-dir", "tmp"]); + + assert_eq!(cmd.build_dir, PathBuf::from("tmp")); + assert_eq!(prov.build_dir, Source::CommandLine); + Ok(()) +} + +#[test] +fn cli_has_precedence_over_env() -> Result<()> { + let _lock = ENV_LOCK.lock().unwrap(); + let _target = EnvVarGuard::set("TSDL_TARGET", "wasm"); + + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; + + let (_parsed_args, matches) = + config::try_parse_from_with_matches(["tsdl", "build", "--target", "native"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + + assert_eq!(cmd.target, Target::Native); + assert_eq!(prov.target, Source::CommandLine); + Ok(()) } #[test] fn current_default_is_default() -> Result<()> { - let config = formatdoc! { - r#" + let config_contents = formatdoc! { + r#" build-dir = "{}" fresh = {} - out = "{}" + out-dir = "{}" show-config = {} [tree-sitter] @@ -64,30 +149,50 @@ fn current_default_is_default() -> Result<()> { repo = "{}" platform = "{}" "#, - TSDL_BUILD_DIR, - TSDL_FRESH, - TSDL_OUT_DIR, - TSDL_SHOW_CONFIG, - TREE_SITTER_VERSION, - TREE_SITTER_REPO, - TREE_SITTER_PLATFORM, - }; - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.write_str(&config)?; - assert_eq!(def, config::current(&generated, None).unwrap()); - assert_eq!(def, config::current(&generated, Some(&def)).unwrap()); - Ok(()) + BUILD_DIR, + FRESH, + PARSER_OUT_DIR, + SHOW_CONFIG, + VERSION, + REPO, + PLATFORM, + }; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.write_str(&config_contents)?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); + Ok(()) +} + +#[test] +fn current_from_empty() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.touch()?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + Ok(()) +} + +#[test] +fn current_from_generated_default() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.write_str(&toml::to_string(&def)?)?; + assert_eq!(def, config::current(generated.path(), None).unwrap()); + Ok(()) } #[test] fn current_overrides_default() -> Result<()> { - let config = indoc! { - r#" + let config_contents = indoc! { + r#" build-dir = "/root" fresh = true - out = "tree-sitter-parsers" + out-dir = "tree-sitter-parsers" show-config = true [tree-sitter] @@ -95,13 +200,94 @@ fn current_overrides_default() -> Result<()> { repo = "https://gitlab.com/tree-sitter/tree-sitter" platform = "linux-arm64" "# - }; - let temp = assert_fs::TempDir::new()?; - let generated = temp.child("generated.toml"); - let def = BuildCommand::default(); - generated.write_str(config)?; - generated.assert(config); - assert_ne!(def, config::current(&generated, None).unwrap()); - assert_ne!(def, config::current(&generated, Some(&def)).unwrap()); - Ok(()) + }; + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + let def = BuildCommand::default(); + generated.write_str(config_contents)?; + generated.assert(config_contents); + assert_ne!(def, config::current(generated.path(), None).unwrap()); + assert_ne!(def, current_with_cli(&generated, &["tsdl", "build"])); + Ok(()) +} + +#[test] +fn current_preserves_cli_languages() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; + + let def = BuildCommand::default(); + assert_eq!(def, current_with_cli(&generated, &["tsdl", "build"])); + + let mut expected = BuildCommand { + languages: Some(vec!["rust".to_string()]), + ..BuildCommand::default() + }; + assert_eq!( + expected, + current_with_cli(&generated, &["tsdl", "build", "rust"]) + ); + + expected.languages = Some(vec!["rust".to_string(), "ruby".to_string()]); + assert_eq!( + expected, + current_with_cli(&generated, &["tsdl", "build", "rust", "ruby"]) + ); + Ok(()) +} + +#[test] +fn env_can_override_config_to_builtin_default_value() -> Result<()> { + let _lock = ENV_LOCK.lock().unwrap(); + let _prefix = EnvVarGuard::set("PREFIX", PREFIX); + + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("prefix = \"\"\n")?; + + let (_parsed_args, matches) = config::try_parse_from_with_matches(["tsdl", "build"]).unwrap(); + let build_matches = args::build_matches(&matches); + let (cmd, prov) = config::current_with_provenance(generated.path(), build_matches)?; + + assert_eq!(cmd.prefix, PREFIX); + assert_eq!(prov.prefix, Source::Environment); + Ok(()) +} + +#[test] +fn negative_boolean_flag_overrides_positive() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.touch()?; + + let (cmd, prov) = + current_with_cli_provenance(&generated, &["tsdl", "build", "--force=true", "--no-force"]); + + assert!(!cmd.force); + assert_eq!(prov.force, Source::CommandLine); + Ok(()) +} + +#[test] +fn negative_boolean_flags_override_config_file() -> Result<()> { + let temp = assert_fs::TempDir::new()?; + let generated = temp.child("generated.toml"); + generated.write_str("force = true\nfresh = true\nshow-config = true\n")?; + + let resolved = current_with_cli( + &generated, + &[ + "tsdl", + "build", + "--no-force", + "--no-fresh", + "--no-show-config", + ], + ); + + assert!(!resolved.force); + assert!(!resolved.fresh); + assert!(!resolved.show_config); + Ok(()) } diff --git a/tests/test_lock_takeover.sh b/tests/test_lock_takeover.sh new file mode 100644 index 0000000..ee2aeca --- /dev/null +++ b/tests/test_lock_takeover.sh @@ -0,0 +1,156 @@ +#!/bin/sh +# ------------------------------------------------------------------ +# Lock takeover smoke test. +# +# Spawns two tsdl processes sharing the same build directory. +# Process A acquires the lock and starts building. Process B +# discovers the lock is held, prompts the user whether to continue, +# sends SIGTERM to A, waits for A to exit, then acquires the lock +# and runs its own build. +# +# Assertions (checked against log files): +# 1. A acquired the lock +# 2. A received SIGTERM and shut down gracefully +# 3. B sent SIGTERM to A via lock takeover +# 4. B acquired the lock after A released it +# 5. A exits 143 (128 + SIGTERM); B exits 0 +# +# Run: sh tests/test_lock_takeover.sh +# Requires: debug build (cargo build), Unix. +# ------------------------------------------------------------------ +set -eu + +cd "$(dirname "$0")/.." +ROOT="$(pwd)" + +echo "=== cleaning build dir ===" +rm -rf tmp 2>/dev/null +mkdir -p tmp/test parsers + +cargo build 2>&1 | tail -1 + +# ------------------------------------------------------------------ +# Process A: start building (acquires the lock, runs subprocesses). +# Use -v for debug-level file logging and a generous unlock_timeout +# so B doesn't time out waiting for A to shut down. +# ------------------------------------------------------------------ +echo "=== starting process A (lock owner) ===" +LOG_A="$ROOT/tmp/test/test_lock_a.log" +TSDL_TEST_DELAY_MS=500 \ +./target/debug/tsdl -v b json rust toml --force --progress plain \ + --log "$LOG_A" --log-color no --unlock-timeout 30 & +PID_A=$! + +# Wait for A to acquire the lock. +echo "=== waiting for A to acquire lock ===" +DEADLINE=$(($(date +%s) + 15)) +while [ $(date +%s) -lt $DEADLINE ]; do + if grep -q "Acquired lock" "$LOG_A" 2>/dev/null; then + echo " A acquired lock (PID $PID_A)" + break + fi + sleep 0.5 +done + +if ! grep -q "Acquired lock" "$LOG_A" 2>/dev/null; then + echo " [FAIL] A never acquired lock" + kill $PID_A 2>/dev/null || true + exit 1 +fi + +# Wait for A to actually start building subprocesses before B tries +# to steal the lock (ensures B SIGTERM exercises the subprocess-kill path). +echo "=== waiting for A to start building ===" +DEADLINE=$(($(date +%s) + 15)) +while [ $(date +%s) -lt $DEADLINE ]; do + if grep -q "spawned pid=Some(" "$LOG_A" 2>/dev/null; then + echo " A has spawned subprocesses" + break + fi + sleep 0.5 +done + +# ------------------------------------------------------------------ +# Process B: try to build on the same directory. Pipe "y\n" +# to stdin so it accepts the prompt and takes over the lock. +# ------------------------------------------------------------------ +echo "=== starting process B (lock taker) ===" +LOG_B="$ROOT/tmp/test/test_lock_b.log" +echo "y" | \ +TSDL_TEST_DELAY_MS=500 \ +./target/debug/tsdl -v b json rust --force --progress plain \ + --log "$LOG_B" --log-color no --unlock-timeout 30 & +PID_B=$! + +# Wait for both to finish. +set +e +wait $PID_A 2>/dev/null; EXIT_A=$? +wait $PID_B 2>/dev/null; EXIT_B=$? +set -e + +echo "" +echo "=== A exit code: $EXIT_A ===" +echo "=== B exit code: $EXIT_B ===" +echo "" + +# ------------------------------------------------------------------ +# Report +# ------------------------------------------------------------------ +failures=0 + +echo "--- process A (lock owner) ---" +if [ -f "$LOG_A" ]; then + grep -q "Acquired lock" "$LOG_A" \ + && echo " [PASS] acquired lock" \ + || { echo " [FAIL] acquired lock — log: $LOG_A"; failures=$((failures+1)); } + grep -q "Received SIGTERM" "$LOG_A" \ + && echo " [PASS] received SIGTERM" \ + || { echo " [FAIL] received SIGTERM — log: $LOG_A"; failures=$((failures+1)); } + grep -q "SHUTDOWN sending SIGTERM" "$LOG_A" \ + && echo " [PASS] signal forwarded to child" \ + || echo " [WARN] signal forwarded to child (may have completed before signal)" +else + echo " [FAIL] log file not created: $LOG_A" + failures=$((failures+1)) +fi + +echo "--- process B (lock taker) ---" +if [ -f "$LOG_B" ]; then + grep -q "Sending SIGTERM to lock owner" "$LOG_B" \ + && echo " [PASS] sent SIGTERM to A" \ + || { echo " [FAIL] sent SIGTERM to A — log: $LOG_B"; failures=$((failures+1)); } + grep -q "Acquired lock" "$LOG_B" \ + && echo " [PASS] acquired lock" \ + || { echo " [FAIL] acquired lock — log: $LOG_B"; failures=$((failures+1)); } + grep -q "panicked" "$LOG_B" && { + echo " [FAIL] panic in B log — log: $LOG_B"; failures=$((failures+1)); + } +else + echo " [FAIL] log file not created: $LOG_B" + failures=$((failures+1)) +fi + +echo "" +if [ $EXIT_A -eq 143 ]; then + echo " [PASS] A exit code 143 (SIGTERM)" +else + echo " [FAIL] A exit code $EXIT_A (expected 143)" + failures=$((failures+1)) +fi + +if [ $EXIT_B -eq 0 ]; then + echo " [PASS] B exit code 0 (graceful)" +else + echo " [FAIL] B exit code $EXIT_B (expected 0)" + failures=$((failures+1)) +fi + +if [ $failures -eq 0 ]; then + echo "" + echo "All assertions passed." + exit 0 +else + echo "" + echo "$failures assertion(s) FAILED." + exit 1 +fi diff --git a/tests/test_second_signal.sh b/tests/test_second_signal.sh new file mode 100644 index 0000000..6ccbc1a --- /dev/null +++ b/tests/test_second_signal.sh @@ -0,0 +1,157 @@ +#!/bin/sh +# ------------------------------------------------------------------ +# Second-signal escalation smoke test. +# +# Verifies that when tsdl receives two signals in succession: +# 1. First SIGTERM → graceful shutdown: cancel() + cooperative exit +# 2. Second SIGTERM → escalation: libc::kill(-pgid, SIGKILL) + exit(143) +# +# Design: +# TSDL_TEST_DELAY_MS=2000 guarantees every spawned child has a long +# window mid-execution. The first SIGTERM triggers graceful shutdown +# but the delay cascade keeps the process alive long enough for the +# second signal to reliably hit the escalation path. +# +# process::exit() kills the process with code 143 and flushes stderr, +# but may not flush the tracing log file. We capture stderr for the +# escalation message and use the tracing log file for the first-signal +# assertions. +# +# Assertions: +# 1. Exit code is 143 (SIGTERM escalation, not 0 = graceful) +# 2. stderr contains "Received SIGTERM" (first signal) +# 3. stderr contains "Received second SIGTERM; forcing exit" (escalation) +# 4. File log contains "SHUTDOWN killing pgid=" (optional, best-effort) +# +# Run: sh tests/test_second_signal.sh +# Requires: debug build (cargo build), Unix. +# ------------------------------------------------------------------ +set -eu + +cd "$(dirname "$0")/.." +ROOT="$(pwd)" + +echo "=== cleaning build dir ===" +rm -rf tmp 2>/dev/null +mkdir -p tmp/test parsers + +cargo build 2>&1 | tail -1 + +# ------------------------------------------------------------------ +# Start tsdl. Capture stderr to a separate file because process::exit +# flushes C stdio but not the tracing non-blocking writer. +# The tracing log file (--log) is also written for first-signal assertions. +# ------------------------------------------------------------------ +echo "=== starting tsdl (will receive 2 signals) ===" +LOG="$ROOT/tmp/test/test_2sig.log" +STDERR="$ROOT/tmp/test/test_2sig.stderr" +TSDL_TEST_DELAY_MS=2000 \ +./target/debug/tsdl -v b json rust toml php --force --progress plain \ + --log "$LOG" --log-color no \ + 2>"$STDERR" & +PID=$! + +# ------------------------------------------------------------------ +# Wait for builds to be underway (spawned pid line in log). +# ------------------------------------------------------------------ +echo "=== waiting for builds to start ===" +DEADLINE=$(($(date +%s) + 30)) +while [ $(date +%s) -lt $DEADLINE ]; do + if grep -q "spawned pid=Some(" "$LOG" 2>/dev/null; then + echo " build underway" + break + fi + sleep 0.5 +done + +if ! grep -q "spawned pid=Some(" "$LOG" 2>/dev/null; then + echo " [FAIL] build never started" + kill $PID 2>/dev/null || true + exit 1 +fi + +# ------------------------------------------------------------------ +# First SIGTERM: graceful shutdown begins. The test_delay cascade +# keeps the process alive for 1-2 seconds. +# ------------------------------------------------------------------ +echo "=== sending first SIGTERM ===" +kill -TERM $PID 2>/dev/null || true + +# This is shorter than any single test_delay, so the second signal +# will reliably arrive during the graceful shutdown cascade. +sleep 0.2 + +# ------------------------------------------------------------------ +# Second SIGTERM: escalation path. process::exit(143) kills the +# process immediately after SIGKILLing child process groups. +# ------------------------------------------------------------------ +echo "=== sending second SIGTERM ===" +kill -TERM $PID 2>/dev/null || true + +# Wait for the process to die (escalation is immediate). +sleep 2 +if kill -0 $PID 2>/dev/null; then + echo "=== still alive, sending SIGKILL ===" + kill -KILL $PID +fi + +# process::exit(143) means wait returns 143. set +e so the script +# doesn't abort on the non-zero exit. +set +e +wait $PID 2>/dev/null +EXIT=$? +set -e + +echo "" +echo "=== exit code: $EXIT ===" +echo "" + +# ------------------------------------------------------------------ +# Assertions +# ------------------------------------------------------------------ +failures=0 + +echo "--- first signal (tracing log) ---" +if [ -f "$LOG" ]; then + grep -q "Received SIGTERM" "$LOG" \ + && echo " [PASS] first signal received" \ + || { echo " [FAIL] first signal not in log — $LOG"; failures=$((failures+1)); } + # Best-effort: child kill evidence (may not appear if escalation fires first) + grep -q "SHUTDOWN killing pgid=" "$LOG" \ + && echo " [PASS] child killed mid-exec" \ + || echo " [WARN] no child kill in log (escalation may have preempted)" +else + echo " [FAIL] tracing log not created: $LOG" + failures=$((failures+1)) +fi + +echo "--- second signal (stderr capture) ---" +if [ -f "$STDERR" ]; then + grep -q "Received SIGTERM" "$STDERR" \ + && echo " [PASS] first signal on stderr" \ + || { echo " [WARN] first signal not on stderr (may race with exit)"; } + grep -q "Received second SIGTERM" "$STDERR" \ + && echo " [PASS] second signal escalation message" \ + || { echo " [FAIL] escalation message not on stderr — $STDERR"; failures=$((failures+1)); } +else + echo " [FAIL] stderr capture not created: $STDERR" + failures=$((failures+1)) +fi + +echo "" +if [ $EXIT -eq 143 ]; then + echo " [PASS] exit code 143 (SIGTERM escalation)" +else + echo " [FAIL] exit code $EXIT (expected 143)" + failures=$((failures+1)) +fi + +if [ $failures -eq 0 ]; then + echo "" + echo "All assertions passed." + exit 0 +else + echo "" + echo "$failures assertion(s) FAILED." + exit 1 +fi diff --git a/tests/test_signal.sh b/tests/test_signal.sh new file mode 100755 index 0000000..3550f4e --- /dev/null +++ b/tests/test_signal.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------ +# Manual signal-handling smoke test. +# +# Starts tsdl building several parsers with --force, sends SIGTERM +# mid-build, and checks that: +# 1. "Received SIGTERM" appears in the log +# 2. "SHUTDOWN sending SIGTERM" appears (signal forwarded to child) +# 3. "Command interrupted by SIGTERM" appears +# 4. "pipeline shutdown signalled" appears +# 5. Exit code is 143 (128 + SIGTERM) +# +# Polls the log for a spawned subprocess (rather than a fixed sleep) +# so the test is not sensitive to network speed or CLI download time. +# +# Run: bash tests/test_signal.sh +# Requires: debug build (cargo build), Unix. +# ------------------------------------------------------------------ +set -euo pipefail + +cd "$(dirname "$0")/.." +root="$(pwd)" + +echo "=== cleaning build dir ===" +rm -rf tmp 2>/dev/null +mkdir -p tmp/test parsers + +cargo build 2>&1 | tail -1 + +# ------------------------------------------------------------------ +# 4 parsers with --force, TSDL_TEST_DELAY_MS=500 gives each spawned +# child a 500ms window where it is definitely running. We poll the +# log for a spawned line, then send SIGTERM immediately. +# ------------------------------------------------------------------ +echo "=== starting tsdl (4 parsers, SIGTERM when builds underway) ===" +log_file="$root/tmp/test/test_signal.log" +TSDL_TEST_DELAY_MS=500 \ +./target/debug/tsdl -v b json rust toml php --force --progress plain \ + --log "$log_file" --log-color no & +pid=$! + +# Poll until a subprocess has been spawned. +echo "=== waiting for builds to start ===" +deadline=$(( $(date +%s) + 60 )) +while (( $(date +%s) < deadline )); do + if grep -q "spawned pid=Some(" "$log_file" 2>/dev/null; then + echo " build underway" + break + fi + sleep 0.5 +done + +if ! grep -q "spawned pid=Some(" "$log_file" 2>/dev/null; then + echo " [FAIL] build never started" + kill "$pid" 2>/dev/null || true + exit 1 +fi + +echo "" +echo "=== sending SIGTERM to $pid ===" +kill -TERM "$pid" + +sleep 5 + +if kill -0 "$pid" 2>/dev/null; then + echo "=== sending SIGKILL ===" + kill -KILL "$pid" +fi + +set +e +wait "$pid" 2>/dev/null +exit_code=$? +set -e + +echo "" +echo "=== exit code: $exit_code ===" +echo "" + +# ------------------------------------------------------------------ +# Report +# ------------------------------------------------------------------ +failures=0 + +if [[ -f "$log_file" ]]; then + echo "--- log assertions ---" + if grep -q "Received SIGTERM" "$log_file"; then + echo " [PASS] signal receipt" + else + echo " [FAIL] signal receipt — log: $log_file" + failures=$((failures + 1)) + fi + + if grep -q "SHUTDOWN sending SIGTERM" "$log_file"; then + echo " [PASS] signal forwarded to child" + else + echo " [FAIL] signal forwarded to child — log: $log_file" + failures=$((failures + 1)) + fi + + if grep -q "Command interrupted by SIGTERM" "$log_file"; then + echo " [PASS] interruption logged" + else + echo " [FAIL] interruption logged — log: $log_file" + failures=$((failures + 1)) + fi + + if grep -q "pipeline shutdown signalled" "$log_file"; then + echo " [PASS] pipeline suppression" + else + echo " [FAIL] pipeline suppression — log: $log_file" + failures=$((failures + 1)) + fi + + if grep -q "panicked" "$log_file"; then + echo " [FAIL] panic found in log — log: $log_file" + failures=$((failures + 1)) + else + echo " [PASS] no panics" + fi +else + echo " [FAIL] log file not created: $log_file" + failures=$((failures + 1)) +fi + +if (( exit_code == 143 )); then + echo " [PASS] exit code 143 (SIGTERM)" +else + echo " [FAIL] exit code $exit_code (expected 143)" + failures=$((failures + 1)) +fi + +if (( failures == 0 )); then + echo "" + echo "All assertions passed." + exit 0 +else + echo "" + echo "$failures assertion(s) FAILED." + exit 1 +fi diff --git a/typos.toml b/typos.toml new file mode 100644 index 0000000..372dc95 --- /dev/null +++ b/typos.toml @@ -0,0 +1,2 @@ +[default.extend-words] +ratatui = "ratatui"