From 1371a43d6fd54cd3c35bf42a5ccfe0ff487227b5 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:42:56 -0400 Subject: [PATCH 01/22] chore: bump dependencies (clap 4.6, tokio 1.52, toml 1.1, shlex 2) --- Cargo.lock | 505 +++++++++++++++++++++++------------------------------ Cargo.toml | 20 +-- 2 files changed, 232 insertions(+), 293 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59b1c40..66238c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -43,15 +43,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -62,7 +62,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -132,9 +132,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -206,9 +206,9 @@ dependencies = [ "semver", "serde", "serde_json", - "shlex", + "shlex 2.0.1", "tokio", - "toml 1.0.3+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", @@ -228,12 +228,12 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 1.3.0", ] [[package]] @@ -264,9 +264,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -274,9 +274,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -286,9 +286,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -298,15 +298,15 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" @@ -413,7 +413,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "crossterm_winapi", "derive_more", "document-features", @@ -554,7 +554,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -596,14 +596,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -620,9 +620,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filedescriptor" @@ -877,6 +877,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -930,9 +936,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -943,7 +949,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1014,12 +1019,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1027,9 +1033,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1040,9 +1046,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1054,15 +1060,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1074,15 +1080,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1118,9 +1124,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1144,12 +1150,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1165,9 +1171,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", @@ -1182,16 +1188,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1209,25 +1205,27 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "kasuari" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", @@ -1254,26 +1252,26 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -1284,9 +1282,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -1311,9 +1309,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -1357,14 +1355,14 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1390,7 +1388,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -1413,14 +1411,14 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1453,9 +1451,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1465,15 +1463,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1497,9 +1494,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -1652,17 +1649,11 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" @@ -1672,9 +1663,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1727,9 +1718,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "rand_core", ] @@ -1760,7 +1751,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "compact_str", "hashbrown 0.16.1", "indoc", @@ -1812,7 +1803,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.16.1", "indoc", "instability", @@ -1827,9 +1818,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -1851,7 +1842,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -1947,18 +1938,18 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] @@ -1986,11 +1977,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2005,7 +1996,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -2024,9 +2015,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -2088,9 +2079,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -2133,6 +2124,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -2166,9 +2163,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -2184,12 +2181,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -2231,6 +2228,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -2275,15 +2278,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2315,7 +2318,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.0", + "bitflags 2.11.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2433,9 +2436,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2443,9 +2446,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2455,14 +2458,14 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -2491,22 +2494,22 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml" -version = "1.0.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2520,27 +2523,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -2559,20 +2562,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2600,11 +2603,12 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", + "symlink", "thiserror 2.0.18", "time", "tracing-subscriber", @@ -2644,9 +2648,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -2664,11 +2668,12 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui-input" -version = "0.15.0" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c1ee964298f136020f5f69e0e601f4d3a1f610a7baf1af9fcb96152e8a2c45" +checksum = "0bd014a652e31cf25ea68d11b10a7b09549863449b19387505c9933f11eb05fa" dependencies = [ "ratatui", + "unicode-segmentation", "unicode-width", ] @@ -2685,9 +2690,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -2703,9 +2708,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -2756,9 +2761,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "atomic", "getrandom 0.4.2", @@ -2820,11 +2825,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -2833,14 +2838,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -2851,23 +2856,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2875,9 +2876,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -2888,9 +2889,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -2923,7 +2924,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -2931,9 +2932,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -3033,7 +3034,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -3101,15 +3102,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -3120,75 +3112,16 @@ dependencies = [ ] [[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", - "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]] -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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[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.53.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "wit-bindgen" @@ -3199,6 +3132,12 @@ 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" @@ -3248,7 +3187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -3280,15 +3219,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3297,9 +3236,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3309,18 +3248,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3336,9 +3275,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3347,9 +3286,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3358,9 +3297,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index b01c3ba..db1dd50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,28 +41,28 @@ module_name_repetitions = "allow" [dependencies] cargo-lock = "11.0.1" -clap = { version = "4.5.60", features = ["derive"] } +clap = { version = "4.6.1", features = ["derive"] } crates_io_api = "0.12.0" crossterm = { version = "0.29.0", features = ["event-stream"] } futures = "0.3.32" fuzzy-matcher = "0.3.7" glob = "0.3.3" ratatui = "0.30.0" -rayon = "1.11.0" +rayon = "1.12.0" serde = { version = "1.0.228", features = ["derive"] } -tokio = { version = "1.49.0", features = ["full"] } -toml = "1.0.3" -once_cell = "1.21.3" -tui-input = "0.15.0" +tokio = { version = "1.52.3", features = ["full"] } +toml = "1.1.2" +once_cell = "1.21.4" +tui-input = "0.15.3" tui-tree-widget = "0.24.0" walkdir = "2.5.0" chrono = "0.4.44" anyhow = "1.0.102" -semver = "1.0.27" +semver = "1.0.28" directories = "6.0.0" serde_json = "1.0.149" ignore = "0.4.25" -tracing-subscriber = "0.3.22" -tracing-appender = "0.2.4" +tracing-subscriber = "0.3.23" +tracing-appender = "0.2.5" tracing = "0.1.44" -shlex = "1.3.0" +shlex = "2.0.1" From 57cb144e7581078256eb909655045e6a10514d5d Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:42:57 -0400 Subject: [PATCH 02/22] refactor: manual per-project update checks, global per-dep version cache, path-keyed projects, Gestalt default theme --- src/app.rs | 243 +++++++++++--- src/cache.rs | 209 +++++++++++- src/components/dependencies.rs | 31 +- src/components/help.rs | 6 +- src/components/mod.rs | 2 - src/components/output.rs | 52 +-- src/components/palette.rs | 32 +- src/components/progress.rs | 3 +- src/components/projects.rs | 13 +- src/components/settings.rs | 61 ++-- src/components/status.rs | 11 +- src/components/text_input.rs | 5 +- src/components/tree_view.rs | 72 +++-- src/components/updater.rs | 87 +++-- src/config/keybinding_config.rs | 4 - src/config/mod.rs | 7 +- src/config/theme_config.rs | 23 +- src/events.rs | 27 +- src/handlers.rs | 432 ++++++++++++------------- src/main.rs | 553 +++++++++++++------------------- src/project.rs | 358 +++++++++++++++++---- src/runner.rs | 434 +++++++++++++------------ src/settings.rs | 3 - src/tree.rs | 22 +- src/ui/styles.rs | 19 +- src/ui/theme.rs | 8 +- 26 files changed, 1621 insertions(+), 1096 deletions(-) diff --git a/src/app.rs b/src/app.rs index ac3463d..73c725d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,10 +10,10 @@ use crate::components::{ use crate::config::Config; use crate::events::{Action, Focus, Mode}; use crate::project::Project; -use crate::runner::UpdateQueue; use crate::tree::{FlattenedTree, TreeNode, TreeSelectionState}; use ratatui::widgets::ListState; use std::collections::HashSet; +use std::path::{Path, PathBuf}; /// Represents the complete state of the CarWash application /// @@ -44,7 +44,7 @@ pub struct AppState { /// Set of workspace names that are collapsed in the tree view pub collapsed_workspaces: HashSet, /// Set of selected project paths - pub selected_projects: HashSet, + pub selected_projects: HashSet, /// Tab panes for command output pub tabs: Vec, /// Index of the currently active tab @@ -57,8 +57,6 @@ pub struct AppState { pub updater: UpdateWizardState, /// State of text input fields pub text_input: TextInputState, - /// Queue of pending update checks - pub update_queue: UpdateQueue, /// Modal state for editing settings pub settings_modal: SettingsModalState, /// Filter/search state @@ -125,7 +123,6 @@ impl Default for AppState { palette: CommandPaletteState::new(), updater: UpdateWizardState::new(), text_input: TextInputState::new(), - update_queue: UpdateQueue::new(), settings_modal: SettingsModalState::new(), filter: FilterState::new(), config: Config::load(), @@ -167,12 +164,12 @@ impl AppState { if let crate::tree::TreeNodeType::Project(tree_project) = &node.node_type { // IMPORTANT: The tree contains cloned copies of projects, // but dependencies are updated in all_projects. - // So we need to look up the project by name in all_projects + // So we need to look up the project by path in all_projects // to get the current state with updated dependencies. return self .all_projects .iter() - .find(|p| p.name == tree_project.name); + .find(|p| p.path == tree_project.path); } } } @@ -218,6 +215,23 @@ impl AppState { .map(|(node, _)| node) } + fn find_tree_node_by_path<'a>( + node: &'a crate::tree::TreeNode, + path: &Path, + ) -> Option<&'a crate::tree::TreeNode> { + if node.node_type.path() == path { + return Some(node); + } + + for child in &node.children { + if let Some(found) = Self::find_tree_node_by_path(child, path) { + return Some(found); + } + } + + None + } + /// Get all projects under the currently selected node /// - If cursor is on a project: returns just that project /// - If cursor is on a directory: returns all projects under that directory @@ -228,16 +242,23 @@ impl AppState { // Return the actual project from all_projects (has updated dependencies) self.all_projects .iter() - .filter(|p| p.name == tree_project.name) + .filter(|p| p.path == tree_project.path) .collect() } crate::tree::TreeNodeType::Directory { .. } => { - // Collect all projects under this directory - let tree_projects = node.collect_projects(); + // Flattened directory nodes intentionally do not carry children. + // Resolve the selected path back into the real tree before collecting. + let tree_projects = self + .tree_root + .as_ref() + .and_then(|root| Self::find_tree_node_by_path(root, node.node_type.path())) + .map(|node| node.collect_projects()) + .unwrap_or_default(); + // Map to actual projects from all_projects (has updated dependencies) tree_projects .into_iter() - .filter_map(|tp| self.all_projects.iter().find(|p| p.name == tp.name)) + .filter_map(|tp| self.all_projects.iter().find(|p| p.path == tp.path)) .collect() } } @@ -275,21 +296,19 @@ pub fn reducer(state: &mut AppState, action: Action) { Action::PaletteSelectPrevious => handle_palette_select_previous(state), Action::StartUpdateWizard => handle_start_update_wizard(state), Action::ToggleUpdateSelection => handle_toggle_update_selection(state), - Action::CheckForUpdates => handle_check_for_updates(state), Action::SettingsUpdateCacheInput(input) => handle_settings_update_cache_input(state, input), - Action::SettingsToggleBackground => handle_settings_toggle_background(state), Action::SaveSettings => handle_save_settings(state), - Action::UpdateDependencies(project_name, deps) => { - handle_update_dependencies(state, project_name, deps) + Action::UpdateDependencies(project_id, deps) => { + handle_update_dependencies(state, project_id, deps) } - Action::UpdateDependenciesStreamStart(project_name) => { - handle_update_dependencies_stream_start(state, project_name) + Action::UpdateDependenciesStreamStart(project_id) => { + handle_update_dependencies_stream_start(state, project_id) } - Action::UpdateSingleDependency(project_name, dep) => { - handle_update_single_dependency(state, project_name, dep) + Action::UpdateSingleDependency(project_id, dep) => { + handle_update_single_dependency(state, project_id, dep) } - Action::UpdateDependencyCheckStatus(project_name, dep_name, status) => { - handle_update_dependency_status(state, Some(project_name), dep_name, status) + Action::UpdateDependencyCheckStatus(project_id, dep_name, status) => { + handle_update_dependency_status(state, Some(project_id), dep_name, status) } Action::CreateTab(title) => handle_create_tab(state, title), Action::AddOutput(tab_index, line) => handle_add_output(state, tab_index, line), @@ -301,20 +320,8 @@ pub fn reducer(state: &mut AppState, action: Action) { Action::RunUpdate => { // Update execution is handled in main event loop } - Action::StartBackgroundUpdateCheck => { - // Background update check is handled in main event loop - } - Action::UpdateDependencyStatus(dep_name, status) => { - handle_update_dependency_status(state, None, dep_name, status) - } - Action::ProcessBackgroundUpdateQueue => { - // Background update queue processing is handled in main event loop - } - Action::QueueBackgroundUpdate(project_name, is_priority) => { - handle_queue_background_update(state, project_name, is_priority) - } - Action::UpdateProjectCheckStatus(project_name, check_status) => { - handle_update_project_check_status(state, project_name, check_status) + Action::UpdateProjectCheckStatus(project_id, check_status) => { + handle_update_project_check_status(state, project_id, check_status) } Action::EnterFilterMode => handle_enter_filter_mode(state), Action::ExitFilterMode => handle_exit_filter_mode(state), @@ -332,8 +339,8 @@ pub fn reducer(state: &mut AppState, action: Action) { Action::CalculateProjectSizes => { // Size calculation is handled in main event loop (async) } - Action::UpdateProjectSize(project_name, total_size, target_size) => { - handle_update_project_size(state, project_name, total_size, target_size) + Action::UpdateProjectSize(project_id, total_size, target_size) => { + handle_update_project_size(state, project_id, total_size, target_size) } Action::FocusNext => handle_focus_next(state), Action::InitializeTree(target_dir) => handle_initialize_tree(state, target_dir), @@ -348,13 +355,17 @@ pub fn reducer(state: &mut AppState, action: Action) { mod tests { use super::*; use crate::events::{Action, Mode}; - use crate::project::{Project, ProjectStatus}; + use crate::project::{Dependency, DependencyCheckStatus, Project, ProjectStatus}; use std::path::PathBuf; fn create_test_project(name: &str) -> Project { + create_test_project_at(name, "test") + } + + fn create_test_project_at(name: &str, path: &str) -> Project { Project { name: name.to_string(), - path: PathBuf::from("test"), + path: PathBuf::from(path), status: ProjectStatus::Pending, version: "0.1.0".to_string(), authors: vec![], @@ -438,11 +449,161 @@ mod tests { // Select the project reducer(&mut state, Action::ToggleSelection); - assert!(state.selected_projects.contains("test1")); + assert!(state.selected_projects.contains(&PathBuf::from("test"))); // Deselect the project reducer(&mut state, Action::ToggleSelection); - assert!(!state.selected_projects.contains("test1")); + assert!(!state.selected_projects.contains(&PathBuf::from("test"))); + } + + #[test] + fn test_duplicate_project_names_are_selected_by_path() { + let mut state = AppState::new(); + let project_a = create_test_project_at("shared", "/repo/a"); + let project_b = create_test_project_at("shared", "/repo/b"); + state.all_projects = vec![project_a.clone(), project_b.clone()]; + + let project_node = crate::tree::TreeNode::project(project_b.clone(), 0); + let mut flattened = crate::tree::FlattenedTree::new(); + flattened.items.push((project_node, 0)); + state.flattened_tree = flattened; + state.tree_state.select(Some(0)); + + let selected = state.get_selected_project().expect("selected project"); + assert_eq!(selected.path, project_b.path); + + reducer(&mut state, Action::ToggleSelection); + assert!(state.selected_projects.contains(&project_b.path)); + assert!(!state.selected_projects.contains(&project_a.path)); + } + + #[test] + fn test_start_update_wizard_ignores_directory_selection() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + state.is_scanning = false; + + let directory = + crate::tree::TreeNode::directory("repo".to_string(), PathBuf::from("/repo"), 0); + let mut flattened = crate::tree::FlattenedTree::new(); + flattened.items.push((directory, 0)); + state.flattened_tree = flattened; + state.tree_state.select(Some(0)); + + reducer(&mut state, Action::StartUpdateWizard); + + assert_eq!(state.mode, Mode::Normal); + assert!(!state.is_checking_updates); + assert!(state.updater.locked_project_id.is_none()); + } + + #[test] + fn test_start_update_wizard_locks_selected_project_by_path() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + state.is_scanning = false; + + let project = create_test_project_at("shared", "/repo/project-a"); + state.all_projects = vec![project.clone()]; + state.projects = vec![project.clone()]; + + let project_node = crate::tree::TreeNode::project(project.clone(), 0); + let mut flattened = crate::tree::FlattenedTree::new(); + flattened.items.push((project_node, 0)); + state.flattened_tree = flattened; + state.tree_state.select(Some(0)); + + reducer(&mut state, Action::StartUpdateWizard); + + assert_eq!(state.mode, Mode::UpdateWizard); + assert!(state.is_checking_updates); + assert_eq!( + state.updater.locked_project_id.as_deref(), + Some(project.path.as_path()) + ); + } + + #[test] + fn test_duplicate_project_names_update_by_path() { + let mut state = AppState::new(); + let mut project_a = create_test_project_at("shared", "/repo/a"); + let mut project_b = create_test_project_at("shared", "/repo/b"); + project_a.dependencies = vec![Dependency { + name: "serde".to_string(), + current_version: "1.0.0".to_string(), + latest_version: None, + check_status: DependencyCheckStatus::NotChecked, + last_checked: None, + }]; + project_b.dependencies = vec![Dependency { + name: "serde".to_string(), + current_version: "1.0.0".to_string(), + latest_version: None, + check_status: DependencyCheckStatus::NotChecked, + last_checked: None, + }]; + state.all_projects = vec![project_a.clone(), project_b.clone()]; + state.projects = vec![project_a.clone(), project_b.clone()]; + state.tree_root = Some(crate::tree::TreeNode { + node_type: crate::tree::TreeNodeType::Directory { + name: "repo".to_string(), + path: PathBuf::from("/repo"), + }, + children: vec![ + crate::tree::TreeNode::project(project_a.clone(), 1), + crate::tree::TreeNode::project(project_b.clone(), 1), + ], + expanded: true, + children_loaded: true, + loading: false, + depth: 0, + }); + + let updated_dep = Dependency { + name: "serde".to_string(), + current_version: "1.0.0".to_string(), + latest_version: Some("1.0.1".to_string()), + check_status: DependencyCheckStatus::Checked, + last_checked: None, + }; + reducer( + &mut state, + Action::UpdateSingleDependency(project_b.path.clone(), updated_dep), + ); + + assert_eq!(state.all_projects[0].dependencies[0].latest_version, None); + assert_eq!( + state.all_projects[1].dependencies[0] + .latest_version + .as_deref(), + Some("1.0.1") + ); + } + + #[test] + fn test_directory_selection_uses_real_tree_children() { + let mut state = AppState::new(); + let project = create_test_project_at("nested", "/repo/nested"); + let root = crate::tree::TreeNode { + node_type: crate::tree::TreeNodeType::Directory { + name: "repo".to_string(), + path: PathBuf::from("/repo"), + }, + children: vec![crate::tree::TreeNode::project(project.clone(), 1)], + expanded: true, + children_loaded: true, + loading: false, + depth: 0, + }; + + state.all_projects = vec![project.clone()]; + state.tree_root = Some(root.clone()); + state.flattened_tree = crate::tree::FlattenedTree::from_tree(&root); + state.tree_state.select(Some(0)); + + let projects = state.get_projects_under_selected(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].path, project.path); } #[test] diff --git a/src/cache.rs b/src/cache.rs index 5a0b166..e558c3c 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -29,6 +29,15 @@ pub struct ProjectCache { pub dependencies: HashMap, } +/// Cross-platform cache directory for carwash +fn app_cache_dir() -> PathBuf { + if let Some(dirs) = directories::ProjectDirs::from("com", "epistates", "carwash") { + dirs.cache_dir().to_path_buf() + } else { + PathBuf::from("/tmp/carwash-cache") + } +} + /// Manages cross-platform caching of update check results #[derive(Clone)] pub struct UpdateCache { @@ -38,16 +47,9 @@ pub struct UpdateCache { impl UpdateCache { /// Create a new cache manager pub fn new() -> Self { - let cache_dir = if let Some(cache_base) = - directories::ProjectDirs::from("com", "epistates", "carwash") - { - cache_base.cache_dir().to_path_buf() - } else { - // Fallback to temp directory if project dirs not available - std::path::PathBuf::from("/tmp/carwash-cache") - }; - - Self { cache_dir } + Self { + cache_dir: app_cache_dir(), + } } /// Compute hash of a Cargo.lock file @@ -125,6 +127,136 @@ impl UpdateCache { } } +/// A single cached dependency version lookup result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DepVersionEntry { + /// The latest version available on crates.io + pub latest: String, + /// Unix timestamp (seconds) when this was checked + pub checked_at: u64, +} + +/// Global per-dependency version cache. +/// +/// Keyed by `"name@current_version"` so the same dependency at the same pinned +/// version is never checked twice across any number of projects. +/// When Cargo.lock changes a dep's version, the key changes and a fresh check occurs. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DependencyVersionCache { + entries: HashMap, +} + +impl DependencyVersionCache { + fn cache_key(name: &str, current_version: &str) -> String { + format!("{}@{}", name, current_version) + } + + fn cache_file() -> PathBuf { + app_cache_dir().join("dep_versions.json") + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Load the global dep version cache from disk + pub fn load() -> Self { + let path = Self::cache_file(); + let contents = match fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Self::default(), + }; + serde_json::from_str(&contents).unwrap_or_default() + } + + /// Evict entries older than `max_age_secs` + pub fn evict_stale(&mut self, max_age_secs: u64) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.entries + .retain(|_, entry| now.saturating_sub(entry.checked_at) <= max_age_secs); + } + + /// Save the global dep version cache to disk (evicts entries older than 7 days) + pub fn save(&mut self) -> Result<()> { + self.evict_stale(7 * 24 * 3600); + let dir = app_cache_dir(); + fs::create_dir_all(&dir).context("Failed to create cache directory")?; + let json = serde_json::to_string(self).context("Failed to serialize dep cache")?; + fs::write(Self::cache_file(), json).context("Failed to write dep cache")?; + Ok(()) + } + + /// Look up a cached latest version for a dependency. + /// Returns `None` if not cached or if the entry is older than `max_age`. + pub fn lookup( + &self, + name: &str, + current_version: &str, + max_age: std::time::Duration, + ) -> Option<&DepVersionEntry> { + let key = Self::cache_key(name, current_version); + let entry = self.entries.get(&key)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if now.saturating_sub(entry.checked_at) > max_age.as_secs() { + None + } else { + Some(entry) + } + } + + /// Insert or update a cached version entry + pub fn insert(&mut self, name: &str, current_version: &str, latest: String) { + let key = Self::cache_key(name, current_version); + let checked_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.entries + .insert(key, DepVersionEntry { latest, checked_at }); + } + + /// One-time migration: read old per-project cache files and convert to per-dep format. + /// Deletes old files after successful migration. + pub fn migrate_from_project_caches() -> Self { + let dir = app_cache_dir(); + let cache = Self::default(); + + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => return cache, + }; + + let mut old_files = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name.starts_with("project_") + && name.ends_with(".json") + && let Ok(contents) = fs::read_to_string(&path) + && serde_json::from_str::(&contents).is_ok() + { + // Old per-project cache file — can't migrate entries because the old format + // doesn't store current_version (needed for the new key). Mark for cleanup; + // deps will be re-checked on first use. + old_files.push(path); + } + } + + // Clean up old cache files + for file in old_files { + let _ = fs::remove_file(file); + } + + cache + } +} + #[cfg(test)] mod tests { use super::*; @@ -151,4 +283,61 @@ mod tests { let cache2 = cache.get_cache_path(path2); assert_ne!(cache1, cache2); } + + #[test] + fn test_dep_version_cache_insert_and_lookup() { + let mut cache = DependencyVersionCache::default(); + cache.insert("serde", "1.0.200", "1.0.228".to_string()); + + let entry = cache + .lookup("serde", "1.0.200", std::time::Duration::from_secs(300)) + .expect("should find entry"); + assert_eq!(entry.latest, "1.0.228"); + } + + #[test] + fn test_dep_version_cache_different_versions() { + let mut cache = DependencyVersionCache::default(); + cache.insert("serde", "1.0.200", "1.0.228".to_string()); + cache.insert("serde", "1.0.210", "1.0.228".to_string()); + + // Both entries exist independently + assert!( + cache + .lookup("serde", "1.0.200", std::time::Duration::from_secs(300)) + .is_some() + ); + assert!( + cache + .lookup("serde", "1.0.210", std::time::Duration::from_secs(300)) + .is_some() + ); + + // Different version key returns None + assert!( + cache + .lookup("serde", "1.0.100", std::time::Duration::from_secs(300)) + .is_none() + ); + } + + #[test] + fn test_dep_version_cache_expired() { + let mut cache = DependencyVersionCache::default(); + let key = DependencyVersionCache::cache_key("tokio", "1.42.0"); + cache.entries.insert( + key, + DepVersionEntry { + latest: "1.43.0".to_string(), + checked_at: 0, // epoch = very old + }, + ); + + // Should be expired with any reasonable max_age + assert!( + cache + .lookup("tokio", "1.42.0", std::time::Duration::from_secs(300)) + .is_none() + ); + } } diff --git a/src/components/dependencies.rs b/src/components/dependencies.rs index 2bc6ab6..e1c1ec4 100644 --- a/src/components/dependencies.rs +++ b/src/components/dependencies.rs @@ -51,14 +51,20 @@ impl DependenciesPane { &dep.current_version, Style::default().fg(Color::DarkGray), ), - ratatui::text::Span::styled(" → ", Style::default().fg(Color::Yellow)), - ratatui::text::Span::styled( - dep.latest_version.as_ref().unwrap(), + ]; + + if let Some(ref latest) = dep.latest_version { + spans.push(ratatui::text::Span::styled( + " → ", + Style::default().fg(Color::Yellow), + )); + spans.push(ratatui::text::Span::styled( + latest, Style::default() .fg(Color::Yellow) .add_modifier(ratatui::style::Modifier::BOLD), - ), - ]; + )); + } // Add note for major version updates that require Cargo.toml change if let Some(note) = dep.update_note() { @@ -143,6 +149,7 @@ impl Component for DependenciesPane { } fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { + let colors = app.current_colors(); if let Some(p) = app.get_selected_project() { let mut outdated_count = 0; let mut not_checked_count = 0; @@ -168,7 +175,7 @@ impl Component for DependenciesPane { }) .collect(); - let (title, title_style) = Self::get_title( + let (title, _) = Self::get_title( &p.dependencies, outdated_count, not_checked_count, @@ -176,17 +183,18 @@ impl Component for DependenciesPane { ); // Highlight border when focused - let border_style = if app.focus == Focus::Dependencies { - Style::default().fg(Color::Cyan) + let border_color = if app.focus == Focus::Dependencies { + colors.selection } else { - title_style + colors.primary }; let dependency_list = List::new(dependency_items).block( Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .title(title) - .border_style(border_style), + .border_style(Style::default().fg(border_color)), ); f.render_widget(dependency_list, area); @@ -196,9 +204,10 @@ impl Component for DependenciesPane { .block( Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .title(" Dependencies "), ) - .style(Style::default().fg(Color::DarkGray)); + .style(Style::default().fg(colors.muted)); f.render_widget(empty, area); } } diff --git a/src/components/help.rs b/src/components/help.rs index 474cf03..58f9229 100644 --- a/src/components/help.rs +++ b/src/components/help.rs @@ -234,7 +234,11 @@ impl Component for Help { self.scroll = self.scroll.min(max_scroll); let help_para = Paragraph::new(help_lines) - .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)) + .block( + Block::default() + .borders(Borders::LEFT | Borders::RIGHT) + .border_type(ratatui::widgets::BorderType::Rounded), + ) .wrap(Wrap { trim: false }) .scroll((self.scroll as u16, 0)); diff --git a/src/components/mod.rs b/src/components/mod.rs index 0c87d7c..bdfa0ad 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -23,8 +23,6 @@ pub mod tree_view; pub mod updater; // Re-export commonly used types -pub use updater::PendingDirectoryCheck; - /// Trait for UI components in CarWash /// /// All UI components implement this trait to provide a consistent interface for diff --git a/src/components/output.rs b/src/components/output.rs index deb3414..05d758d 100644 --- a/src/components/output.rs +++ b/src/components/output.rs @@ -5,7 +5,7 @@ use crossterm::event::KeyCode; use ratatui::{ Frame, layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{ Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs, Wrap, @@ -74,6 +74,7 @@ impl Component for TabbedOutputPane { } fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { + let colors = app.current_colors(); if area.height < 4 { return; } @@ -95,12 +96,12 @@ impl Component for TabbedOutputPane { .map(|(i, t)| { let style = if i == app.active_tab { Style::default() - .fg(Color::Yellow) + .fg(colors.warning) .add_modifier(Modifier::BOLD) } else if t.is_finished { - Style::default().fg(Color::Green) + Style::default().fg(colors.success) } else { - Style::default().fg(Color::Cyan) + Style::default().fg(colors.selection) }; let prefix = if t.is_finished { "✓ " } else { "⚙ " }; @@ -109,10 +110,10 @@ impl Component for TabbedOutputPane { .collect(); // Highlight border when focused - let border_style = if app.focus == Focus::Output { - Style::default().fg(Color::Cyan) + let border_color = if app.focus == Focus::Output { + colors.selection } else { - Style::default() + colors.primary }; // Add tab indicator showing current/total tabs @@ -126,19 +127,24 @@ impl Component for TabbedOutputPane { .block( Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .title(tab_indicator) - .border_style(border_style), + .border_style(Style::default().fg(border_color)), ) .select(app.active_tab) .style(Style::default()) .highlight_style( Style::default() - .fg(Color::Yellow) + .fg(colors.warning) .add_modifier(Modifier::BOLD), ); f.render_widget(tabs, chunks[0]); } else { - let empty_block = Block::default().borders(Borders::ALL).title(" Output "); + let empty_block = Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .title(" Output ") + .border_style(Style::default().fg(colors.primary)); f.render_widget(empty_block, chunks[0]); } @@ -163,16 +169,16 @@ impl Component for TabbedOutputPane { || line.contains("Error") || line.contains("ERROR") { - Style::default().fg(Color::Red) + Style::default().fg(colors.error) } else if line.contains("warning") || line.contains("Warning") || line.contains("WARN") { - Style::default().fg(Color::Yellow) + Style::default().fg(colors.warning) } else if line.contains("Finished") || line.contains("success") { - Style::default().fg(Color::Green) + Style::default().fg(colors.success) } else if line.starts_with(" Compiling") || line.starts_with(" Checking") { - Style::default().fg(Color::Cyan) + Style::default().fg(colors.selection) } else { Style::default() }; @@ -198,12 +204,13 @@ impl Component for TabbedOutputPane { .block( Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .title(status_info) - .border_style(if active_tab.is_finished { - Style::default().fg(Color::Green) + .border_style(Style::default().fg(if active_tab.is_finished { + colors.success } else { - Style::default().fg(Color::Cyan) - }), + colors.selection + })), ) .wrap(Wrap { trim: false }); @@ -234,8 +241,13 @@ impl Component for TabbedOutputPane { let empty_para = Paragraph::new( "No commands running.\n\nPress ':' to open command palette and run cargo commands.", ) - .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::DarkGray)); + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(colors.primary)), + ) + .style(Style::default().fg(colors.muted)); f.render_widget(empty_para, chunks[1]); } } diff --git a/src/components/palette.rs b/src/components/palette.rs index 01e867d..48c3e0f 100644 --- a/src/components/palette.rs +++ b/src/components/palette.rs @@ -6,7 +6,7 @@ use crossterm::event::KeyCode; use ratatui::{ Frame, layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, }; @@ -70,6 +70,7 @@ impl Component for CommandPalette { } fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { + let colors = app.current_colors(); // Center the palette let popup_area = Self::centered_rect(60, 60, area); @@ -86,12 +87,13 @@ impl Component for CommandPalette { // Input box let input_box = Paragraph::new(app.palette.input.value()) - .style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(colors.warning)) .block( Block::default() .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .title(" Command ") - .border_style(Style::default().fg(Color::Cyan)), + .border_style(Style::default().fg(colors.selection)), ); f.render_widget(input_box, chunks[0]); @@ -106,10 +108,10 @@ impl Component for CommandPalette { Span::styled( "cargo ", Style::default() - .fg(Color::Cyan) + .fg(colors.selection) .add_modifier(Modifier::BOLD), ), - Span::styled(command, Style::default().fg(Color::White)), + Span::styled(command, Style::default().fg(colors.text)), ]), _ => Line::from(format!("{:?}", cmd)), }; @@ -128,13 +130,10 @@ impl Component for CommandPalette { Block::default() .title(list_title) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ) - .highlight_style( - Style::default() - .bg(Color::Rgb(60, 60, 80)) - .add_modifier(Modifier::BOLD), + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(colors.selection)), ) + .highlight_style(Style::default().bg(colors.dim).add_modifier(Modifier::BOLD)) .highlight_symbol("▶ "); f.render_stateful_widget(list, chunks[1], &mut app.palette.list_state); @@ -148,12 +147,17 @@ impl Component for CommandPalette { let help = Paragraph::new(selected_info) .style(if app.selected_projects.is_empty() { - Style::default().fg(Color::Yellow) + Style::default().fg(colors.warning) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(colors.muted) }) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(colors.primary)), + ); f.render_widget(help, chunks[2]); } } diff --git a/src/components/progress.rs b/src/components/progress.rs index 257716f..960b80a 100644 --- a/src/components/progress.rs +++ b/src/components/progress.rs @@ -196,7 +196,8 @@ impl ProgressComponent { .block( Block::default() .title(format!(" {} ({}/{}) ", state.title, completed, state.total)) - .borders(Borders::ALL), + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded), ) .gauge_style(Style::default().fg(colors.success)) .percent(overall) diff --git a/src/components/projects.rs b/src/components/projects.rs index dd14965..41630c4 100644 --- a/src/components/projects.rs +++ b/src/components/projects.rs @@ -63,7 +63,7 @@ impl ProjectList { impl ProjectList { /// Create a list item for a project from the tree (with indentation) fn create_tree_project_item<'a>( - selected_projects: &'a std::collections::HashSet, + selected_projects: &'a std::collections::HashSet, project: &'a crate::project::Project, depth: usize, is_selected: bool, @@ -71,7 +71,7 @@ impl ProjectList { ) -> ListItem<'a> { let (status_icon, status_style) = Self::get_project_status(project, colors); - let is_checked = selected_projects.contains(&project.name); + let is_checked = selected_projects.contains(&project.path); let checkbox_symbol = if is_checked { "☑" } else { "☐" }; let checkbox_style = if is_checked { Style::default().fg(colors.success) @@ -97,7 +97,7 @@ impl ProjectList { ratatui::text::Span::styled(checkbox_symbol, checkbox_style), ratatui::text::Span::raw(" "), ratatui::text::Span::styled(status_icon, status_style), - ratatui::text::Span::raw(" "), + ratatui::text::Span::raw(" 🦀 "), ratatui::text::Span::styled(&project.name, name_style), ]; @@ -167,6 +167,7 @@ impl Component for ProjectList { // Render directory node with expand/collapse indicator let indicator = if is_selected { "▶ " } else { " " }; let collapse_indicator = if node.expanded { "▾" } else { "▸" }; + let icon = if node.expanded { "📂" } else { "📁" }; let indent = " ".repeat(node.depth); // Check if any children are selected @@ -178,12 +179,12 @@ impl Component for ProjectList { .take_while(|(child_node, _)| child_node.depth > node.depth) .filter_map(|(child_node, _)| { if let crate::tree::TreeNodeType::Project(p) = &child_node.node_type { - Some(&p.name) + Some(&p.path) } else { None } }) - .any(|name| app.selected_projects.contains(name)); + .any(|path| app.selected_projects.contains(path)); let checkbox_symbol = if has_selected_children { "☑" } else { "☐" }; let checkbox_style = if has_selected_children { @@ -206,7 +207,7 @@ impl Component for ProjectList { ratatui::text::Span::styled(checkbox_symbol, checkbox_style), ratatui::text::Span::raw(" "), ratatui::text::Span::styled( - format!("{} {}", collapse_indicator, name), + format!("{} {} {}", collapse_indicator, icon, name), style, ), ]))); diff --git a/src/components/settings.rs b/src/components/settings.rs index bee3e10..a32d2a1 100644 --- a/src/components/settings.rs +++ b/src/components/settings.rs @@ -5,7 +5,7 @@ use crossterm::event::KeyCode; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, }; @@ -14,7 +14,6 @@ use tui_input::{Input, backend::crossterm::EventHandler}; #[derive(Debug, Clone)] pub struct SettingsModalState { pub cache_minutes_input: Input, - pub background_updates_enabled: bool, pub error_message: Option, } @@ -22,7 +21,6 @@ impl SettingsModalState { pub fn new() -> Self { Self { cache_minutes_input: Input::default(), - background_updates_enabled: false, error_message: None, } } @@ -61,9 +59,6 @@ impl Component for SettingsModal { match key { KeyCode::Esc => Some(Action::CloseSettings), KeyCode::Enter => Some(Action::SaveSettings), - KeyCode::Char(' ') | KeyCode::Char('b') | KeyCode::Char('B') => { - Some(Action::SettingsToggleBackground) - } _ => { let mut input = app.settings_modal.cache_minutes_input.clone(); if input @@ -79,6 +74,7 @@ impl Component for SettingsModal { } fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { + let colors = app.current_colors(); let popup_area = Self::centered_rect(60, 50, area); f.render_widget(Clear, popup_area); @@ -86,7 +82,6 @@ impl Component for SettingsModal { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Min(3), @@ -96,58 +91,41 @@ impl Component for SettingsModal { let title = Block::default() .title(" Settings ") - .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) .border_style( Style::default() - .fg(Color::Magenta) + .fg(colors.selection) .add_modifier(Modifier::BOLD), ); f.render_widget(title, chunks[0]); - let background_status = if app.settings_modal.background_updates_enabled { - Span::styled( - "Enabled", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::styled("Disabled", Style::default().fg(Color::DarkGray)) - }; - - let background_line = Line::from(vec![ - Span::styled(" Background updates ", Style::default().fg(Color::White)), - Span::raw(" "), - background_status, - Span::raw(" (Space) toggle"), - ]); - - let background_para = Paragraph::new(background_line) - .alignment(Alignment::Left) - .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)); - f.render_widget(background_para, chunks[1]); - let cache_label = Line::from(vec![ - Span::styled(" Cache TTL (minutes) ", Style::default().fg(Color::White)), + Span::styled(" Cache TTL (minutes) ", Style::default().fg(colors.text)), Span::raw(" "), Span::styled( app.settings_modal.cache_minutes_input.value(), Style::default() - .fg(Color::Yellow) + .fg(colors.warning) .add_modifier(Modifier::BOLD), ), ]); let cache_para = Paragraph::new(cache_label) .alignment(Alignment::Left) - .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)); - f.render_widget(cache_para, chunks[2]); + .block( + Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(colors.dim)), + ); + f.render_widget(cache_para, chunks[1]); let mut lines = vec![Line::from(vec![ - Span::styled(" Enter", Style::default().fg(Color::Green)), + Span::styled(" Enter", Style::default().fg(colors.success)), Span::raw(": Save"), Span::raw(" "), - Span::styled("Esc", Style::default().fg(Color::Red)), + Span::styled("Esc", Style::default().fg(colors.error)), Span::raw(": Cancel"), ])]; @@ -155,7 +133,7 @@ impl Component for SettingsModal { lines.push(Line::from(vec![Span::styled( error.as_str(), Style::default() - .fg(Color::Red) + .fg(colors.error) .add_modifier(Modifier::BOLD | Modifier::ITALIC), )])); } @@ -163,8 +141,9 @@ impl Component for SettingsModal { let help_para = Paragraph::new(lines).alignment(Alignment::Center).block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)), + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(colors.selection)), ); - f.render_widget(help_para, chunks[4]); + f.render_widget(help_para, chunks[3]); } } diff --git a/src/components/status.rs b/src/components/status.rs index d4de4c8..1e2f42f 100644 --- a/src/components/status.rs +++ b/src/components/status.rs @@ -50,7 +50,7 @@ impl Component for StatusBar { Mode::Settings => ( "SETTINGS", Color::Magenta, - "Digits set cache | Space toggles background | Enter save | Esc cancel", + "Digits set cache | Enter save | Esc cancel", ), Mode::TextInput => ("INPUT", Color::Blue, "Enter confirm | Esc cancel"), Mode::Help => ("HELP", Color::Yellow, "Esc or 'q' to close"), @@ -92,15 +92,6 @@ impl Component for StatusBar { )); } - // Pending background tasks indicator - let pending_count = app.update_queue.queue.len(); - if pending_count > 0 { - bg_spans.push(Span::styled( - format!(" {} queued ", pending_count), - Style::default().fg(Color::DarkGray), - )); - } - // Focus indicator - only show in Normal mode let focus_info = if app.mode == Mode::Normal { let (icon, label) = match app.focus { diff --git a/src/components/text_input.rs b/src/components/text_input.rs index 638bfcd..d13ffda 100644 --- a/src/components/text_input.rs +++ b/src/components/text_input.rs @@ -5,7 +5,7 @@ use crossterm::event::KeyCode; use ratatui::{ Frame, layout::Rect, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, BorderType, Borders, Clear, Paragraph}, }; use tui_input::{Input, backend::crossterm::EventHandler}; @@ -58,7 +58,8 @@ impl Component for TextInput { f.render_widget(Clear, area); let block = Block::default() .title(app.text_input.title.as_str()) - .borders(Borders::ALL); + .borders(Borders::ALL) + .border_type(BorderType::Rounded); let para = Paragraph::new(app.text_input.input.value()).block(block); f.render_widget(para, area); } diff --git a/src/components/tree_view.rs b/src/components/tree_view.rs index b939cfb..c284350 100644 --- a/src/components/tree_view.rs +++ b/src/components/tree_view.rs @@ -35,6 +35,7 @@ impl TreeView { match &node.node_type { TreeNodeType::Directory { name, .. } => { + let icon = if node.expanded { "📂" } else { "📁" }; let indicator = if node.loading { // Show spinner when loading "⠹".to_string() @@ -43,9 +44,14 @@ impl TreeView { } else { "▸".to_string() }; - (format!("{}{} {}", indent, indicator, name), node.loading) + ( + format!("{}{} {} {}", indent, indicator, icon, name), + node.loading, + ) + } + TreeNodeType::Project(_) => { + (format!("{} 🦀 {}", indent, node.node_type.name()), false) } - TreeNodeType::Project(_) => (format!("{} • {}", indent, node.node_type.name()), false), } } @@ -56,42 +62,51 @@ impl TreeView { } let (node, _) = &state.flattened_tree.items[node_index]; + let colors = state.current_colors(); match &node.node_type { TreeNodeType::Directory { .. } => { - // Loading or selected directories get cyan, others get muted purple - if node.loading || is_selected { + if is_selected { + Style::default() + .fg(colors.text) + .bg(colors.selection) + .add_modifier(Modifier::BOLD) + } else if node.loading { Style::default() - .fg(Color::Cyan) + .fg(colors.selection) .add_modifier(Modifier::BOLD) } else { Style::default() - .fg(Color::Rgb(150, 150, 200)) + .fg(colors.primary) .add_modifier(Modifier::BOLD) } } TreeNodeType::Project(project) => { use crate::project::ProjectCheckStatus; - let base_style = if is_selected { + let mut style = if is_selected { Style::default() - .fg(Color::Green) + .fg(colors.text) + .bg(colors.selection) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::White) + Style::default().fg(colors.text) }; - // Add status color based on project check status - match project.check_status { - ProjectCheckStatus::Unchecked => base_style.fg(Color::DarkGray), - ProjectCheckStatus::Checking => { - base_style.fg(Color::Blue).add_modifier(Modifier::BOLD) - } - ProjectCheckStatus::HasUpdates => { - base_style.fg(Color::Yellow).add_modifier(Modifier::BOLD) + if !is_selected { + // Add status color based on project check status + match project.check_status { + ProjectCheckStatus::Unchecked => style = style.fg(colors.muted), + ProjectCheckStatus::Checking => { + style = style.fg(Color::Blue).add_modifier(Modifier::BOLD) + } + ProjectCheckStatus::HasUpdates => { + style = style.fg(colors.warning).add_modifier(Modifier::BOLD) + } + ProjectCheckStatus::UpToDate => style = style.fg(colors.success), } - ProjectCheckStatus::UpToDate => base_style.fg(Color::Green), } + style } } } @@ -122,6 +137,7 @@ impl Component for TreeView { } fn draw(&mut self, f: &mut Frame, state: &mut AppState, area: Rect) { + let colors = state.current_colors(); // Create the list of items let items: Vec = state .flattened_tree @@ -137,18 +153,17 @@ impl Component for TreeView { let line = match &node.node_type { TreeNodeType::Project(project) => { let status_indicator = Self::get_project_status_indicator(project); - let indicator_span = - Span::styled(status_indicator, style.add_modifier(Modifier::BOLD)); - - // Extract just the project name part (skip indent and dots) - let project_part = format!("{}", project.name); - let text_span = Span::styled(project_part, style); + let indicator_style = if is_selected { + style + } else { + style.add_modifier(Modifier::BOLD) + }; + let indicator_span = Span::styled(status_indicator, indicator_style); let mut line_spans = vec![ - Span::raw(" ".to_string()), + Span::styled(line_text, style), + Span::styled(" ", style), indicator_span, - Span::raw(" ".to_string()), - text_span, ]; // Add selection indicator @@ -180,9 +195,10 @@ impl Component for TreeView { Block::default() .title(" Projects ") .borders(Borders::ALL) + .border_style(Style::default().fg(colors.primary)) .border_type(ratatui::widgets::BorderType::Rounded), ) - .style(Style::default().fg(Color::White)); + .style(Style::default().fg(colors.text)); // Render the list f.render_widget(list, area); diff --git a/src/components/updater.rs b/src/components/updater.rs index a6c7698..e3a52fc 100644 --- a/src/components/updater.rs +++ b/src/components/updater.rs @@ -8,37 +8,26 @@ use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, + widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph}, }; use std::collections::HashSet; - -/// Pending directory check for multiple projects -#[derive(Debug, Clone)] -pub struct PendingDirectoryCheck { - pub directory_name: String, - pub project_names: Vec, -} +use std::path::PathBuf; /// State for the update wizard UI component /// -/// The wizard is "locked" to a specific project when it opens, ensuring that -/// background update checks for other projects don't interfere with the display. +/// The wizard is locked to a single project while a user-initiated check runs. #[derive(Debug, Clone)] pub struct UpdateWizardState { pub outdated_dependencies: Vec, pub selected_dependencies: HashSet, pub list_state: ratatui::widgets::ListState, - /// The project this wizard is locked to (prevents background updates from changing display) - pub locked_project_name: Option, + /// The project this wizard is locked to. + pub locked_project_id: Option, /// Whether a user-initiated check is in progress for the locked project - /// This prevents background checks from clearing the is_checking_updates flag prematurely pub user_check_in_progress: bool, - /// Pending directory check (set when user presses 'u' on a directory) - /// The async handler will process this and queue all projects for checking - pub pending_directory_check: Option, - /// Project name pending dependency reload after update command completes + /// Project path pending dependency reload after update command completes /// Set when RunUpdate starts, cleared and processed when command finishes - pub pending_reload_project: Option, + pub pending_reload_project: Option, } impl UpdateWizardState { @@ -47,9 +36,8 @@ impl UpdateWizardState { outdated_dependencies: Vec::new(), selected_dependencies: HashSet::new(), list_state: ratatui::widgets::ListState::default(), - locked_project_name: None, + locked_project_id: None, user_check_in_progress: false, - pending_directory_check: None, pending_reload_project: None, } } @@ -92,6 +80,11 @@ impl Component for UpdateWizard { None } KeyCode::Down | KeyCode::Char('j') => { + if app.updater.outdated_dependencies.is_empty() { + app.updater.list_state.select(None); + return None; + } + let i = match app.updater.list_state.selected() { Some(i) => { if i >= app.updater.outdated_dependencies.len() - 1 { @@ -106,6 +99,11 @@ impl Component for UpdateWizard { None } KeyCode::Up | KeyCode::Char('k') => { + if app.updater.outdated_dependencies.is_empty() { + app.updater.list_state.select(None); + return None; + } + let i = match app.updater.list_state.selected() { Some(i) => { if i == 0 { @@ -137,8 +135,22 @@ impl Component for UpdateWizard { ]) .split(popup_area); - // Title - show the locked project name and checking status - let title_text = if let Some(ref project_name) = app.updater.locked_project_name { + // Title - show the locked project label and checking status + let locked_project_title = app.updater.locked_project_id.as_ref().map(|project_id| { + app.all_projects + .iter() + .find(|p| &p.path == project_id) + .or_else(|| app.projects.iter().find(|p| &p.path == project_id)) + .map(|p| p.name.clone()) + .unwrap_or_else(|| { + project_id + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| project_id.display().to_string()) + }) + }); + + let title_text = if let Some(project_name) = locked_project_title { if app.is_checking_updates { format!(" Update Dependencies - {} ⟳ ", project_name) } else { @@ -165,10 +177,10 @@ impl Component for UpdateWizard { if app.updater.outdated_dependencies.is_empty() { // Check if dependencies have been checked (have latest_version set) // Use the LOCKED project, not the currently selected one (user may have moved cursor) - let has_been_checked = if let Some(ref locked_name) = app.updater.locked_project_name { - app.projects + let has_been_checked = if let Some(ref locked_id) = app.updater.locked_project_id { + app.all_projects .iter() - .find(|p| &p.name == locked_name) + .find(|p| &p.path == locked_id) .map(|project| { !project.dependencies.is_empty() && project @@ -198,7 +210,11 @@ impl Component for UpdateWizard { Color::Green }), ) - .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)); + .block( + Block::default() + .borders(Borders::LEFT | Borders::RIGHT) + .border_type(BorderType::Rounded), + ); f.render_widget(empty_para, chunks[1]); } else { let items: Vec = app @@ -228,14 +244,17 @@ impl Component for UpdateWizard { ), Span::raw(" "), Span::styled(&dep.current_version, Style::default().fg(Color::Red)), - Span::styled(" → ", Style::default().fg(Color::Yellow)), - Span::styled( - dep.latest_version.as_ref().unwrap(), + ]; + + if let Some(ref latest) = dep.latest_version { + spans.push(Span::styled(" → ", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + latest, Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD), - ), - ]; + )); + } // Add note for major version updates if is_major { @@ -254,7 +273,11 @@ impl Component for UpdateWizard { .collect(); let list = List::new(items) - .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)) + .block( + Block::default() + .borders(Borders::LEFT | Borders::RIGHT) + .border_type(BorderType::Rounded), + ) .highlight_style( Style::default() .bg(Color::Rgb(60, 40, 60)) diff --git a/src/config/keybinding_config.rs b/src/config/keybinding_config.rs index 89a11ce..981132a 100644 --- a/src/config/keybinding_config.rs +++ b/src/config/keybinding_config.rs @@ -36,7 +36,6 @@ pub enum KeyAction { ShowSettings, ShowHelp, StartUpdateWizard, - CheckForUpdates, // Application Quit, @@ -66,7 +65,6 @@ impl KeyAction { KeyAction::ShowSettings => "Show settings", KeyAction::ShowHelp => "Show help", KeyAction::StartUpdateWizard => "Start update wizard", - KeyAction::CheckForUpdates => "Check for updates", KeyAction::Quit => "Quit application", } } @@ -161,7 +159,6 @@ impl KeybindingConfig { ), ("ShowHelp".to_string(), vec!["?".to_string()]), ("StartUpdateWizard".to_string(), vec!["u".to_string()]), - ("CheckForUpdates".to_string(), vec!["u".to_string()]), ("Quit".to_string(), vec!["q".to_string()]), ] .into_iter() @@ -186,7 +183,6 @@ impl KeybindingConfig { ("ShowSettings".to_string(), vec![";".to_string()]), ("ShowHelp".to_string(), vec!["?".to_string()]), ("StartUpdateWizard".to_string(), vec!["u".to_string()]), - ("CheckForUpdates".to_string(), vec!["U".to_string()]), ("Quit".to_string(), vec!["q".to_string()]), ] .into_iter() diff --git a/src/config/mod.rs b/src/config/mod.rs index 4656a34..35c74f9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -33,7 +33,7 @@ pub struct Config { #[serde(default)] pub progress: ProgressConfig, - /// App settings (cache, background updates, etc) + /// App settings (cache TTL, folder visibility, etc) #[serde(default)] pub app: AppSettings, } @@ -116,7 +116,10 @@ impl Config { if path.exists() { match fs::read_to_string(&path) { Ok(content) => match toml::from_str::(&content) { - Ok(config) => config, + Ok(mut config) => { + config.app = config.app.normalize(); + config + } Err(e) => { eprintln!("Warning: Failed to parse config: {}", e); Self::default() diff --git a/src/config/theme_config.rs b/src/config/theme_config.rs index cf3ebc5..cad2c03 100644 --- a/src/config/theme_config.rs +++ b/src/config/theme_config.rs @@ -65,6 +65,7 @@ impl ThemeConfig { "nord" => ColorScheme::Nord, "dracula" => ColorScheme::Dracula, "cosmic" => ColorScheme::Cosmic, + "gestalt" => ColorScheme::Gestalt, _ => ColorScheme::Dark, } } @@ -77,7 +78,8 @@ impl ThemeConfig { ColorScheme::Light => ColorScheme::Nord, ColorScheme::Nord => ColorScheme::Dracula, ColorScheme::Dracula => ColorScheme::Cosmic, - ColorScheme::Cosmic => ColorScheme::Dark, + ColorScheme::Cosmic => ColorScheme::Gestalt, + ColorScheme::Gestalt => ColorScheme::Dark, } } @@ -89,13 +91,14 @@ impl ThemeConfig { "nord" => "Nord".to_string(), "dracula" => "Dracula".to_string(), "cosmic" => "Cosmic".to_string(), + "gestalt" => "Gestalt".to_string(), name => format!("Custom: {}", name), } } /// List all available theme names pub fn available_themes() -> Vec<&'static str> { - vec!["dark", "light", "nord", "dracula", "cosmic"] + vec!["dark", "light", "nord", "dracula", "cosmic", "gestalt"] } /// Set the active theme by name @@ -114,6 +117,7 @@ impl ThemeConfig { ColorScheme::Nord => "nord", ColorScheme::Dracula => "dracula", ColorScheme::Cosmic => "cosmic", + ColorScheme::Gestalt => "gestalt", } .to_string(); } @@ -126,6 +130,7 @@ impl ThemeConfig { "Nord".to_string(), "Dracula".to_string(), "Cosmic".to_string(), + "Gestalt".to_string(), ] } } @@ -166,6 +171,9 @@ mod tests { let config = ThemeConfig::new("cosmic"); assert_eq!(config.current_scheme(), ColorScheme::Cosmic); + + let config = ThemeConfig::new("gestalt"); + assert_eq!(config.current_scheme(), ColorScheme::Gestalt); } #[test] @@ -177,6 +185,9 @@ mod tests { assert_eq!(config.next_scheme(), ColorScheme::Cosmic); let config = ThemeConfig::new("cosmic"); + assert_eq!(config.next_scheme(), ColorScheme::Gestalt); + + let config = ThemeConfig::new("gestalt"); assert_eq!(config.next_scheme(), ColorScheme::Dark); } @@ -192,6 +203,8 @@ mod tests { config.cycle_next(); assert_eq!(config.current, "cosmic"); config.cycle_next(); + assert_eq!(config.current, "gestalt"); + config.cycle_next(); assert_eq!(config.current, "dark"); } @@ -200,6 +213,9 @@ mod tests { let config = ThemeConfig::new("dark"); assert_eq!(config.display_name(), "Dark"); + let config = ThemeConfig::new("gestalt"); + assert_eq!(config.display_name(), "Gestalt"); + let mut config = ThemeConfig::new("custom"); config.custom = Some(CustomTheme { selection: None, @@ -217,10 +233,11 @@ mod tests { #[test] fn test_available_themes() { let themes = ThemeConfig::available_themes(); - assert_eq!(themes.len(), 5); + assert_eq!(themes.len(), 6); assert!(themes.contains(&"dark")); assert!(themes.contains(&"light")); assert!(themes.contains(&"cosmic")); + assert!(themes.contains(&"gestalt")); } #[test] diff --git a/src/events.rs b/src/events.rs index df0f07a..edea181 100644 --- a/src/events.rs +++ b/src/events.rs @@ -4,6 +4,7 @@ //! commands that can be executed by the application. use crate::project::{Dependency, Project}; +use std::path::PathBuf; /// Represents a command that can be executed in CarWash #[derive(Debug, Clone, PartialEq, Eq)] @@ -57,26 +58,18 @@ pub enum Action { ToggleUpdateSelection, /// Update cache duration text in settings modal SettingsUpdateCacheInput(String), - /// Toggle background update preference in settings modal - SettingsToggleBackground, /// Persist settings changes SaveSettings, /// Run selected updates RunUpdate, - /// Check for dependency updates - CheckForUpdates, - /// Update dependency information with project name - UpdateDependencies(String, Vec), - /// Start background update checking - StartBackgroundUpdateCheck, - /// Update status of a specific dependency - UpdateDependencyStatus(String, crate::project::DependencyCheckStatus), + /// Update dependency information for a project path + UpdateDependencies(PathBuf, Vec), /// Stream start - beginning of update checks - UpdateDependenciesStreamStart(String), + UpdateDependenciesStreamStart(PathBuf), /// Update a single dependency with latest info - UpdateSingleDependency(String, Dependency), + UpdateSingleDependency(PathBuf, Dependency), /// Update check status for a specific dependency (for UI streaming) - UpdateDependencyCheckStatus(String, String, crate::project::DependencyCheckStatus), + UpdateDependencyCheckStatus(PathBuf, String, crate::project::DependencyCheckStatus), /// Create a new output tab CreateTab(String), /// Add output line to a tab @@ -85,12 +78,8 @@ pub enum Action { FinishCommand(usize), /// Switch to a specific tab SwitchToTab(usize), - /// Process pending background update tasks - ProcessBackgroundUpdateQueue, - /// Queue a project for update checking (background or priority) - QueueBackgroundUpdate(String, bool), /// Update the visual check status of a project - UpdateProjectCheckStatus(String, crate::project::ProjectCheckStatus), + UpdateProjectCheckStatus(PathBuf, crate::project::ProjectCheckStatus), /// Enter filter/search mode EnterFilterMode, /// Exit filter/search mode @@ -120,7 +109,7 @@ pub enum Action { /// Calculate sizes for all projects (total + target directory) CalculateProjectSizes, /// Update a single project's size information - UpdateProjectSize(String, Option, Option), + UpdateProjectSize(PathBuf, Option, Option), /// Change focus to next pane FocusNext, /// Quit the application diff --git a/src/handlers.rs b/src/handlers.rs index 9ad208a..aad110f 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -4,12 +4,49 @@ //! a clean separation between action dispatch and state mutation logic. use crate::app::{AppState, Tab}; -use crate::components::PendingDirectoryCheck; use crate::events::{Action, Command, Mode}; use crate::project::Project; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +fn find_project_mut<'a>(projects: &'a mut [Project], project_id: &Path) -> Option<&'a mut Project> { + projects + .iter_mut() + .find(|project| project.path == project_id) +} + +fn collect_projects_for_path(node: &crate::tree::TreeNode, path: &Path) -> Vec { + if node.node_type.path() == path { + return node.collect_projects().into_iter().cloned().collect(); + } + + for child in &node.children { + let projects = collect_projects_for_path(child, path); + if !projects.is_empty() { + return projects; + } + } + + Vec::new() +} + +fn add_project_if_missing(state: &mut AppState, project: Project) { + if let Some(existing) = state.all_projects.iter().find(|p| p.path == project.path) { + if !existing.dependencies.is_empty() + && !state.projects.iter().any(|p| p.path == existing.path) + { + state.projects.push(existing.clone()); + } + return; + } + + state.all_projects.push(project.clone()); + if !project.dependencies.is_empty() { + state.projects.push(project); + } +} /// Handle application quit pub fn handle_quit(state: &mut AppState) { @@ -24,7 +61,7 @@ pub fn handle_enter_normal_mode(state: &mut AppState) { state.updater.outdated_dependencies.clear(); state.updater.selected_dependencies.clear(); state.updater.list_state.select(None); - state.updater.locked_project_name = None; // Clear project lock + state.updater.locked_project_id = None; // Clear project lock state.updater.user_check_in_progress = false; // Clear check flag } state.mode = Mode::Normal; @@ -42,7 +79,6 @@ pub fn handle_show_settings(state: &mut AppState) { .cache_minutes_input .clone() .with_value(state.config.app.cache_ttl_minutes.to_string()); - state.settings_modal.background_updates_enabled = state.config.app.background_updates_enabled; state.settings_modal.error_message = None; state.mode = Mode::Settings; } @@ -152,22 +188,26 @@ pub fn handle_select_previous(state: &mut AppState) { } } -/// Handle selecting parent (collapse directory/workspace) +/// Handle selecting parent (collapse directory/workspace or move to parent) pub fn handle_select_parent(state: &mut AppState) { - // Use tree navigation if tree is available if let Some(tree_root) = &mut state.tree_root && let Some(selected_idx) = state.tree_state.selected() && selected_idx < state.flattened_tree.items.len() { let (node, _) = &state.flattened_tree.items[selected_idx]; - // Only collapse if it's an expanded directory + // If directory is expanded, collapse it if node.node_type.is_directory() && node.expanded { - // Find and toggle the node toggle_node_expanded(tree_root, node.node_type.path()); - - // Re-flatten the tree state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); + } else { + // Otherwise, jump to the parent node + let current_path = node.node_type.path(); + if let Some(parent_path) = current_path.parent() + && let Some(parent_idx) = state.flattened_tree.get_index(parent_path) + { + state.tree_state.select(Some(parent_idx)); + } } } else { // Fallback to old workspace-based collapse @@ -177,22 +217,27 @@ pub fn handle_select_parent(state: &mut AppState) { } } -/// Handle selecting child (expand directory/workspace) +/// Handle selecting child (expand directory/workspace or move to first child) pub fn handle_select_child(state: &mut AppState) { - // Use tree navigation if tree is available if let Some(tree_root) = &mut state.tree_root && let Some(selected_idx) = state.tree_state.selected() && selected_idx < state.flattened_tree.items.len() { let (node, _) = &state.flattened_tree.items[selected_idx]; - // Only expand if it's a collapsed directory + // If directory is collapsed, expand it if node.node_type.is_directory() && !node.expanded { - // Find and toggle the node toggle_node_expanded(tree_root, node.node_type.path()); - - // Re-flatten the tree state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); + } else if node.node_type.is_directory() && node.expanded { + // If already expanded, move to the first child + let next_idx = selected_idx + 1; + if next_idx < state.flattened_tree.items.len() { + let (next_node, _) = &state.flattened_tree.items[next_idx]; + if next_node.depth > node.depth { + state.tree_state.select(Some(next_idx)); + } + } } } else { // Fallback to old workspace-based expand @@ -275,19 +320,27 @@ pub fn handle_toggle_selection(state: &mut AppState) { crate::tree::TreeNodeType::Directory { path, .. } => { // For directories, we need to ensure children are loaded first // so we can actually select the projects inside - if let Some(tree_root) = &mut state.tree_root { + let loaded_projects = if let Some(tree_root) = &mut state.tree_root { let path_clone = path.clone(); let show_all_folders = state.config.app.show_all_folders; // Load children if not already loaded, and expand the directory load_and_expand_node(tree_root, &path_clone, show_all_folders); + let loaded_projects = collect_projects_for_path(tree_root, &path_clone); // Re-flatten the tree to reflect the newly loaded children state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); + loaded_projects + } else { + Vec::new() + }; + + for project in loaded_projects { + add_project_if_missing(state, project); } - // Now collect all project names in this directory's subtree - let mut project_names = Vec::new(); + // Now collect all project paths in this directory's subtree + let mut project_ids = Vec::new(); // Iterate through subsequent nodes until we leave this directory for (child_node, _) in state.flattened_tree.items.iter().skip(selected_index + 1) { @@ -298,39 +351,39 @@ pub fn handle_toggle_selection(state: &mut AppState) { // If it's a project, add it to our list if let crate::tree::TreeNodeType::Project(project) = &child_node.node_type { - project_names.push(project.name.clone()); + project_ids.push(project.path.clone()); } } // Toggle all projects in this directory - if !project_names.is_empty() { - let all_selected = project_names + if !project_ids.is_empty() { + let all_selected = project_ids .iter() - .all(|name| state.selected_projects.contains(name)); + .all(|project_id| state.selected_projects.contains(project_id)); if all_selected { // Deselect all - for name in project_names { - state.selected_projects.remove(&name); + for project_id in project_ids { + state.selected_projects.remove(&project_id); } } else { // Select all - for name in project_names { - state.selected_projects.insert(name); + for project_id in project_ids { + state.selected_projects.insert(project_id); } } } } crate::tree::TreeNodeType::Project(project) => { // Toggle single project - let project_name = project.name.clone(); - toggle_single_project_selection(state, &project_name); + let project_id = project.path.clone(); + toggle_single_project_selection(state, &project_id); } } } -fn toggle_single_project_selection(state: &mut AppState, project_name: &str) { - if !state.selected_projects.remove(project_name) { - state.selected_projects.insert(project_name.to_string()); +fn toggle_single_project_selection(state: &mut AppState, project_id: &Path) { + if !state.selected_projects.remove(project_id) { + state.selected_projects.insert(project_id.to_path_buf()); } } @@ -344,13 +397,7 @@ pub fn handle_settings_update_cache_input(state: &mut AppState, input: String) { state.settings_modal.error_message = None; } -/// Toggle background update preference while editing settings -pub fn handle_settings_toggle_background(state: &mut AppState) { - state.settings_modal.background_updates_enabled = - !state.settings_modal.background_updates_enabled; -} - -/// Persist settings from the modal, validating input and updating background queue +/// Persist settings from the modal, validating input. pub fn handle_save_settings(state: &mut AppState) { let raw_value = state.settings_modal.cache_minutes_input.value().trim(); let minutes = match raw_value.parse::() { @@ -363,9 +410,7 @@ pub fn handle_save_settings(state: &mut AppState) { }; let mut new_settings = state.config.app.clone(); - let was_background_enabled = new_settings.background_updates_enabled; new_settings.cache_ttl_minutes = minutes; - new_settings.background_updates_enabled = state.settings_modal.background_updates_enabled; state.config.app = new_settings; if let Err(err) = state.config.save() { @@ -375,46 +420,6 @@ pub fn handle_save_settings(state: &mut AppState) { state.settings_modal.error_message = None; state.mode = Mode::Normal; - - if !state.config.app.background_updates_enabled { - state.update_queue.clear(); - state.is_checking_updates = false; - return; - } - - if !was_background_enabled && state.config.app.background_updates_enabled { - queue_background_updates_on_enable(state); - } -} - -fn queue_background_updates_on_enable(state: &mut AppState) { - let now = std::time::SystemTime::now(); - let cache_duration = state.config.app.cache_duration(); - - for project in &state.all_projects { - if project.dependencies.is_empty() { - continue; - } - - let needs_check = project.dependencies.iter().any(|dep| { - if let Some(last_checked) = dep.last_checked { - if let Ok(elapsed) = now.duration_since(last_checked) { - elapsed > cache_duration - } else { - true - } - } else { - true - } - }); - - if needs_check { - state.update_queue.add_task(crate::runner::UpdateCheckTask { - project_name: project.name.clone(), - is_priority: false, - }); - } - } } /// Handle showing command palette @@ -468,6 +473,11 @@ pub fn handle_update_palette_input(state: &mut AppState, input: String) { /// Handle palette next selection pub fn handle_palette_select_next(state: &mut AppState) { + if state.palette.filtered_commands.is_empty() { + state.palette.list_state.select(None); + return; + } + let i = match state.palette.list_state.selected() { Some(i) => { if i >= state.palette.filtered_commands.len() - 1 { @@ -483,6 +493,11 @@ pub fn handle_palette_select_next(state: &mut AppState) { /// Handle palette previous selection pub fn handle_palette_select_previous(state: &mut AppState) { + if state.palette.filtered_commands.is_empty() { + state.palette.list_state.select(None); + return; + } + let i = match state.palette.list_state.selected() { Some(i) => { if i == 0 { @@ -496,11 +511,11 @@ pub fn handle_palette_select_previous(state: &mut AppState) { state.palette.list_state.select(Some(i)); } -/// Handle starting update wizard or directory-wide update check +/// Handle starting update wizard for a single selected project. /// /// Behavior depends on what the cursor is on: /// - **Project**: Opens the update wizard for that single project -/// - **Directory**: Queues priority background checks for all projects under it (no wizard) +/// - **Directory**: Does nothing; update checks are intentionally project-scoped pub fn handle_start_update_wizard(state: &mut AppState) { use crate::tree::TreeNodeType; @@ -512,48 +527,8 @@ pub fn handle_start_update_wizard(state: &mut AppState) { // Single project selected - open the wizard handle_start_update_wizard_for_project(state); } - Some(TreeNodeType::Directory { name, .. }) => { - // Directory selected - queue all projects under it for background checking - // We use is_priority=false because we're NOT opening a wizard for each project - // The main.rs async handler will trigger processing after these are queued - let projects = state.get_projects_under_selected(); - let project_names: Vec = projects.iter().map(|p| p.name.clone()).collect(); - let count = project_names.len(); - - if count == 0 { - // No projects under this directory - nothing to do - return; - } - - // Store the project names and directory info in state for async handler - // The async handler will queue them and start processing - state.updater.pending_directory_check = Some(PendingDirectoryCheck { - directory_name: name.clone(), - project_names, - }); - - // Show feedback - create a tab to show what we're doing - let tab_title = format!("Checking {} ({} projects)", name, count); - handle_create_tab(state, tab_title); - let tab_index = state.tabs.len().saturating_sub(1); - handle_add_output( - state, - tab_index, - format!("Queued {} projects for update check...", count), - ); - handle_add_output( - state, - tab_index, - "Watch the tree view - projects will change color when updates are found.".into(), - ); - handle_add_output( - state, - tab_index, - "Select a specific project and press 'u' to open the update wizard.".into(), - ); - - // Switch to output pane to show progress - state.focus = crate::events::Focus::Output; + Some(TreeNodeType::Directory { .. }) => { + state.updater.locked_project_id = None; } None => { // Nothing selected - do nothing @@ -568,14 +543,13 @@ fn handle_start_update_wizard_for_project(state: &mut AppState) { state.updater.selected_dependencies.clear(); state.updater.list_state.select(None); - // CRITICAL: Lock the wizard to the currently selected project - // This prevents background updates for other projects from changing the wizard display + // CRITICAL: Lock the wizard to the currently selected project. if let Some(project) = state.get_selected_project() { - let project_name = project.name.clone(); + let project_id = project.path.clone(); // CRITICAL: Populate wizard with CURRENT dependency data immediately - // This ensures the wizard shows up-to-date data even if background check - // completed BEFORE the wizard opened (race condition fix) + // This ensures the wizard shows cached data immediately while a fresh manual + // check runs or is skipped by the cache TTL. // Uses has_stable_update() to properly handle pre-release versions let outdated_deps: Vec<_> = project .dependencies @@ -585,7 +559,7 @@ fn handle_start_update_wizard_for_project(state: &mut AppState) { .collect(); // Now we can mutate state.updater - state.updater.locked_project_name = Some(project_name); + state.updater.locked_project_id = Some(project_id); state.updater.outdated_dependencies = outdated_deps; // Select first item if there are outdated dependencies @@ -598,7 +572,7 @@ fn handle_start_update_wizard_for_project(state: &mut AppState) { // (happens when cache hits instantly) state.updater.user_check_in_progress = true; } else { - state.updater.locked_project_name = None; + state.updater.locked_project_id = None; } state.is_checking_updates = true; @@ -615,21 +589,16 @@ pub fn handle_toggle_update_selection(state: &mut AppState) { } } -/// Handle check for updates request -pub fn handle_check_for_updates(state: &mut AppState) { - state.is_checking_updates = true; -} - /// Handle updating dependencies for a project pub fn handle_update_dependencies( state: &mut AppState, - project_name: String, + project_id: PathBuf, deps: Vec, ) { let is_wizard_locked_project = state.mode == Mode::UpdateWizard - && state.updater.locked_project_name.as_ref() == Some(&project_name); + && state.updater.locked_project_id.as_deref() == Some(project_id.as_path()); - update_project_dependencies(state, &project_name, deps.clone()); + update_project_dependencies(state, &project_id, deps.clone()); if is_wizard_locked_project { update_wizard_dependencies(state, deps); @@ -638,24 +607,50 @@ pub fn handle_update_dependencies( fn update_project_dependencies( state: &mut AppState, - project_name: &str, + project_id: &Path, deps: Vec, ) { let new_check_status = Project::compute_check_status_from_deps(&deps); - if let Some(all_proj) = state - .all_projects - .iter_mut() - .find(|p| p.name == project_name) - { + if let Some(all_proj) = find_project_mut(&mut state.all_projects, project_id) { all_proj.dependencies = deps.clone(); all_proj.check_status = new_check_status.clone(); } - if let Some(proj) = state.projects.iter_mut().find(|p| p.name == project_name) { - proj.dependencies = deps; + if let Some(proj) = find_project_mut(&mut state.projects, project_id) { + proj.dependencies = deps.clone(); proj.check_status = new_check_status; } + + update_project_in_tree(state, project_id, |project| { + project.dependencies = deps.clone(); + project.check_status = Project::compute_check_status_from_deps(&project.dependencies); + }); +} + +fn update_project_in_tree(state: &mut AppState, project_id: &Path, mut update: F) +where + F: FnMut(&mut crate::project::Project), +{ + fn visit(node: &mut crate::tree::TreeNode, project_id: &Path, update: &mut F) + where + F: FnMut(&mut crate::project::Project), + { + if let crate::tree::TreeNodeType::Project(project) = &mut node.node_type + && project.path == project_id + { + update(project); + } + + for child in &mut node.children { + visit(child, project_id, update); + } + } + + if let Some(tree_root) = &mut state.tree_root { + visit(tree_root, project_id, &mut update); + state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); + } } fn update_wizard_dependencies(state: &mut AppState, deps: Vec) { @@ -673,9 +668,9 @@ fn update_wizard_dependencies(state: &mut AppState, deps: Vec, + project_id: Option, dep_name: String, status: crate::project::DependencyCheckStatus, ) { - let target_names: Vec = if let Some(name) = project_name { - vec![name] + let target_ids: Vec = if let Some(project_id) = project_id { + vec![project_id] } else { state .get_selected_project() - .map(|p| p.name.clone()) + .map(|p| p.path.clone()) .into_iter() .collect() }; - for project_name in target_names { - if let Some(proj) = state - .all_projects - .iter_mut() - .find(|p| p.name == project_name) + for project_id in target_ids { + if let Some(proj) = find_project_mut(&mut state.all_projects, &project_id) && let Some(dep) = proj.dependencies.iter_mut().find(|d| d.name == dep_name) { dep.check_status = status.clone(); + proj.check_status = Project::compute_check_status_from_deps(&proj.dependencies); } - if let Some(proj) = state.projects.iter_mut().find(|p| p.name == project_name) + if let Some(proj) = find_project_mut(&mut state.projects, &project_id) && let Some(dep) = proj.dependencies.iter_mut().find(|d| d.name == dep_name) { dep.check_status = status.clone(); + proj.check_status = Project::compute_check_status_from_deps(&proj.dependencies); } if state.mode == crate::events::Mode::UpdateWizard - && state.updater.locked_project_name.as_ref() == Some(&project_name) + && state.updater.locked_project_id.as_deref() == Some(project_id.as_path()) && let Some(dep) = state .updater .outdated_dependencies @@ -813,39 +813,34 @@ pub fn handle_update_dependency_status( { dep.check_status = status.clone(); } - } -} -/// Handle queuing background update -pub fn handle_queue_background_update( - state: &mut AppState, - project_name: String, - is_priority: bool, -) { - state.update_queue.add_task(crate::runner::UpdateCheckTask { - project_name, - is_priority, - }); + update_project_in_tree(state, &project_id, |project| { + if let Some(dep) = project.dependencies.iter_mut().find(|d| d.name == dep_name) { + dep.check_status = status.clone(); + } + project.check_status = Project::compute_check_status_from_deps(&project.dependencies); + }); + } } /// Handle updating project check status pub fn handle_update_project_check_status( state: &mut AppState, - project_name: String, + project_id: PathBuf, check_status: crate::project::ProjectCheckStatus, ) { // Update in all_projects (source of truth) - if let Some(proj) = state - .all_projects - .iter_mut() - .find(|p| p.name == project_name) - { + if let Some(proj) = find_project_mut(&mut state.all_projects, &project_id) { proj.check_status = check_status.clone(); } // Also update in filtered projects list - if let Some(proj) = state.projects.iter_mut().find(|p| p.name == project_name) { - proj.check_status = check_status; + if let Some(proj) = find_project_mut(&mut state.projects, &project_id) { + proj.check_status = check_status.clone(); } + + update_project_in_tree(state, &project_id, |project| { + project.check_status = check_status.clone(); + }); } /// Handle entering filter/search mode @@ -860,6 +855,7 @@ pub fn handle_exit_filter_mode(state: &mut AppState) { // If a match was selected, update tree selection to that item if let Some(idx) = state.filter.selected_tree_index() { state.tree_selection.selected_index = Some(idx); + state.tree_state.select(Some(idx)); } state.filter.clear(); } @@ -1045,7 +1041,7 @@ pub async fn handle_calculate_project_sizes( let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_SIZE_CALCS)); for project in &state.all_projects { - let project_name = project.name.clone(); + let project_id = project.path.clone(); let project_path = project.path.clone(); let tx = action_tx.clone(); let sem = semaphore.clone(); @@ -1087,7 +1083,7 @@ pub async fn handle_calculate_project_sizes( // Send update back to main thread let _ = tx .send(Action::UpdateProjectSize( - project_name, + project_id, total_size, target_size, )) @@ -1099,29 +1095,25 @@ pub async fn handle_calculate_project_sizes( /// Handle updating a single project's size information pub fn handle_update_project_size( state: &mut AppState, - project_name: String, + project_id: PathBuf, total_size: Option, target_size: Option, ) { // Update in all_projects - if let Some(project) = state - .all_projects - .iter_mut() - .find(|p| p.name == project_name) - { + if let Some(project) = find_project_mut(&mut state.all_projects, &project_id) { project.total_size = total_size; project.target_size = target_size; } // Update in projects (filtered view) - if let Some(project) = state.projects.iter_mut().find(|p| p.name == project_name) { + if let Some(project) = find_project_mut(&mut state.projects, &project_id) { project.total_size = total_size; project.target_size = target_size; } // Update in tree nodes if let Some(ref mut tree_root) = state.tree_root { - update_project_size_in_tree(tree_root, &project_name, total_size, target_size); + update_project_size_in_tree(tree_root, &project_id, total_size, target_size); // Re-flatten tree to pick up changes state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); @@ -1131,19 +1123,19 @@ pub fn handle_update_project_size( /// Recursively update project size in tree fn update_project_size_in_tree( node: &mut crate::tree::TreeNode, - project_name: &str, + project_id: &Path, total_size: Option, target_size: Option, ) { if let crate::tree::TreeNodeType::Project(ref mut project) = node.node_type - && project.name == project_name + && project.path == project_id { project.total_size = total_size; project.target_size = target_size; } for child in &mut node.children { - update_project_size_in_tree(child, project_name, total_size, target_size); + update_project_size_in_tree(child, project_id, total_size, target_size); } } @@ -1151,16 +1143,25 @@ fn update_project_size_in_tree( pub fn handle_initialize_tree(state: &mut AppState, target_directory: String) { // Build root node only (fast, no I/O) - returns immediately let tree_root = crate::project::build_project_tree(&target_directory); + let root_projects: Vec = tree_root.collect_projects().into_iter().cloned().collect(); state.tree_root = Some(tree_root.clone()); state.flattened_tree = crate::tree::FlattenedTree::from_tree(&tree_root); + for project in root_projects { + add_project_if_missing(state, project); + } // Select first item state.tree_state.select(Some(0)); - // Keep in Loading mode while root children load asynchronously - // Mode will be set to Normal in handle_directory_loaded once root is loaded - state.is_scanning = true; // Keep scanning flag for other indicators + if tree_root.node_type.is_project() { + state.is_scanning = false; + state.mode = Mode::Normal; + } else { + // Keep in Loading mode while root children load asynchronously + // Mode will be set to Normal in handle_directory_loaded once root is loaded + state.is_scanning = true; // Keep scanning flag for other indicators + } } /// Handle directory loaded async result @@ -1207,16 +1208,10 @@ pub fn handle_directory_loaded( } } - // Collect projects before moving children into the tree + // Collect projects recursively (workspace dirs contain nested project children) let loaded_projects: Vec = children .iter() - .filter_map(|node| { - if let crate::tree::TreeNodeType::Project(p) = &node.node_type { - Some(p.clone()) - } else { - None - } - }) + .flat_map(|node| node.collect_projects().into_iter().cloned()) .collect(); if update_node(root, &path, &children) { @@ -1233,20 +1228,7 @@ pub fn handle_directory_loaded( // Process newly loaded projects for project in loaded_projects { - // Add to all_projects if not present (avoid duplicates from deep scan) - if !state.all_projects.iter().any(|p| p.path == project.path) { - state.all_projects.push(project.clone()); - // Also add to filtered list if it has deps - if !project.dependencies.is_empty() { - state.projects.push(project.clone()); - } - } - - // Queue background update check if enabled (not priority - those are for user-initiated checks) - if state.config.app.background_updates_enabled && !project.dependencies.is_empty() { - // is_priority=false: these are background checks, user-initiated checks use priority=true - handle_queue_background_update(state, project.name, false); - } + add_project_if_missing(state, project); } } } diff --git a/src/main.rs b/src/main.rs index 49f937a..b27be6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,13 @@ use carwash::Args; use carwash::app::{AppState, reducer}; -use carwash::cache::UpdateCache; +use carwash::cache::DependencyVersionCache; use carwash::components::{ Component, dependencies::DependenciesPane, help::Help, output::TabbedOutputPane, palette::CommandPalette, projects::ProjectList, settings::SettingsModal, text_input::TextInput, updater::UpdateWizard, }; use carwash::events::{Action, Command, Focus, Mode}; -use carwash::project::{ProjectCheckStatus, find_rust_projects}; +use carwash::project::ProjectCheckStatus; use carwash::runner::{check_dependencies_with_cache, check_for_updates, run_command}; use carwash::tree::TreeNode; use carwash::ui::ui; @@ -84,72 +84,59 @@ fn restore_terminal() -> anyhow::Result<()> { Ok(()) } -/// Save current dependency check progress to persistent cache -fn save_cache_progress(state: &AppState) { - use std::collections::HashMap; - - let cache = UpdateCache::new(); - - // Use all_projects, not filtered state.projects, so we cache ALL projects including those without dependencies - for project in &state.all_projects { - // Compute Cargo.lock hash if it exists - let lock_path = project.path.join("Cargo.lock"); - if let Some(lock_hash) = UpdateCache::hash_cargo_lock(&lock_path) { - // Build cache data from current dependencies - let mut cached_deps = HashMap::new(); - for dep in &project.dependencies { - // CRITICAL: Only save dependencies that have been CHECKED - // If latest_version is None, the dep was never checked, so don't cache it - if dep.latest_version.is_some() { - cached_deps.insert( - dep.name.clone(), - carwash::cache::CachedDependency { - latest_version: dep.latest_version.clone(), - cached_at: dep.last_checked.unwrap_or_else(std::time::SystemTime::now), - }, - ); - } - } +/// Apply the global dep version cache to a slice of projects. +/// Sets latest_version, check_status, and last_checked on each dependency that has a cached entry. +fn apply_dep_version_cache( + projects: &mut [carwash::project::Project], + cache: &DependencyVersionCache, + cache_duration: std::time::Duration, +) { + use carwash::project::{DependencyCheckStatus, Project}; - // Save to cache (skip if no checked dependencies) - if !cached_deps.is_empty() { - let _ = cache.save(&project.path, lock_hash, cached_deps.clone()); + for project in projects.iter_mut() { + for dep in &mut project.dependencies { + if let Some(entry) = cache.lookup(&dep.name, &dep.current_version, cache_duration) { + dep.latest_version = Some(entry.latest.clone()); + dep.check_status = DependencyCheckStatus::Checked; + dep.last_checked = Some( + std::time::SystemTime::UNIX_EPOCH + + std::time::Duration::from_secs(entry.checked_at), + ); } } + project.check_status = Project::compute_check_status_from_deps(&project.dependencies); } } -/// Load dependency check progress from persistent cache -fn load_cache_progress(projects: &mut [carwash::project::Project]) { - use carwash::project::{DependencyCheckStatus, Project}; - - let cache = UpdateCache::new(); - - for project in projects.iter_mut() { - // Compute Cargo.lock hash - let lock_path = project.path.join("Cargo.lock"); - if let Some(lock_hash) = UpdateCache::hash_cargo_lock(&lock_path) { - // Try to load cached data - if let Some(cached_deps) = cache.load(&project.path, lock_hash) { - // Apply cached data to dependencies - for dep in &mut project.dependencies { - if let Some(cached_dep) = cached_deps.get(&dep.name) { - // Update with cached version info AND set the timestamp - dep.latest_version = cached_dep.latest_version.clone(); - dep.check_status = DependencyCheckStatus::Checked; - // Set last_checked to the cache timestamp so checks respect cache duration - dep.last_checked = Some(cached_dep.cached_at); - } - } +fn apply_dep_version_cache_to_tree( + node: &mut TreeNode, + cache: &DependencyVersionCache, + cache_duration: std::time::Duration, +) { + if let carwash::tree::TreeNodeType::Project(project) = &mut node.node_type { + apply_dep_version_cache(std::slice::from_mut(project), cache, cache_duration); + } - // Calculate project check status from cached data - project.check_status = - Project::compute_check_status_from_deps(&project.dependencies); - } - } + for child in &mut node.children { + apply_dep_version_cache_to_tree(child, cache, cache_duration); } } +fn normalize_target_directory(target_directory: &str) -> String { + let path = std::path::Path::new(target_directory); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + }; + std::fs::canonicalize(&absolute) + .unwrap_or(absolute) + .to_string_lossy() + .to_string() +} + async fn handle_event( event: Event, state: &mut AppState, @@ -222,24 +209,25 @@ async fn handle_event( KeyCode::Char('}') | KeyCode::Char(']') => Some(Action::IncreaseLeftPane), KeyCode::Char('(') | KeyCode::Char('-') => Some(Action::IncreaseTopRight), KeyCode::Char(')') | KeyCode::Char('+') => Some(Action::DecreaseTopRight), + KeyCode::Char('r') | KeyCode::Char('R') + if key.modifiers.contains(KeyModifiers::SHIFT) => + { + Some(Action::ResetLayout) + } KeyCode::Char('r') | KeyCode::Char('R') => { - if key.modifiers.contains(KeyModifiers::SHIFT) { - Some(Action::ResetLayout) - } else { - // Dispatch to focused component - match state.focus { - Focus::Projects => { - let mut project_list = ProjectList::new(); - project_list.handle_key_events(key.code, state) - } - Focus::Dependencies => { - let mut deps = DependenciesPane::new(); - deps.handle_key_events(key.code, state) - } - Focus::Output => { - let mut output = TabbedOutputPane::new(); - output.handle_key_events(key.code, state) - } + // Dispatch to focused component + match state.focus { + Focus::Projects => { + let mut project_list = ProjectList::new(); + project_list.handle_key_events(key.code, state) + } + Focus::Dependencies => { + let mut deps = DependenciesPane::new(); + deps.handle_key_events(key.code, state) + } + Focus::Output => { + let mut output = TabbedOutputPane::new(); + output.handle_key_events(key.code, state) } } } @@ -314,7 +302,6 @@ async fn handle_event( Action::ExecuteCommand(_) | Action::StartUpdateWizard | Action::RunUpdate - | Action::ProcessBackgroundUpdateQueue | Action::UpdateDependencies(..) | Action::UpdateSingleDependency(..) | Action::UpdateDependenciesStreamStart(_) => { @@ -332,10 +319,6 @@ async fn handle_event( _ => { // Handle synchronously through reducer reducer(state, action.clone()); - // Save progress if quitting - if matches!(action, Action::Quit) { - save_cache_progress(state); - } } } } @@ -361,6 +344,15 @@ async fn run_app( state: &mut AppState, target_directory: String, ) -> anyhow::Result<()> { + let target_directory = normalize_target_directory(&target_directory); + let target_path = std::path::Path::new(&target_directory); + if !target_path.is_dir() { + anyhow::bail!( + "Target directory does not exist or is not a directory: {}", + target_path.display() + ); + } + let (action_tx, mut action_rx) = mpsc::channel(100); let mut event_stream = crossterm::event::EventStream::new(); @@ -379,21 +371,15 @@ async fn run_app( let _ = init_tx.send(Action::InitializeTree(init_target)).await; }); - // Spawn deep scan in background for search index (fire and forget) - let action_tx_clone = action_tx.clone(); - let target_directory_clone = target_directory.clone(); - tokio::spawn(async move { - let target_dir_for_scan = target_directory_clone.clone(); - - // This can take a while, but it won't block the UI - if let Ok(projects) = - tokio::task::spawn_blocking(move || find_rust_projects(&target_dir_for_scan)).await - { - let _ = action_tx_clone - .send(Action::FinishProjectScan(projects, target_directory_clone)) - .await; + // Load global per-dependency version cache (shared across all projects) + let dep_cache = std::sync::Arc::new(tokio::sync::RwLock::new({ + let mut cache = DependencyVersionCache::load(); + // One-time migration from old per-project cache files + if cache.is_empty() { + cache = DependencyVersionCache::migrate_from_project_caches(); } - }); + cache + })); loop { tokio::select! { @@ -417,23 +403,101 @@ async fn run_app( let target_dir = target_dir.clone(); reducer(state, action); - // Spawn async load of root directory children (non-blocking) - let tx = action_tx.clone(); - let show_all = state.config.app.show_all_folders; + let root_is_project = state + .tree_root + .as_ref() + .is_some_and(|root| root.node_type.is_project()); - tokio::task::spawn_blocking(move || { - // Load children of root directory (depth 1, since root is depth 0) - let children = carwash::project::load_directory_children_async( - std::path::Path::new(&target_dir), - 1, // Root children are at depth 1 - show_all - ); - let root_path = std::path::PathBuf::from(&target_dir); - let _ = tx.blocking_send(Action::DirectoryLoaded(root_path, children)); - }); + if root_is_project { + { + let cache = dep_cache.read().await; + let cache_duration = state.config.app.cache_duration(); + apply_dep_version_cache( + &mut state.all_projects, + &cache, + cache_duration, + ); + apply_dep_version_cache(&mut state.projects, &cache, cache_duration); + if let Some(root) = &mut state.tree_root { + apply_dep_version_cache_to_tree(root, &cache, cache_duration); + state.flattened_tree = + carwash::tree::FlattenedTree::from_tree(root); + } + } + + reset_checking_status(state); + + let _ = action_tx.send(Action::CalculateProjectSizes).await; + } else { + // Spawn async load of root directory children (non-blocking) + let tx = action_tx.clone(); + let show_all = state.config.app.show_all_folders; + + tokio::task::spawn_blocking(move || { + // Load children of root directory (depth 1, since root is depth 0) + let children = carwash::project::load_directory_children_async( + std::path::Path::new(&target_dir), + 1, // Root children are at depth 1 + show_all + ); + let root_path = std::path::PathBuf::from(&target_dir); + let _ = tx.blocking_send(Action::DirectoryLoaded(root_path, children)); + }); + } } - Action::DirectoryLoaded(..) => { - reducer(state, action); + Action::DirectoryLoaded(path, children) => { + let path = path.clone(); + let mut children = children.clone(); + { + let cache = dep_cache.read().await; + let cache_duration = state.config.app.cache_duration(); + for child in &mut children { + apply_dep_version_cache_to_tree(child, &cache, cache_duration); + } + } + + let prev_all_count = state.all_projects.len(); + reducer(state, Action::DirectoryLoaded(path, children)); + + if state.all_projects.len() > prev_all_count { + // Size calculations with concurrency limit (reuse existing pattern) + let size_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(3)); + for project in &state.all_projects[prev_all_count..] { + let project_id = project.path.clone(); + let path = project.path.clone(); + let ws_root = project.workspace_root.clone(); + let tx = action_tx.clone(); + let sem = size_semaphore.clone(); + tokio::spawn(async move { + let _permit = sem.acquire().await.ok(); + let total = tokio::task::spawn_blocking({ + let path = path.clone(); + move || carwash::project::calculate_directory_size(&path) + }) + .await + .ok() + .flatten(); + + let target_path = + ws_root.unwrap_or_else(|| path.clone()).join("target"); + let target = if target_path.exists() { + tokio::task::spawn_blocking(move || { + carwash::project::calculate_directory_size(&target_path) + }) + .await + .ok() + .flatten() + } else { + Some(0) + }; + + let _ = tx + .send(Action::UpdateProjectSize(project_id, total, target)) + .await; + }); + } + } + } Action::SelectChild => { reducer(state, action); @@ -465,6 +529,7 @@ async fn run_app( false } mark_loading(root, &path); + state.flattened_tree = carwash::tree::FlattenedTree::from_tree(root); } // Spawn async load @@ -495,6 +560,7 @@ async fn run_app( false } mark_loading(root, path); + state.flattened_tree = carwash::tree::FlattenedTree::from_tree(root); } // Spawn async load @@ -525,69 +591,16 @@ async fn run_app( // Process the scan result FIRST (copies projects to state) reducer(state, action); - // THEN load cached dependency data (updates state with cache) - load_cache_progress(&mut state.all_projects); - - // Also update the filtered projects list with the cache data - load_cache_progress(&mut state.projects); + { + let cache = dep_cache.read().await; + let cache_duration = state.config.app.cache_duration(); + apply_dep_version_cache(&mut state.all_projects, &cache, cache_duration); + apply_dep_version_cache(&mut state.projects, &cache, cache_duration); + } // Reset any "Checking" status to "Unchecked" (app was interrupted) reset_checking_status(state); - if state.config.app.background_updates_enabled { - // NOW queue projects for background checks (after cache is loaded) - // Queue projects that: - // 1. Have expired cache (> cache TTL) - // 2. Have no cache (never checked) - // 3. Were interrupted (status is Unchecked) - // 4. Have ANY dependency that needs checking - let mut queue_idx = 0; - let cache_duration = state.config.app.cache_duration(); - for project in &state.all_projects { - // Skip projects with no dependencies - if project.dependencies.is_empty() { - continue; - } - - let needs_check = project.dependencies.iter().any(|dep| { - if let Some(last_checked) = dep.last_checked { - if let Ok(elapsed) = - std::time::SystemTime::now().duration_since(last_checked) - { - elapsed > cache_duration - } else { - true // Invalid timestamp, needs check - } - } else { - true // Never checked - needs check! - } - }); - - // Also queue if status is Unchecked (was interrupted or never checked) - let needs_check = - needs_check || project.check_status == ProjectCheckStatus::Unchecked; - - if needs_check { - let tx = action_tx.clone(); - let project_name = project.name.clone(); - - // Stagger the queue operations to keep UI responsive - let delay = std::time::Duration::from_millis(100 * queue_idx); - queue_idx += 1; - - tokio::spawn(async move { - tokio::time::sleep(delay).await; - let _ = tx - .send(Action::QueueBackgroundUpdate( - project_name, - false, - )) - .await; - }); - } - } - } - // Trigger size calculation in background (non-blocking) let tx = action_tx.clone(); tokio::spawn(async move { @@ -601,161 +614,47 @@ async fn run_app( carwash::handlers::handle_calculate_project_sizes(state, action_tx.clone()).await; } Action::StartUpdateWizard => { - let selected_project_name = state + let selected_project_id = state .get_selected_project() - .map(|p| p.name.clone()); - let is_currently_checking_same_project = selected_project_name + .map(|p| p.path.clone()); + let is_currently_checking_same_project = selected_project_id .as_ref() - .map(|name| { + .map(|project_id| { state.is_checking_updates && state .updater - .locked_project_name - .as_ref() - == Some(name) + .locked_project_id + .as_deref() + == Some(project_id.as_path()) }) .unwrap_or(false); + let is_checking_different_project = + state.is_checking_updates && !is_currently_checking_same_project; - // Process the action (may set pending_directory_check or open wizard) - reducer(state, action.clone()); - - // Check if this is a directory check (multiple projects) - if let Some(pending) = state.updater.pending_directory_check.take() { - // Queue all projects for background checking (not priority - no wizard) - for project_name in pending.project_names { - let _ = action_tx - .send(Action::QueueBackgroundUpdate(project_name, false)) - .await; - } - } else if let Some(project_name) = state.updater.locked_project_name.clone() { - // Single project - open wizard and queue priority check - if is_currently_checking_same_project { - // Already processing this project; just keep displaying the wizard - continue; - } - let _ = action_tx - .send(Action::QueueBackgroundUpdate(project_name, true)) - .await; + if is_checking_different_project { + continue; } - } - Action::StartBackgroundUpdateCheck => { - let action_tx_clone = action_tx.clone(); - check_for_updates(state, action_tx_clone).await; // Use new non-blocking check - reducer(state, action); - } - Action::ProcessBackgroundUpdateQueue => { - // Check if there are tasks to process in the queue - if let Some(task) = state.update_queue.get_next_task() { - let action_tx_clone = action_tx.clone(); - let project_name = task.project_name.clone(); - let is_priority = task.is_priority; - - // Find the project by name in all_projects (not filtered list) so background checks work for all projects - if let Some(project) = state.all_projects.iter().find(|p| p.name == project_name) { - let deps = project.dependencies.clone(); - let project_path = project.path.clone(); - let proj_name = project.name.clone(); - - // For priority tasks (user pressed 'u'), enter wizard mode IMMEDIATELY - if is_priority { - // Enter wizard mode right away - state.mode = Mode::UpdateWizard; - state.is_checking_updates = true; - - // Show cached data immediately if available - // Uses has_stable_update() to properly handle pre-release versions - state.updater.outdated_dependencies = deps - .iter() - .filter(|d| d.has_stable_update()) - .cloned() - .collect(); - - state.updater.selected_dependencies.clear(); - - // Select first item if there are outdated dependencies - if !state.updater.outdated_dependencies.is_empty() { - state.updater.list_state.select(Some(0)); - } else { - state.updater.list_state.select(None); - } - } - - // CRITICAL FIX: Only set status to "Checking" if deps actually need checking - // Don't overwrite cached status if all deps are fresh - let now = std::time::SystemTime::now(); - let cache_duration = state.config.app.cache_duration(); - let has_deps_needing_check = deps.iter().any(|dep| { - if let Some(last_checked) = dep.last_checked { - // Check if cache expired - if let Ok(elapsed) = now.duration_since(last_checked) { - elapsed > cache_duration - } else { - true // Invalid timestamp - } - } else { - true // Never checked - } - }); - // If all deps are fresh in cache, skip the async check entirely - if !has_deps_needing_check { - // All deps are fresh - no need to re-check - state.is_checking_updates = false; - state.update_queue.task_completed(); + // Process the action. Directory selections intentionally do nothing: + // update checks are manual and scoped to one selected project. + reducer(state, action.clone()); - // Continue queue for background tasks - if !is_priority { - let _ = action_tx.send(Action::ProcessBackgroundUpdateQueue).await; - } - } else { - // Some deps need checking - set status and spawn async check - reducer(state, Action::UpdateProjectCheckStatus( - proj_name.clone(), - ProjectCheckStatus::Checking - )); - - let action_tx_clone_2 = action_tx_clone.clone(); - let is_priority_task = is_priority; - let cache_duration = state.config.app.cache_duration(); - - // Perform the update check asynchronously - tokio::spawn(async move { - check_dependencies_with_cache( - proj_name, - deps, - action_tx_clone, - true, // use_cache=true to respect TTL - Some(project_path), - cache_duration, - ) - .await; + if is_currently_checking_same_project { + // Already processing this project; just keep displaying the wizard. + continue; + } - // Only continue queue for background tasks, not priority - if !is_priority_task { - let _ = action_tx_clone_2.send(Action::ProcessBackgroundUpdateQueue).await; - } - }); - } - } else { - // Project not found, mark as complete so queue can continue - state.update_queue.task_completed(); - // Try to process the next task immediately - let _ = action_tx.send(Action::ProcessBackgroundUpdateQueue).await; - } + if state.updater.locked_project_id.is_some() { + let action_tx_clone = action_tx.clone(); + let state_snapshot = state.clone(); + let dep_cache_clone = dep_cache.clone(); + tokio::spawn(async move { + check_for_updates(&state_snapshot, action_tx_clone, dep_cache_clone).await; + }); } } - Action::QueueBackgroundUpdate(_, _) => { - // Add to queue and start processing - reducer(state, action); - let _ = action_tx.send(Action::ProcessBackgroundUpdateQueue).await; - } Action::UpdateDependencies(..) => { - // Mark the background update task as complete - state.update_queue.task_completed(); - // Process the update results reducer(state, action); - // Continue processing the queue - let _ = action_tx.send(Action::ProcessBackgroundUpdateQueue).await; } Action::UpdateSingleDependency(..) => { // Update individual dependency and continue @@ -767,17 +666,9 @@ async fn run_app( } Action::SaveSettings => { reducer(state, action.clone()); - - if state.config.app.background_updates_enabled - && state.update_queue.has_pending_tasks() - { - let _ = action_tx - .send(Action::ProcessBackgroundUpdateQueue) - .await; - } } Action::RunUpdate => { - // Build the cargo update command for selected dependencies + // Construct the cargo update command for selected dependencies // Use name@version format to avoid ambiguity when multiple versions exist let selected_deps: Vec = state .updater @@ -798,16 +689,14 @@ async fn run_app( // Run update only on the currently highlighted project // (the one whose dependencies are shown in the update wizard) - if let Some(project) = state.get_selected_project() { - let project_name = project.name.clone(); - + if let Some(project_id) = state.updater.locked_project_id.clone() { // Temporarily clear selected projects and set only the current one let previous_selection = state.selected_projects.clone(); state.selected_projects.clear(); - state.selected_projects.insert(project_name.clone()); + state.selected_projects.insert(project_id.clone()); // Set pending reload - will be processed when FinishCommand is received - state.updater.pending_reload_project = Some(project_name); + state.updater.pending_reload_project = Some(project_id); // Clear wizard selections (but keep wizard open until command finishes) state.updater.selected_dependencies.clear(); @@ -824,16 +713,15 @@ async fn run_app( } } Action::FinishCommand(tab_index) => { - // NOTE: reducer was already called in sync path (handle_component_events) - // This async handler only runs when there's a pending reload + reducer(state, Action::FinishCommand(*tab_index)); // Check if we have a pending dependency reload after update - if let Some(project_name) = state.updater.pending_reload_project.take() - && let Some(all_proj) = state.all_projects.iter_mut().find(|p| p.name == project_name) + if let Some(project_id) = state.updater.pending_reload_project.take() + && let Some(all_proj) = state.all_projects.iter_mut().find(|p| p.path == project_id) && let Ok(()) = all_proj.reload_dependencies() { // Successfully reloaded! Now sync to filtered projects list - if let Some(proj) = state.projects.iter_mut().find(|p| p.name == project_name) { + if let Some(proj) = state.projects.iter_mut().find(|p| p.path == project_id) { proj.dependencies = all_proj.dependencies.clone(); } @@ -842,20 +730,20 @@ async fn run_app( // Now re-check with the FRESH dependencies to get latest versions let fresh_deps = all_proj.dependencies.clone(); - let project_path = all_proj.path.clone(); - let proj_name = all_proj.name.clone(); + let proj_id = all_proj.path.clone(); let cache_duration = state.config.app.cache_duration(); let action_tx_clone = action_tx.clone(); + let dep_cache_clone = dep_cache.clone(); tokio::spawn(async move { // Re-check with fresh dependencies from disk check_dependencies_with_cache( - proj_name, + proj_id, fresh_deps, action_tx_clone, false, // Don't use cache - force fresh check - Some(project_path), cache_duration, + dep_cache_clone, ) .await; }); @@ -877,7 +765,8 @@ async fn run_app( _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => { // Periodic cache persistence (every 30 seconds) if last_cache_save.elapsed() > std::time::Duration::from_secs(30) { - save_cache_progress(state); + let mut cache = dep_cache.write().await; + let _ = cache.save(); last_cache_save = std::time::Instant::now(); } continue; @@ -885,6 +774,8 @@ async fn run_app( }; if state.should_quit { + let mut cache = dep_cache.write().await; + let _ = cache.save(); return Ok(()); } } diff --git a/src/project.rs b/src/project.rs index de2f1f1..0db85c5 100644 --- a/src/project.rs +++ b/src/project.rs @@ -244,7 +244,7 @@ impl Project { } } - fn from_toml( + pub fn from_toml( path: &Path, toml: &CargoToml, workspace_root: Option, @@ -253,23 +253,7 @@ impl Project { let package = toml.package.as_ref()?; let project_path = path.parent()?.to_path_buf(); - // Collect dependency names from this crate's Cargo.toml - let mut declared_deps: HashSet = HashSet::new(); - - // Add regular dependencies - for dep_name in toml.dependencies.keys() { - declared_deps.insert(dep_name.clone()); - } - - // Add dev-dependencies - for dep_name in toml.dev_dependencies.keys() { - declared_deps.insert(dep_name.clone()); - } - - // Add build-dependencies - for dep_name in toml.build_dependencies.keys() { - declared_deps.insert(dep_name.clone()); - } + let declared_deps = collect_declared_dependency_names(toml); // For workspace members, try to load Cargo.lock from workspace root first let lockfile_path = if let Some(ref ws_root) = workspace_root { @@ -288,7 +272,9 @@ impl Project { .packages .iter() // Filter to only dependencies declared in this crate's Cargo.toml - .filter(|pkg| declared_deps.contains(pkg.name.as_str())) + .filter(|pkg| { + declared_deps.contains(pkg.name.as_str()) && is_crates_io_package(pkg) + }) .map(Dependency::from) .collect() } else { @@ -297,6 +283,8 @@ impl Project { let authors = package.authors_vec(); + let check_status = Project::compute_check_status_from_deps(&dependencies); + Some(Self { name: package.name.clone(), path: project_path, @@ -307,10 +295,10 @@ impl Project { workspace_root, workspace_name, cargo_lock_hash: None, // No hash available here, will be calculated later - check_status: ProjectCheckStatus::Unchecked, // Start as unchecked + check_status, git_status: GitStatus::Unknown, // Check git status asynchronously - total_size: None, // Calculate on demand - target_size: None, // Calculate on demand + total_size: None, // Calculate on demand + target_size: None, // Calculate on demand }) } @@ -330,17 +318,7 @@ impl Project { let toml: CargoToml = toml::from_str(&toml_content) .map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?; - // Collect declared dependency names - let mut declared_deps: HashSet = HashSet::new(); - for dep_name in toml.dependencies.keys() { - declared_deps.insert(dep_name.clone()); - } - for dep_name in toml.dev_dependencies.keys() { - declared_deps.insert(dep_name.clone()); - } - for dep_name in toml.build_dependencies.keys() { - declared_deps.insert(dep_name.clone()); - } + let declared_deps = collect_declared_dependency_names(&toml); // Determine which Cargo.lock to use (workspace or project) let lockfile_path = if let Some(ref ws_root) = self.workspace_root { @@ -362,7 +340,7 @@ impl Project { let mut new_deps: Vec = lockfile .packages .iter() - .filter(|pkg| declared_deps.contains(pkg.name.as_str())) + .filter(|pkg| declared_deps.contains(pkg.name.as_str()) && is_crates_io_package(pkg)) .map(|pkg| { // Try to preserve latest_version, check_status, and last_checked from existing deps let existing = self @@ -398,6 +376,24 @@ impl Project { /// Returns `HasUpdates` if any dependency has a newer stable version available, /// otherwise returns `UpToDate`. pub fn compute_check_status_from_deps(deps: &[Dependency]) -> ProjectCheckStatus { + if deps.is_empty() { + return ProjectCheckStatus::UpToDate; + } + + if deps + .iter() + .any(|d| d.check_status == DependencyCheckStatus::Checking) + { + return ProjectCheckStatus::Checking; + } + + if deps + .iter() + .any(|d| d.check_status != DependencyCheckStatus::Checked || d.latest_version.is_none()) + { + return ProjectCheckStatus::Unchecked; + } + let has_updates = deps.iter().any(|d| d.has_stable_update()); if has_updates { @@ -447,14 +443,20 @@ impl Project { pub fn calculate_directory_size(path: &Path) -> Option { use walkdir::WalkDir; - WalkDir::new(path) - .follow_links(false) // Don't follow symlinks to avoid infinite loops - .into_iter() - .filter_map(|entry| entry.ok()) - .filter_map(|entry| entry.metadata().ok()) - .filter(|metadata| metadata.is_file()) - .map(|metadata| metadata.len()) - .reduce(|acc, size| acc + size) + if !path.exists() { + return None; + } + + Some( + WalkDir::new(path) + .follow_links(false) // Don't follow symlinks to avoid infinite loops + .into_iter() + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.metadata().ok()) + .filter(|metadata| metadata.is_file()) + .map(|metadata| metadata.len()) + .sum(), + ) } #[derive(Debug, Deserialize)] @@ -520,6 +522,34 @@ pub struct Workspace { pub other: std::collections::HashMap, } +fn collect_declared_dependency_names(toml: &CargoToml) -> HashSet { + let mut declared_deps = HashSet::new(); + insert_dependency_names(&toml.dependencies, &mut declared_deps); + insert_dependency_names(&toml.dev_dependencies, &mut declared_deps); + insert_dependency_names(&toml.build_dependencies, &mut declared_deps); + declared_deps +} + +fn insert_dependency_names( + dependencies: &HashMap, + declared_deps: &mut HashSet, +) { + for (dep_name, dep_value) in dependencies { + let package_name = dep_value + .as_table() + .and_then(|table| table.get("package")) + .and_then(toml::Value::as_str) + .unwrap_or(dep_name); + declared_deps.insert(package_name.to_string()); + } +} + +fn is_crates_io_package(pkg: &LockPackage) -> bool { + pkg.source + .as_ref() + .is_some_and(|source| source.is_default_registry()) +} + /// Recursively finds all Rust projects in the given directory path. /// /// This function scans for Cargo.toml files, identifies workspaces, @@ -693,11 +723,27 @@ pub fn build_project_tree(path: &str) -> crate::tree::TreeNode { .unwrap_or_else(|| PathBuf::from(path)) }; + if let Some(project) = standalone_project_at(&base_path) { + return crate::tree::TreeNode::project(project, 0); + } + // Build ONLY the root level - no recursive scanning! // Children will be loaded lazily when directories are expanded build_tree_level_only(&base_path, 0) } +fn standalone_project_at(path: &Path) -> Option { + let cargo_toml_path = path.join("Cargo.toml"); + let content = fs::read_to_string(&cargo_toml_path).ok()?; + let toml = toml::from_str::(&content).ok()?; + + if toml.workspace.is_some() || toml.package.is_none() { + return None; + } + + Project::from_toml(&cargo_toml_path, &toml, None, None) +} + /// Build a single level of the tree (non-recursive) fn build_tree_level_only(dir_path: &Path, depth: usize) -> crate::tree::TreeNode { let dir_name = dir_path @@ -833,43 +879,46 @@ pub fn load_directory_children_async( // Workspaces should be expandable directories to show their members if toml.workspace.is_some() { // It's a workspace root - treat as expandable directory - // Even if it also has a [package] section, prioritize showing members if show_all_folders || directory_contains_rust_projects(&path) { let mut dir_node = build_tree_level_only(&path, depth + 1); // Load children eagerly so "crates" dirs can be auto-expanded - // NOTE: For async version, we might want to skip this eager loading or make it recursive? - // For now, let's keep it consistent but synchronous for this sublevel load_directory_children(&mut dir_node, show_all_folders); - // But keep the workspace itself collapsed - user can expand with h/l or ←/→ + load_auto_expanded_descendants(&mut dir_node, show_all_folders); + + // Patch workspace info on child project nodes so they + // resolve deps from the workspace-level Cargo.lock + let ws_name = toml + .package + .as_ref() + .map(|p| p.name.clone()) + .unwrap_or_else(|| { + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("workspace") + .to_string() + }); + patch_workspace_info(&mut dir_node, &path, &ws_name); + + // Keep the workspace itself collapsed dir_node.expanded = false; children.push(dir_node); } - } else if let Some(package) = &toml.package { - // It's a standalone project (no workspace) - add as project node - let project = Project { - name: package.name.clone(), - path: path.clone(), - version: package.version_string(), - authors: package.authors_vec(), - dependencies: Vec::new(), // Will be loaded on-demand when needed - workspace_root: None, - workspace_name: None, - cargo_lock_hash: None, - status: ProjectStatus::Pending, - check_status: ProjectCheckStatus::Unchecked, - git_status: GitStatus::Unknown, // Check asynchronously - total_size: None, // Calculate on demand - target_size: None, // Calculate on demand - }; - let project_node = crate::tree::TreeNode::project(project, depth + 1); - children.push(project_node); + } else if toml.package.is_some() { + // It's a standalone project — use from_toml to get deps from Cargo.lock + if let Some(project) = + Project::from_toml(&cargo_toml_path, &toml, None, None) + { + let project_node = crate::tree::TreeNode::project(project, depth + 1); + children.push(project_node); + } } } else if path.is_dir() { // No Cargo.toml - check if we should show it as a directory if show_all_folders || directory_contains_rust_projects(&path) { - let dir_node = build_tree_level_only(&path, depth + 1); + let mut dir_node = build_tree_level_only(&path, depth + 1); // Don't override expanded state - build_tree_level_only sets it correctly // (e.g., "crates" directories are auto-expanded) + load_auto_expanded_descendants(&mut dir_node, show_all_folders); children.push(dir_node); } } @@ -898,6 +947,37 @@ pub fn load_directory_children_async( children } +/// Recursively patch workspace_root and workspace_name on child project nodes, +/// then reload their dependencies from the workspace-level Cargo.lock. +fn patch_workspace_info( + node: &mut crate::tree::TreeNode, + workspace_root: &Path, + workspace_name: &str, +) { + for child in &mut node.children { + if let crate::tree::TreeNodeType::Project(ref mut project) = child.node_type { + project.workspace_root = Some(workspace_root.to_path_buf()); + project.workspace_name = Some(workspace_name.to_string()); + // Re-load deps now that workspace_root is set (correct Cargo.lock resolution) + let _ = project.reload_dependencies(); + } + // Recurse into subdirectories (e.g., crates/) + patch_workspace_info(child, workspace_root, workspace_name); + } +} + +/// Populate descendants that are expanded by convention, such as workspace +/// `crates/` directories, before the tree is flattened. +fn load_auto_expanded_descendants(node: &mut crate::tree::TreeNode, show_all_folders: bool) { + if node.node_type.is_directory() && node.expanded && !node.children_loaded { + load_directory_children(node, show_all_folders); + } + + for child in &mut node.children { + load_auto_expanded_descendants(child, show_all_folders); + } +} + /// Load children for a directory node (lazy loading) /// This scans only the immediate children (1 level deep) /// @@ -921,6 +1001,14 @@ pub fn load_directory_children(node: &mut crate::tree::TreeNode, show_all_folder mod tests { use super::*; + fn unique_temp_dir(prefix: &str) -> PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("{}-{}", prefix, unique)) + } + #[test] fn test_project_discovery() { // Test that the discovery function runs without panicking @@ -1018,6 +1106,25 @@ serde = { workspace = true } assert_eq!(package.authors_vec(), Vec::::new()); // workspace-inherited, so empty } + #[test] + fn test_renamed_dependency_uses_package_name() { + let toml_content = r#" +[package] +name = "test-crate" +version = "0.1.0" + +[dependencies] +serde_renamed = { package = "serde", version = "1" } +tokio = "1" +"#; + let cargo_toml: CargoToml = toml::from_str(toml_content).unwrap(); + let names = collect_declared_dependency_names(&cargo_toml); + + assert!(names.contains("serde")); + assert!(names.contains("tokio")); + assert!(!names.contains("serde_renamed")); + } + #[test] fn test_project_status_default() { let status = ProjectStatus::Pending; @@ -1056,6 +1163,30 @@ serde = { workspace = true } assert!(dep.latest_version.is_none()); } + #[test] + fn test_crates_io_package_filter() { + use cargo_lock::{Package as LockPackage, SourceId, Version}; + + let crates_io_pkg = LockPackage { + name: "serde".parse().unwrap(), + version: Version::parse("1.0.0").unwrap(), + source: Some( + SourceId::from_url("registry+https://github.com/rust-lang/crates.io-index") + .unwrap(), + ), + checksum: None, + dependencies: Vec::new(), + replace: None, + }; + assert!(is_crates_io_package(&crates_io_pkg)); + + let path_pkg = LockPackage { + source: None, + ..crates_io_pkg + }; + assert!(!is_crates_io_package(&path_pkg)); + } + #[test] fn test_is_prerelease() { // Stable versions @@ -1151,6 +1282,33 @@ serde = { workspace = true } assert!(!dep.has_stable_update()); } + #[test] + fn test_compute_check_status_requires_complete_checked_data() { + let unchecked_dep = Dependency { + name: "unchecked".into(), + current_version: "1.0.0".into(), + latest_version: None, + check_status: DependencyCheckStatus::NotChecked, + last_checked: None, + }; + assert_eq!( + Project::compute_check_status_from_deps(&[unchecked_dep]), + ProjectCheckStatus::Unchecked + ); + + let checked_dep = Dependency { + name: "checked".into(), + current_version: "1.0.0".into(), + latest_version: Some("1.0.0".into()), + check_status: DependencyCheckStatus::Checked, + last_checked: None, + }; + assert_eq!( + Project::compute_check_status_from_deps(&[checked_dep]), + ProjectCheckStatus::UpToDate + ); + } + #[test] fn test_update_type() { // Stable → stable @@ -1227,11 +1385,16 @@ serde = { workspace = true } #[test] fn test_build_project_tree() { - // Test that tree building doesn't panic - let tree = build_project_tree("."); + let root = unique_temp_dir("carwash-tree-test"); + std::fs::create_dir_all(&root).unwrap(); + + let tree = build_project_tree(root.to_string_lossy().as_ref()); // Tree should have a root node - assert_eq!(tree.node_type.name(), "carwash"); + assert_eq!( + tree.node_type.name(), + root.file_name().unwrap().to_string_lossy() + ); // Root should be a directory assert!(tree.node_type.is_directory()); @@ -1241,6 +1404,34 @@ serde = { workspace = true } // Tree structure should be created successfully let _ = tree; + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn test_build_project_tree_root_standalone_project() { + let root = unique_temp_dir("carwash-root-project-test"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("Cargo.toml"), + r#" +[package] +name = "root-project" +version = "0.1.0" +"#, + ) + .unwrap(); + + let tree = build_project_tree(root.to_string_lossy().as_ref()); + assert!(tree.node_type.is_project()); + assert!(tree.children_loaded); + + let crate::tree::TreeNodeType::Project(project) = tree.node_type else { + panic!("expected root project node"); + }; + assert_eq!(project.name, "root-project"); + assert_eq!(project.path, root); + + let _ = std::fs::remove_dir_all(project.path); } #[test] @@ -1252,4 +1443,33 @@ serde = { workspace = true } // Flattened tree should have at least the root node assert!(!flattened.items.is_empty()); } + + #[test] + fn test_auto_expanded_crates_directory_loads_projects() { + let root = unique_temp_dir("carwash-crates-test"); + let member = root.join("crates").join("member"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write( + member.join("Cargo.toml"), + r#" +[package] +name = "member" +version = "0.1.0" +"#, + ) + .unwrap(); + + let mut tree = build_project_tree(root.to_string_lossy().as_ref()); + load_directory_children(&mut tree, false); + let flattened = crate::tree::FlattenedTree::from_tree(&tree); + + assert!(flattened.items.iter().any(|(node, _)| { + matches!( + &node.node_type, + crate::tree::TreeNodeType::Project(project) if project.name == "member" + ) + })); + + let _ = std::fs::remove_dir_all(root); + } } diff --git a/src/runner.rs b/src/runner.rs index 7d20668..d379dd0 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,14 +1,13 @@ //! Task execution and update checking //! //! This module handles the execution of cargo commands and dependency update checking. -//! It manages a queue of tasks that are executed with proper concurrency limits and caching. +//! Dependency update checks are user-initiated and scoped to one project at a time. use crate::app::AppState; -use crate::cache::UpdateCache; use crate::events::Action; use crate::project::{Dependency, DependencyCheckStatus, Project}; use crates_io_api::AsyncClient; -use std::collections::VecDeque; +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use std::time::SystemTime; @@ -19,101 +18,20 @@ use tokio::{ }; const PARALLEL_UPDATE_CHECKS: usize = 5; -/// A task to check for dependency updates on a project -#[derive(Debug, Clone)] -pub struct UpdateCheckTask { - /// Name of the project to check - pub project_name: String, - /// Whether this is a priority task (user-initiated) - pub is_priority: bool, -} - -/// Queue for managing parallel update check tasks -/// -/// The queue manages a limited number of concurrent update checks to avoid overwhelming -/// the crates.io API. Priority tasks (user-initiated) are processed before background tasks. -#[derive(Debug, Clone)] -pub struct UpdateQueue { - /// Queue of pending tasks - pub queue: VecDeque, - /// Number of tasks currently in progress - pub in_progress: usize, -} - -impl UpdateQueue { - pub fn new() -> Self { - Self { - queue: VecDeque::new(), - in_progress: 0, - } - } - - pub fn add_task(&mut self, task: UpdateCheckTask) { - // Check if this project is already in the queue - let already_queued = self - .queue - .iter() - .any(|t| t.project_name == task.project_name); - - if already_queued { - // If it's a priority task and the existing one isn't, upgrade it - if task.is_priority { - // Remove the existing non-priority task - self.queue.retain(|t| t.project_name != task.project_name); - // Add the priority version at the front - self.queue.push_front(task); - } - // Otherwise skip (duplicate) - return; - } - - if task.is_priority { - // Insert at the front for priority tasks - self.queue.push_front(task); - } else { - // Append to the back for background tasks - self.queue.push_back(task); - } - } - - pub fn get_next_task(&mut self) -> Option { - if self.in_progress >= PARALLEL_UPDATE_CHECKS { - return None; - } - - if let Some(task) = self.queue.pop_front() { - self.in_progress += 1; - Some(task) - } else { - None - } - } - - pub fn task_completed(&mut self) { - if self.in_progress > 0 { - self.in_progress -= 1; - } - } - - pub fn has_pending_tasks(&self) -> bool { - !self.queue.is_empty() || self.in_progress > 0 - } - - pub fn clear(&mut self) { - self.queue.clear(); - self.in_progress = 0; - } -} /// Check for updates on selected project with proper caching /// This is called when user presses 'u' or opens update wizard -pub async fn check_for_updates(state: &AppState, tx: mpsc::Sender) { +pub async fn check_for_updates( + state: &AppState, + tx: mpsc::Sender, + dep_cache: Arc>, +) { // CRITICAL FIX: When called from wizard, use the LOCKED project, not current selection! // User might have moved cursor after opening wizard let project_to_check = if state.mode == crate::events::Mode::UpdateWizard { // Wizard is open - use the locked project - if let Some(ref locked_name) = state.updater.locked_project_name { - state.all_projects.iter().find(|p| &p.name == locked_name) + if let Some(ref locked_id) = state.updater.locked_project_id { + state.all_projects.iter().find(|p| &p.path == locked_id) } else { state.get_selected_project() } @@ -123,22 +41,20 @@ pub async fn check_for_updates(state: &AppState, tx: mpsc::Sender) { if let Some(project) = project_to_check { let deps = project.dependencies.clone(); - let project_name = project.name.clone(); - let project_path = project.path.clone(); + let project_id = project.path.clone(); // Send initial action to show we're checking let _ = tx - .send(Action::UpdateDependenciesStreamStart(project_name.clone())) + .send(Action::UpdateDependenciesStreamStart(project_id.clone())) .await; - // Perform checks asynchronously - don't await here check_dependencies_with_cache( - project_name, + project_id, deps, tx, true, - Some(project_path), state.config.app.cache_duration(), + dep_cache, ) .await; } @@ -149,12 +65,13 @@ async fn check_single_dependency( dep: Dependency, client: &AsyncClient, tx: &mpsc::Sender, - project_name: &str, + project_id: &Path, use_cache: bool, cache_duration: std::time::Duration, now: SystemTime, -) -> Option { +) -> Option<(Dependency, bool)> { let mut updated_dep = dep.clone(); + let mut checked_remote = false; let should_check = if use_cache { if let Some(last_checked) = updated_dep.last_checked { if let Ok(elapsed) = now.duration_since(last_checked) { @@ -174,7 +91,7 @@ async fn check_single_dependency( let _ = tx .send(Action::UpdateDependencyCheckStatus( - project_name.to_string(), + project_id.to_path_buf(), updated_dep.name.clone(), DependencyCheckStatus::Checking, )) @@ -190,35 +107,51 @@ async fn check_single_dependency( updated_dep.latest_version = Some(crate_info.crate_data.max_version); updated_dep.check_status = DependencyCheckStatus::Checked; updated_dep.last_checked = Some(SystemTime::now()); + checked_remote = true; } _ => { - updated_dep.check_status = DependencyCheckStatus::Checked; - updated_dep.last_checked = Some(SystemTime::now()); + updated_dep.check_status = DependencyCheckStatus::NotChecked; } } - // Only send update for deps we actually checked (avoid redundant UI updates) + // Only send update for deps we actually checked (avoid redundant UI updates). + // Failed checks intentionally remain NotChecked so the project is not shown + // as up to date from incomplete data. let _ = tx .send(Action::UpdateSingleDependency( - project_name.to_string(), + project_id.to_path_buf(), updated_dep.clone(), )) .await; } // Skip sending UpdateSingleDependency for cached deps - they're already up to date - Some(updated_dep) + Some((updated_dep, checked_remote)) } pub async fn check_dependencies_with_cache( - project_name: String, + project_id: PathBuf, deps: Vec, tx: mpsc::Sender, use_cache: bool, - project_path: Option, cache_duration: std::time::Duration, + dep_cache: Arc>, ) { - let cache = UpdateCache::new(); + let mut deps = deps; + if use_cache { + let cache = dep_cache.read().await; + for dep in &mut deps { + if let Some(entry) = cache.lookup(&dep.name, &dep.current_version, cache_duration) { + dep.latest_version = Some(entry.latest.clone()); + dep.check_status = DependencyCheckStatus::Checked; + dep.last_checked = Some( + std::time::SystemTime::UNIX_EPOCH + + std::time::Duration::from_secs(entry.checked_at), + ); + } + } + } + let semaphore = Arc::new(Semaphore::new(PARALLEL_UPDATE_CHECKS)); let client = match AsyncClient::new( "carwash/0.1.0 (https://github.com/epistates/carwash)", @@ -226,9 +159,7 @@ pub async fn check_dependencies_with_cache( ) { Ok(client) => client, Err(_) => { - let _ = tx - .send(Action::UpdateDependencies(project_name, deps)) - .await; + let _ = tx.send(Action::UpdateDependencies(project_id, deps)).await; return; } }; @@ -239,7 +170,7 @@ pub async fn check_dependencies_with_cache( let semaphore_clone = semaphore.clone(); let client_clone = client.clone(); let tx_clone = tx.clone(); - let project_name_clone = project_name.clone(); + let project_id_clone = project_id.clone(); let task = tokio::spawn(async move { let _permit = semaphore_clone.acquire().await.ok()?; @@ -247,7 +178,7 @@ pub async fn check_dependencies_with_cache( dep, &client_clone, &tx_clone, - &project_name_clone, + &project_id_clone, use_cache, cache_duration, now, @@ -258,41 +189,37 @@ pub async fn check_dependencies_with_cache( } let mut updated_deps = Vec::new(); + let mut remotely_checked_deps = Vec::new(); for task in tasks { - if let Ok(Some(dep)) = task.await { + if let Ok(Some((dep, checked_remote))) = task.await { + if checked_remote { + remotely_checked_deps.push(dep.clone()); + } updated_deps.push(dep); } } - if let Some(path) = project_path - && let Some(lock_hash) = UpdateCache::hash_cargo_lock(&path.join("Cargo.lock")) - { - let mut cached_deps = std::collections::HashMap::new(); - for dep in &updated_deps { - if dep.latest_version.is_some() { - cached_deps.insert( - dep.name.clone(), - crate::cache::CachedDependency { - latest_version: dep.latest_version.clone(), - cached_at: dep.last_checked.unwrap_or_else(SystemTime::now), - }, - ); + // Update the global per-dependency version cache + if !remotely_checked_deps.is_empty() { + let mut cache = dep_cache.write().await; + for dep in &remotely_checked_deps { + if dep.check_status == DependencyCheckStatus::Checked + && let Some(ref latest) = dep.latest_version + { + cache.insert(&dep.name, &dep.current_version, latest.clone()); } } - - if !cached_deps.is_empty() { - let _ = cache.save(&path, lock_hash, cached_deps.clone()); - } + let _ = cache.save(); } let _ = tx - .send(Action::UpdateDependencies(project_name, updated_deps)) + .send(Action::UpdateDependencies(project_id, updated_deps)) .await; } /// Recalculate the size of a single project after a command (e.g., cargo clean) async fn recalculate_project_size(project: &Project, tx: &mpsc::Sender) { - let project_name = project.name.clone(); + let project_id = project.path.clone(); let project_path = project.path.clone(); let workspace_root = project.workspace_root.clone(); let tx = tx.clone(); @@ -326,7 +253,7 @@ async fn recalculate_project_size(project: &Project, tx: &mpsc::Sender) // Send update back to main thread let _ = tx .send(Action::UpdateProjectSize( - project_name, + project_id, total_size, target_size, )) @@ -421,110 +348,153 @@ async fn spawn_and_stream_command( Ok(()) } +#[derive(Debug, Clone)] +struct CleanCommandTarget { + tab_index: usize, + command_project: Project, + affected_projects: Vec, +} + +fn clean_target_path(project: &Project) -> PathBuf { + project + .workspace_root + .clone() + .unwrap_or_else(|| project.path.clone()) +} + +fn clean_target_name(first_project: &Project, target_path: &Path) -> String { + first_project.workspace_name.clone().unwrap_or_else(|| { + target_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(first_project.name.as_str()) + .to_string() + }) +} + +fn plan_clean_command_targets( + projects: Vec, + start_tab_count: usize, +) -> Vec { + let mut grouped_projects: Vec<(PathBuf, Vec)> = Vec::new(); + + for project in projects { + let target_path = clean_target_path(&project); + if let Some((_, existing_projects)) = grouped_projects + .iter_mut() + .find(|(path, _)| *path == target_path) + { + existing_projects.push(project); + } else { + grouped_projects.push((target_path, vec![project])); + } + } + + grouped_projects + .into_iter() + .enumerate() + .map(|(offset, (target_path, affected_projects))| { + let mut command_project = affected_projects[0].clone(); + command_project.name = clean_target_name(&command_project, &target_path); + command_project.path = target_path; + command_project.workspace_root = None; + + CleanCommandTarget { + tab_index: start_tab_count + offset, + command_project, + affected_projects, + } + }) + .collect() +} + pub async fn run_command(command_str: &str, state: &AppState, tx: mpsc::Sender) { if command_str.is_empty() { return; } let projects_to_run: Vec = state - .projects + .all_projects .iter() - .filter(|p| state.selected_projects.contains(&p.name)) + .filter(|p| state.selected_projects.contains(&p.path)) .cloned() .collect(); if projects_to_run.is_empty() { + let tab_index = state.tabs.len(); let _ = tx .send(Action::CreateTab("Error: No projects selected".to_string())) .await; let _ = tx .send(Action::AddOutput( - 0, + tab_index, "Please select at least one project to run commands.".to_string(), )) .await; - let _ = tx.send(Action::FinishCommand(0)).await; + let _ = tx.send(Action::FinishCommand(tab_index)).await; return; } let start_tab_count = state.tabs.len(); - let is_clean_command = command_str.contains("clean"); + let is_clean_command = shlex::split(command_str) + .unwrap_or_else(|| command_str.split_whitespace().map(String::from).collect()) + .first() + .is_some_and(|command| command == "clean"); - // For clean commands on workspace members, we need to run sequentially to avoid race conditions - // Group projects by workspace root if is_clean_command { - use std::collections::HashMap; - use std::path::PathBuf; - - let mut workspace_groups: HashMap, Vec<(usize, Project)>> = HashMap::new(); - for (i, project) in projects_to_run.into_iter().enumerate() { - workspace_groups - .entry(project.workspace_root.clone()) - .or_default() - .push((i, project)); + let clean_targets = plan_clean_command_targets(projects_to_run, start_tab_count); + + for target in &clean_targets { + let tab_title = format!("{}: {}", command_str, target.command_project.name); + let _ = tx.send(Action::CreateTab(tab_title)).await; } - // Process each workspace group - for (_ws_root, group) in workspace_groups { - let has_shared_target = group.len() > 1 && group[0].1.workspace_root.is_some(); - - if has_shared_target { - // Spawn a single background task to run workspace member cleans sequentially - // This prevents race conditions while keeping the UI responsive - let tx = tx.clone(); - let command_str = command_str.to_string(); - - tokio::spawn(async move { - for (i, project) in group { - let tab_title = format!("{}: {}", command_str, project.name); - let tab_index = start_tab_count + i; - - let _ = tx.send(Action::CreateTab(tab_title)).await; - - if let Err(e) = - spawn_and_stream_command(&command_str, &project, &tx, tab_index).await - { - let _ = tx - .send(Action::AddOutput(tab_index, format!("❌ Error: {}", e))) - .await; - } - recalculate_project_size(&project, &tx).await; - let _ = tx.send(Action::FinishCommand(tab_index)).await; - } - }); - } else { - // Run in parallel for standalone projects - for (i, project) in group { - let tx = tx.clone(); - let command_str = command_str.to_string(); - let tab_title = format!("{}: {}", command_str, project.name); - let tab_index = start_tab_count + i; - - let _ = tx.send(Action::CreateTab(tab_title)).await; - - tokio::spawn(async move { - if let Err(e) = - spawn_and_stream_command(&command_str, &project, &tx, tab_index).await - { - let _ = tx - .send(Action::AddOutput(tab_index, format!("❌ Error: {}", e))) - .await; - } - recalculate_project_size(&project, &tx).await; - let _ = tx.send(Action::FinishCommand(tab_index)).await; - }); + // `cargo clean` acts on the workspace target directory for workspace members. + // Run it once per unique target directory, then refresh every affected row. + for target in clean_targets { + let tx = tx.clone(); + let command_str = command_str.to_string(); + + tokio::spawn(async move { + if let Err(e) = spawn_and_stream_command( + &command_str, + &target.command_project, + &tx, + target.tab_index, + ) + .await + { + let _ = tx + .send(Action::AddOutput( + target.tab_index, + format!("❌ Error: {}", e), + )) + .await; } - } + + for project in target.affected_projects { + recalculate_project_size(&project, &tx).await; + } + + let _ = tx.send(Action::FinishCommand(target.tab_index)).await; + }); } } else { + let projects_with_tabs: Vec<(usize, Project)> = projects_to_run + .into_iter() + .enumerate() + .map(|(i, project)| (start_tab_count + i, project)) + .collect(); + + for (_, project) in &projects_with_tabs { + let tab_title = format!("{}: {}", command_str, project.name); + let _ = tx.send(Action::CreateTab(tab_title)).await; + } + // Non-clean commands: run in parallel as before - for (i, project) in projects_to_run.into_iter().enumerate() { + for (tab_index, project) in projects_with_tabs { let tx = tx.clone(); let command_str = command_str.to_string(); - let tab_title = format!("{}: {}", command_str, project.name); - let tab_index = start_tab_count + i; - - let _ = tx.send(Action::CreateTab(tab_title)).await; tokio::spawn(async move { if let Err(e) = @@ -539,3 +509,57 @@ pub async fn run_command(command_str: &str, state: &AppState, tx: mpsc::Sender) -> Project { + Project { + name: name.to_string(), + path: PathBuf::from(path), + status: ProjectStatus::Pending, + version: "0.1.0".to_string(), + authors: vec![], + dependencies: vec![], + workspace_root: workspace_root.map(PathBuf::from), + workspace_name: workspace_root.map(|_| "workspace".to_string()), + cargo_lock_hash: None, + check_status: ProjectCheckStatus::Unchecked, + git_status: GitStatus::Clean, + total_size: None, + target_size: None, + } + } + + #[test] + fn clean_targets_dedupe_workspace_members() { + let member_a = project("member-a", "/repo/member-a", Some("/repo")); + let member_b = project("member-b", "/repo/member-b", Some("/repo")); + + let targets = plan_clean_command_targets(vec![member_a, member_b], 3); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].tab_index, 3); + assert_eq!(targets[0].command_project.path, PathBuf::from("/repo")); + assert_eq!(targets[0].affected_projects.len(), 2); + } + + #[test] + fn clean_targets_keep_standalone_projects_separate() { + let workspace_member = project("member-a", "/repo/member-a", Some("/repo")); + let standalone = project("tool", "/tools/tool", None); + + let targets = plan_clean_command_targets(vec![workspace_member, standalone], 10); + + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].tab_index, 10); + assert_eq!(targets[0].command_project.path, PathBuf::from("/repo")); + assert_eq!(targets[1].tab_index, 11); + assert_eq!( + targets[1].command_project.path, + PathBuf::from("/tools/tool") + ); + } +} diff --git a/src/settings.rs b/src/settings.rs index 7b0cf5f..e2579fc 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -5,8 +5,6 @@ const DEFAULT_CACHE_TTL_MINUTES: u64 = 5; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppSettings { - #[serde(default)] - pub background_updates_enabled: bool, #[serde(default = "default_cache_ttl")] pub cache_ttl_minutes: u64, #[serde(default)] @@ -20,7 +18,6 @@ fn default_cache_ttl() -> u64 { impl Default for AppSettings { fn default() -> Self { Self { - background_updates_enabled: false, cache_ttl_minutes: DEFAULT_CACHE_TTL_MINUTES, show_all_folders: false, } diff --git a/src/tree.rs b/src/tree.rs index 4740b97..f1c4de5 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -198,7 +198,7 @@ pub struct TreeSelectionState { /// Index of the currently selected item in the flattened tree pub selected_index: Option, /// Set of selected project paths (for multi-select) - pub selected_projects: HashSet, + pub selected_projects: HashSet, } impl TreeSelectionState { @@ -229,17 +229,17 @@ impl TreeSelectionState { } /// Toggle selection of a project - pub fn toggle_project(&mut self, name: String) { - if self.selected_projects.contains(&name) { - self.selected_projects.remove(&name); + pub fn toggle_project(&mut self, path: PathBuf) { + if self.selected_projects.contains(&path) { + self.selected_projects.remove(&path); } else { - self.selected_projects.insert(name); + self.selected_projects.insert(path); } } /// Check if a project is selected - pub fn is_project_selected(&self, name: &str) -> bool { - self.selected_projects.contains(name) + pub fn is_project_selected(&self, path: &Path) -> bool { + self.selected_projects.contains(path) } } @@ -281,10 +281,10 @@ mod tests { state.select_prev(); assert_eq!(state.selected_index, Some(0)); - state.toggle_project("proj1".to_string()); - assert!(state.is_project_selected("proj1")); + state.toggle_project(PathBuf::from("proj1")); + assert!(state.is_project_selected(Path::new("proj1"))); - state.toggle_project("proj1".to_string()); - assert!(!state.is_project_selected("proj1")); + state.toggle_project(PathBuf::from("proj1")); + assert!(!state.is_project_selected(Path::new("proj1"))); } } diff --git a/src/ui/styles.rs b/src/ui/styles.rs index 13fd6db..b0bb08a 100644 --- a/src/ui/styles.rs +++ b/src/ui/styles.rs @@ -128,11 +128,26 @@ impl Colors { primary: Color::Rgb(180, 0, 255), // Neon Purple } } + + /// Gestalt theme - Inspired by Catppuccin Mocha + pub fn gestalt() -> Self { + Self { + selection: Color::Rgb(203, 166, 247), // Mauve + success: Color::Rgb(166, 227, 161), // Green + warning: Color::Rgb(249, 226, 175), // Yellow + error: Color::Rgb(243, 139, 168), // Red + muted: Color::Rgb(108, 112, 134), // Overlay0 + text: Color::Rgb(205, 214, 244), // Text + dim: Color::Rgb(69, 71, 90), // Surface1 + primary: Color::Rgb(180, 190, 254), // Lavender + } + } } /// Named color scheme presets #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColorScheme { + Gestalt, Dark, Light, Nord, @@ -143,6 +158,7 @@ pub enum ColorScheme { impl ColorScheme { pub fn colors(self) -> Colors { match self { + ColorScheme::Gestalt => Colors::gestalt(), ColorScheme::Dark => Colors::dark(), ColorScheme::Light => Colors::light(), ColorScheme::Nord => Colors::nord(), @@ -153,6 +169,7 @@ impl ColorScheme { pub fn all() -> &'static [ColorScheme] { &[ + ColorScheme::Gestalt, ColorScheme::Dark, ColorScheme::Light, ColorScheme::Nord, @@ -329,7 +346,7 @@ mod tests { #[test] fn test_color_scheme_all() { - assert_eq!(ColorScheme::all().len(), 5); + assert_eq!(ColorScheme::all().len(), 6); } #[test] diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 5fffe42..a791358 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -25,7 +25,7 @@ impl Default for ThemeManager { impl ThemeManager { /// Create a new theme manager with default theme pub fn new() -> Self { - let scheme = ColorScheme::Dark; + let scheme = ColorScheme::Gestalt; let colors = scheme.colors(); Self { current_scheme: scheme, @@ -98,14 +98,14 @@ mod tests { #[test] fn test_theme_manager_creation() { let tm = ThemeManager::new(); - assert_eq!(tm.scheme(), ColorScheme::Dark); + assert_eq!(tm.scheme(), ColorScheme::Gestalt); } #[test] fn test_next_theme() { let mut tm = ThemeManager::new(); tm.next_theme(); - assert_ne!(tm.scheme(), ColorScheme::Dark); + assert_ne!(tm.scheme(), ColorScheme::Gestalt); } #[test] @@ -113,6 +113,6 @@ mod tests { let mut tm = ThemeManager::new(); tm.next_theme(); tm.previous_theme(); - assert_eq!(tm.scheme(), ColorScheme::Dark); + assert_eq!(tm.scheme(), ColorScheme::Gestalt); } } From 5df984b300801777ab68400af54d6e1b9da3a1d7 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:17:18 -0400 Subject: [PATCH 03/22] chore: remove the v0.3 Rust-only implementation ahead of the rewrite The rewrite replaces the single-crate app with a workspace: a UI-agnostic engine (carwash-core) and the carwash binary. Everything removed here is recoverable from the previous commit. --- src/app.rs | 712 --------------- src/cache.rs | 343 ------- src/components/dependencies.rs | 214 ----- src/components/filter.rs | 182 ---- src/components/help.rs | 307 ------- src/components/mod.rs | 51 -- src/components/output.rs | 254 ------ src/components/palette.rs | 211 ----- src/components/progress.rs | 298 ------- src/components/projects.rs | 291 ------ src/components/settings.rs | 149 ---- src/components/spinner.rs | 62 -- src/components/status.rs | 172 ---- src/components/text_input.rs | 66 -- src/components/tree_view.rs | 217 ----- src/components/updater.rs | 382 -------- src/config/keybinding_config.rs | 375 -------- src/config/mod.rs | 207 ----- src/config/theme_config.rs | 250 ------ src/events.rs | 218 ----- src/handlers.rs | 1234 -------------------------- src/lib.rs | 72 -- src/main.rs | 782 ---------------- src/project.rs | 1475 ------------------------------- src/runner.rs | 565 ------------ src/settings.rs | 38 - src/tree.rs | 290 ------ src/ui/layout.rs | 167 ---- src/ui/mod.rs | 94 -- src/ui/modal.rs | 126 --- src/ui/styles.rs | 358 -------- src/ui/theme.rs | 118 --- 32 files changed, 10280 deletions(-) delete mode 100644 src/app.rs delete mode 100644 src/cache.rs delete mode 100644 src/components/dependencies.rs delete mode 100644 src/components/filter.rs delete mode 100644 src/components/help.rs delete mode 100644 src/components/mod.rs delete mode 100644 src/components/output.rs delete mode 100644 src/components/palette.rs delete mode 100644 src/components/progress.rs delete mode 100644 src/components/projects.rs delete mode 100644 src/components/settings.rs delete mode 100644 src/components/spinner.rs delete mode 100644 src/components/status.rs delete mode 100644 src/components/text_input.rs delete mode 100644 src/components/tree_view.rs delete mode 100644 src/components/updater.rs delete mode 100644 src/config/keybinding_config.rs delete mode 100644 src/config/mod.rs delete mode 100644 src/config/theme_config.rs delete mode 100644 src/events.rs delete mode 100644 src/handlers.rs delete mode 100644 src/lib.rs delete mode 100644 src/main.rs delete mode 100644 src/project.rs delete mode 100644 src/runner.rs delete mode 100644 src/settings.rs delete mode 100644 src/tree.rs delete mode 100644 src/ui/layout.rs delete mode 100644 src/ui/mod.rs delete mode 100644 src/ui/modal.rs delete mode 100644 src/ui/styles.rs delete mode 100644 src/ui/theme.rs diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 73c725d..0000000 --- a/src/app.rs +++ /dev/null @@ -1,712 +0,0 @@ -//! Application state management -//! -//! This module defines the core application state and UI structure for CarWash. -//! It manages the project tree, command history, tabs, and various UI modes. - -use crate::components::{ - filter::FilterState, palette::CommandPaletteState, progress::ProgressState, - settings::SettingsModalState, text_input::TextInputState, updater::UpdateWizardState, -}; -use crate::config::Config; -use crate::events::{Action, Focus, Mode}; -use crate::project::Project; -use crate::tree::{FlattenedTree, TreeNode, TreeSelectionState}; -use ratatui::widgets::ListState; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; - -/// Represents the complete state of the CarWash application -/// -/// This struct maintains all mutable state including the project list, UI selections, -/// command history, and various input states for different UI modes. -#[derive(Debug, Clone)] -pub struct AppState { - /// Whether the application should quit - pub should_quit: bool, - /// Whether the application is currently scanning for projects - pub is_scanning: bool, - /// Whether the application is checking for updates - pub is_checking_updates: bool, - /// Current application mode - pub mode: Mode, - /// Root node of the hierarchical project tree - pub tree_root: Option, - /// Flattened view of the tree for rendering and navigation - pub flattened_tree: FlattenedTree, - /// Tree navigation and selection state - pub tree_selection: TreeSelectionState, - /// State of the project tree view (legacy, kept for compatibility) - pub tree_state: ListState, - /// Filtered list of projects currently displayed - pub projects: Vec, - /// Complete list of all discovered projects - pub all_projects: Vec, - /// Set of workspace names that are collapsed in the tree view - pub collapsed_workspaces: HashSet, - /// Set of selected project paths - pub selected_projects: HashSet, - /// Tab panes for command output - pub tabs: Vec, - /// Index of the currently active tab - pub active_tab: usize, - /// History of executed commands - pub command_history: Vec, - /// State of the command palette - pub palette: CommandPaletteState, - /// State of the update wizard - pub updater: UpdateWizardState, - /// State of text input fields - pub text_input: TextInputState, - /// Modal state for editing settings - pub settings_modal: SettingsModalState, - /// Filter/search state - pub filter: FilterState, - /// Application configuration (themes, layout, keybindings, settings, etc.) - pub config: Config, - /// Progress tracking for command execution - pub progress: Option, - /// Currently focused pane - pub focus: Focus, -} - -/// Represents a tab pane for displaying command output -#[derive(Debug, Clone)] -pub struct Tab { - /// Title/name of the tab - pub title: String, - /// Buffer of output lines - pub buffer: Vec, - /// Whether the command execution has finished - pub is_finished: bool, - /// Scroll position for this specific tab - pub scroll: usize, -} - -impl Default for AppState { - fn default() -> Self { - let command_history = vec![ - "test".into(), - "check".into(), - "build".into(), - "build --release".into(), - "clean".into(), - "clippy".into(), - "clippy -- -D warnings".into(), - "fmt".into(), - "fmt -- --check".into(), - "doc".into(), - "doc --open".into(), - "update".into(), - "bench".into(), - "run".into(), - "run --release".into(), - ]; - let mut tree_state = ListState::default(); - tree_state.select(Some(0)); - - Self { - should_quit: false, - is_scanning: true, - is_checking_updates: false, - mode: Mode::Loading, - tree_root: None, - flattened_tree: FlattenedTree::new(), - tree_selection: TreeSelectionState::new(), - tree_state, - projects: Vec::new(), - all_projects: Vec::new(), - collapsed_workspaces: HashSet::new(), // All workspaces start collapsed - selected_projects: HashSet::new(), - tabs: Vec::new(), - active_tab: 0, - command_history, - palette: CommandPaletteState::new(), - updater: UpdateWizardState::new(), - text_input: TextInputState::new(), - settings_modal: SettingsModalState::new(), - filter: FilterState::new(), - config: Config::load(), - progress: None, - focus: Focus::default(), - } - } -} - -impl AppState { - pub fn new() -> Self { - Self::default() - } - - /// Get the current active color scheme based on config - pub fn current_colors(&self) -> crate::ui::styles::Colors { - self.config.theme().current_scheme().colors() - } - - /// Get keybindings from config - pub fn get_keybindings(&self) -> std::collections::HashMap> { - self.config.keybindings.get_bindings() - } - - /// Check if a key matches an action - pub fn key_matches_action(&self, key: &str, action: &str) -> bool { - if let Some(keys) = self.config.keybindings.get_action_keys(action) { - keys.iter().any(|k| k.eq_ignore_ascii_case(key)) - } else { - false - } - } - - pub fn get_selected_project(&self) -> Option<&Project> { - if let Some(selected_index) = self.tree_state.selected() { - // Get the node from the flattened tree - if let Some((node, _index)) = self.flattened_tree.items.get(selected_index) { - // Check if it's a project node (not a directory) - if let crate::tree::TreeNodeType::Project(tree_project) = &node.node_type { - // IMPORTANT: The tree contains cloned copies of projects, - // but dependencies are updated in all_projects. - // So we need to look up the project by path in all_projects - // to get the current state with updated dependencies. - return self - .all_projects - .iter() - .find(|p| p.path == tree_project.path); - } - } - } - None - } - - /// Get list of projects that should be visible (excluding collapsed workspace members) - pub fn get_visible_projects(&self) -> Vec<&Project> { - let mut last_workspace: Option<&String> = None; - - self.projects - .iter() - .filter(|project| match &project.workspace_name { - Some(ws_name) => { - let is_new_workspace = last_workspace != Some(ws_name); - if is_new_workspace { - last_workspace = Some(ws_name); - } - is_new_workspace || !self.collapsed_workspaces.contains(ws_name) - } - None => { - last_workspace = None; - true - } - }) - .collect() - } - - /// Get the currently selected workspace name (if cursor is on a workspace item) - pub fn get_selected_workspace(&self) -> Option { - if let Some(project) = self.get_selected_project() { - project.workspace_name.clone() - } else { - None - } - } - - /// Get the currently selected tree node (could be project OR directory) - pub fn get_selected_node(&self) -> Option<&crate::tree::TreeNode> { - self.tree_state - .selected() - .and_then(|idx| self.flattened_tree.items.get(idx)) - .map(|(node, _)| node) - } - - fn find_tree_node_by_path<'a>( - node: &'a crate::tree::TreeNode, - path: &Path, - ) -> Option<&'a crate::tree::TreeNode> { - if node.node_type.path() == path { - return Some(node); - } - - for child in &node.children { - if let Some(found) = Self::find_tree_node_by_path(child, path) { - return Some(found); - } - } - - None - } - - /// Get all projects under the currently selected node - /// - If cursor is on a project: returns just that project - /// - If cursor is on a directory: returns all projects under that directory - pub fn get_projects_under_selected(&self) -> Vec<&Project> { - if let Some(node) = self.get_selected_node() { - match &node.node_type { - crate::tree::TreeNodeType::Project(tree_project) => { - // Return the actual project from all_projects (has updated dependencies) - self.all_projects - .iter() - .filter(|p| p.path == tree_project.path) - .collect() - } - crate::tree::TreeNodeType::Directory { .. } => { - // Flattened directory nodes intentionally do not carry children. - // Resolve the selected path back into the real tree before collecting. - let tree_projects = self - .tree_root - .as_ref() - .and_then(|root| Self::find_tree_node_by_path(root, node.node_type.path())) - .map(|node| node.collect_projects()) - .unwrap_or_default(); - - // Map to actual projects from all_projects (has updated dependencies) - tree_projects - .into_iter() - .filter_map(|tp| self.all_projects.iter().find(|p| p.path == tp.path)) - .collect() - } - } - } else { - Vec::new() - } - } -} - -/// Main reducer function that dispatches actions to appropriate handlers -/// -/// This function acts as a clean dispatch layer, delegating actual state -/// mutations to specialized handler functions in the handlers module. -pub fn reducer(state: &mut AppState, action: Action) { - use crate::handlers::*; - - match action { - Action::Quit => handle_quit(state), - Action::EnterNormalMode => handle_enter_normal_mode(state), - Action::ShowHelp => handle_show_help(state), - Action::ShowSettings => handle_show_settings(state), - Action::CloseSettings => handle_close_settings(state), - Action::FinishProjectScan(projects, target_dir) => { - handle_finish_project_scan(state, projects, target_dir) - } - Action::UpdateTextInput(s) => handle_update_text_input(state, s), - Action::SelectNext => handle_select_next(state), - Action::SelectPrevious => handle_select_previous(state), - Action::SelectParent => handle_select_parent(state), - Action::SelectChild => handle_select_child(state), - Action::ToggleSelection => handle_toggle_selection(state), - Action::ShowCommandPalette => handle_show_command_palette(state), - Action::UpdatePaletteInput(input) => handle_update_palette_input(state, input), - Action::PaletteSelectNext => handle_palette_select_next(state), - Action::PaletteSelectPrevious => handle_palette_select_previous(state), - Action::StartUpdateWizard => handle_start_update_wizard(state), - Action::ToggleUpdateSelection => handle_toggle_update_selection(state), - Action::SettingsUpdateCacheInput(input) => handle_settings_update_cache_input(state, input), - Action::SaveSettings => handle_save_settings(state), - Action::UpdateDependencies(project_id, deps) => { - handle_update_dependencies(state, project_id, deps) - } - Action::UpdateDependenciesStreamStart(project_id) => { - handle_update_dependencies_stream_start(state, project_id) - } - Action::UpdateSingleDependency(project_id, dep) => { - handle_update_single_dependency(state, project_id, dep) - } - Action::UpdateDependencyCheckStatus(project_id, dep_name, status) => { - handle_update_dependency_status(state, Some(project_id), dep_name, status) - } - Action::CreateTab(title) => handle_create_tab(state, title), - Action::AddOutput(tab_index, line) => handle_add_output(state, tab_index, line), - Action::FinishCommand(tab_index) => handle_finish_command(state, tab_index), - Action::SwitchToTab(tab_index) => handle_switch_to_tab(state, tab_index), - Action::ExecuteCommand(_command) => { - // Command execution is handled in main event loop - } - Action::RunUpdate => { - // Update execution is handled in main event loop - } - Action::UpdateProjectCheckStatus(project_id, check_status) => { - handle_update_project_check_status(state, project_id, check_status) - } - Action::EnterFilterMode => handle_enter_filter_mode(state), - Action::ExitFilterMode => handle_exit_filter_mode(state), - Action::UpdateFilterInput(input) => handle_update_filter_input(state, input), - Action::ClearFilter => handle_clear_filter(state), - Action::CycleTheme => handle_cycle_theme(state), - Action::SetTheme(theme_name) => handle_set_theme(state, theme_name), - Action::IncreaseLeftPane => handle_increase_left_pane(state), - Action::DecreaseLeftPane => handle_decrease_left_pane(state), - Action::IncreaseTopRight => handle_increase_top_right(state), - Action::DecreaseTopRight => handle_decrease_top_right(state), - Action::ResetLayout => handle_reset_layout(state), - Action::SaveConfig => handle_save_config(state), - Action::ToggleShowAllFolders => handle_toggle_show_all_folders(state), - Action::CalculateProjectSizes => { - // Size calculation is handled in main event loop (async) - } - Action::UpdateProjectSize(project_id, total_size, target_size) => { - handle_update_project_size(state, project_id, total_size, target_size) - } - Action::FocusNext => handle_focus_next(state), - Action::InitializeTree(target_dir) => handle_initialize_tree(state, target_dir), - Action::ExpandDirectory(..) => { - // Handled in main loop asynchronously - } - Action::DirectoryLoaded(path, children) => handle_directory_loaded(state, path, children), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::events::{Action, Mode}; - use crate::project::{Dependency, DependencyCheckStatus, Project, ProjectStatus}; - use std::path::PathBuf; - - fn create_test_project(name: &str) -> Project { - create_test_project_at(name, "test") - } - - fn create_test_project_at(name: &str, path: &str) -> Project { - Project { - name: name.to_string(), - path: PathBuf::from(path), - status: ProjectStatus::Pending, - version: "0.1.0".to_string(), - authors: vec![], - dependencies: vec![], - workspace_root: None, - workspace_name: None, - cargo_lock_hash: None, - check_status: crate::project::ProjectCheckStatus::Unchecked, - git_status: crate::project::GitStatus::Clean, - total_size: None, - target_size: None, - } - } - - #[test] - fn test_app_state_new() { - let state = AppState::new(); - assert!(!state.should_quit); - assert!(state.is_scanning); - assert_eq!(state.mode, Mode::Loading); - assert!(state.projects.is_empty()); - assert!(state.selected_projects.is_empty()); - assert!(!state.command_history.is_empty()); - } - - #[test] - fn test_reducer_quit() { - let mut state = AppState::new(); - assert!(!state.should_quit); - - reducer(&mut state, Action::Quit); - assert!(state.should_quit); - } - - #[test] - fn test_reducer_enter_normal_mode() { - let mut state = AppState::new(); - state.mode = Mode::Loading; - - reducer(&mut state, Action::EnterNormalMode); - assert_eq!(state.mode, Mode::Normal); - } - - #[test] - fn test_reducer_show_help() { - let mut state = AppState::new(); - - reducer(&mut state, Action::ShowHelp); - assert_eq!(state.mode, Mode::Help); - } - - #[test] - fn test_reducer_finish_project_scan() { - let mut state = AppState::new(); - let mut project = create_test_project("test1"); - project.dependencies = vec![]; // Empty dependencies - should be filtered out - let projects = vec![project]; - - reducer( - &mut state, - Action::FinishProjectScan(projects, ".".to_string()), - ); - assert!(!state.is_scanning); - assert_eq!(state.mode, Mode::Normal); - // Projects with empty dependencies should be filtered - assert_eq!(state.projects.len(), 0); - } - - #[test] - fn test_reducer_toggle_selection() { - let mut state = AppState::new(); - let project = create_test_project("test1"); - state.projects = vec![project.clone()]; - - // Create a simple tree with one project node - let project_node = crate::tree::TreeNode::project(project, 0); - let mut flattened = crate::tree::FlattenedTree::new(); - flattened.items.push((project_node, 0)); - state.flattened_tree = flattened; - state.tree_state.select(Some(0)); - - // Select the project - reducer(&mut state, Action::ToggleSelection); - assert!(state.selected_projects.contains(&PathBuf::from("test"))); - - // Deselect the project - reducer(&mut state, Action::ToggleSelection); - assert!(!state.selected_projects.contains(&PathBuf::from("test"))); - } - - #[test] - fn test_duplicate_project_names_are_selected_by_path() { - let mut state = AppState::new(); - let project_a = create_test_project_at("shared", "/repo/a"); - let project_b = create_test_project_at("shared", "/repo/b"); - state.all_projects = vec![project_a.clone(), project_b.clone()]; - - let project_node = crate::tree::TreeNode::project(project_b.clone(), 0); - let mut flattened = crate::tree::FlattenedTree::new(); - flattened.items.push((project_node, 0)); - state.flattened_tree = flattened; - state.tree_state.select(Some(0)); - - let selected = state.get_selected_project().expect("selected project"); - assert_eq!(selected.path, project_b.path); - - reducer(&mut state, Action::ToggleSelection); - assert!(state.selected_projects.contains(&project_b.path)); - assert!(!state.selected_projects.contains(&project_a.path)); - } - - #[test] - fn test_start_update_wizard_ignores_directory_selection() { - let mut state = AppState::new(); - state.mode = Mode::Normal; - state.is_scanning = false; - - let directory = - crate::tree::TreeNode::directory("repo".to_string(), PathBuf::from("/repo"), 0); - let mut flattened = crate::tree::FlattenedTree::new(); - flattened.items.push((directory, 0)); - state.flattened_tree = flattened; - state.tree_state.select(Some(0)); - - reducer(&mut state, Action::StartUpdateWizard); - - assert_eq!(state.mode, Mode::Normal); - assert!(!state.is_checking_updates); - assert!(state.updater.locked_project_id.is_none()); - } - - #[test] - fn test_start_update_wizard_locks_selected_project_by_path() { - let mut state = AppState::new(); - state.mode = Mode::Normal; - state.is_scanning = false; - - let project = create_test_project_at("shared", "/repo/project-a"); - state.all_projects = vec![project.clone()]; - state.projects = vec![project.clone()]; - - let project_node = crate::tree::TreeNode::project(project.clone(), 0); - let mut flattened = crate::tree::FlattenedTree::new(); - flattened.items.push((project_node, 0)); - state.flattened_tree = flattened; - state.tree_state.select(Some(0)); - - reducer(&mut state, Action::StartUpdateWizard); - - assert_eq!(state.mode, Mode::UpdateWizard); - assert!(state.is_checking_updates); - assert_eq!( - state.updater.locked_project_id.as_deref(), - Some(project.path.as_path()) - ); - } - - #[test] - fn test_duplicate_project_names_update_by_path() { - let mut state = AppState::new(); - let mut project_a = create_test_project_at("shared", "/repo/a"); - let mut project_b = create_test_project_at("shared", "/repo/b"); - project_a.dependencies = vec![Dependency { - name: "serde".to_string(), - current_version: "1.0.0".to_string(), - latest_version: None, - check_status: DependencyCheckStatus::NotChecked, - last_checked: None, - }]; - project_b.dependencies = vec![Dependency { - name: "serde".to_string(), - current_version: "1.0.0".to_string(), - latest_version: None, - check_status: DependencyCheckStatus::NotChecked, - last_checked: None, - }]; - state.all_projects = vec![project_a.clone(), project_b.clone()]; - state.projects = vec![project_a.clone(), project_b.clone()]; - state.tree_root = Some(crate::tree::TreeNode { - node_type: crate::tree::TreeNodeType::Directory { - name: "repo".to_string(), - path: PathBuf::from("/repo"), - }, - children: vec![ - crate::tree::TreeNode::project(project_a.clone(), 1), - crate::tree::TreeNode::project(project_b.clone(), 1), - ], - expanded: true, - children_loaded: true, - loading: false, - depth: 0, - }); - - let updated_dep = Dependency { - name: "serde".to_string(), - current_version: "1.0.0".to_string(), - latest_version: Some("1.0.1".to_string()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - reducer( - &mut state, - Action::UpdateSingleDependency(project_b.path.clone(), updated_dep), - ); - - assert_eq!(state.all_projects[0].dependencies[0].latest_version, None); - assert_eq!( - state.all_projects[1].dependencies[0] - .latest_version - .as_deref(), - Some("1.0.1") - ); - } - - #[test] - fn test_directory_selection_uses_real_tree_children() { - let mut state = AppState::new(); - let project = create_test_project_at("nested", "/repo/nested"); - let root = crate::tree::TreeNode { - node_type: crate::tree::TreeNodeType::Directory { - name: "repo".to_string(), - path: PathBuf::from("/repo"), - }, - children: vec![crate::tree::TreeNode::project(project.clone(), 1)], - expanded: true, - children_loaded: true, - loading: false, - depth: 0, - }; - - state.all_projects = vec![project.clone()]; - state.tree_root = Some(root.clone()); - state.flattened_tree = crate::tree::FlattenedTree::from_tree(&root); - state.tree_state.select(Some(0)); - - let projects = state.get_projects_under_selected(); - assert_eq!(projects.len(), 1); - assert_eq!(projects[0].path, project.path); - } - - #[test] - fn test_reducer_show_command_palette() { - let mut state = AppState::new(); - - reducer(&mut state, Action::ShowCommandPalette); - assert_eq!(state.mode, Mode::CommandPalette); - assert!(!state.palette.filtered_commands.is_empty()); - } - - #[test] - fn test_reducer_create_tab() { - let mut state = AppState::new(); - let title = "Test Tab".to_string(); - - reducer(&mut state, Action::CreateTab(title.clone())); - assert_eq!(state.tabs.len(), 1); - assert_eq!(state.tabs[0].title, title); - assert!(!state.tabs[0].is_finished); - } - - #[test] - fn test_reducer_add_output() { - let mut state = AppState::new(); - reducer(&mut state, Action::CreateTab("Test".to_string())); - - let output = "Test output".to_string(); - reducer(&mut state, Action::AddOutput(0, output.clone())); - assert_eq!(state.tabs[0].buffer.len(), 1); - assert_eq!(state.tabs[0].buffer[0], output); - } - - #[test] - fn test_reducer_finish_command() { - let mut state = AppState::new(); - reducer(&mut state, Action::CreateTab("Test".to_string())); - - reducer(&mut state, Action::FinishCommand(0)); - assert!(state.tabs[0].is_finished); - } - - #[test] - fn test_reducer_switch_to_tab() { - let mut state = AppState::new(); - reducer(&mut state, Action::CreateTab("Tab1".to_string())); - reducer(&mut state, Action::CreateTab("Tab2".to_string())); - - state.active_tab = 0; - reducer(&mut state, Action::SwitchToTab(1)); - assert_eq!(state.active_tab, 1); - } - - #[test] - fn test_get_visible_projects_with_collapsed_workspace() { - let mut state = AppState::new(); - - let mut project1 = create_test_project("workspace-member1"); - project1.workspace_name = Some("test-workspace".to_string()); - - let mut project2 = create_test_project("workspace-member2"); - project2.workspace_name = Some("test-workspace".to_string()); - - state.projects = vec![project1, project2]; - state - .collapsed_workspaces - .insert("test-workspace".to_string()); - - let visible = state.get_visible_projects(); - // Only the first member of collapsed workspace should be visible - assert_eq!(visible.len(), 1); - } - - #[test] - fn test_get_visible_projects_with_expanded_workspace() { - let mut state = AppState::new(); - - let mut project1 = create_test_project("workspace-member1"); - project1.workspace_name = Some("test-workspace".to_string()); - - let mut project2 = create_test_project("workspace-member2"); - project2.workspace_name = Some("test-workspace".to_string()); - - state.projects = vec![project1, project2]; - // Don't collapse the workspace - - let visible = state.get_visible_projects(); - // All members should be visible - assert_eq!(visible.len(), 2); - } - - #[test] - fn test_tab_clone() { - let tab = Tab { - title: "Test".to_string(), - buffer: vec!["line1".to_string()], - is_finished: false, - scroll: 0, - }; - - let cloned = tab.clone(); - assert_eq!(tab.title, cloned.title); - assert_eq!(tab.buffer.len(), cloned.buffer.len()); - assert_eq!(tab.is_finished, cloned.is_finished); - } -} diff --git a/src/cache.rs b/src/cache.rs deleted file mode 100644 index e558c3c..0000000 --- a/src/cache.rs +++ /dev/null @@ -1,343 +0,0 @@ -//! Cross-platform caching for dependency update checks -//! -//! This module provides persistent caching of dependency check results, -//! keyed by Cargo.lock file hash to automatically invalidate when dependencies change. - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::collections::hash_map::DefaultHasher; -use std::fs; -use std::hash::{Hash, Hasher}; -use std::path::{Path, PathBuf}; - -/// Cached dependency information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CachedDependency { - /// The latest version available - pub latest_version: Option, - /// When this was cached - pub cached_at: std::time::SystemTime, -} - -/// Cache entry for a project -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProjectCache { - /// Hash of the Cargo.lock file this cache is based on - pub lock_file_hash: u64, - /// Cached dependency information - pub dependencies: HashMap, -} - -/// Cross-platform cache directory for carwash -fn app_cache_dir() -> PathBuf { - if let Some(dirs) = directories::ProjectDirs::from("com", "epistates", "carwash") { - dirs.cache_dir().to_path_buf() - } else { - PathBuf::from("/tmp/carwash-cache") - } -} - -/// Manages cross-platform caching of update check results -#[derive(Clone)] -pub struct UpdateCache { - cache_dir: PathBuf, -} - -impl UpdateCache { - /// Create a new cache manager - pub fn new() -> Self { - Self { - cache_dir: app_cache_dir(), - } - } - - /// Compute hash of a Cargo.lock file - pub fn hash_cargo_lock(lock_path: &Path) -> Option { - let contents = fs::read(lock_path).ok()?; - let mut hasher = DefaultHasher::new(); - contents.hash(&mut hasher); - Some(hasher.finish()) - } - - /// Get the cache file path for a project - fn get_cache_path(&self, project_path: &Path) -> PathBuf { - // Create a unique cache filename based on project path - let path_str = project_path.to_string_lossy(); - let mut hasher = DefaultHasher::new(); - path_str.hash(&mut hasher); - let path_hash = hasher.finish(); - - self.cache_dir.join(format!("project_{:x}.json", path_hash)) - } - - /// Load cached dependencies if Cargo.lock hasn't changed - pub fn load( - &self, - project_path: &Path, - current_lock_hash: u64, - ) -> Option> { - let cache_path = self.get_cache_path(project_path); - - let contents = fs::read_to_string(&cache_path).ok()?; - let cache: ProjectCache = serde_json::from_str(&contents).ok()?; - - // Only return cache if lock file hash matches (not invalidated) - if cache.lock_file_hash == current_lock_hash { - Some(cache.dependencies) - } else { - None - } - } - - /// Save dependency information to cache - pub fn save( - &self, - project_path: &Path, - lock_hash: u64, - dependencies: HashMap, - ) -> Result<()> { - // Create cache directory if it doesn't exist - fs::create_dir_all(&self.cache_dir).context("Failed to create cache directory")?; - - let cache = ProjectCache { - lock_file_hash: lock_hash, - dependencies, - }; - - let cache_path = self.get_cache_path(project_path); - let json = serde_json::to_string(&cache).context("Failed to serialize cache data")?; - fs::write(&cache_path, json) - .with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?; - - Ok(()) - } - - /// Clear all cached data - pub fn clear(&self) -> Result<()> { - if self.cache_dir.exists() { - fs::remove_dir_all(&self.cache_dir).with_context(|| { - format!( - "Failed to clear cache directory: {}", - self.cache_dir.display() - ) - })?; - } - Ok(()) - } -} - -/// A single cached dependency version lookup result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DepVersionEntry { - /// The latest version available on crates.io - pub latest: String, - /// Unix timestamp (seconds) when this was checked - pub checked_at: u64, -} - -/// Global per-dependency version cache. -/// -/// Keyed by `"name@current_version"` so the same dependency at the same pinned -/// version is never checked twice across any number of projects. -/// When Cargo.lock changes a dep's version, the key changes and a fresh check occurs. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DependencyVersionCache { - entries: HashMap, -} - -impl DependencyVersionCache { - fn cache_key(name: &str, current_version: &str) -> String { - format!("{}@{}", name, current_version) - } - - fn cache_file() -> PathBuf { - app_cache_dir().join("dep_versions.json") - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Load the global dep version cache from disk - pub fn load() -> Self { - let path = Self::cache_file(); - let contents = match fs::read_to_string(&path) { - Ok(c) => c, - Err(_) => return Self::default(), - }; - serde_json::from_str(&contents).unwrap_or_default() - } - - /// Evict entries older than `max_age_secs` - pub fn evict_stale(&mut self, max_age_secs: u64) { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.entries - .retain(|_, entry| now.saturating_sub(entry.checked_at) <= max_age_secs); - } - - /// Save the global dep version cache to disk (evicts entries older than 7 days) - pub fn save(&mut self) -> Result<()> { - self.evict_stale(7 * 24 * 3600); - let dir = app_cache_dir(); - fs::create_dir_all(&dir).context("Failed to create cache directory")?; - let json = serde_json::to_string(self).context("Failed to serialize dep cache")?; - fs::write(Self::cache_file(), json).context("Failed to write dep cache")?; - Ok(()) - } - - /// Look up a cached latest version for a dependency. - /// Returns `None` if not cached or if the entry is older than `max_age`. - pub fn lookup( - &self, - name: &str, - current_version: &str, - max_age: std::time::Duration, - ) -> Option<&DepVersionEntry> { - let key = Self::cache_key(name, current_version); - let entry = self.entries.get(&key)?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - if now.saturating_sub(entry.checked_at) > max_age.as_secs() { - None - } else { - Some(entry) - } - } - - /// Insert or update a cached version entry - pub fn insert(&mut self, name: &str, current_version: &str, latest: String) { - let key = Self::cache_key(name, current_version); - let checked_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.entries - .insert(key, DepVersionEntry { latest, checked_at }); - } - - /// One-time migration: read old per-project cache files and convert to per-dep format. - /// Deletes old files after successful migration. - pub fn migrate_from_project_caches() -> Self { - let dir = app_cache_dir(); - let cache = Self::default(); - - let entries = match fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => return cache, - }; - - let mut old_files = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - let name = path.file_name().unwrap_or_default().to_string_lossy(); - if name.starts_with("project_") - && name.ends_with(".json") - && let Ok(contents) = fs::read_to_string(&path) - && serde_json::from_str::(&contents).is_ok() - { - // Old per-project cache file — can't migrate entries because the old format - // doesn't store current_version (needed for the new key). Mark for cleanup; - // deps will be re-checked on first use. - old_files.push(path); - } - } - - // Clean up old cache files - for file in old_files { - let _ = fs::remove_file(file); - } - - cache - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cache_path_consistency() { - let cache = UpdateCache::new(); - let path = Path::new("/home/user/project1"); - - // Same path should generate same cache filename - let path1 = cache.get_cache_path(path); - let path2 = cache.get_cache_path(path); - assert_eq!(path1, path2); - } - - #[test] - fn test_different_projects_different_cache() { - let cache = UpdateCache::new(); - let path1 = Path::new("/home/user/project1"); - let path2 = Path::new("/home/user/project2"); - - // Different paths should generate different cache filenames - let cache1 = cache.get_cache_path(path1); - let cache2 = cache.get_cache_path(path2); - assert_ne!(cache1, cache2); - } - - #[test] - fn test_dep_version_cache_insert_and_lookup() { - let mut cache = DependencyVersionCache::default(); - cache.insert("serde", "1.0.200", "1.0.228".to_string()); - - let entry = cache - .lookup("serde", "1.0.200", std::time::Duration::from_secs(300)) - .expect("should find entry"); - assert_eq!(entry.latest, "1.0.228"); - } - - #[test] - fn test_dep_version_cache_different_versions() { - let mut cache = DependencyVersionCache::default(); - cache.insert("serde", "1.0.200", "1.0.228".to_string()); - cache.insert("serde", "1.0.210", "1.0.228".to_string()); - - // Both entries exist independently - assert!( - cache - .lookup("serde", "1.0.200", std::time::Duration::from_secs(300)) - .is_some() - ); - assert!( - cache - .lookup("serde", "1.0.210", std::time::Duration::from_secs(300)) - .is_some() - ); - - // Different version key returns None - assert!( - cache - .lookup("serde", "1.0.100", std::time::Duration::from_secs(300)) - .is_none() - ); - } - - #[test] - fn test_dep_version_cache_expired() { - let mut cache = DependencyVersionCache::default(); - let key = DependencyVersionCache::cache_key("tokio", "1.42.0"); - cache.entries.insert( - key, - DepVersionEntry { - latest: "1.43.0".to_string(), - checked_at: 0, // epoch = very old - }, - ); - - // Should be expired with any reasonable max_age - assert!( - cache - .lookup("tokio", "1.42.0", std::time::Duration::from_secs(300)) - .is_none() - ); - } -} diff --git a/src/components/dependencies.rs b/src/components/dependencies.rs deleted file mode 100644 index e1c1ec4..0000000 --- a/src/components/dependencies.rs +++ /dev/null @@ -1,214 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::{Action, Focus}; -use crate::project::DependencyCheckStatus; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - style::{Color, Style}, - widgets::{Block, Borders, List, ListItem, Paragraph}, -}; - -pub struct DependenciesPane {} - -impl DependenciesPane { - pub fn new() -> Self { - Self {} - } -} - -impl DependenciesPane { - // ... (new function remains the same) - - fn create_dependency_list_item<'a>(dep: &'a crate::project::Dependency) -> ListItem<'a> { - let is_outdated = dep.has_stable_update(); - - let (icon, style) = match dep.check_status { - DependencyCheckStatus::NotChecked => ("⋯", Style::default().fg(Color::DarkGray)), - DependencyCheckStatus::Checking => ("⟳", Style::default().fg(Color::Cyan)), - DependencyCheckStatus::Checked => { - if is_outdated { - ( - "⚠", - Style::default() - .fg(Color::Yellow) - .add_modifier(ratatui::style::Modifier::BOLD), - ) - } else { - ("✓", Style::default().fg(Color::Green)) - } - } - }; - - let line = if is_outdated && dep.check_status == DependencyCheckStatus::Checked { - let mut spans = vec![ - ratatui::text::Span::styled(icon, style), - ratatui::text::Span::raw(" "), - ratatui::text::Span::styled(&dep.name, Style::default().fg(Color::White)), - ratatui::text::Span::raw(" "), - ratatui::text::Span::styled( - &dep.current_version, - Style::default().fg(Color::DarkGray), - ), - ]; - - if let Some(ref latest) = dep.latest_version { - spans.push(ratatui::text::Span::styled( - " → ", - Style::default().fg(Color::Yellow), - )); - spans.push(ratatui::text::Span::styled( - latest, - Style::default() - .fg(Color::Yellow) - .add_modifier(ratatui::style::Modifier::BOLD), - )); - } - - // Add note for major version updates that require Cargo.toml change - if let Some(note) = dep.update_note() { - spans.push(ratatui::text::Span::styled( - format!(" ({})", note), - Style::default() - .fg(Color::DarkGray) - .add_modifier(ratatui::style::Modifier::ITALIC), - )); - } - - ratatui::text::Line::from(spans) - } else { - let status_text = match dep.check_status { - DependencyCheckStatus::NotChecked => " (not checked)", - DependencyCheckStatus::Checking => " (checking...)", - DependencyCheckStatus::Checked => "", - }; - - ratatui::text::Line::from(vec![ - ratatui::text::Span::styled(icon, style), - ratatui::text::Span::raw(" "), - ratatui::text::Span::styled(&dep.name, Style::default().fg(Color::White)), - ratatui::text::Span::raw(" "), - ratatui::text::Span::styled( - format!("v{}", dep.current_version), - Style::default().fg(Color::DarkGray), - ), - ratatui::text::Span::styled( - status_text, - Style::default() - .fg(Color::DarkGray) - .add_modifier(ratatui::style::Modifier::ITALIC), - ), - ]) - }; - - ListItem::new(line) - } - - fn get_title( - dependencies: &[crate::project::Dependency], - outdated_count: usize, - not_checked_count: usize, - checking_count: usize, - ) -> (String, Style) { - if dependencies.is_empty() { - ( - " Dependencies (none) ".to_string(), - Style::default().fg(Color::DarkGray), - ) - } else if checking_count > 0 { - ( - format!(" Dependencies (checking...) "), - Style::default().fg(Color::Cyan), - ) - } else if not_checked_count > 0 { - ( - format!(" Dependencies ({} not checked) ", dependencies.len()), - Style::default().fg(Color::DarkGray), - ) - } else if outdated_count > 0 { - ( - format!(" Dependencies ({} outdated) ", outdated_count), - Style::default().fg(Color::Yellow), - ) - } else { - ( - format!(" Dependencies ({} up-to-date) ", dependencies.len()), - Style::default().fg(Color::Green), - ) - } - } -} - -impl Component for DependenciesPane { - fn handle_key_events(&mut self, key: KeyCode, _app: &mut AppState) -> Option { - match key { - KeyCode::Char('u') => Some(Action::StartUpdateWizard), - _ => None, - } - } - - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { - let colors = app.current_colors(); - if let Some(p) = app.get_selected_project() { - let mut outdated_count = 0; - let mut not_checked_count = 0; - let mut checking_count = 0; - - let dependency_items: Vec = p - .dependencies - .iter() - .map(|dep| { - let is_outdated = dep.has_stable_update(); - - if is_outdated { - outdated_count += 1; - } - - match dep.check_status { - DependencyCheckStatus::NotChecked => not_checked_count += 1, - DependencyCheckStatus::Checking => checking_count += 1, - DependencyCheckStatus::Checked => {} - } - - Self::create_dependency_list_item(dep) - }) - .collect(); - - let (title, _) = Self::get_title( - &p.dependencies, - outdated_count, - not_checked_count, - checking_count, - ); - - // Highlight border when focused - let border_color = if app.focus == Focus::Dependencies { - colors.selection - } else { - colors.primary - }; - - let dependency_list = List::new(dependency_items).block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .title(title) - .border_style(Style::default().fg(border_color)), - ); - - f.render_widget(dependency_list, area); - } else { - let empty = - Paragraph::new("No project selected.\n\nSelect a project to view dependencies.") - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .title(" Dependencies "), - ) - .style(Style::default().fg(colors.muted)); - f.render_widget(empty, area); - } - } -} diff --git a/src/components/filter.rs b/src/components/filter.rs deleted file mode 100644 index f8306d5..0000000 --- a/src/components/filter.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Filter/search component for project discovery -//! -//! Provides live search functionality similar to vim's `/` search mode. -//! Allows users to quickly find projects by name or path. - -use crate::app::AppState; -use crate::events::Action; -use crossterm::event::KeyCode; -use ratatui::Frame; -use ratatui::layout::Rect; - -/// State for filter/search functionality -#[derive(Debug, Clone, Default)] -pub struct FilterState { - /// Current filter input text - pub input: String, - /// Filtered indices in the flattened tree - pub filtered_indices: Vec, - /// Current selection within filtered results - pub selected: usize, -} - -impl FilterState { - pub fn new() -> Self { - Self { - input: String::new(), - filtered_indices: Vec::new(), - selected: 0, - } - } - - /// Update the filter text and recompute filtered indices - pub fn update_input(&mut self, input: String, app: &AppState) { - self.input = input; - self.selected = 0; - self.recompute_matches(app); - } - - /// Recompute which indices match the current filter - pub fn recompute_matches(&mut self, app: &AppState) { - self.filtered_indices.clear(); - - if self.input.is_empty() { - // If filter is empty, show all items - self.filtered_indices = (0..app.flattened_tree.items.len()).collect(); - } else { - let input_lower = self.input.to_lowercase(); - - for (idx, (node, _)) in app.flattened_tree.items.iter().enumerate() { - let matches = match &node.node_type { - crate::tree::TreeNodeType::Directory { name, .. } => { - name.to_lowercase().contains(&input_lower) - } - crate::tree::TreeNodeType::Project(project) => { - project.name.to_lowercase().contains(&input_lower) - || project - .path - .to_string_lossy() - .to_lowercase() - .contains(&input_lower) - } - }; - - if matches { - self.filtered_indices.push(idx); - } - } - } - - // Clamp selection - if self.selected >= self.filtered_indices.len() { - self.selected = self.filtered_indices.len().saturating_sub(1); - } - } - - /// Get the currently selected index in the full tree - pub fn selected_tree_index(&self) -> Option { - self.filtered_indices.get(self.selected).copied() - } - - /// Move selection to next match - pub fn select_next(&mut self) { - if !self.filtered_indices.is_empty() { - self.selected = (self.selected + 1) % self.filtered_indices.len(); - } - } - - /// Move selection to previous match - pub fn select_previous(&mut self) { - if !self.filtered_indices.is_empty() { - self.selected = if self.selected == 0 { - self.filtered_indices.len() - 1 - } else { - self.selected - 1 - }; - } - } - - /// Clear the filter input - pub fn clear(&mut self) { - self.input.clear(); - self.filtered_indices.clear(); - self.selected = 0; - } -} - -pub struct FilterComponent; - -impl FilterComponent { - pub fn new() -> Self { - Self - } - - pub fn handle_key_events(&mut self, key: KeyCode, _state: &mut AppState) -> Option { - match key { - KeyCode::Esc => Some(Action::ExitFilterMode), - KeyCode::Enter => Some(Action::ExitFilterMode), - _ => None, - } - } - - pub fn draw(&mut self, _f: &mut Frame, _state: &mut AppState, _area: Rect) { - // Filter is rendered as a status line or modal overlay - // The actual rendering is handled in the status bar component - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_filter_state_new() { - let filter = FilterState::new(); - assert!(filter.input.is_empty()); - assert!(filter.filtered_indices.is_empty()); - assert_eq!(filter.selected, 0); - } - - #[test] - fn test_filter_clear() { - let mut filter = FilterState::new(); - filter.input = "test".to_string(); - filter.filtered_indices = vec![1, 2, 3]; - filter.selected = 2; - - filter.clear(); - - assert!(filter.input.is_empty()); - assert!(filter.filtered_indices.is_empty()); - assert_eq!(filter.selected, 0); - } - - #[test] - fn test_filter_select_next() { - let mut filter = FilterState::new(); - filter.filtered_indices = vec![0, 2, 5]; - filter.selected = 0; - - filter.select_next(); - assert_eq!(filter.selected, 1); - - filter.select_next(); - assert_eq!(filter.selected, 2); - - filter.select_next(); // wraps around - assert_eq!(filter.selected, 0); - } - - #[test] - fn test_filter_select_previous() { - let mut filter = FilterState::new(); - filter.filtered_indices = vec![0, 2, 5]; - filter.selected = 0; - - filter.select_previous(); - assert_eq!(filter.selected, 2); - - filter.select_previous(); - assert_eq!(filter.selected, 1); - } -} diff --git a/src/components/help.rs b/src/components/help.rs deleted file mode 100644 index 58f9229..0000000 --- a/src/components/help.rs +++ /dev/null @@ -1,307 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{ - Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, - }, -}; - -pub struct Help { - scroll: usize, - scroll_state: ScrollbarState, -} - -impl Help { - pub fn new() -> Self { - Self { - scroll: 0, - scroll_state: ScrollbarState::default(), - } - } -} - -impl Component for Help { - fn handle_key_events(&mut self, key: KeyCode, _app: &mut AppState) -> Option { - match key { - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => Some(Action::EnterNormalMode), - KeyCode::Up | KeyCode::Char('k') => { - self.scroll = self.scroll.saturating_sub(1); - None - } - KeyCode::Down | KeyCode::Char('j') => { - self.scroll = self.scroll.saturating_add(1); - None - } - KeyCode::PageUp => { - self.scroll = self.scroll.saturating_sub(10); - None - } - KeyCode::PageDown => { - self.scroll = self.scroll.saturating_add(10); - None - } - KeyCode::Home => { - self.scroll = 0; - None - } - _ => None, - } - } - - fn draw(&mut self, f: &mut Frame, _app: &mut AppState, area: Rect) { - let popup_area = Self::centered_rect(80, 85, area); - - f.render_widget(Clear, popup_area); - - let chunks = Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(0), - Constraint::Length(3), - ]) - .split(popup_area); - - // Title - let title = Block::default() - .title(" CarWash - Rust Project Manager ") - .title_alignment(Alignment::Center) - .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) - .border_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ); - f.render_widget(title, chunks[0]); - - // Help content - let help_lines = vec![ - Line::from(vec![Span::styled( - "Navigation", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(vec![ - Span::styled(" ↑↓ / j k ", Style::default().fg(Color::Cyan)), - Span::raw("Navigate projects"), - ]), - Line::from(vec![ - Span::styled(" Space ", Style::default().fg(Color::Cyan)), - Span::raw("Toggle project selection"), - ]), - Line::from(vec![ - Span::styled(" ←→ / h l ", Style::default().fg(Color::Cyan)), - Span::raw("Collapse/Expand workspaces"), - ]), - Line::from(vec![ - Span::styled(" Tab / S-Tab ", Style::default().fg(Color::Cyan)), - Span::raw("Cycle focus between panes"), - ]), - Line::from(vec![ - Span::styled(" Ctrl+[ / ] ", Style::default().fg(Color::Cyan)), - Span::raw("Switch output tabs (works from any pane)"), - ]), - Line::from(vec![ - Span::styled(" - / + ", Style::default().fg(Color::Cyan)), - Span::raw("Adjust output pane height"), - ]), - Line::from(vec![ - Span::styled(" [ ] / { } ", Style::default().fg(Color::Cyan)), - Span::raw("Adjust left pane width"), - ]), - Line::from(vec![ - Span::styled(" Shift+R ", Style::default().fg(Color::Cyan)), - Span::raw("Reset layout to defaults"), - ]), - Line::from(""), - Line::from(vec![Span::styled( - "Commands", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(vec![ - Span::styled(" : ", Style::default().fg(Color::Cyan)), - Span::raw("Open command palette"), - ]), - Line::from(vec![ - Span::styled(" u ", Style::default().fg(Color::Cyan)), - Span::raw("Check for dependency updates"), - ]), - Line::from(vec![ - Span::styled(" ? ", Style::default().fg(Color::Cyan)), - Span::raw("Toggle this help screen"), - ]), - Line::from(vec![ - Span::styled(" q ", Style::default().fg(Color::Cyan)), - Span::raw("Quit application"), - ]), - Line::from(vec![ - Span::styled(" Ctrl+C ", Style::default().fg(Color::Cyan)), - Span::raw("Force quit"), - ]), - Line::from(""), - Line::from(vec![Span::styled( - "Command Palette", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(vec![ - Span::styled(" Type ", Style::default().fg(Color::Cyan)), - Span::raw("Filter commands (fuzzy search)"), - ]), - Line::from(vec![ - Span::styled(" Tab ", Style::default().fg(Color::Cyan)), - Span::raw("Toggle scope (Selected/All projects)"), - ]), - Line::from(vec![ - Span::styled(" Enter ", Style::default().fg(Color::Cyan)), - Span::raw("Execute selected command"), - ]), - Line::from(vec![ - Span::styled(" Esc ", Style::default().fg(Color::Cyan)), - Span::raw("Cancel"), - ]), - Line::from(""), - Line::from(vec![Span::styled( - "Common Commands", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(vec![ - Span::styled(" test ", Style::default().fg(Color::Green)), - Span::raw("Run tests"), - ]), - Line::from(vec![ - Span::styled(" check ", Style::default().fg(Color::Green)), - Span::raw("Check for errors"), - ]), - Line::from(vec![ - Span::styled(" build ", Style::default().fg(Color::Green)), - Span::raw("Build projects"), - ]), - Line::from(vec![ - Span::styled(" clean ", Style::default().fg(Color::Green)), - Span::raw("Clean build artifacts"), - ]), - Line::from(vec![ - Span::styled(" clippy ", Style::default().fg(Color::Green)), - Span::raw("Run Clippy lints"), - ]), - Line::from(vec![ - Span::styled(" fmt ", Style::default().fg(Color::Green)), - Span::raw("Format code"), - ]), - Line::from(""), - Line::from(vec![Span::styled( - "Tips", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(" • Select multiple projects with Space, then run commands on all"), - Line::from( - " • Output is color-coded: errors (red), warnings (yellow), success (green)", - ), - Line::from(" • Use Tab to cycle focus: Projects → Dependencies → Output"), - Line::from(" • Focus indicator shows current pane in the status bar"), - Line::from(" • When Output is focused: ←→/hl switches tabs, j/k or PgUp/Dn scrolls"), - Line::from(" • Use Ctrl+[ and Ctrl+] to switch output tabs from any pane"), - Line::from(" • Commands run in parallel across selected projects"), - Line::from(" • Press 'u' on selected project to check for outdated dependencies"), - ]; - - // Calculate content height and viewport - let content_height = help_lines.len(); - let viewport_height = (chunks[1].height.saturating_sub(2)) as usize; // Subtract borders - - // Clamp scroll to valid range - let max_scroll = content_height.saturating_sub(viewport_height); - self.scroll = self.scroll.min(max_scroll); - - let help_para = Paragraph::new(help_lines) - .block( - Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .border_type(ratatui::widgets::BorderType::Rounded), - ) - .wrap(Wrap { trim: false }) - .scroll((self.scroll as u16, 0)); - - f.render_widget(help_para, chunks[1]); - - // Render scrollbar if content is larger than viewport - if content_height > viewport_height { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")); - - let mut scrollbar_state = self - .scroll_state - .content_length(content_height) - .viewport_content_length(viewport_height) - .position(self.scroll); - - let scrollbar_area = Rect { - x: chunks[1].x + chunks[1].width - 1, - y: chunks[1].y + 1, - width: 1, - height: chunks[1].height.saturating_sub(2), - }; - - f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); - } - - // Footer - let footer_text = if content_height > viewport_height { - " ↑↓/j k: scroll | PgUp/PgDn: page | Home: top | ? or Esc: close " - } else { - " Press ? or Esc to close " - }; - let footer = Paragraph::new(footer_text) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), - ); - f.render_widget(footer, chunks[2]); - } -} - -impl Help { - fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] - } -} diff --git a/src/components/mod.rs b/src/components/mod.rs deleted file mode 100644 index bdfa0ad..0000000 --- a/src/components/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! UI components for the CarWash application -//! -//! This module contains all the reusable UI components that make up the CarWash interface. -//! Each component follows the [`Component`] trait interface for consistent event handling and rendering. - -use crate::app::AppState; -use crate::events::Action; -use crossterm::event::KeyCode; -use ratatui::{Frame, layout::Rect}; - -pub mod dependencies; -pub mod filter; -pub mod help; -pub mod output; -pub mod palette; -pub mod progress; -pub mod projects; -pub mod settings; -pub mod spinner; -pub mod status; -pub mod text_input; -pub mod tree_view; -pub mod updater; - -// Re-export commonly used types -/// Trait for UI components in CarWash -/// -/// All UI components implement this trait to provide a consistent interface for -/// event handling and rendering. -pub trait Component { - /// Handle keyboard input for this component - /// - /// # Arguments - /// - /// * `key` - The key that was pressed - /// * `app` - Mutable reference to the application state - /// - /// # Returns - /// - /// Returns an [`Action`] if the key triggered an action, or `None` otherwise. - fn handle_key_events(&mut self, key: KeyCode, app: &mut AppState) -> Option; - - /// Render this component to the terminal - /// - /// # Arguments - /// - /// * `f` - The terminal frame to draw to - /// * `app` - Mutable reference to the application state - /// * `area` - The rectangular area to draw within - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect); -} diff --git a/src/components/output.rs b/src/components/output.rs deleted file mode 100644 index 05d758d..0000000 --- a/src/components/output.rs +++ /dev/null @@ -1,254 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::{Action, Focus}; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{ - Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs, Wrap, - }, -}; - -pub struct TabbedOutputPane { - scroll_state: ScrollbarState, -} - -impl TabbedOutputPane { - pub fn new() -> Self { - Self { - scroll_state: ScrollbarState::default(), - } - } -} - -impl Component for TabbedOutputPane { - fn handle_key_events(&mut self, key: KeyCode, app: &mut AppState) -> Option { - match key { - // Tab/Esc: Leave output pane, cycle focus to next pane - KeyCode::Tab | KeyCode::Esc => Some(Action::FocusNext), - // BackTab: Cycle focus backwards (to Dependencies) - KeyCode::BackTab => { - // Focus::Output -> Focus::Dependencies (reverse of next()) - app.focus = Focus::Dependencies; - None - } - // h/Left: Previous output tab - KeyCode::Left | KeyCode::Char('h') => { - if app.active_tab > 0 { - Some(Action::SwitchToTab(app.active_tab - 1)) - } else if !app.tabs.is_empty() { - // Wrap around to last tab - Some(Action::SwitchToTab(app.tabs.len() - 1)) - } else { - None - } - } - // l/Right: Next output tab - KeyCode::Right | KeyCode::Char('l') => { - if app.active_tab < app.tabs.len().saturating_sub(1) { - Some(Action::SwitchToTab(app.active_tab + 1)) - } else if !app.tabs.is_empty() { - // Wrap around to first tab - Some(Action::SwitchToTab(0)) - } else { - None - } - } - KeyCode::PageUp | KeyCode::Char('k') | KeyCode::Up => { - if let Some(tab) = app.tabs.get_mut(app.active_tab) { - tab.scroll = tab.scroll.saturating_sub(10); - } - None - } - KeyCode::PageDown | KeyCode::Char('j') | KeyCode::Down => { - if let Some(tab) = app.tabs.get_mut(app.active_tab) { - tab.scroll = (tab.scroll + 10).min(tab.buffer.len().saturating_sub(1)); - } - None - } - _ => None, - } - } - - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { - let colors = app.current_colors(); - if area.height < 4 { - return; - } - - let chunks = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Length(3), - ratatui::layout::Constraint::Min(0), - ]) - .split(area); - - // Render tabs - if !app.tabs.is_empty() { - let titles: Vec = app - .tabs - .iter() - .enumerate() - .map(|(i, t)| { - let style = if i == app.active_tab { - Style::default() - .fg(colors.warning) - .add_modifier(Modifier::BOLD) - } else if t.is_finished { - Style::default().fg(colors.success) - } else { - Style::default().fg(colors.selection) - }; - - let prefix = if t.is_finished { "✓ " } else { "⚙ " }; - Span::styled(format!("{}{}", prefix, t.title), style) - }) - .collect(); - - // Highlight border when focused - let border_color = if app.focus == Focus::Output { - colors.selection - } else { - colors.primary - }; - - // Add tab indicator showing current/total tabs - let tab_indicator = if app.tabs.len() > 1 { - format!(" Output ({}/{}) ", app.active_tab + 1, app.tabs.len()) - } else { - " Output ".to_string() - }; - - let tabs = Tabs::new(titles) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .title(tab_indicator) - .border_style(Style::default().fg(border_color)), - ) - .select(app.active_tab) - .style(Style::default()) - .highlight_style( - Style::default() - .fg(colors.warning) - .add_modifier(Modifier::BOLD), - ); - f.render_widget(tabs, chunks[0]); - } else { - let empty_block = Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .title(" Output ") - .border_style(Style::default().fg(colors.primary)); - f.render_widget(empty_block, chunks[0]); - } - - // Render active tab content - // We use get_mut because we may need to cap the scroll - if let Some(active_tab) = app.tabs.get_mut(app.active_tab) { - let content_height = (chunks[1].height.saturating_sub(2)) as usize; - - // Update scroll bounds - let max_scroll = active_tab.buffer.len().saturating_sub(content_height); - active_tab.scroll = active_tab.scroll.min(max_scroll); - let current_scroll = active_tab.scroll; - - let visible_content: Vec = active_tab - .buffer - .iter() - .skip(current_scroll) - .take(content_height) - .map(|line| { - // Colorize output based on content - let style = if line.contains("error") - || line.contains("Error") - || line.contains("ERROR") - { - Style::default().fg(colors.error) - } else if line.contains("warning") - || line.contains("Warning") - || line.contains("WARN") - { - Style::default().fg(colors.warning) - } else if line.contains("Finished") || line.contains("success") { - Style::default().fg(colors.success) - } else if line.starts_with(" Compiling") || line.starts_with(" Checking") { - Style::default().fg(colors.selection) - } else { - Style::default() - }; - Line::from(Span::styled(line.as_str(), style)) - }) - .collect(); - - let status_info = if active_tab.is_finished { - format!( - " [Finished] Line {}/{} ", - current_scroll + 1, - active_tab.buffer.len().max(1) - ) - } else { - format!( - " [Running...] Line {}/{} ", - current_scroll + 1, - active_tab.buffer.len().max(1) - ) - }; - - let output_para = Paragraph::new(visible_content) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .title(status_info) - .border_style(Style::default().fg(if active_tab.is_finished { - colors.success - } else { - colors.selection - })), - ) - .wrap(Wrap { trim: false }); - - f.render_widget(output_para, chunks[1]); - - // Render scrollbar if content is larger than view - if active_tab.buffer.len() > content_height { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")); - - let mut scrollbar_state = self - .scroll_state - .content_length(active_tab.buffer.len()) - .viewport_content_length(content_height) - .position(current_scroll); - - let scrollbar_area = Rect { - x: chunks[1].x + chunks[1].width - 1, - y: chunks[1].y + 1, - width: 1, - height: chunks[1].height.saturating_sub(2), - }; - - f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); - } - } else { - let empty_para = Paragraph::new( - "No commands running.\n\nPress ':' to open command palette and run cargo commands.", - ) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .border_style(Style::default().fg(colors.primary)), - ) - .style(Style::default().fg(colors.muted)); - f.render_widget(empty_para, chunks[1]); - } - } -} diff --git a/src/components/palette.rs b/src/components/palette.rs deleted file mode 100644 index 48c3e0f..0000000 --- a/src/components/palette.rs +++ /dev/null @@ -1,211 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crate::events::Command; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, -}; -use tui_input::{Input, backend::crossterm::EventHandler}; - -#[derive(Debug, Clone)] -pub struct CommandPaletteState { - pub input: Input, - pub filtered_commands: Vec, - pub list_state: ratatui::widgets::ListState, -} - -impl CommandPaletteState { - pub fn new() -> Self { - Self { - input: Input::default(), - filtered_commands: Vec::new(), - list_state: ratatui::widgets::ListState::default(), - } - } -} - -pub struct CommandPalette {} - -impl CommandPalette { - pub fn new() -> Self { - Self {} - } -} - -impl Component for CommandPalette { - fn handle_key_events(&mut self, key: KeyCode, app: &mut AppState) -> Option { - match key { - KeyCode::Enter => { - let command = app - .palette - .list_state - .selected() - .and_then(|i| app.palette.filtered_commands.get(i)) - .cloned() - .unwrap_or_else(|| Command::Cargo { - command: app.palette.input.value().to_string(), - }); - Some(Action::ExecuteCommand(command)) - } - KeyCode::Esc => Some(Action::EnterNormalMode), - KeyCode::Down | KeyCode::Char('j') => Some(Action::PaletteSelectNext), - KeyCode::Up | KeyCode::Char('k') => Some(Action::PaletteSelectPrevious), - _ => { - let mut input = app.palette.input.clone(); - if input - .handle_event(&crossterm::event::Event::Key(key.into())) - .is_some() - { - Some(Action::UpdatePaletteInput(input.value().to_string())) - } else { - None - } - } - } - } - - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { - let colors = app.current_colors(); - // Center the palette - let popup_area = Self::centered_rect(60, 60, area); - - f.render_widget(Clear, popup_area); - - let chunks = Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(3), - ]) - .split(popup_area); - - // Input box - let input_box = Paragraph::new(app.palette.input.value()) - .style(Style::default().fg(colors.warning)) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .title(" Command ") - .border_style(Style::default().fg(colors.selection)), - ); - f.render_widget(input_box, chunks[0]); - - // Command list - let command_items: Vec = app - .palette - .filtered_commands - .iter() - .map(|cmd| { - let text = match cmd { - Command::Cargo { command } => Line::from(vec![ - Span::styled( - "cargo ", - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD), - ), - Span::styled(command, Style::default().fg(colors.text)), - ]), - _ => Line::from(format!("{:?}", cmd)), - }; - ListItem::new(text) - }) - .collect(); - - let list_title = if app.palette.filtered_commands.is_empty() { - " Commands (no matches) " - } else { - " Commands (↑↓ to select) " - }; - - let list = List::new(command_items) - .block( - Block::default() - .title(list_title) - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .border_style(Style::default().fg(colors.selection)), - ) - .highlight_style(Style::default().bg(colors.dim).add_modifier(Modifier::BOLD)) - .highlight_symbol("▶ "); - - f.render_stateful_widget(list, chunks[1], &mut app.palette.list_state); - - // Help text - let selected_info = if app.selected_projects.is_empty() { - " No projects selected - select with Space first! " - } else { - " Enter: Run on selected projects | Esc: Cancel " - }; - - let help = Paragraph::new(selected_info) - .style(if app.selected_projects.is_empty() { - Style::default().fg(colors.warning) - } else { - Style::default().fg(colors.muted) - }) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .border_style(Style::default().fg(colors.primary)), - ); - f.render_widget(help, chunks[2]); - } -} - -impl CommandPalette { - fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_command_palette_state_new() { - let state = CommandPaletteState::new(); - assert!(state.filtered_commands.is_empty()); - assert_eq!(state.input.value(), ""); - } - - #[test] - fn test_command_palette_state_clone() { - let mut state = CommandPaletteState::new(); - state.filtered_commands.push(Command::Cargo { - command: "test".to_string(), - }); - - let cloned = state.clone(); - assert_eq!( - state.filtered_commands.len(), - cloned.filtered_commands.len() - ); - } -} diff --git a/src/components/progress.rs b/src/components/progress.rs deleted file mode 100644 index 960b80a..0000000 --- a/src/components/progress.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! Progress visualization and tracking for parallel command execution -//! -//! Provides visual feedback for long-running operations, showing per-project -//! progress and overall completion status. - -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crate::ui::styles::Colors; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Gauge, Paragraph}, -}; -use std::time::{Duration, SystemTime}; - -/// Tracks progress of a single project's command execution -#[derive(Debug, Clone)] -pub struct ProjectProgress { - /// Project name - pub name: String, - /// Current step (e.g., "Fetching", "Building", "Testing") - pub current_step: String, - /// Estimated percentage complete (0-100) - pub progress: u16, - /// Whether this project has finished - pub is_finished: bool, - /// Time when this project started - pub started_at: SystemTime, - /// Time when this project finished (if applicable) - pub finished_at: Option, -} - -impl ProjectProgress { - /// Create a new project progress tracker - pub fn new(name: String) -> Self { - Self { - name, - current_step: "Starting...".to_string(), - progress: 0, - is_finished: false, - started_at: SystemTime::now(), - finished_at: None, - } - } - - /// Get elapsed time since start - pub fn elapsed(&self) -> Duration { - self.started_at.elapsed().unwrap_or(Duration::from_secs(0)) - } - - /// Get elapsed time or duration if finished - pub fn total_duration(&self) -> Duration { - if let Some(finished_at) = self.finished_at { - finished_at - .duration_since(self.started_at) - .unwrap_or(Duration::from_secs(0)) - } else { - self.elapsed() - } - } - - /// Mark this project as finished - pub fn finish(&mut self) { - self.is_finished = true; - self.progress = 100; - self.finished_at = Some(SystemTime::now()); - self.current_step = "Complete ✓".to_string(); - } - - /// Update progress step - pub fn set_step(&mut self, step: String, progress: u16) { - self.current_step = step; - self.progress = progress.min(99); // Never show 100% until finished - } -} - -/// Overall progress state for multiple parallel commands -#[derive(Debug, Clone)] -pub struct ProgressState { - /// Progress for each project - pub projects: Vec, - /// Total number of projects to process - pub total: usize, - /// Title for the progress display - pub title: String, - /// Whether progress tracking is active - pub is_active: bool, -} - -impl ProgressState { - /// Create a new progress state - pub fn new(title: String, total: usize) -> Self { - Self { - projects: Vec::new(), - total, - title, - is_active: true, - } - } - - /// Add a project to track - pub fn add_project(&mut self, project: ProjectProgress) { - self.projects.push(project); - } - - /// Get overall progress percentage - pub fn overall_progress(&self) -> u16 { - if self.projects.is_empty() || self.total == 0 { - return 0; - } - let completed = self.projects.iter().filter(|p| p.is_finished).count(); - ((completed as u32 * 100) / self.total as u32) as u16 - } - - /// Get number of completed projects - pub fn completed_count(&self) -> usize { - self.projects.iter().filter(|p| p.is_finished).count() - } - - /// Check if all projects are finished - pub fn is_complete(&self) -> bool { - !self.projects.is_empty() && self.completed_count() == self.projects.len() - } - - /// Clear progress state - pub fn reset(&mut self) { - self.projects.clear(); - self.is_active = false; - } -} - -/// Component for displaying progress information -pub struct ProgressComponent; - -impl ProgressComponent { - pub fn new() -> Self { - Self - } - - /// Render a progress bar for a single project - pub fn render_project_progress(colors: Colors, progress: &ProjectProgress) -> Line<'static> { - let progress_str = format!("[{}%]", progress.progress); - let step_str = progress.current_step.clone(); - - let elapsed_secs = progress.elapsed().as_secs(); - let elapsed_str = if elapsed_secs < 60 { - format!("{}s", elapsed_secs) - } else { - format!("{}m{}s", elapsed_secs / 60, elapsed_secs % 60) - }; - - let name_width = 20; - let name = if progress.name.len() > name_width { - format!("{}...", &progress.name[..name_width - 3]) - } else { - format!("{: 50 { - colors.selection - } else { - colors.muted - }; - - let status_str = if progress.is_finished { "✓" } else { "⟳" }; - - Line::from(vec![ - Span::styled( - format!("{} ", status_str), - Style::default().fg(status_color), - ), - Span::styled(name, Style::default().fg(colors.text)), - Span::raw(" "), - Span::styled(format!("{:<15}", step_str), Style::default().fg(colors.dim)), - Span::styled( - format!("{:>5}", progress_str), - Style::default().fg(status_color), - ), - Span::raw(" "), - Span::styled(elapsed_str, Style::default().fg(colors.muted)), - ]) - } - - /// Render overall progress bar with gauge - pub fn render_overall_progress(colors: Colors, state: &ProgressState) -> Paragraph<'static> { - let overall = state.overall_progress(); - let completed = state.completed_count(); - - let _gauge = Gauge::default() - .block( - Block::default() - .title(format!(" {} ({}/{}) ", state.title, completed, state.total)) - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded), - ) - .gauge_style(Style::default().fg(colors.success)) - .percent(overall) - .label(format!("{}%", overall)); - - Paragraph::new(format!("Overall: {}/{} complete", completed, state.total)) - .style(Style::default().fg(colors.text)) - } -} - -impl Component for ProgressComponent { - fn handle_key_events(&mut self, _key: KeyCode, _app: &mut AppState) -> Option { - None - } - - fn draw(&mut self, _f: &mut Frame, _app: &mut AppState, _area: Rect) { - // Progress is rendered in the output pane, not as a standalone component - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_project_progress_new() { - let progress = ProjectProgress::new("test_project".to_string()); - assert_eq!(progress.name, "test_project"); - assert_eq!(progress.progress, 0); - assert!(!progress.is_finished); - } - - #[test] - fn test_project_progress_finish() { - let mut progress = ProjectProgress::new("test".to_string()); - progress.finish(); - assert!(progress.is_finished); - assert_eq!(progress.progress, 100); - assert_eq!(progress.current_step, "Complete ✓"); - } - - #[test] - fn test_project_progress_set_step() { - let mut progress = ProjectProgress::new("test".to_string()); - progress.set_step("Building".to_string(), 50); - assert_eq!(progress.current_step, "Building"); - assert_eq!(progress.progress, 50); - } - - #[test] - fn test_progress_state_new() { - let state = ProgressState::new("Test".to_string(), 5); - assert_eq!(state.total, 5); - assert!(state.is_active); - } - - #[test] - fn test_progress_state_overall_progress() { - let mut state = ProgressState::new("Test".to_string(), 3); - let mut p1 = ProjectProgress::new("proj1".to_string()); - p1.finish(); - state.add_project(p1); - - assert_eq!(state.completed_count(), 1); - assert_eq!(state.overall_progress(), 33); // 1/3 = ~33% - } - - #[test] - fn test_progress_state_complete() { - let mut state = ProgressState::new("Test".to_string(), 2); - let mut p1 = ProjectProgress::new("proj1".to_string()); - p1.finish(); - let mut p2 = ProjectProgress::new("proj2".to_string()); - p2.finish(); - state.add_project(p1); - state.add_project(p2); - - assert!(state.is_complete()); - } - - #[test] - fn test_progress_state_reset() { - let mut state = ProgressState::new("Test".to_string(), 2); - state.add_project(ProjectProgress::new("proj1".to_string())); - assert_eq!(state.projects.len(), 1); - - state.reset(); - assert!(state.projects.is_empty()); - assert!(!state.is_active); - } - - #[test] - fn test_elapsed_time() { - let progress = ProjectProgress::new("test".to_string()); - let elapsed = progress.elapsed(); - assert!(elapsed.as_secs() < 2); // Should be very quick - } -} diff --git a/src/components/projects.rs b/src/components/projects.rs deleted file mode 100644 index 41630c4..0000000 --- a/src/components/projects.rs +++ /dev/null @@ -1,291 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - style::{Color, Modifier, Style}, - widgets::ListItem, -}; - -pub struct ProjectList {} - -impl ProjectList { - pub fn new() -> Self { - Self {} - } - - /// Get status indicator and color for a project based on its check status - /// - /// Visual indicators: - /// - Gray (⋯): Unchecked or cache invalidated - /// - Blue (⟳): Currently checking for updates - /// - Yellow (⚠): Some dependencies are outdated - /// - Green (✓): All dependencies up to date - fn get_project_status( - p: &crate::project::Project, - colors: crate::ui::styles::Colors, - ) -> (&'static str, Style) { - use crate::project::ProjectCheckStatus; - - match p.check_status { - ProjectCheckStatus::Unchecked => { - // Gray - not checked yet or cache invalidated - ("⋯", Style::default().fg(colors.muted)) - } - ProjectCheckStatus::Checking => { - // Blue - currently being checked - ( - "⟳", - Style::default() - .fg(Color::Blue) - .add_modifier(Modifier::BOLD), - ) - } - ProjectCheckStatus::HasUpdates => { - // Yellow - has outdated dependencies - ( - "⚠", - Style::default() - .fg(colors.warning) - .add_modifier(Modifier::BOLD), - ) - } - ProjectCheckStatus::UpToDate => { - // Green - all up to date - ("✓", Style::default().fg(colors.success)) - } - } - } -} - -impl ProjectList { - /// Create a list item for a project from the tree (with indentation) - fn create_tree_project_item<'a>( - selected_projects: &'a std::collections::HashSet, - project: &'a crate::project::Project, - depth: usize, - is_selected: bool, - colors: crate::ui::styles::Colors, - ) -> ListItem<'a> { - let (status_icon, status_style) = Self::get_project_status(project, colors); - - let is_checked = selected_projects.contains(&project.path); - let checkbox_symbol = if is_checked { "☑" } else { "☐" }; - let checkbox_style = if is_checked { - Style::default().fg(colors.success) - } else { - Style::default().fg(colors.muted) - }; - - let indicator = if is_selected { "▶ " } else { " " }; - let indent = " ".repeat(depth); - - let name_style = if is_selected { - Style::default() - .fg(colors.text) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(colors.text) - }; - - // Build size information string if available - let mut spans = vec![ - ratatui::text::Span::raw(indicator), - ratatui::text::Span::raw(indent), - ratatui::text::Span::styled(checkbox_symbol, checkbox_style), - ratatui::text::Span::raw(" "), - ratatui::text::Span::styled(status_icon, status_style), - ratatui::text::Span::raw(" 🦀 "), - ratatui::text::Span::styled(&project.name, name_style), - ]; - - // Git status indicator - if project.git_status == crate::project::GitStatus::Dirty { - spans.push(ratatui::text::Span::raw(" ")); - spans.push(ratatui::text::Span::styled( - crate::ui::styles::StatusSymbols::GIT_DIRTY, - Style::default() - .fg(colors.warning) - .add_modifier(Modifier::BOLD), - )); - } - - // Add size information if available - if let Some(target_size) = project.target_size { - let size_str = crate::project::Project::format_size(target_size); - let size_style = if target_size > 1_000_000_000 { - // > 1GB: red warning - Style::default() - .fg(colors.error) - .add_modifier(ratatui::style::Modifier::BOLD) - } else if target_size > 100_000_000 { - // > 100MB: yellow warning - Style::default().fg(colors.warning) - } else if target_size > 0 { - // > 0: dim gray - Style::default().fg(colors.muted) - } else { - // 0 bytes: very dim - Style::default().fg(Color::Rgb(80, 80, 80)) - }; - spans.push(ratatui::text::Span::raw(" ")); - spans.push(ratatui::text::Span::styled( - format!("📦{}", size_str), - size_style, - )); - } - - ListItem::new(ratatui::text::Line::from(spans)) - } -} - -impl Component for ProjectList { - fn handle_key_events(&mut self, key: KeyCode, _app: &mut AppState) -> Option { - match key { - KeyCode::Down | KeyCode::Char('j') => Some(Action::SelectNext), - KeyCode::Up | KeyCode::Char('k') => Some(Action::SelectPrevious), - KeyCode::Left | KeyCode::Char('h') => Some(Action::SelectParent), - KeyCode::Right | KeyCode::Char('l') => Some(Action::SelectChild), - KeyCode::Char(' ') => Some(Action::ToggleSelection), - _ => None, - } - } - - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { - let selected_index = app.tree_state.selected(); - let mut items: Vec = Vec::new(); - let colors = app.current_colors(); - - // Render from flattened tree instead of projects list - for (idx, (node, _)) in app.flattened_tree.items.iter().enumerate() { - let is_selected = selected_index == Some(idx); - - match &node.node_type { - crate::tree::TreeNodeType::Directory { name, .. } => { - // Render directory node with expand/collapse indicator - let indicator = if is_selected { "▶ " } else { " " }; - let collapse_indicator = if node.expanded { "▾" } else { "▸" }; - let icon = if node.expanded { "📂" } else { "📁" }; - let indent = " ".repeat(node.depth); - - // Check if any children are selected - let has_selected_children = app - .flattened_tree - .items - .iter() - .skip(idx + 1) - .take_while(|(child_node, _)| child_node.depth > node.depth) - .filter_map(|(child_node, _)| { - if let crate::tree::TreeNodeType::Project(p) = &child_node.node_type { - Some(&p.path) - } else { - None - } - }) - .any(|path| app.selected_projects.contains(path)); - - let checkbox_symbol = if has_selected_children { "☑" } else { "☐" }; - let checkbox_style = if has_selected_children { - Style::default().fg(colors.success) - } else { - Style::default().fg(colors.muted) - }; - - let style = if is_selected { - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(colors.dim).add_modifier(Modifier::BOLD) - }; - - items.push(ListItem::new(ratatui::text::Line::from(vec![ - ratatui::text::Span::raw(indicator), - ratatui::text::Span::raw(indent), - ratatui::text::Span::styled(checkbox_symbol, checkbox_style), - ratatui::text::Span::raw(" "), - ratatui::text::Span::styled( - format!("{} {} {}", collapse_indicator, icon, name), - style, - ), - ]))); - } - crate::tree::TreeNodeType::Project(project) => { - // Render project node - items.push(Self::create_tree_project_item( - &app.selected_projects, - project, - node.depth, - is_selected, - colors, - )); - } - } - } - - let selected_count = app.selected_projects.len(); - let total_count = app - .flattened_tree - .items - .iter() - .filter(|(node, _)| node.node_type.is_project()) - .count(); - let all_count = app.all_projects.len(); - - let title = if selected_count > 0 { - if all_count > total_count { - format!( - " Projects ({}/{} selected, {}/{} shown) ", - selected_count, total_count, total_count, all_count - ) - } else { - format!(" Projects ({}/{} selected) ", selected_count, total_count) - } - } else if all_count > total_count { - format!(" Projects ({}/{} shown) ", total_count, all_count) - } else { - format!(" Projects ({}) ", total_count) - }; - - let help_text = if area.height > items.len() as u16 + 4 { - "\n\n ↑↓/jk: Navigate\n ←→/hl: Collapse/Expand\n Space: Select\n :: Command" - } else { - "" - }; - - let mut list_items = items; - if !help_text.is_empty() { - for line in help_text.lines().skip(1) { - list_items.push(ListItem::new(ratatui::text::Line::from( - ratatui::text::Span::styled(line, Style::default().fg(colors.muted)), - ))); - } - } - - // Highlight border when focused - let border_color = if app.focus == crate::events::Focus::Projects { - Color::Cyan - } else { - colors.primary - }; - - let project_list = ratatui::widgets::List::new(list_items) - .block( - ratatui::widgets::Block::default() - .borders(ratatui::widgets::Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .title(title) - .border_style(Style::default().fg(border_color)), - ) - .highlight_style( - Style::default() - .bg(Color::Rgb(40, 40, 60)) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(""); - - f.render_stateful_widget(project_list, area, &mut app.tree_state); - } -} diff --git a/src/components/settings.rs b/src/components/settings.rs deleted file mode 100644 index a32d2a1..0000000 --- a/src/components/settings.rs +++ /dev/null @@ -1,149 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; -use tui_input::{Input, backend::crossterm::EventHandler}; - -#[derive(Debug, Clone)] -pub struct SettingsModalState { - pub cache_minutes_input: Input, - pub error_message: Option, -} - -impl SettingsModalState { - pub fn new() -> Self { - Self { - cache_minutes_input: Input::default(), - error_message: None, - } - } -} - -pub struct SettingsModal; - -impl SettingsModal { - pub fn new() -> Self { - Self - } - - fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] - } -} - -impl Component for SettingsModal { - fn handle_key_events(&mut self, key: KeyCode, app: &mut AppState) -> Option { - match key { - KeyCode::Esc => Some(Action::CloseSettings), - KeyCode::Enter => Some(Action::SaveSettings), - _ => { - let mut input = app.settings_modal.cache_minutes_input.clone(); - if input - .handle_event(&crossterm::event::Event::Key(key.into())) - .is_some() - { - Some(Action::SettingsUpdateCacheInput(input.value().to_string())) - } else { - None - } - } - } - } - - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { - let colors = app.current_colors(); - let popup_area = Self::centered_rect(60, 50, area); - - f.render_widget(Clear, popup_area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(3), - Constraint::Length(3), - ]) - .split(popup_area); - - let title = Block::default() - .title(" Settings ") - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .border_style( - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD), - ); - f.render_widget(title, chunks[0]); - - let cache_label = Line::from(vec![ - Span::styled(" Cache TTL (minutes) ", Style::default().fg(colors.text)), - Span::raw(" "), - Span::styled( - app.settings_modal.cache_minutes_input.value(), - Style::default() - .fg(colors.warning) - .add_modifier(Modifier::BOLD), - ), - ]); - - let cache_para = Paragraph::new(cache_label) - .alignment(Alignment::Left) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .border_style(Style::default().fg(colors.dim)), - ); - f.render_widget(cache_para, chunks[1]); - - let mut lines = vec![Line::from(vec![ - Span::styled(" Enter", Style::default().fg(colors.success)), - Span::raw(": Save"), - Span::raw(" "), - Span::styled("Esc", Style::default().fg(colors.error)), - Span::raw(": Cancel"), - ])]; - - if let Some(ref error) = app.settings_modal.error_message { - lines.push(Line::from(vec![Span::styled( - error.as_str(), - Style::default() - .fg(colors.error) - .add_modifier(Modifier::BOLD | Modifier::ITALIC), - )])); - } - - let help_para = Paragraph::new(lines).alignment(Alignment::Center).block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .border_style(Style::default().fg(colors.selection)), - ); - f.render_widget(help_para, chunks[3]); - } -} diff --git a/src/components/spinner.rs b/src/components/spinner.rs deleted file mode 100644 index afdd827..0000000 --- a/src/components/spinner.rs +++ /dev/null @@ -1,62 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Alignment, Rect}, - style::{Color, Style}, - widgets::{Block, BorderType, Borders, Paragraph}, -}; -use std::time::SystemTime; - -pub struct Spinner; - -impl Spinner { - pub fn new() -> Self { - Self - } -} - -impl Component for Spinner { - fn handle_key_events(&mut self, _key: KeyCode, _app: &mut AppState) -> Option { - None - } - - fn draw(&mut self, f: &mut Frame, _app: &mut AppState, area: Rect) { - let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let timestamp = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let idx = (timestamp / 80) as usize % frames.len(); // 80ms per frame - let symbol = frames[idx]; - - let text = format!(" {} Scanning for projects... ", symbol); - - // Calculate center for a small box - let width = (text.len() as u16) + 2; - let height = 3; - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + (area.height.saturating_sub(height)) / 2; - - // Ensure we don't draw outside the screen or weirdly - if x >= area.width || y >= area.height { - return; - } - - let centered_area = Rect::new(x, y, width, height); - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Color::Cyan)); - - let paragraph = Paragraph::new(text) - .block(block) - .alignment(Alignment::Center) - .style(Style::default().fg(Color::Cyan)); - - f.render_widget(paragraph, centered_area); - } -} diff --git a/src/components/status.rs b/src/components/status.rs deleted file mode 100644 index 1e2f42f..0000000 --- a/src/components/status.rs +++ /dev/null @@ -1,172 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::{Action, Focus, Mode}; -use ratatui::{ - Frame, - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, -}; - -pub struct StatusBar {} - -impl StatusBar { - pub fn new() -> Self { - Self {} - } -} - -impl Component for StatusBar { - fn handle_key_events( - &mut self, - _key: crossterm::event::KeyCode, - _app: &mut AppState, - ) -> Option { - None - } - - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { - let mode_info = match app.mode { - Mode::Loading => ("LOADING", Color::Yellow, "Scanning for projects..."), - Mode::Normal => { - let hint = if app.tabs.len() > 1 { - "Tab: cycle | Ctrl+[/]: tabs | ':' cmd | '/' search | 'u' update | '?' help | 'q' quit" - } else { - "Tab: cycle | ':' cmd | '/' search | 't' theme | 'u' update | 's' settings | '?' help" - }; - ("NORMAL", Color::Green, hint) - } - Mode::CommandPalette => ( - "COMMAND", - Color::Cyan, - "Type to filter | ↑↓ select | Enter run | Esc cancel", - ), - Mode::UpdateWizard => ( - "UPDATE", - Color::Magenta, - "Space select | ↑↓ navigate | Enter update | Esc cancel", - ), - Mode::Settings => ( - "SETTINGS", - Color::Magenta, - "Digits set cache | Enter save | Esc cancel", - ), - Mode::TextInput => ("INPUT", Color::Blue, "Enter confirm | Esc cancel"), - Mode::Help => ("HELP", Color::Yellow, "Esc or 'q' to close"), - Mode::Filter => ( - "FILTER", - Color::Cyan, - "Type to search | ↑↓ navigate | Enter select | Esc cancel", - ), - }; - - let mode_span = Span::styled( - format!(" {} ", mode_info.0), - Style::default() - .fg(Color::Black) - .bg(mode_info.1) - .add_modifier(Modifier::BOLD), - ); - - // Build background activity indicators - let mut bg_spans: Vec = Vec::new(); - - // Scanning indicator - if app.is_scanning { - bg_spans.push(Span::styled( - " ⟳ Scanning ", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )); - } - - // Update checking indicator - if app.is_checking_updates { - bg_spans.push(Span::styled( - " ⟳ Checking ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )); - } - - // Focus indicator - only show in Normal mode - let focus_info = if app.mode == Mode::Normal { - let (icon, label) = match app.focus { - Focus::Projects => ("📋", "Projects"), - Focus::Dependencies => ("📦", "Dependencies"), - Focus::Output => ("📄", "Output"), - }; - Span::styled( - format!(" {} {} ", icon, label), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::styled("", Style::default()) - }; - - let selected_info = if !app.selected_projects.is_empty() { - Span::styled( - format!(" {} selected ", app.selected_projects.len()), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::styled("", Style::default()) - }; - - let tabs_info = if !app.tabs.is_empty() { - let running = app.tabs.iter().filter(|t| !t.is_finished).count(); - let finished = app.tabs.len() - running; - - if running > 0 { - Span::styled( - format!(" ⚙ {}/{} running ", running, app.tabs.len()), - Style::default().fg(Color::Cyan), - ) - } else { - Span::styled( - format!(" ✓ {}/{} complete ", finished, app.tabs.len()), - Style::default().fg(Color::Green), - ) - } - } else { - Span::raw("") - }; - - let help_text = Span::styled( - format!(" {} ", mode_info.2), - Style::default().fg(Color::DarkGray), - ); - - // Build the final status line - let mut spans = vec![mode_span, Span::raw(" ")]; - - // Add background activity spans - for span in bg_spans { - spans.push(span); - } - - spans.push(focus_info); - spans.push(selected_info); - spans.push(tabs_info); - - // Only add separator if we have help text - if !mode_info.2.is_empty() { - spans.push(Span::styled("│", Style::default().fg(Color::DarkGray))); - spans.push(help_text); - } - - let status_line = Line::from(spans); - - let status_bar = - Paragraph::new(status_line).style(Style::default().bg(Color::Rgb(30, 30, 30))); - - f.render_widget(status_bar, area); - } -} diff --git a/src/components/text_input.rs b/src/components/text_input.rs deleted file mode 100644 index d13ffda..0000000 --- a/src/components/text_input.rs +++ /dev/null @@ -1,66 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - widgets::{Block, BorderType, Borders, Clear, Paragraph}, -}; -use tui_input::{Input, backend::crossterm::EventHandler}; - -#[derive(Debug, Clone)] -pub struct TextInputState { - pub title: String, - pub input: Input, -} - -impl TextInputState { - pub fn new() -> Self { - Self { - title: String::new(), - input: Input::default(), - } - } -} - -pub struct TextInput {} - -impl TextInput { - pub fn new() -> Self { - Self {} - } -} - -impl Component for TextInput { - fn handle_key_events(&mut self, key: KeyCode, app: &mut AppState) -> Option { - match key { - KeyCode::Esc => Some(Action::EnterNormalMode), - KeyCode::Enter => { - // another hack - None - } - _ => { - let mut input = app.text_input.input.clone(); - if input - .handle_event(&crossterm::event::Event::Key(key.into())) - .is_some() - { - Some(Action::UpdateTextInput(input.value().to_string())) - } else { - None - } - } - } - } - - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { - f.render_widget(Clear, area); - let block = Block::default() - .title(app.text_input.title.as_str()) - .borders(Borders::ALL) - .border_type(BorderType::Rounded); - let para = Paragraph::new(app.text_input.input.value()).block(block); - f.render_widget(para, area); - } -} diff --git a/src/components/tree_view.rs b/src/components/tree_view.rs deleted file mode 100644 index c284350..0000000 --- a/src/components/tree_view.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! Tree view component for hierarchical project display -//! -//! This component renders the project tree with support for expanding/collapsing directories, -//! selecting projects, and showing project status indicators. - -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crate::tree::TreeNodeType; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem}, -}; - -pub struct TreeView {} - -impl TreeView { - pub fn new() -> Self { - Self {} - } - - /// Get the display name for a node with proper indentation and expand/collapse indicators - /// Returns: (text, is_loading) tuple - fn format_tree_line(state: &AppState, node_index: usize) -> (String, bool) { - if node_index >= state.flattened_tree.items.len() { - return (String::new(), false); - } - - let (node, _) = &state.flattened_tree.items[node_index]; - let indent = " ".repeat(node.depth); - - match &node.node_type { - TreeNodeType::Directory { name, .. } => { - let icon = if node.expanded { "📂" } else { "📁" }; - let indicator = if node.loading { - // Show spinner when loading - "⠹".to_string() - } else if node.expanded { - "▾".to_string() - } else { - "▸".to_string() - }; - ( - format!("{}{} {} {}", indent, indicator, icon, name), - node.loading, - ) - } - TreeNodeType::Project(_) => { - (format!("{} 🦀 {}", indent, node.node_type.name()), false) - } - } - } - - /// Get the style for a node based on its type and selection state - fn get_node_style(state: &AppState, node_index: usize, is_selected: bool) -> Style { - if node_index >= state.flattened_tree.items.len() { - return Style::default(); - } - - let (node, _) = &state.flattened_tree.items[node_index]; - let colors = state.current_colors(); - - match &node.node_type { - TreeNodeType::Directory { .. } => { - if is_selected { - Style::default() - .fg(colors.text) - .bg(colors.selection) - .add_modifier(Modifier::BOLD) - } else if node.loading { - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD) - } - } - TreeNodeType::Project(project) => { - use crate::project::ProjectCheckStatus; - - let mut style = if is_selected { - Style::default() - .fg(colors.text) - .bg(colors.selection) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(colors.text) - }; - - if !is_selected { - // Add status color based on project check status - match project.check_status { - ProjectCheckStatus::Unchecked => style = style.fg(colors.muted), - ProjectCheckStatus::Checking => { - style = style.fg(Color::Blue).add_modifier(Modifier::BOLD) - } - ProjectCheckStatus::HasUpdates => { - style = style.fg(colors.warning).add_modifier(Modifier::BOLD) - } - ProjectCheckStatus::UpToDate => style = style.fg(colors.success), - } - } - style - } - } - } - - /// Get the status indicator for a project - fn get_project_status_indicator(project: &crate::project::Project) -> &'static str { - use crate::project::ProjectCheckStatus; - - match project.check_status { - ProjectCheckStatus::Unchecked => "⋯", - ProjectCheckStatus::Checking => "⟳", - ProjectCheckStatus::HasUpdates => "⚠", - ProjectCheckStatus::UpToDate => "✓", - } - } -} - -impl Component for TreeView { - fn handle_key_events(&mut self, key: KeyCode, _state: &mut AppState) -> Option { - match key { - KeyCode::Up => Some(Action::SelectPrevious), - KeyCode::Down => Some(Action::SelectNext), - KeyCode::Right => Some(Action::SelectChild), - KeyCode::Left => Some(Action::SelectParent), - KeyCode::Char(' ') => Some(Action::ToggleSelection), - _ => None, - } - } - - fn draw(&mut self, f: &mut Frame, state: &mut AppState, area: Rect) { - let colors = state.current_colors(); - // Create the list of items - let items: Vec = state - .flattened_tree - .items - .iter() - .enumerate() - .map(|(idx, (node, _))| { - let is_selected = state.tree_selection.selected_index == Some(idx); - let style = Self::get_node_style(state, idx, is_selected); - let (line_text, _is_loading) = Self::format_tree_line(state, idx); - - // For projects, add status indicator - let line = match &node.node_type { - TreeNodeType::Project(project) => { - let status_indicator = Self::get_project_status_indicator(project); - let indicator_style = if is_selected { - style - } else { - style.add_modifier(Modifier::BOLD) - }; - let indicator_span = Span::styled(status_indicator, indicator_style); - - let mut line_spans = vec![ - Span::styled(line_text, style), - Span::styled(" ", style), - indicator_span, - ]; - - // Add selection indicator - if is_selected { - line_spans.insert(0, Span::styled("▶ ", style)); - } - - Line::from(line_spans) - } - TreeNodeType::Directory { .. } => { - let mut line_spans = vec![Span::styled(line_text, style)]; - - // Add selection indicator - if is_selected { - line_spans.insert(0, Span::styled("▶ ", style)); - } - - Line::from(line_spans) - } - }; - - ListItem::new(line) - }) - .collect(); - - // Create the list widget - let list = List::new(items) - .block( - Block::default() - .title(" Projects ") - .borders(Borders::ALL) - .border_style(Style::default().fg(colors.primary)) - .border_type(ratatui::widgets::BorderType::Rounded), - ) - .style(Style::default().fg(colors.text)); - - // Render the list - f.render_widget(list, area); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_tree_view_creation() { - let _tree_view = TreeView::new(); - // TreeView is a unit struct, just verify it can be instantiated - } -} diff --git a/src/components/updater.rs b/src/components/updater.rs deleted file mode 100644 index e3a52fc..0000000 --- a/src/components/updater.rs +++ /dev/null @@ -1,382 +0,0 @@ -use crate::app::AppState; -use crate::components::Component; -use crate::events::Action; -use crate::project::Dependency; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph}, -}; -use std::collections::HashSet; -use std::path::PathBuf; - -/// State for the update wizard UI component -/// -/// The wizard is locked to a single project while a user-initiated check runs. -#[derive(Debug, Clone)] -pub struct UpdateWizardState { - pub outdated_dependencies: Vec, - pub selected_dependencies: HashSet, - pub list_state: ratatui::widgets::ListState, - /// The project this wizard is locked to. - pub locked_project_id: Option, - /// Whether a user-initiated check is in progress for the locked project - pub user_check_in_progress: bool, - /// Project path pending dependency reload after update command completes - /// Set when RunUpdate starts, cleared and processed when command finishes - pub pending_reload_project: Option, -} - -impl UpdateWizardState { - pub fn new() -> Self { - Self { - outdated_dependencies: Vec::new(), - selected_dependencies: HashSet::new(), - list_state: ratatui::widgets::ListState::default(), - locked_project_id: None, - user_check_in_progress: false, - pending_reload_project: None, - } - } -} - -pub struct UpdateWizard {} - -impl UpdateWizard { - pub fn new() -> Self { - Self {} - } -} - -impl Component for UpdateWizard { - fn handle_key_events(&mut self, key: KeyCode, app: &mut AppState) -> Option { - match key { - KeyCode::Esc => { - // Cancel any ongoing update check - app.is_checking_updates = false; - Some(Action::EnterNormalMode) - } - KeyCode::Char(' ') => Some(Action::ToggleUpdateSelection), - KeyCode::Enter => { - if !app.updater.selected_dependencies.is_empty() { - Some(Action::RunUpdate) - } else { - None - } - } - KeyCode::Char('a') => { - // Select all - for dep in &app.updater.outdated_dependencies { - app.updater.selected_dependencies.insert(dep.name.clone()); - } - None - } - KeyCode::Char('n') => { - // Select none - app.updater.selected_dependencies.clear(); - None - } - KeyCode::Down | KeyCode::Char('j') => { - if app.updater.outdated_dependencies.is_empty() { - app.updater.list_state.select(None); - return None; - } - - let i = match app.updater.list_state.selected() { - Some(i) => { - if i >= app.updater.outdated_dependencies.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - app.updater.list_state.select(Some(i)); - None - } - KeyCode::Up | KeyCode::Char('k') => { - if app.updater.outdated_dependencies.is_empty() { - app.updater.list_state.select(None); - return None; - } - - let i = match app.updater.list_state.selected() { - Some(i) => { - if i == 0 { - app.updater.outdated_dependencies.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - app.updater.list_state.select(Some(i)); - None - } - _ => None, - } - } - - fn draw(&mut self, f: &mut Frame, app: &mut AppState, area: Rect) { - let popup_area = Self::centered_rect(70, 70, area); - - f.render_widget(Clear, popup_area); - - let chunks = Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(5), - Constraint::Length(4), - ]) - .split(popup_area); - - // Title - show the locked project label and checking status - let locked_project_title = app.updater.locked_project_id.as_ref().map(|project_id| { - app.all_projects - .iter() - .find(|p| &p.path == project_id) - .or_else(|| app.projects.iter().find(|p| &p.path == project_id)) - .map(|p| p.name.clone()) - .unwrap_or_else(|| { - project_id - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| project_id.display().to_string()) - }) - }); - - let title_text = if let Some(project_name) = locked_project_title { - if app.is_checking_updates { - format!(" Update Dependencies - {} ⟳ ", project_name) - } else { - format!(" Update Dependencies - {} ", project_name) - } - } else if app.is_checking_updates { - " Update Dependencies ⟳ ".to_string() - } else { - " Update Dependencies ".to_string() - }; - - let title = Block::default() - .title(title_text) - .title_alignment(Alignment::Center) - .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) - .border_style( - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::BOLD), - ); - f.render_widget(title, chunks[0]); - - // Dependency list - if app.updater.outdated_dependencies.is_empty() { - // Check if dependencies have been checked (have latest_version set) - // Use the LOCKED project, not the currently selected one (user may have moved cursor) - let has_been_checked = if let Some(ref locked_id) = app.updater.locked_project_id { - app.all_projects - .iter() - .find(|p| &p.path == locked_id) - .map(|project| { - !project.dependencies.is_empty() - && project - .dependencies - .iter() - .any(|d| d.latest_version.is_some()) - }) - .unwrap_or(false) - } else { - false - }; - - let empty_msg = if app.is_checking_updates { - " ⟳ Checking for updates...\n\n Please wait or press Esc to cancel " - } else if has_been_checked { - " ✓ All dependencies are up to date!\n\n Press Esc to close " - } else { - " ⟳ Initializing...\n\n Press Esc to cancel " - }; - - let empty_para = Paragraph::new(empty_msg) - .alignment(Alignment::Center) - .style( - Style::default().fg(if app.is_checking_updates || !has_been_checked { - Color::Yellow - } else { - Color::Green - }), - ) - .block( - Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded), - ); - f.render_widget(empty_para, chunks[1]); - } else { - let items: Vec = app - .updater - .outdated_dependencies - .iter() - .map(|dep| { - let is_selected = app.updater.selected_dependencies.contains(&dep.name); - let is_major = dep.is_major_update(); - let checkbox = if is_selected { "☑" } else { "☐" }; - - let mut spans = vec![ - Span::styled( - checkbox, - if is_selected { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::DarkGray) - }, - ), - Span::raw(" "), - Span::styled( - &dep.name, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled(&dep.current_version, Style::default().fg(Color::Red)), - ]; - - if let Some(ref latest) = dep.latest_version { - spans.push(Span::styled(" → ", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - latest, - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )); - } - - // Add note for major version updates - if is_major { - spans.push(Span::styled( - " (major)", - Style::default() - .fg(Color::Magenta) - .add_modifier(Modifier::ITALIC), - )); - } - - let line = Line::from(spans); - - ListItem::new(line) - }) - .collect(); - - let list = List::new(items) - .block( - Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded), - ) - .highlight_style( - Style::default() - .bg(Color::Rgb(60, 40, 60)) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("▶ "); - - f.render_stateful_widget(list, chunks[1], &mut app.updater.list_state); - } - - // Help footer - let selected_count = app.updater.selected_dependencies.len(); - let total_count = app.updater.outdated_dependencies.len(); - - let help_lines = vec![ - Line::from(vec![ - Span::styled(" Space", Style::default().fg(Color::Cyan)), - Span::raw(": Toggle | "), - Span::styled("a", Style::default().fg(Color::Cyan)), - Span::raw(": All | "), - Span::styled("n", Style::default().fg(Color::Cyan)), - Span::raw(": None | "), - Span::styled( - "Enter", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Span::raw(": Update | "), - Span::styled("Esc", Style::default().fg(Color::Red)), - Span::raw(": Cancel "), - ]), - Line::from(""), - Line::from(vec![Span::styled( - format!(" {} of {} selected ", selected_count, total_count), - if selected_count > 0 { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }, - )]), - ]; - - let footer = Paragraph::new(help_lines) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Magenta)), - ); - f.render_widget(footer, chunks[2]); - } -} - -impl UpdateWizard { - fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_update_wizard_state_new() { - let state = UpdateWizardState::new(); - assert!(state.outdated_dependencies.is_empty()); - assert!(state.selected_dependencies.is_empty()); - } - - #[test] - fn test_update_wizard_state_clone() { - let mut state = UpdateWizardState::new(); - state.selected_dependencies.insert("serde".to_string()); - - let cloned = state.clone(); - assert_eq!( - state.selected_dependencies.len(), - cloned.selected_dependencies.len() - ); - assert!(cloned.selected_dependencies.contains("serde")); - } -} diff --git a/src/config/keybinding_config.rs b/src/config/keybinding_config.rs deleted file mode 100644 index 981132a..0000000 --- a/src/config/keybinding_config.rs +++ /dev/null @@ -1,375 +0,0 @@ -//! Keybinding configuration system -//! -//! Manages customizable keybindings with sensible defaults and user overrides. -//! Supports vim-style, emacs-style, and custom keybinding schemes. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Keybinding action identifier -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum KeyAction { - // Navigation - SelectNext, - SelectPrevious, - SelectUp, - SelectDown, - SelectParent, - SelectChild, - ToggleSelection, - - // Searching and filtering - SearchProjects, - EnterFilter, - ExitFilter, - - // Theme and UI - CycleTheme, - IncreaseLeftPane, - DecreaseLeftPane, - IncreaseTopRight, - DecreaseTopRight, - ResetLayout, - - // Commands and modes - ShowCommandPalette, - ShowSettings, - ShowHelp, - StartUpdateWizard, - - // Application - Quit, -} - -impl KeyAction { - /// Get human-readable description for this action - pub fn description(&self) -> &'static str { - match self { - KeyAction::SelectNext => "Select next item", - KeyAction::SelectPrevious => "Select previous item", - KeyAction::SelectUp => "Move selection up", - KeyAction::SelectDown => "Move selection down", - KeyAction::SelectParent => "Select parent/collapse", - KeyAction::SelectChild => "Select child/expand", - KeyAction::ToggleSelection => "Toggle project selection", - KeyAction::SearchProjects => "Search projects", - KeyAction::EnterFilter => "Enter filter mode", - KeyAction::ExitFilter => "Exit filter mode", - KeyAction::CycleTheme => "Cycle to next theme", - KeyAction::IncreaseLeftPane => "Increase left pane width", - KeyAction::DecreaseLeftPane => "Decrease left pane width", - KeyAction::IncreaseTopRight => "Increase dependencies pane height", - KeyAction::DecreaseTopRight => "Decrease dependencies pane height", - KeyAction::ResetLayout => "Reset layout to defaults", - KeyAction::ShowCommandPalette => "Show command palette", - KeyAction::ShowSettings => "Show settings", - KeyAction::ShowHelp => "Show help", - KeyAction::StartUpdateWizard => "Start update wizard", - KeyAction::Quit => "Quit application", - } - } -} - -/// Keybinding configuration from TOML file -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KeybindingConfig { - /// Keybinding scheme: "default", "vim", "emacs", or "custom" - #[serde(default = "default_scheme")] - pub scheme: String, - - /// Custom keybindings (action -> list of key strings) - /// Example: "SelectNext" -> ["j", "Down"] - #[serde(default)] - pub custom: HashMap>, -} - -fn default_scheme() -> String { - "default".to_string() -} - -impl KeybindingConfig { - /// Get keybindings for a specific scheme - pub fn get_bindings(&self) -> HashMap> { - let mut bindings = self.default_bindings(); - - // Apply custom overrides - for (action, keys) in &self.custom { - bindings.insert(action.clone(), keys.clone()); - } - - bindings - } - - /// Default keybindings (what we ship with) - pub fn default_bindings(&self) -> HashMap> { - match self.scheme.as_str() { - "vim" => self.vim_bindings(), - "emacs" => self.emacs_bindings(), - _ => self.default_scheme_bindings(), - } - } - - /// Default CarWash keybindings (blended vim+standard) - fn default_scheme_bindings(&self) -> HashMap> { - vec![ - ( - "SelectNext".to_string(), - vec!["j".to_string(), "Down".to_string()], - ), - ( - "SelectPrevious".to_string(), - vec!["k".to_string(), "Up".to_string()], - ), - ("SelectUp".to_string(), vec!["Up".to_string()]), - ("SelectDown".to_string(), vec!["Down".to_string()]), - ( - "SelectParent".to_string(), - vec!["h".to_string(), "Left".to_string()], - ), - ( - "SelectChild".to_string(), - vec!["l".to_string(), "Right".to_string()], - ), - ("ToggleSelection".to_string(), vec!["space".to_string()]), - ("SearchProjects".to_string(), vec!["/".to_string()]), - ("EnterFilter".to_string(), vec!["/".to_string()]), - ("ExitFilter".to_string(), vec!["Esc".to_string()]), - ( - "CycleTheme".to_string(), - vec!["t".to_string(), "T".to_string()], - ), - ( - "IncreaseLeftPane".to_string(), - vec!["]".to_string(), "{".to_string()], - ), - ("DecreaseLeftPane".to_string(), vec!["[".to_string()]), - ( - "IncreaseTopRight".to_string(), - vec![")".to_string(), "+".to_string()], - ), - ( - "DecreaseTopRight".to_string(), - vec!["-".to_string(), "(".to_string()], - ), - ("ResetLayout".to_string(), vec!["R".to_string()]), - ("ShowCommandPalette".to_string(), vec![":".to_string()]), - ( - "ShowSettings".to_string(), - vec!["s".to_string(), "S".to_string()], - ), - ("ShowHelp".to_string(), vec!["?".to_string()]), - ("StartUpdateWizard".to_string(), vec!["u".to_string()]), - ("Quit".to_string(), vec!["q".to_string()]), - ] - .into_iter() - .collect() - } - - /// Vim-style keybindings - fn vim_bindings(&self) -> HashMap> { - vec![ - ("SelectNext".to_string(), vec!["j".to_string()]), - ("SelectPrevious".to_string(), vec!["k".to_string()]), - ("SelectUp".to_string(), vec!["k".to_string()]), - ("SelectDown".to_string(), vec!["j".to_string()]), - ("SelectParent".to_string(), vec!["h".to_string()]), - ("SelectChild".to_string(), vec!["l".to_string()]), - ("ToggleSelection".to_string(), vec!["v".to_string()]), - ("SearchProjects".to_string(), vec!["/".to_string()]), - ("EnterFilter".to_string(), vec!["/".to_string()]), - ("ExitFilter".to_string(), vec!["Esc".to_string()]), - ("CycleTheme".to_string(), vec!["T".to_string()]), - ("ShowCommandPalette".to_string(), vec![":".to_string()]), - ("ShowSettings".to_string(), vec![";".to_string()]), - ("ShowHelp".to_string(), vec!["?".to_string()]), - ("StartUpdateWizard".to_string(), vec!["u".to_string()]), - ("Quit".to_string(), vec!["q".to_string()]), - ] - .into_iter() - .collect() - } - - /// Emacs-style keybindings - fn emacs_bindings(&self) -> HashMap> { - vec![ - ("SelectNext".to_string(), vec!["Down".to_string()]), - ("SelectPrevious".to_string(), vec!["Up".to_string()]), - ("SelectUp".to_string(), vec!["Up".to_string()]), - ("SelectDown".to_string(), vec!["Down".to_string()]), - ("SelectParent".to_string(), vec!["Left".to_string()]), - ("SelectChild".to_string(), vec!["Right".to_string()]), - ("ToggleSelection".to_string(), vec!["space".to_string()]), - ("SearchProjects".to_string(), vec!["C-s".to_string()]), - ("EnterFilter".to_string(), vec!["C-s".to_string()]), - ("ExitFilter".to_string(), vec!["C-g".to_string()]), - ("CycleTheme".to_string(), vec!["C-t".to_string()]), - ("ShowCommandPalette".to_string(), vec!["C-x".to_string()]), - ("ShowSettings".to_string(), vec!["C-,".to_string()]), - ("ShowHelp".to_string(), vec!["C-h".to_string()]), - ("StartUpdateWizard".to_string(), vec!["C-u".to_string()]), - ("Quit".to_string(), vec!["C-c".to_string()]), - ] - .into_iter() - .collect() - } - - /// Get keybindings for a specific action - pub fn get_action_keys(&self, action: &str) -> Option> { - self.get_bindings().get(action).cloned() - } - - /// Get all actions with their keybindings and descriptions - pub fn actions_with_keys(&self) -> Vec<(String, Vec, &'static str)> { - let bindings = self.get_bindings(); - vec![ - ( - "SelectNext".to_string(), - bindings.get("SelectNext").cloned().unwrap_or_default(), - KeyAction::SelectNext.description(), - ), - ( - "SelectPrevious".to_string(), - bindings.get("SelectPrevious").cloned().unwrap_or_default(), - KeyAction::SelectPrevious.description(), - ), - ( - "SelectParent".to_string(), - bindings.get("SelectParent").cloned().unwrap_or_default(), - KeyAction::SelectParent.description(), - ), - ( - "SelectChild".to_string(), - bindings.get("SelectChild").cloned().unwrap_or_default(), - KeyAction::SelectChild.description(), - ), - ( - "ToggleSelection".to_string(), - bindings.get("ToggleSelection").cloned().unwrap_or_default(), - KeyAction::ToggleSelection.description(), - ), - ( - "SearchProjects".to_string(), - bindings.get("SearchProjects").cloned().unwrap_or_default(), - KeyAction::SearchProjects.description(), - ), - ( - "CycleTheme".to_string(), - bindings.get("CycleTheme").cloned().unwrap_or_default(), - KeyAction::CycleTheme.description(), - ), - ( - "ShowCommandPalette".to_string(), - bindings - .get("ShowCommandPalette") - .cloned() - .unwrap_or_default(), - KeyAction::ShowCommandPalette.description(), - ), - ( - "ShowSettings".to_string(), - bindings.get("ShowSettings").cloned().unwrap_or_default(), - KeyAction::ShowSettings.description(), - ), - ( - "ShowHelp".to_string(), - bindings.get("ShowHelp").cloned().unwrap_or_default(), - KeyAction::ShowHelp.description(), - ), - ( - "StartUpdateWizard".to_string(), - bindings - .get("StartUpdateWizard") - .cloned() - .unwrap_or_default(), - KeyAction::StartUpdateWizard.description(), - ), - ( - "Quit".to_string(), - bindings.get("Quit").cloned().unwrap_or_default(), - KeyAction::Quit.description(), - ), - ] - } -} - -impl Default for KeybindingConfig { - fn default() -> Self { - Self { - scheme: default_scheme(), - custom: HashMap::new(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_keybinding_config_default() { - let config = KeybindingConfig::default(); - assert_eq!(config.scheme, "default"); - assert!(config.custom.is_empty()); - } - - #[test] - fn test_default_scheme_bindings() { - let config = KeybindingConfig::default(); - let bindings = config.default_bindings(); - assert!(bindings.contains_key("SelectNext")); - assert!(bindings.contains_key("Quit")); - } - - #[test] - fn test_vim_scheme_bindings() { - let config = KeybindingConfig { - scheme: "vim".to_string(), - custom: HashMap::new(), - }; - let bindings = config.default_bindings(); - assert_eq!(bindings.get("SelectNext"), Some(&vec!["j".to_string()])); - } - - #[test] - fn test_emacs_scheme_bindings() { - let config = KeybindingConfig { - scheme: "emacs".to_string(), - custom: HashMap::new(), - }; - let bindings = config.default_bindings(); - assert!(bindings.contains_key("SelectNext")); - } - - #[test] - fn test_custom_overrides() { - let mut config = KeybindingConfig::default(); - config - .custom - .insert("SelectNext".to_string(), vec!["w".to_string()]); - - let bindings = config.get_bindings(); - assert_eq!(bindings.get("SelectNext"), Some(&vec!["w".to_string()])); - } - - #[test] - fn test_get_action_keys() { - let config = KeybindingConfig::default(); - assert!(config.get_action_keys("SelectNext").is_some()); - assert!(config.get_action_keys("Quit").is_some()); - } - - #[test] - fn test_key_action_descriptions() { - assert_eq!(KeyAction::SelectNext.description(), "Select next item"); - assert_eq!(KeyAction::Quit.description(), "Quit application"); - } - - #[test] - fn test_serialization() { - let config = KeybindingConfig::default(); - let toml_str = toml::to_string(&config).expect("Failed to serialize"); - let deserialized: KeybindingConfig = - toml::from_str(&toml_str).expect("Failed to deserialize"); - assert_eq!(config.scheme, deserialized.scheme); - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs deleted file mode 100644 index 35c74f9..0000000 --- a/src/config/mod.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Configuration management for CarWash -//! -//! This module handles loading, saving, and managing user configuration including -//! themes, keybindings, layout preferences, and other settings. - -pub mod keybinding_config; -pub mod theme_config; - -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; - -use crate::settings::AppSettings; -pub use keybinding_config::KeybindingConfig; -pub use theme_config::ThemeConfig; - -/// Main configuration structure for CarWash -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Config { - /// Theme configuration - pub theme: ThemeConfig, - - /// Layout preferences - #[serde(default)] - pub layout: LayoutConfig, - - /// Keybinding configuration - #[serde(default)] - pub keybindings: KeybindingConfig, - - /// Progress visualization settings - #[serde(default)] - pub progress: ProgressConfig, - - /// App settings (cache TTL, folder visibility, etc) - #[serde(default)] - pub app: AppSettings, -} - -/// Layout preference configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LayoutConfig { - /// Left pane width percentage (projects) - #[serde(default = "default_left_pane")] - pub left_pane_percent: u16, - - /// Top-right pane height percentage (dependencies) - #[serde(default = "default_top_right")] - pub top_right_percent: u16, -} - -fn default_left_pane() -> u16 { - 40 -} - -fn default_top_right() -> u16 { - 40 -} - -impl Default for LayoutConfig { - fn default() -> Self { - Self { - left_pane_percent: default_left_pane(), - top_right_percent: default_top_right(), - } - } -} - -// KeybindingConfig is now defined in keybinding_config.rs and re-exported above - -/// Progress visualization configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProgressConfig { - /// Show visual progress bars - #[serde(default = "default_show_bars")] - pub show_visual_bars: bool, - - /// Animation speed (slow, normal, fast) - #[serde(default = "default_animation_speed")] - pub animation_speed: String, -} - -fn default_show_bars() -> bool { - true -} - -fn default_animation_speed() -> String { - "normal".to_string() -} - -impl Default for ProgressConfig { - fn default() -> Self { - Self { - show_visual_bars: default_show_bars(), - animation_speed: default_animation_speed(), - } - } -} - -impl Config { - /// Get the path to the config file - pub fn config_path() -> Result { - let config_dir = directories::ProjectDirs::from("", "", "carwash") - .context("Unable to determine config directory")? - .config_dir() - .to_path_buf(); - - Ok(config_dir.join("config.toml")) - } - - /// Load configuration from disk, or return defaults if file doesn't exist - pub fn load() -> Self { - match Self::config_path() { - Ok(path) => { - if path.exists() { - match fs::read_to_string(&path) { - Ok(content) => match toml::from_str::(&content) { - Ok(mut config) => { - config.app = config.app.normalize(); - config - } - Err(e) => { - eprintln!("Warning: Failed to parse config: {}", e); - Self::default() - } - }, - Err(e) => { - eprintln!("Warning: Failed to read config: {}", e); - Self::default() - } - } - } else { - Self::default() - } - } - Err(e) => { - eprintln!("Warning: Could not determine config path: {}", e); - Self::default() - } - } - } - - /// Save configuration to disk - pub fn save(&self) -> Result<()> { - let path = Self::config_path()?; - - // Create parent directory if it doesn't exist - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).context("Failed to create config directory")?; - } - - let content = toml::to_string_pretty(self).context("Failed to serialize config")?; - - fs::write(&path, content).context("Failed to write config file")?; - - Ok(()) - } - - /// Get theme configuration reference - pub fn theme(&self) -> &ThemeConfig { - &self.theme - } - - /// Get mutable theme configuration - pub fn theme_mut(&mut self) -> &mut ThemeConfig { - &mut self.theme - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_config_default() { - let config = Config::default(); - assert_eq!(config.layout.left_pane_percent, 40); - assert_eq!(config.layout.top_right_percent, 40); - assert!(config.progress.show_visual_bars); - } - - #[test] - fn test_layout_config_default() { - let layout = LayoutConfig::default(); - assert_eq!(layout.left_pane_percent, 40); - assert_eq!(layout.top_right_percent, 40); - } - - #[test] - fn test_progress_config_default() { - let progress = ProgressConfig::default(); - assert!(progress.show_visual_bars); - assert_eq!(progress.animation_speed, "normal"); - } - - #[test] - fn test_config_serialization() { - let config = Config::default(); - let toml_str = toml::to_string(&config).expect("Failed to serialize"); - let deserialized: Config = toml::from_str(&toml_str).expect("Failed to deserialize"); - assert_eq!( - config.layout.left_pane_percent, - deserialized.layout.left_pane_percent - ); - } -} diff --git a/src/config/theme_config.rs b/src/config/theme_config.rs deleted file mode 100644 index cad2c03..0000000 --- a/src/config/theme_config.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Theme configuration and management -//! -//! Handles loading, switching, and saving theme preferences from config files. - -use crate::ui::styles::ColorScheme; -use serde::{Deserialize, Serialize}; - -/// Theme configuration stored in config file -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThemeConfig { - /// Current active theme name - #[serde(default = "default_theme")] - pub current: String, - - /// Custom theme definitions (for future use) - #[serde(default)] - pub custom: Option, -} - -fn default_theme() -> String { - "dark".to_string() -} - -/// Custom theme definition (user-defined colors) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CustomTheme { - /// Hex color for selection/focus - pub selection: Option, - - /// Hex color for success/completed - pub success: Option, - - /// Hex color for warning/attention - pub warning: Option, - - /// Hex color for error/failure - pub error: Option, - - /// Hex color for muted/disabled - pub muted: Option, - - /// Hex color for primary text - pub text: Option, - - /// Hex color for dim elements - pub dim: Option, - - /// Hex color for primary UI (directories, headers) - pub primary: Option, -} - -impl ThemeConfig { - /// Create a new theme config with specified scheme - pub fn new(scheme: &str) -> Self { - Self { - current: scheme.to_string(), - custom: None, - } - } - - /// Get the current color scheme - pub fn current_scheme(&self) -> ColorScheme { - match self.current.as_str() { - "light" => ColorScheme::Light, - "nord" => ColorScheme::Nord, - "dracula" => ColorScheme::Dracula, - "cosmic" => ColorScheme::Cosmic, - "gestalt" => ColorScheme::Gestalt, - _ => ColorScheme::Dark, - } - } - - /// Get the next theme in the cycle - pub fn next_scheme(&self) -> ColorScheme { - let current = self.current_scheme(); - match current { - ColorScheme::Dark => ColorScheme::Light, - ColorScheme::Light => ColorScheme::Nord, - ColorScheme::Nord => ColorScheme::Dracula, - ColorScheme::Dracula => ColorScheme::Cosmic, - ColorScheme::Cosmic => ColorScheme::Gestalt, - ColorScheme::Gestalt => ColorScheme::Dark, - } - } - - /// Get the theme name as a display string - pub fn display_name(&self) -> String { - match self.current.as_str() { - "dark" => "Dark".to_string(), - "light" => "Light".to_string(), - "nord" => "Nord".to_string(), - "dracula" => "Dracula".to_string(), - "cosmic" => "Cosmic".to_string(), - "gestalt" => "Gestalt".to_string(), - name => format!("Custom: {}", name), - } - } - - /// List all available theme names - pub fn available_themes() -> Vec<&'static str> { - vec!["dark", "light", "nord", "dracula", "cosmic", "gestalt"] - } - - /// Set the active theme by name - pub fn set_theme(&mut self, name: &str) { - if Self::available_themes().contains(&name) || self.custom.is_some() { - self.current = name.to_string(); - } - } - - /// Switch to the next theme - pub fn cycle_next(&mut self) { - let next = self.next_scheme(); - self.current = match next { - ColorScheme::Dark => "dark", - ColorScheme::Light => "light", - ColorScheme::Nord => "nord", - ColorScheme::Dracula => "dracula", - ColorScheme::Cosmic => "cosmic", - ColorScheme::Gestalt => "gestalt", - } - .to_string(); - } - - /// Get a list of all available themes as display strings - pub fn available_themes_display() -> Vec { - vec![ - "Dark".to_string(), - "Light".to_string(), - "Nord".to_string(), - "Dracula".to_string(), - "Cosmic".to_string(), - "Gestalt".to_string(), - ] - } -} - -impl Default for ThemeConfig { - fn default() -> Self { - Self { - current: default_theme(), - custom: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_theme_config_default() { - let config = ThemeConfig::default(); - assert_eq!(config.current, "dark"); - assert!(config.custom.is_none()); - } - - #[test] - fn test_current_scheme() { - let config = ThemeConfig::new("dark"); - assert_eq!(config.current_scheme(), ColorScheme::Dark); - - let config = ThemeConfig::new("light"); - assert_eq!(config.current_scheme(), ColorScheme::Light); - - let config = ThemeConfig::new("nord"); - assert_eq!(config.current_scheme(), ColorScheme::Nord); - - let config = ThemeConfig::new("dracula"); - assert_eq!(config.current_scheme(), ColorScheme::Dracula); - - let config = ThemeConfig::new("cosmic"); - assert_eq!(config.current_scheme(), ColorScheme::Cosmic); - - let config = ThemeConfig::new("gestalt"); - assert_eq!(config.current_scheme(), ColorScheme::Gestalt); - } - - #[test] - fn test_next_scheme() { - let config = ThemeConfig::new("dark"); - assert_eq!(config.next_scheme(), ColorScheme::Light); - - let config = ThemeConfig::new("dracula"); - assert_eq!(config.next_scheme(), ColorScheme::Cosmic); - - let config = ThemeConfig::new("cosmic"); - assert_eq!(config.next_scheme(), ColorScheme::Gestalt); - - let config = ThemeConfig::new("gestalt"); - assert_eq!(config.next_scheme(), ColorScheme::Dark); - } - - #[test] - fn test_cycle_next() { - let mut config = ThemeConfig::new("dark"); - config.cycle_next(); - assert_eq!(config.current, "light"); - config.cycle_next(); - assert_eq!(config.current, "nord"); - config.cycle_next(); - assert_eq!(config.current, "dracula"); - config.cycle_next(); - assert_eq!(config.current, "cosmic"); - config.cycle_next(); - assert_eq!(config.current, "gestalt"); - config.cycle_next(); - assert_eq!(config.current, "dark"); - } - - #[test] - fn test_display_name() { - let config = ThemeConfig::new("dark"); - assert_eq!(config.display_name(), "Dark"); - - let config = ThemeConfig::new("gestalt"); - assert_eq!(config.display_name(), "Gestalt"); - - let mut config = ThemeConfig::new("custom"); - config.custom = Some(CustomTheme { - selection: None, - success: None, - warning: None, - error: None, - muted: None, - text: None, - dim: None, - primary: None, - }); - assert_eq!(config.display_name(), "Custom: custom"); - } - - #[test] - fn test_available_themes() { - let themes = ThemeConfig::available_themes(); - assert_eq!(themes.len(), 6); - assert!(themes.contains(&"dark")); - assert!(themes.contains(&"light")); - assert!(themes.contains(&"cosmic")); - assert!(themes.contains(&"gestalt")); - } - - #[test] - fn test_config_serialization() { - let config = ThemeConfig::default(); - let toml_str = toml::to_string(&config).expect("Failed to serialize"); - let deserialized: ThemeConfig = toml::from_str(&toml_str).expect("Failed to deserialize"); - assert_eq!(config.current, deserialized.current); - } -} diff --git a/src/events.rs b/src/events.rs deleted file mode 100644 index edea181..0000000 --- a/src/events.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Event handling and command processing -//! -//! This module defines the types for handling user input, application modes, and -//! commands that can be executed by the application. - -use crate::project::{Dependency, Project}; -use std::path::PathBuf; - -/// Represents a command that can be executed in CarWash -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Command { - /// Execute a cargo command on selected projects - Cargo { command: String }, - /// Set the target directory for scanning - SetTargetDir, - /// Quit the application - Quit, -} - -/// Application actions that can be performed by the user or internal events -#[derive(Debug, Clone)] -pub enum Action { - /// Project scanning has finished with results - FinishProjectScan(Vec, String), // (projects, target_directory) - /// Move selection to next item - SelectNext, - /// Move selection to previous item - SelectPrevious, - /// Move selection to parent item - SelectParent, - /// Move selection to child item - SelectChild, - /// Toggle selection on current item - ToggleSelection, - /// Open the command palette - ShowCommandPalette, - /// Show help screen - ShowHelp, - /// Show settings modal - ShowSettings, - /// Close settings modal without saving - CloseSettings, - /// Enter normal mode - EnterNormalMode, - /// Update command palette input - UpdatePaletteInput(String), - /// Select next item in palette - PaletteSelectNext, - /// Select previous item in palette - PaletteSelectPrevious, - /// Execute a command - ExecuteCommand(Command), - /// Update text input buffer - UpdateTextInput(String), - /// Start the update wizard - StartUpdateWizard, - /// Toggle selection in update wizard - ToggleUpdateSelection, - /// Update cache duration text in settings modal - SettingsUpdateCacheInput(String), - /// Persist settings changes - SaveSettings, - /// Run selected updates - RunUpdate, - /// Update dependency information for a project path - UpdateDependencies(PathBuf, Vec), - /// Stream start - beginning of update checks - UpdateDependenciesStreamStart(PathBuf), - /// Update a single dependency with latest info - UpdateSingleDependency(PathBuf, Dependency), - /// Update check status for a specific dependency (for UI streaming) - UpdateDependencyCheckStatus(PathBuf, String, crate::project::DependencyCheckStatus), - /// Create a new output tab - CreateTab(String), - /// Add output line to a tab - AddOutput(usize, String), - /// Mark command execution as finished - FinishCommand(usize), - /// Switch to a specific tab - SwitchToTab(usize), - /// Update the visual check status of a project - UpdateProjectCheckStatus(PathBuf, crate::project::ProjectCheckStatus), - /// Enter filter/search mode - EnterFilterMode, - /// Exit filter/search mode - ExitFilterMode, - /// Update filter input text - UpdateFilterInput(String), - /// Clear filter - ClearFilter, - /// Cycle to next theme - CycleTheme, - /// Set a specific theme by name - SetTheme(String), - /// Increase left pane width - IncreaseLeftPane, - /// Decrease left pane width - DecreaseLeftPane, - /// Increase top-right pane height - IncreaseTopRight, - /// Decrease top-right pane height - DecreaseTopRight, - /// Reset layout to defaults - ResetLayout, - /// Save configuration to disk - SaveConfig, - /// Toggle showing all folders (default: only Rust project folders) - ToggleShowAllFolders, - /// Calculate sizes for all projects (total + target directory) - CalculateProjectSizes, - /// Update a single project's size information - UpdateProjectSize(PathBuf, Option, Option), - /// Change focus to next pane - FocusNext, - /// Quit the application - Quit, - /// Initialize the project tree with the root directory (shallow) - InitializeTree(String), - /// Request to expand a directory node (async lazy load) - /// Contains: (path, depth) - ExpandDirectory(std::path::PathBuf, usize), - /// Directory children have been loaded (async result) - DirectoryLoaded(std::path::PathBuf, Vec), -} - -/// Current mode of the application UI -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Mode { - /// Loading projects state - Loading, - /// Normal browsing mode - Normal, - /// Filter/search mode (like vim's / search) - Filter, - /// Command palette is open - CommandPalette, - /// Update wizard is open - UpdateWizard, - /// Text input mode - TextInput, - /// Help screen is displayed - Help, - /// Settings modal is open - Settings, -} - -/// Tracks which pane currently has focus for keyboard input -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub enum Focus { - /// Project list pane (left side) - #[default] - Projects, - /// Dependencies pane (top right) - Dependencies, - /// Command output pane (bottom right) - Output, -} - -impl Focus { - /// Cycle to the next pane in focus order - pub fn next(&self) -> Self { - match self { - Self::Projects => Self::Dependencies, - Self::Dependencies => Self::Output, - Self::Output => Self::Projects, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_command_equality() { - let cmd1 = Command::Cargo { - command: "build".to_string(), - }; - let cmd2 = Command::Cargo { - command: "build".to_string(), - }; - assert_eq!(cmd1, cmd2); - } - - #[test] - fn test_command_clone() { - let cmd = Command::Cargo { - command: "test".to_string(), - }; - let cloned = cmd.clone(); - assert_eq!(cmd, cloned); - } - - #[test] - fn test_mode_equality() { - assert_eq!(Mode::Normal, Mode::Normal); - assert_eq!(Mode::Loading, Mode::Loading); - assert_eq!(Mode::CommandPalette, Mode::CommandPalette); - assert_eq!(Mode::UpdateWizard, Mode::UpdateWizard); - assert_eq!(Mode::TextInput, Mode::TextInput); - assert_eq!(Mode::Help, Mode::Help); - assert_eq!(Mode::Settings, Mode::Settings); - } - - #[test] - fn test_mode_clone() { - let mode = Mode::Normal; - let cloned = mode.clone(); - assert_eq!(mode, cloned); - } - - #[test] - fn test_mode_inequality() { - assert_ne!(Mode::Normal, Mode::Loading); - assert_ne!(Mode::CommandPalette, Mode::UpdateWizard); - assert_ne!(Mode::Settings, Mode::Normal); - } -} diff --git a/src/handlers.rs b/src/handlers.rs deleted file mode 100644 index aad110f..0000000 --- a/src/handlers.rs +++ /dev/null @@ -1,1234 +0,0 @@ -//! Action handlers for state mutations -//! -//! This module contains handler functions for each Action type, providing -//! a clean separation between action dispatch and state mutation logic. - -use crate::app::{AppState, Tab}; -use crate::events::{Action, Command, Mode}; -use crate::project::Project; -use fuzzy_matcher::FuzzyMatcher; -use fuzzy_matcher::skim::SkimMatcherV2; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; - -fn find_project_mut<'a>(projects: &'a mut [Project], project_id: &Path) -> Option<&'a mut Project> { - projects - .iter_mut() - .find(|project| project.path == project_id) -} - -fn collect_projects_for_path(node: &crate::tree::TreeNode, path: &Path) -> Vec { - if node.node_type.path() == path { - return node.collect_projects().into_iter().cloned().collect(); - } - - for child in &node.children { - let projects = collect_projects_for_path(child, path); - if !projects.is_empty() { - return projects; - } - } - - Vec::new() -} - -fn add_project_if_missing(state: &mut AppState, project: Project) { - if let Some(existing) = state.all_projects.iter().find(|p| p.path == project.path) { - if !existing.dependencies.is_empty() - && !state.projects.iter().any(|p| p.path == existing.path) - { - state.projects.push(existing.clone()); - } - return; - } - - state.all_projects.push(project.clone()); - if !project.dependencies.is_empty() { - state.projects.push(project); - } -} - -/// Handle application quit -pub fn handle_quit(state: &mut AppState) { - state.should_quit = true; - let _ = state.config.save(); -} - -/// Handle entering normal mode -pub fn handle_enter_normal_mode(state: &mut AppState) { - // Clear updater state when leaving update wizard - if state.mode == Mode::UpdateWizard { - state.updater.outdated_dependencies.clear(); - state.updater.selected_dependencies.clear(); - state.updater.list_state.select(None); - state.updater.locked_project_id = None; // Clear project lock - state.updater.user_check_in_progress = false; // Clear check flag - } - state.mode = Mode::Normal; -} - -/// Handle showing help -pub fn handle_show_help(state: &mut AppState) { - state.mode = Mode::Help; -} - -/// Show settings modal populated with persisted values -pub fn handle_show_settings(state: &mut AppState) { - state.settings_modal.cache_minutes_input = state - .settings_modal - .cache_minutes_input - .clone() - .with_value(state.config.app.cache_ttl_minutes.to_string()); - state.settings_modal.error_message = None; - state.mode = Mode::Settings; -} - -/// Close settings modal without persisting changes -pub fn handle_close_settings(state: &mut AppState) { - state.mode = Mode::Normal; - state.settings_modal.error_message = None; -} - -/// Handle completing project scan -pub fn handle_finish_project_scan( - state: &mut AppState, - projects: Vec, - target_directory: String, -) { - state.all_projects = projects.clone(); - // Only show projects with dependencies - state.projects = projects - .into_iter() - .filter(|p| !p.dependencies.is_empty()) - .collect(); - - // Collect all workspace names and mark them as collapsed by default - let workspace_names: HashSet = state - .projects - .iter() - .filter_map(|p| p.workspace_name.clone()) - .collect(); - state.collapsed_workspaces = workspace_names; - - // Build the hierarchical project tree from the ACTUAL target directory - let mut tree_root = crate::project::build_project_tree(&target_directory); - - // Load the root level children immediately (since root starts expanded) - crate::project::load_directory_children(&mut tree_root, state.config.app.show_all_folders); - - // Auto-load children for any "crates" directories (they're auto-expanded) - load_crates_directories_recursively(&mut tree_root, state.config.app.show_all_folders); - - state.tree_root = Some(tree_root.clone()); - - // Flatten the tree for rendering - state.flattened_tree = crate::tree::FlattenedTree::from_tree(&tree_root); - - if !state.projects.is_empty() { - state.tree_state.select(Some(0)); - } - state.is_scanning = false; - state.mode = Mode::Normal; -} - -/// Handle text input updates -pub fn handle_update_text_input(state: &mut AppState, s: String) { - state.text_input.input = state.text_input.input.clone().with_value(s); -} - -/// Handle selecting next item in list -pub fn handle_select_next(state: &mut AppState) { - // Use tree navigation if tree is available - if state.tree_root.is_some() { - let max_index = state.flattened_tree.items.len().saturating_sub(1); - // Update tree_state (used by ProjectList for rendering) - let current = state.tree_state.selected().unwrap_or(0); - let next = if current >= max_index { 0 } else { current + 1 }; - state.tree_state.select(Some(next)); - } else { - // Fallback to old project-based navigation - let visible_count = state.get_visible_projects().len(); - let i = match state.tree_state.selected() { - Some(i) => { - if i >= visible_count.saturating_sub(1) { - 0 - } else { - i + 1 - } - } - None => 0, - }; - state.tree_state.select(Some(i)); - } -} - -/// Handle selecting previous item in list -pub fn handle_select_previous(state: &mut AppState) { - // Use tree navigation if tree is available - if state.tree_root.is_some() { - let max_index = state.flattened_tree.items.len().saturating_sub(1); - // Update tree_state (used by ProjectList for rendering) - let current = state.tree_state.selected().unwrap_or(0); - let prev = if current == 0 { max_index } else { current - 1 }; - state.tree_state.select(Some(prev)); - } else { - // Fallback to old project-based navigation - let visible_count = state.get_visible_projects().len(); - let i = match state.tree_state.selected() { - Some(i) => { - if i == 0 { - visible_count.saturating_sub(1) - } else { - i - 1 - } - } - None => 0, - }; - state.tree_state.select(Some(i)); - } -} - -/// Handle selecting parent (collapse directory/workspace or move to parent) -pub fn handle_select_parent(state: &mut AppState) { - if let Some(tree_root) = &mut state.tree_root - && let Some(selected_idx) = state.tree_state.selected() - && selected_idx < state.flattened_tree.items.len() - { - let (node, _) = &state.flattened_tree.items[selected_idx]; - - // If directory is expanded, collapse it - if node.node_type.is_directory() && node.expanded { - toggle_node_expanded(tree_root, node.node_type.path()); - state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); - } else { - // Otherwise, jump to the parent node - let current_path = node.node_type.path(); - if let Some(parent_path) = current_path.parent() - && let Some(parent_idx) = state.flattened_tree.get_index(parent_path) - { - state.tree_state.select(Some(parent_idx)); - } - } - } else { - // Fallback to old workspace-based collapse - if let Some(workspace_name) = state.get_selected_workspace() { - state.collapsed_workspaces.insert(workspace_name); - } - } -} - -/// Handle selecting child (expand directory/workspace or move to first child) -pub fn handle_select_child(state: &mut AppState) { - if let Some(tree_root) = &mut state.tree_root - && let Some(selected_idx) = state.tree_state.selected() - && selected_idx < state.flattened_tree.items.len() - { - let (node, _) = &state.flattened_tree.items[selected_idx]; - - // If directory is collapsed, expand it - if node.node_type.is_directory() && !node.expanded { - toggle_node_expanded(tree_root, node.node_type.path()); - state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); - } else if node.node_type.is_directory() && node.expanded { - // If already expanded, move to the first child - let next_idx = selected_idx + 1; - if next_idx < state.flattened_tree.items.len() { - let (next_node, _) = &state.flattened_tree.items[next_idx]; - if next_node.depth > node.depth { - state.tree_state.select(Some(next_idx)); - } - } - } - } else { - // Fallback to old workspace-based expand - if let Some(workspace_name) = state.get_selected_workspace() { - state.collapsed_workspaces.remove(&workspace_name); - } - } -} - -/// Recursively load and expand all directories under a node -fn load_all_descendants(node: &mut crate::tree::TreeNode, show_all_folders: bool) { - // Load this node's children if not already loaded - if !node.children_loaded && node.node_type.is_directory() { - crate::project::load_directory_children(node, show_all_folders); - } - - // Expand this node if it's a directory - if node.node_type.is_directory() && !node.expanded { - node.expanded = true; - } - - // Recursively load all children - for child in &mut node.children { - if child.node_type.is_directory() { - load_all_descendants(child, show_all_folders); - } - } -} - -/// Load children and expand a node by its path (used for selection) -fn load_and_expand_node( - node: &mut crate::tree::TreeNode, - target_path: &std::path::Path, - show_all_folders: bool, -) { - if node.node_type.path() == target_path { - // Recursively load all descendants to find all projects - load_all_descendants(node, show_all_folders); - return; - } - - // Recursively search for the node - for child in &mut node.children { - load_and_expand_node(child, target_path, show_all_folders); - } -} - -/// Toggle the expanded state of a node by its path -/// NOTE: This does NOT load children - async loading is handled in main.rs via ExpandDirectory action -fn toggle_node_expanded(node: &mut crate::tree::TreeNode, target_path: &std::path::Path) { - if node.node_type.path() == target_path { - node.toggle_expanded(); - return; - } - - // Recursively search for the node - for child in &mut node.children { - toggle_node_expanded(child, target_path); - } -} - -/// Handle toggling project selection -pub fn handle_toggle_selection(state: &mut AppState) { - let selected_index = match state.tree_state.selected() { - Some(i) => i, - None => return, - }; - - // Get the selected node from the flattened tree - let node = match state.flattened_tree.items.get(selected_index) { - Some((node, _)) => node, - None => return, - }; - - // Clone what we need to avoid borrow issues - let node_type_clone = node.node_type.clone(); - let node_depth = node.depth; - - match &node_type_clone { - crate::tree::TreeNodeType::Directory { path, .. } => { - // For directories, we need to ensure children are loaded first - // so we can actually select the projects inside - let loaded_projects = if let Some(tree_root) = &mut state.tree_root { - let path_clone = path.clone(); - let show_all_folders = state.config.app.show_all_folders; - - // Load children if not already loaded, and expand the directory - load_and_expand_node(tree_root, &path_clone, show_all_folders); - let loaded_projects = collect_projects_for_path(tree_root, &path_clone); - - // Re-flatten the tree to reflect the newly loaded children - state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); - loaded_projects - } else { - Vec::new() - }; - - for project in loaded_projects { - add_project_if_missing(state, project); - } - - // Now collect all project paths in this directory's subtree - let mut project_ids = Vec::new(); - - // Iterate through subsequent nodes until we leave this directory - for (child_node, _) in state.flattened_tree.items.iter().skip(selected_index + 1) { - // Stop when we reach a node at the same or lower depth (sibling or parent) - if child_node.depth <= node_depth { - break; - } - - // If it's a project, add it to our list - if let crate::tree::TreeNodeType::Project(project) = &child_node.node_type { - project_ids.push(project.path.clone()); - } - } - - // Toggle all projects in this directory - if !project_ids.is_empty() { - let all_selected = project_ids - .iter() - .all(|project_id| state.selected_projects.contains(project_id)); - if all_selected { - // Deselect all - for project_id in project_ids { - state.selected_projects.remove(&project_id); - } - } else { - // Select all - for project_id in project_ids { - state.selected_projects.insert(project_id); - } - } - } - } - crate::tree::TreeNodeType::Project(project) => { - // Toggle single project - let project_id = project.path.clone(); - toggle_single_project_selection(state, &project_id); - } - } -} - -fn toggle_single_project_selection(state: &mut AppState, project_id: &Path) { - if !state.selected_projects.remove(project_id) { - state.selected_projects.insert(project_id.to_path_buf()); - } -} - -/// Update cache duration input while editing settings -pub fn handle_settings_update_cache_input(state: &mut AppState, input: String) { - state.settings_modal.cache_minutes_input = state - .settings_modal - .cache_minutes_input - .clone() - .with_value(input); - state.settings_modal.error_message = None; -} - -/// Persist settings from the modal, validating input. -pub fn handle_save_settings(state: &mut AppState) { - let raw_value = state.settings_modal.cache_minutes_input.value().trim(); - let minutes = match raw_value.parse::() { - Ok(value) if value > 0 => value, - _ => { - state.settings_modal.error_message = - Some("Cache TTL must be a positive number of minutes".to_string()); - return; - } - }; - - let mut new_settings = state.config.app.clone(); - new_settings.cache_ttl_minutes = minutes; - - state.config.app = new_settings; - if let Err(err) = state.config.save() { - state.settings_modal.error_message = Some(format!("Failed to save settings: {}", err)); - return; - } - - state.settings_modal.error_message = None; - state.mode = Mode::Normal; -} - -/// Handle showing command palette -pub fn handle_show_command_palette(state: &mut AppState) { - state.mode = Mode::CommandPalette; - // Reset input - state.palette.input = state.palette.input.clone().with_value(String::new()); - // Populate commands - state.palette.filtered_commands = state - .command_history - .iter() - .map(|c| Command::Cargo { command: c.clone() }) - .collect(); - // Ensure first item is selected - if !state.palette.filtered_commands.is_empty() { - state.palette.list_state.select(Some(0)); - } else { - state.palette.list_state.select(None); - } -} - -/// Handle updating command palette input -pub fn handle_update_palette_input(state: &mut AppState, input: String) { - state.palette.input = state.palette.input.clone().with_value(input.clone()); - - if input.is_empty() { - // Show all commands when input is empty - state.palette.filtered_commands = state - .command_history - .iter() - .map(|c| Command::Cargo { command: c.clone() }) - .collect(); - } else { - // Filter by fuzzy match - let matcher = SkimMatcherV2::default(); - state.palette.filtered_commands = state - .command_history - .iter() - .filter(|cmd| matcher.fuzzy_match(cmd, &input).is_some()) - .map(|c| Command::Cargo { command: c.clone() }) - .collect(); - } - - // Select first item if available - if !state.palette.filtered_commands.is_empty() { - state.palette.list_state.select(Some(0)); - } else { - state.palette.list_state.select(None); - } -} - -/// Handle palette next selection -pub fn handle_palette_select_next(state: &mut AppState) { - if state.palette.filtered_commands.is_empty() { - state.palette.list_state.select(None); - return; - } - - let i = match state.palette.list_state.selected() { - Some(i) => { - if i >= state.palette.filtered_commands.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - state.palette.list_state.select(Some(i)); -} - -/// Handle palette previous selection -pub fn handle_palette_select_previous(state: &mut AppState) { - if state.palette.filtered_commands.is_empty() { - state.palette.list_state.select(None); - return; - } - - let i = match state.palette.list_state.selected() { - Some(i) => { - if i == 0 { - state.palette.filtered_commands.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - state.palette.list_state.select(Some(i)); -} - -/// Handle starting update wizard for a single selected project. -/// -/// Behavior depends on what the cursor is on: -/// - **Project**: Opens the update wizard for that single project -/// - **Directory**: Does nothing; update checks are intentionally project-scoped -pub fn handle_start_update_wizard(state: &mut AppState) { - use crate::tree::TreeNodeType; - - // Check what type of node is selected - let selected_node = state.get_selected_node().cloned(); - - match selected_node.as_ref().map(|n| &n.node_type) { - Some(TreeNodeType::Project(_)) => { - // Single project selected - open the wizard - handle_start_update_wizard_for_project(state); - } - Some(TreeNodeType::Directory { .. }) => { - state.updater.locked_project_id = None; - } - None => { - // Nothing selected - do nothing - } - } -} - -/// Handle starting update wizard for a single project -fn handle_start_update_wizard_for_project(state: &mut AppState) { - // Clear any stale updater state from previous wizard sessions - state.updater.outdated_dependencies.clear(); - state.updater.selected_dependencies.clear(); - state.updater.list_state.select(None); - - // CRITICAL: Lock the wizard to the currently selected project. - if let Some(project) = state.get_selected_project() { - let project_id = project.path.clone(); - - // CRITICAL: Populate wizard with CURRENT dependency data immediately - // This ensures the wizard shows cached data immediately while a fresh manual - // check runs or is skipped by the cache TTL. - // Uses has_stable_update() to properly handle pre-release versions - let outdated_deps: Vec<_> = project - .dependencies - .iter() - .filter(|d| d.has_stable_update()) - .cloned() - .collect(); - - // Now we can mutate state.updater - state.updater.locked_project_id = Some(project_id); - state.updater.outdated_dependencies = outdated_deps; - - // Select first item if there are outdated dependencies - if !state.updater.outdated_dependencies.is_empty() { - state.updater.list_state.select(Some(0)); - } - - // CRITICAL FIX: Set user_check_in_progress IMMEDIATELY, not when StreamStart arrives - // This prevents race condition where UpdateDependencies arrives before StreamStart - // (happens when cache hits instantly) - state.updater.user_check_in_progress = true; - } else { - state.updater.locked_project_id = None; - } - - state.is_checking_updates = true; - state.mode = Mode::UpdateWizard; -} - -/// Handle toggling update selection -pub fn handle_toggle_update_selection(state: &mut AppState) { - if let Some(index) = state.updater.list_state.selected() - && let Some(dep) = state.updater.outdated_dependencies.get(index) - && !state.updater.selected_dependencies.remove(&dep.name) - { - state.updater.selected_dependencies.insert(dep.name.clone()); - } -} - -/// Handle updating dependencies for a project -pub fn handle_update_dependencies( - state: &mut AppState, - project_id: PathBuf, - deps: Vec, -) { - let is_wizard_locked_project = state.mode == Mode::UpdateWizard - && state.updater.locked_project_id.as_deref() == Some(project_id.as_path()); - - update_project_dependencies(state, &project_id, deps.clone()); - - if is_wizard_locked_project { - update_wizard_dependencies(state, deps); - } -} - -fn update_project_dependencies( - state: &mut AppState, - project_id: &Path, - deps: Vec, -) { - let new_check_status = Project::compute_check_status_from_deps(&deps); - - if let Some(all_proj) = find_project_mut(&mut state.all_projects, project_id) { - all_proj.dependencies = deps.clone(); - all_proj.check_status = new_check_status.clone(); - } - - if let Some(proj) = find_project_mut(&mut state.projects, project_id) { - proj.dependencies = deps.clone(); - proj.check_status = new_check_status; - } - - update_project_in_tree(state, project_id, |project| { - project.dependencies = deps.clone(); - project.check_status = Project::compute_check_status_from_deps(&project.dependencies); - }); -} - -fn update_project_in_tree(state: &mut AppState, project_id: &Path, mut update: F) -where - F: FnMut(&mut crate::project::Project), -{ - fn visit(node: &mut crate::tree::TreeNode, project_id: &Path, update: &mut F) - where - F: FnMut(&mut crate::project::Project), - { - if let crate::tree::TreeNodeType::Project(project) = &mut node.node_type - && project.path == project_id - { - update(project); - } - - for child in &mut node.children { - visit(child, project_id, update); - } - } - - if let Some(tree_root) = &mut state.tree_root { - visit(tree_root, project_id, &mut update); - state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); - } -} - -fn update_wizard_dependencies(state: &mut AppState, deps: Vec) { - // Uses has_stable_update() to properly handle pre-release versions - state.updater.outdated_dependencies = - deps.into_iter().filter(|d| d.has_stable_update()).collect(); - - if !state.updater.outdated_dependencies.is_empty() { - state.updater.list_state.select(Some(0)); - } - - state.is_checking_updates = false; - state.updater.user_check_in_progress = false; -} - -/// Handle start of dependency update stream -/// -/// This is called for user-initiated checks (pressing 'u'). -/// NOTE: This action may arrive AFTER UpdateDependencies when cache hits instantly! -pub fn handle_update_dependencies_stream_start(state: &mut AppState, _project_id: PathBuf) { - // CRITICAL FIX: Don't blindly set is_checking_updates = true! - // If UpdateDependencies already arrived (cache hit), it would have cleared the flag. - // Setting it back to true here would leave the wizard stuck "checking" forever. - // - // We already set is_checking_updates = true in handle_start_update_wizard, - // and user_check_in_progress = true there too. This action is just a signal that - // the check started, but by the time we process it, the check might be done! - // - // So we do NOTHING here. The flag is already set correctly. - - // For non-wizard checks, we still want to set the flag - if state.mode != Mode::UpdateWizard { - state.is_checking_updates = true; - } - - // Note: We DON'T set user_check_in_progress here anymore. - // It's already set immediately in handle_start_update_wizard. - // This prevents race conditions where UpdateDependencies arrives before this action. -} - -/// Handle updating a single dependency -pub fn handle_update_single_dependency( - state: &mut AppState, - project_id: PathBuf, - dep: crate::project::Dependency, -) { - // CRITICAL: Check if wizard is open AND if this is the LOCKED project - let is_wizard_locked_project = state.mode == Mode::UpdateWizard - && state.updater.locked_project_id.as_deref() == Some(project_id.as_path()); - - // Update in all_projects first (source of truth) - if let Some(all_proj) = find_project_mut(&mut state.all_projects, &project_id) - && let Some(existing_dep) = all_proj - .dependencies - .iter_mut() - .find(|d| d.name == dep.name) - { - *existing_dep = dep.clone(); - all_proj.check_status = Project::compute_check_status_from_deps(&all_proj.dependencies); - } - - // Also update in filtered projects if it exists - if let Some(proj) = find_project_mut(&mut state.projects, &project_id) { - if let Some(existing_dep) = proj.dependencies.iter_mut().find(|d| d.name == dep.name) { - *existing_dep = dep.clone(); - } - proj.check_status = Project::compute_check_status_from_deps(&proj.dependencies); - - // ONLY update wizard display if this is the LOCKED project - if is_wizard_locked_project { - // Uses has_stable_update() to properly handle pre-release versions - state.updater.outdated_dependencies = proj - .dependencies - .iter() - .filter(|d| d.has_stable_update()) - .cloned() - .collect(); - } - } - - update_project_in_tree(state, &project_id, |project| { - if let Some(existing_dep) = project.dependencies.iter_mut().find(|d| d.name == dep.name) { - *existing_dep = dep.clone(); - } - project.check_status = Project::compute_check_status_from_deps(&project.dependencies); - }); -} - -/// Handle tab creation -pub fn handle_create_tab(state: &mut AppState, title: String) { - state.tabs.push(Tab { - title, - buffer: Vec::new(), - is_finished: false, - scroll: 0, - }); - state.active_tab = state.tabs.len() - 1; -} - -/// Handle adding output to tab -pub fn handle_add_output(state: &mut AppState, tab_index: usize, line: String) { - if let Some(tab) = state.tabs.get_mut(tab_index) { - tab.buffer.push(line); - } -} - -/// Handle command finish -pub fn handle_finish_command(state: &mut AppState, tab_index: usize) { - if let Some(tab) = state.tabs.get_mut(tab_index) { - tab.is_finished = true; - } -} - -/// Handle switching to tab -pub fn handle_switch_to_tab(state: &mut AppState, tab_index: usize) { - if tab_index < state.tabs.len() { - state.active_tab = tab_index; - } -} - -/// Handle updating dependency status -pub fn handle_update_dependency_status( - state: &mut AppState, - project_id: Option, - dep_name: String, - status: crate::project::DependencyCheckStatus, -) { - let target_ids: Vec = if let Some(project_id) = project_id { - vec![project_id] - } else { - state - .get_selected_project() - .map(|p| p.path.clone()) - .into_iter() - .collect() - }; - - for project_id in target_ids { - if let Some(proj) = find_project_mut(&mut state.all_projects, &project_id) - && let Some(dep) = proj.dependencies.iter_mut().find(|d| d.name == dep_name) - { - dep.check_status = status.clone(); - proj.check_status = Project::compute_check_status_from_deps(&proj.dependencies); - } - - if let Some(proj) = find_project_mut(&mut state.projects, &project_id) - && let Some(dep) = proj.dependencies.iter_mut().find(|d| d.name == dep_name) - { - dep.check_status = status.clone(); - proj.check_status = Project::compute_check_status_from_deps(&proj.dependencies); - } - - if state.mode == crate::events::Mode::UpdateWizard - && state.updater.locked_project_id.as_deref() == Some(project_id.as_path()) - && let Some(dep) = state - .updater - .outdated_dependencies - .iter_mut() - .find(|d| d.name == dep_name) - { - dep.check_status = status.clone(); - } - - update_project_in_tree(state, &project_id, |project| { - if let Some(dep) = project.dependencies.iter_mut().find(|d| d.name == dep_name) { - dep.check_status = status.clone(); - } - project.check_status = Project::compute_check_status_from_deps(&project.dependencies); - }); - } -} - -/// Handle updating project check status -pub fn handle_update_project_check_status( - state: &mut AppState, - project_id: PathBuf, - check_status: crate::project::ProjectCheckStatus, -) { - // Update in all_projects (source of truth) - if let Some(proj) = find_project_mut(&mut state.all_projects, &project_id) { - proj.check_status = check_status.clone(); - } - // Also update in filtered projects list - if let Some(proj) = find_project_mut(&mut state.projects, &project_id) { - proj.check_status = check_status.clone(); - } - - update_project_in_tree(state, &project_id, |project| { - project.check_status = check_status.clone(); - }); -} - -/// Handle entering filter/search mode -pub fn handle_enter_filter_mode(state: &mut AppState) { - state.mode = crate::events::Mode::Filter; - state.filter.clear(); -} - -/// Handle exiting filter/search mode -pub fn handle_exit_filter_mode(state: &mut AppState) { - state.mode = crate::events::Mode::Normal; - // If a match was selected, update tree selection to that item - if let Some(idx) = state.filter.selected_tree_index() { - state.tree_selection.selected_index = Some(idx); - state.tree_state.select(Some(idx)); - } - state.filter.clear(); -} - -/// Handle updating filter input text -pub fn handle_update_filter_input(state: &mut AppState, input: String) { - let flattened_tree = state.flattened_tree.clone(); - - // Create a temporary AppState-like struct for filtering - struct TempState { - flattened_tree: crate::tree::FlattenedTree, - } - - let temp = TempState { flattened_tree }; - - // Update filter with input - state.filter.input = input; - state.filter.selected = 0; - state.filter.filtered_indices.clear(); - - if state.filter.input.is_empty() { - state.filter.filtered_indices = (0..temp.flattened_tree.items.len()).collect(); - } else { - let input_lower = state.filter.input.to_lowercase(); - - for (idx, (node, _)) in temp.flattened_tree.items.iter().enumerate() { - let matches = match &node.node_type { - crate::tree::TreeNodeType::Directory { name, .. } => { - name.to_lowercase().contains(&input_lower) - } - crate::tree::TreeNodeType::Project(project) => { - project.name.to_lowercase().contains(&input_lower) - || project - .path - .to_string_lossy() - .to_lowercase() - .contains(&input_lower) - } - }; - - if matches { - state.filter.filtered_indices.push(idx); - } - } - } - - if state.filter.selected >= state.filter.filtered_indices.len() { - state.filter.selected = state.filter.filtered_indices.len().saturating_sub(1); - } -} - -/// Handle clearing filter -pub fn handle_clear_filter(state: &mut AppState) { - state.filter.clear(); -} - -/// Handle cycling to the next theme -pub fn handle_cycle_theme(state: &mut AppState) { - state.config.theme_mut().cycle_next(); - // Save the new theme preference to config file - let _ = state.config.save(); -} - -/// Handle setting a specific theme -pub fn handle_set_theme(state: &mut AppState, theme_name: String) { - state.config.theme_mut().set_theme(&theme_name); - // Save the new theme preference to config file - let _ = state.config.save(); -} - -/// Handle saving configuration to disk -pub fn handle_save_config(state: &mut AppState) { - if let Err(e) = state.config.save() { - eprintln!("Failed to save config: {}", e); - } -} - -/// Handle toggling show_all_folders setting -pub fn handle_toggle_show_all_folders(state: &mut AppState) { - // Toggle the setting - state.config.app.show_all_folders = !state.config.app.show_all_folders; - - // Save the setting - if let Err(e) = state.config.save() { - eprintln!("Failed to save settings: {}", e); - } - - // Rebuild the tree with the new setting - if let Some(tree_root) = &mut state.tree_root { - // Mark all nodes as needing reload - mark_all_nodes_unloaded(tree_root); - - // Reload root children with new setting - crate::project::load_directory_children(tree_root, state.config.app.show_all_folders); - - // Re-flatten the tree - state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); - } -} - -/// Recursively mark all nodes as needing reload -fn mark_all_nodes_unloaded(node: &mut crate::tree::TreeNode) { - node.children_loaded = false; - node.children.clear(); - for child in &mut node.children { - mark_all_nodes_unloaded(child); - } -} - -/// Handle increasing left pane width -pub fn handle_increase_left_pane(state: &mut AppState) { - let current = state.config.layout.left_pane_percent; - let new_width = (current + 5).min(80); // Max 80% - state.config.layout.left_pane_percent = new_width; - let _ = state.config.save(); -} - -/// Handle decreasing left pane width -pub fn handle_decrease_left_pane(state: &mut AppState) { - let current = state.config.layout.left_pane_percent; - let new_width = current.saturating_sub(5).max(20); // Min 20% - state.config.layout.left_pane_percent = new_width; - let _ = state.config.save(); -} - -/// Handle increasing top-right pane height -pub fn handle_increase_top_right(state: &mut AppState) { - let current = state.config.layout.top_right_percent; - let new_height = (current + 5).min(80); // Max 80% - state.config.layout.top_right_percent = new_height; - let _ = state.config.save(); -} - -/// Handle decreasing top-right pane height -pub fn handle_decrease_top_right(state: &mut AppState) { - let current = state.config.layout.top_right_percent; - let new_height = current.saturating_sub(5).max(20); // Min 20% - state.config.layout.top_right_percent = new_height; - let _ = state.config.save(); -} - -/// Handle resetting layout to defaults -pub fn handle_reset_layout(state: &mut AppState) { - state.config.layout = crate::config::LayoutConfig::default(); - let _ = state.config.save(); -} - -/// Handle switching focus to next pane -pub fn handle_focus_next(state: &mut AppState) { - state.focus = state.focus.next(); -} - -/// Recursively load children for any "crates" directories -/// This ensures "crates" directories are automatically populated since they're auto-expanded -fn load_crates_directories_recursively(node: &mut crate::tree::TreeNode, show_all_folders: bool) { - // Check if this is a "crates" directory that needs loading - if let crate::tree::TreeNodeType::Directory { name, .. } = &node.node_type - && name == "crates" - && !node.children_loaded - { - crate::project::load_directory_children(node, show_all_folders); - } - - // Recursively check all children - for child in &mut node.children { - load_crates_directories_recursively(child, show_all_folders); - } -} - -/// Handle calculating sizes for all projects -/// This spawns async tasks to calculate sizes in the background -/// Maximum concurrent size calculations to prevent I/O overload -const MAX_CONCURRENT_SIZE_CALCS: usize = 3; - -pub async fn handle_calculate_project_sizes( - state: &AppState, - action_tx: tokio::sync::mpsc::Sender, -) { - use std::sync::Arc; - use tokio::sync::Semaphore; - - // Limit concurrent size calculations to prevent I/O contention and UI lag - let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_SIZE_CALCS)); - - for project in &state.all_projects { - let project_id = project.path.clone(); - let project_path = project.path.clone(); - let tx = action_tx.clone(); - let sem = semaphore.clone(); - - // For workspace members, we need to check the workspace root for target/ - let workspace_root = project.workspace_root.clone(); - - // Spawn task to calculate sizes in background with concurrency limiting - tokio::spawn(async move { - // Acquire semaphore permit (limits concurrent calculations) - let _permit = sem.acquire().await.ok(); - - // Use spawn_blocking for the blocking WalkDir I/O operations - // This prevents blocking the async executor and causing UI lag - let (total_size, target_size) = tokio::task::spawn_blocking(move || { - let total_size = crate::project::calculate_directory_size(&project_path); - - // For workspace members, look for target/ at workspace root - // For standalone projects, look in the project directory - let target_size = { - let target_path = if let Some(ws_root) = workspace_root { - ws_root.join("target") - } else { - project_path.join("target") - }; - - if target_path.exists() && target_path.is_dir() { - crate::project::calculate_directory_size(&target_path) - } else { - Some(0) - } - }; - - (total_size, target_size) - }) - .await - .unwrap_or((None, None)); - - // Send update back to main thread - let _ = tx - .send(Action::UpdateProjectSize( - project_id, - total_size, - target_size, - )) - .await; - }); - } -} - -/// Handle updating a single project's size information -pub fn handle_update_project_size( - state: &mut AppState, - project_id: PathBuf, - total_size: Option, - target_size: Option, -) { - // Update in all_projects - if let Some(project) = find_project_mut(&mut state.all_projects, &project_id) { - project.total_size = total_size; - project.target_size = target_size; - } - - // Update in projects (filtered view) - if let Some(project) = find_project_mut(&mut state.projects, &project_id) { - project.total_size = total_size; - project.target_size = target_size; - } - - // Update in tree nodes - if let Some(ref mut tree_root) = state.tree_root { - update_project_size_in_tree(tree_root, &project_id, total_size, target_size); - - // Re-flatten tree to pick up changes - state.flattened_tree = crate::tree::FlattenedTree::from_tree(tree_root); - } -} - -/// Recursively update project size in tree -fn update_project_size_in_tree( - node: &mut crate::tree::TreeNode, - project_id: &Path, - total_size: Option, - target_size: Option, -) { - if let crate::tree::TreeNodeType::Project(ref mut project) = node.node_type - && project.path == project_id - { - project.total_size = total_size; - project.target_size = target_size; - } - - for child in &mut node.children { - update_project_size_in_tree(child, project_id, total_size, target_size); - } -} - -/// Handle initializing the tree with a shallow scan (non-blocking) -pub fn handle_initialize_tree(state: &mut AppState, target_directory: String) { - // Build root node only (fast, no I/O) - returns immediately - let tree_root = crate::project::build_project_tree(&target_directory); - let root_projects: Vec = tree_root.collect_projects().into_iter().cloned().collect(); - - state.tree_root = Some(tree_root.clone()); - state.flattened_tree = crate::tree::FlattenedTree::from_tree(&tree_root); - for project in root_projects { - add_project_if_missing(state, project); - } - - // Select first item - state.tree_state.select(Some(0)); - - if tree_root.node_type.is_project() { - state.is_scanning = false; - state.mode = Mode::Normal; - } else { - // Keep in Loading mode while root children load asynchronously - // Mode will be set to Normal in handle_directory_loaded once root is loaded - state.is_scanning = true; // Keep scanning flag for other indicators - } -} - -/// Handle directory loaded async result -pub fn handle_directory_loaded( - state: &mut AppState, - path: std::path::PathBuf, - children: Vec, -) { - if let Some(root) = &mut state.tree_root { - // Helper to find and update node - fn update_node( - node: &mut crate::tree::TreeNode, - target_path: &std::path::Path, - new_children: &[crate::tree::TreeNode], - ) -> bool { - if node.node_type.path() == target_path { - // Fix up depths of children relative to parent - node.children = new_children - .iter() - .map(|c| { - let mut child = c.clone(); - child.depth = node.depth + 1; - fix_depth_recursive(&mut child, node.depth + 1); - child - }) - .collect(); - node.children_loaded = true; - node.loading = false; - node.expanded = true; // Auto expand when loaded - return true; - } - for child in &mut node.children { - if update_node(child, target_path, new_children) { - return true; - } - } - false - } - - fn fix_depth_recursive(node: &mut crate::tree::TreeNode, depth: usize) { - node.depth = depth; - for child in &mut node.children { - fix_depth_recursive(child, depth + 1); - } - } - - // Collect projects recursively (workspace dirs contain nested project children) - let loaded_projects: Vec = children - .iter() - .flat_map(|node| node.collect_projects().into_iter().cloned()) - .collect(); - - if update_node(root, &path, &children) { - // Re-flatten - state.flattened_tree = crate::tree::FlattenedTree::from_tree(root); - - // If this is the root directory being loaded, exit Loading mode - let root_path = root.node_type.path(); - if root_path == path { - state.mode = Mode::Normal; - state.is_scanning = false; - } - } - - // Process newly loaded projects - for project in loaded_projects { - add_project_if_missing(state, project); - } - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 6df2fca..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! CarWash - A beautiful TUI for managing multiple Rust projects -//! -//! CarWash provides a terminal user interface for managing Rust projects, -//! checking dependencies, and running cargo commands across multiple projects -//! with workspace support. -//! -//! # Features -//! -//! - **Multi-Project Management**: Scan and manage multiple Rust projects in a single view -//! - **Workspace Support**: Full support for Cargo workspaces with hierarchical project organization -//! - **Dependency Checking**: Check for outdated dependencies across all projects -//! - **Parallel Execution**: Run cargo commands in parallel across multiple projects -//! - **Beautiful TUI**: Modern terminal interface with intuitive navigation -//! - **Command History**: Built-in command history for quick access to previous commands -//! -//! # Getting Started -//! -//! CarWash is primarily used as a CLI application. You can scan a directory for Rust projects: -//! -//! ```sh -//! carwash /path/to/projects -//! carwash . # Scan current directory -//! ``` -//! -//! # Library Usage -//! -//! While CarWash is primarily a CLI application, you can use its library components: -//! -//! ```ignore -//! use carwash::project::Project; -//! use carwash::runner::UpdateQueue; -//! -//! // Use public APIs for project discovery and management -//! ``` -//! -//! # Modules -//! -//! - [`app`] - Application state management -//! - [`cache`] - Cache management for project data -//! - [`components`] - UI components (palette, text input, help, etc.) -//! - [`events`] - Event handling and command processing -//! - [`project`] - Project structure and dependency management -//! - [`runner`] - Task execution and update checking -//! - [`tree`] - Hierarchical project tree structure with lazy loading -//! - [`ui`] - Terminal UI rendering - -pub mod app; -pub mod cache; -pub mod components; -pub mod config; -pub mod events; -pub mod handlers; -pub mod project; -pub mod runner; -pub mod settings; -pub mod tree; -pub mod ui; - -pub use clap::Parser; - -/// Command-line arguments for CarWash -/// -/// # Fields -/// -/// * `target_directory` - The directory to scan for Rust projects (defaults to current directory) -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -pub struct Args { - /// Target directory to scan for Rust projects - #[arg(default_value = ".")] - pub target_directory: String, -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index b27be6f..0000000 --- a/src/main.rs +++ /dev/null @@ -1,782 +0,0 @@ -use carwash::Args; -use carwash::app::{AppState, reducer}; -use carwash::cache::DependencyVersionCache; -use carwash::components::{ - Component, dependencies::DependenciesPane, help::Help, output::TabbedOutputPane, - palette::CommandPalette, projects::ProjectList, settings::SettingsModal, text_input::TextInput, - updater::UpdateWizard, -}; -use carwash::events::{Action, Command, Focus, Mode}; -use carwash::project::ProjectCheckStatus; -use carwash::runner::{check_dependencies_with_cache, check_for_updates, run_command}; -use carwash::tree::TreeNode; -use carwash::ui::ui; - -use clap::Parser; -use crossterm::{ - event::{Event, KeyCode, KeyModifiers}, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use futures::StreamExt; -use ratatui::{ - Terminal, - backend::{Backend, CrosstermBackend}, -}; -use std::io; -use tokio::sync::mpsc; - -use anyhow::Context; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Setup logging to file - let file_appender = tracing_appender::rolling::never(std::env::temp_dir(), "carwash.log"); - let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender); - tracing_subscriber::fmt() - .with_writer(non_blocking) - .with_ansi(false) - .init(); - - // Set up panic handler to ensure clean terminal restoration - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - let _ = restore_terminal(); - original_hook(panic_info); - })); - - let args = Args::parse(); - - // Check if we have a TTY (after argument parsing so --help works) - if !crossterm::tty::IsTty::is_tty(&io::stdin()) { - anyhow::bail!("CarWash requires an interactive terminal (TTY)."); - } - - let mut terminal = setup_terminal().context("Failed to set up terminal")?; - - // Clear screen immediately to prevent any error messages from showing - let _ = terminal.clear(); - - let mut state = AppState::new(); - let res = run_app(&mut terminal, &mut state, args.target_directory).await; - - restore_terminal().context("Failed to restore terminal")?; - - if let Err(err) = res { - eprintln!("Error: {:?}", err); - std::process::exit(1); - } - - Ok(()) -} - -fn setup_terminal() -> anyhow::Result>> { - enable_raw_mode().context("Failed to enable raw mode")?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?; - let backend = CrosstermBackend::new(stdout); - Terminal::new(backend).context("Failed to create terminal") -} - -fn restore_terminal() -> anyhow::Result<()> { - disable_raw_mode().context("Failed to disable raw mode")?; - execute!(io::stdout(), LeaveAlternateScreen).context("Failed to leave alternate screen")?; - Ok(()) -} - -/// Apply the global dep version cache to a slice of projects. -/// Sets latest_version, check_status, and last_checked on each dependency that has a cached entry. -fn apply_dep_version_cache( - projects: &mut [carwash::project::Project], - cache: &DependencyVersionCache, - cache_duration: std::time::Duration, -) { - use carwash::project::{DependencyCheckStatus, Project}; - - for project in projects.iter_mut() { - for dep in &mut project.dependencies { - if let Some(entry) = cache.lookup(&dep.name, &dep.current_version, cache_duration) { - dep.latest_version = Some(entry.latest.clone()); - dep.check_status = DependencyCheckStatus::Checked; - dep.last_checked = Some( - std::time::SystemTime::UNIX_EPOCH - + std::time::Duration::from_secs(entry.checked_at), - ); - } - } - project.check_status = Project::compute_check_status_from_deps(&project.dependencies); - } -} - -fn apply_dep_version_cache_to_tree( - node: &mut TreeNode, - cache: &DependencyVersionCache, - cache_duration: std::time::Duration, -) { - if let carwash::tree::TreeNodeType::Project(project) = &mut node.node_type { - apply_dep_version_cache(std::slice::from_mut(project), cache, cache_duration); - } - - for child in &mut node.children { - apply_dep_version_cache_to_tree(child, cache, cache_duration); - } -} - -fn normalize_target_directory(target_directory: &str) -> String { - let path = std::path::Path::new(target_directory); - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir() - .map(|cwd| cwd.join(path)) - .unwrap_or_else(|_| path.to_path_buf()) - }; - std::fs::canonicalize(&absolute) - .unwrap_or(absolute) - .to_string_lossy() - .to_string() -} - -async fn handle_event( - event: Event, - state: &mut AppState, - action_tx: &mpsc::Sender, -) -> anyhow::Result<()> { - if let Event::Key(key) = event { - if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { - reducer(state, Action::Quit); - } - - let action: Option = match state.mode { - Mode::Loading => { - // Allow quitting even while loading - match key.code { - KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit), - _ => None, - } - } - Mode::Normal => { - // Handle normal mode keys without interfering with workspace navigation - match key.code { - KeyCode::Tab => { - // Tab always cycles focus between panes - // Output pane uses h/l or Left/Right for tab switching - Some(Action::FocusNext) - } - KeyCode::BackTab => { - // Shift+Tab: cycle focus backwards - // Output -> Dependencies -> Projects -> Output - state.focus = match state.focus { - Focus::Projects => Focus::Output, - Focus::Dependencies => Focus::Projects, - Focus::Output => Focus::Dependencies, - }; - None - } - KeyCode::Char('q') => Some(Action::Quit), - KeyCode::Char('?') => Some(Action::ShowHelp), - KeyCode::Char('s') | KeyCode::Char('S') => Some(Action::ShowSettings), - KeyCode::Char('t') | KeyCode::Char('T') => Some(Action::CycleTheme), - KeyCode::Char('a') | KeyCode::Char('A') => Some(Action::ToggleShowAllFolders), - KeyCode::Char(':') => Some(Action::ShowCommandPalette), - KeyCode::Char('/') => Some(Action::EnterFilterMode), - KeyCode::Char('u') => Some(Action::StartUpdateWizard), - // Ctrl+[ and Ctrl+] for output tab navigation (works regardless of focus) - KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Previous tab - if state.active_tab > 0 { - Some(Action::SwitchToTab(state.active_tab - 1)) - } else if !state.tabs.is_empty() { - // Wrap around to last tab - Some(Action::SwitchToTab(state.tabs.len() - 1)) - } else { - None - } - } - KeyCode::Char(']') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Next tab - if state.active_tab < state.tabs.len().saturating_sub(1) { - Some(Action::SwitchToTab(state.active_tab + 1)) - } else if !state.tabs.is_empty() { - // Wrap around to first tab - Some(Action::SwitchToTab(0)) - } else { - None - } - } - // Layout adjustment controls - KeyCode::Char('{') | KeyCode::Char('[') => Some(Action::DecreaseLeftPane), - KeyCode::Char('}') | KeyCode::Char(']') => Some(Action::IncreaseLeftPane), - KeyCode::Char('(') | KeyCode::Char('-') => Some(Action::IncreaseTopRight), - KeyCode::Char(')') | KeyCode::Char('+') => Some(Action::DecreaseTopRight), - KeyCode::Char('r') | KeyCode::Char('R') - if key.modifiers.contains(KeyModifiers::SHIFT) => - { - Some(Action::ResetLayout) - } - KeyCode::Char('r') | KeyCode::Char('R') => { - // Dispatch to focused component - match state.focus { - Focus::Projects => { - let mut project_list = ProjectList::new(); - project_list.handle_key_events(key.code, state) - } - Focus::Dependencies => { - let mut deps = DependenciesPane::new(); - deps.handle_key_events(key.code, state) - } - Focus::Output => { - let mut output = TabbedOutputPane::new(); - output.handle_key_events(key.code, state) - } - } - } - _ => { - // Dispatch to focused component - match state.focus { - Focus::Projects => { - let mut project_list = ProjectList::new(); - project_list.handle_key_events(key.code, state) - } - Focus::Dependencies => { - let mut deps = DependenciesPane::new(); - deps.handle_key_events(key.code, state) - } - Focus::Output => { - let mut output = TabbedOutputPane::new(); - output.handle_key_events(key.code, state) - } - } - } - } - } - Mode::CommandPalette => { - let mut palette = CommandPalette::new(); - palette.handle_key_events(key.code, state) - } - Mode::UpdateWizard => { - let mut updater = UpdateWizard::new(); - updater.handle_key_events(key.code, state) - } - Mode::TextInput => { - let mut text_input = TextInput::new(); - text_input.handle_key_events(key.code, state) - } - Mode::Help => { - let mut help = Help::new(); - help.handle_key_events(key.code, state) - } - Mode::Settings => { - let mut settings = SettingsModal::new(); - settings.handle_key_events(key.code, state) - } - Mode::Filter => { - // Handle filter mode keys - match key.code { - KeyCode::Esc => Some(Action::ExitFilterMode), - KeyCode::Enter => Some(Action::ExitFilterMode), - KeyCode::Char(c) => Some(Action::UpdateFilterInput( - state.filter.input.clone() + &c.to_string(), - )), - KeyCode::Backspace => { - let mut input = state.filter.input.clone(); - input.pop(); - Some(Action::UpdateFilterInput(input)) - } - KeyCode::Up => { - state.filter.select_previous(); - None - } - KeyCode::Down => { - state.filter.select_next(); - None - } - _ => None, - } - } - }; - - if let Some(action) = action { - // Some actions need to be sent through the action channel for async processing - match &action { - Action::ExecuteCommand(_) - | Action::StartUpdateWizard - | Action::RunUpdate - | Action::UpdateDependencies(..) - | Action::UpdateSingleDependency(..) - | Action::UpdateDependenciesStreamStart(_) => { - // Send through channel for async handling - let _ = action_tx.send(action).await; - } - Action::FinishCommand(_) => { - // Handle sync first, then check if we need async reload - reducer(state, action.clone()); - // Only route to async if there's a pending update reload - if state.updater.pending_reload_project.is_some() { - let _ = action_tx.send(action).await; - } - } - _ => { - // Handle synchronously through reducer - reducer(state, action.clone()); - } - } - } - } - Ok(()) -} - -fn reset_checking_status(state: &mut AppState) { - for project in &mut state.all_projects { - if project.check_status == ProjectCheckStatus::Checking { - project.check_status = ProjectCheckStatus::Unchecked; - } - } - for project in &mut state.projects { - if project.check_status == ProjectCheckStatus::Checking { - project.check_status = ProjectCheckStatus::Unchecked; - } - } -} - -async fn run_app( - terminal: &mut Terminal, - state: &mut AppState, - target_directory: String, -) -> anyhow::Result<()> { - let target_directory = normalize_target_directory(&target_directory); - let target_path = std::path::Path::new(&target_directory); - if !target_path.is_dir() { - anyhow::bail!( - "Target directory does not exist or is not a directory: {}", - target_path.display() - ); - } - - let (action_tx, mut action_rx) = mpsc::channel(100); - let mut event_stream = crossterm::event::EventStream::new(); - - // Set up frame rate for consistent redraws (following ratatui async pattern) - const FRAMES_PER_SECOND: f32 = 30.0; - let period = std::time::Duration::from_secs_f32(1.0 / FRAMES_PER_SECOND); - let mut interval = tokio::time::interval(period); - - // Track last cache save time for periodic persistence - let mut last_cache_save = std::time::Instant::now(); - - // Trigger initial shallow scan immediately - let init_tx = action_tx.clone(); - let init_target = target_directory.clone(); - tokio::spawn(async move { - let _ = init_tx.send(Action::InitializeTree(init_target)).await; - }); - - // Load global per-dependency version cache (shared across all projects) - let dep_cache = std::sync::Arc::new(tokio::sync::RwLock::new({ - let mut cache = DependencyVersionCache::load(); - // One-time migration from old per-project cache files - if cache.is_empty() { - cache = DependencyVersionCache::migrate_from_project_caches(); - } - cache - })); - - loop { - tokio::select! { - // Prioritize keyboard events with biased selection - biased; - - // Handle keyboard events (events come in as they happen) - Some(Ok(event)) = event_stream.next() => { - handle_event(event, state, &action_tx).await?; - } - // Redraw at consistent frame rate (30 FPS) - _ = interval.tick() => { - if let Err(e) = terminal.draw(|f| ui(f, state)) { - tracing::error!("Draw error: {:?}", e); - anyhow::bail!("Draw error: {:?}", e); - } - } - Some(action) = action_rx.recv() => { - match &action { - Action::InitializeTree(target_dir) => { - let target_dir = target_dir.clone(); - reducer(state, action); - - let root_is_project = state - .tree_root - .as_ref() - .is_some_and(|root| root.node_type.is_project()); - - if root_is_project { - { - let cache = dep_cache.read().await; - let cache_duration = state.config.app.cache_duration(); - apply_dep_version_cache( - &mut state.all_projects, - &cache, - cache_duration, - ); - apply_dep_version_cache(&mut state.projects, &cache, cache_duration); - if let Some(root) = &mut state.tree_root { - apply_dep_version_cache_to_tree(root, &cache, cache_duration); - state.flattened_tree = - carwash::tree::FlattenedTree::from_tree(root); - } - } - - reset_checking_status(state); - - let _ = action_tx.send(Action::CalculateProjectSizes).await; - } else { - // Spawn async load of root directory children (non-blocking) - let tx = action_tx.clone(); - let show_all = state.config.app.show_all_folders; - - tokio::task::spawn_blocking(move || { - // Load children of root directory (depth 1, since root is depth 0) - let children = carwash::project::load_directory_children_async( - std::path::Path::new(&target_dir), - 1, // Root children are at depth 1 - show_all - ); - let root_path = std::path::PathBuf::from(&target_dir); - let _ = tx.blocking_send(Action::DirectoryLoaded(root_path, children)); - }); - } - } - Action::DirectoryLoaded(path, children) => { - let path = path.clone(); - let mut children = children.clone(); - { - let cache = dep_cache.read().await; - let cache_duration = state.config.app.cache_duration(); - for child in &mut children { - apply_dep_version_cache_to_tree(child, &cache, cache_duration); - } - } - - let prev_all_count = state.all_projects.len(); - reducer(state, Action::DirectoryLoaded(path, children)); - - if state.all_projects.len() > prev_all_count { - // Size calculations with concurrency limit (reuse existing pattern) - let size_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(3)); - for project in &state.all_projects[prev_all_count..] { - let project_id = project.path.clone(); - let path = project.path.clone(); - let ws_root = project.workspace_root.clone(); - let tx = action_tx.clone(); - let sem = size_semaphore.clone(); - tokio::spawn(async move { - let _permit = sem.acquire().await.ok(); - let total = tokio::task::spawn_blocking({ - let path = path.clone(); - move || carwash::project::calculate_directory_size(&path) - }) - .await - .ok() - .flatten(); - - let target_path = - ws_root.unwrap_or_else(|| path.clone()).join("target"); - let target = if target_path.exists() { - tokio::task::spawn_blocking(move || { - carwash::project::calculate_directory_size(&target_path) - }) - .await - .ok() - .flatten() - } else { - Some(0) - }; - - let _ = tx - .send(Action::UpdateProjectSize(project_id, total, target)) - .await; - }); - } - } - - } - Action::SelectChild => { - reducer(state, action); - - // After expanding, check if we need to async load children - if let Some(selected_idx) = state.tree_state.selected() - && selected_idx < state.flattened_tree.items.len() - { - let (node, _) = &state.flattened_tree.items[selected_idx]; - // If node is a directory, expanded, but children not loaded, queue async load - if node.node_type.is_directory() && node.expanded && !node.children_loaded { - let path = node.node_type.path().to_path_buf(); - let depth = node.depth + 1; // Children are one level deeper - let tx = action_tx.clone(); - let show_all = state.config.app.show_all_folders; - - // Mark as loading immediately - if let Some(root) = &mut state.tree_root { - fn mark_loading(node: &mut TreeNode, target: &std::path::Path) -> bool { - if node.node_type.path() == target { - node.loading = true; - return true; - } - for child in &mut node.children { - if mark_loading(child, target) { - return true; - } - } - false - } - mark_loading(root, &path); - state.flattened_tree = carwash::tree::FlattenedTree::from_tree(root); - } - - // Spawn async load - tokio::task::spawn_blocking(move || { - let children = carwash::project::load_directory_children_async( - &path, - depth, - show_all - ); - let _ = tx.blocking_send(Action::DirectoryLoaded(path, children)); - }); - } - } - } - Action::ExpandDirectory(path, depth) => { - // Mark node as loading immediately (UI feedback) - if let Some(root) = &mut state.tree_root { - fn mark_loading(node: &mut TreeNode, target: &std::path::Path) -> bool { - if node.node_type.path() == target { - node.loading = true; - return true; - } - for child in &mut node.children { - if mark_loading(child, target) { - return true; - } - } - false - } - mark_loading(root, path); - state.flattened_tree = carwash::tree::FlattenedTree::from_tree(root); - } - - // Spawn async load - let path_clone = path.clone(); - let tx = action_tx.clone(); - let show_all = state.config.app.show_all_folders; - let depth = *depth; - - tokio::task::spawn_blocking(move || { - let children = carwash::project::load_directory_children_async( - &path_clone, - depth, - show_all - ); - let _ = tx.blocking_send(Action::DirectoryLoaded(path_clone, children)); - }); - } - Action::ExecuteCommand(command) => { - if let Command::Cargo { command } = command { - let action_tx_clone = action_tx.clone(); - let command_str = command.clone(); - // Always run on selected projects (on_all = false) - run_command(&command_str, state, action_tx_clone).await; - reducer(state, Action::EnterNormalMode); - } - } - Action::FinishProjectScan(_, _) => { - // Process the scan result FIRST (copies projects to state) - reducer(state, action); - - { - let cache = dep_cache.read().await; - let cache_duration = state.config.app.cache_duration(); - apply_dep_version_cache(&mut state.all_projects, &cache, cache_duration); - apply_dep_version_cache(&mut state.projects, &cache, cache_duration); - } - - // Reset any "Checking" status to "Unchecked" (app was interrupted) - reset_checking_status(state); - - // Trigger size calculation in background (non-blocking) - let tx = action_tx.clone(); - tokio::spawn(async move { - // Small delay to let UI render first - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - let _ = tx.send(Action::CalculateProjectSizes).await; - }); - } - Action::CalculateProjectSizes => { - // Spawn size calculation tasks for all projects - carwash::handlers::handle_calculate_project_sizes(state, action_tx.clone()).await; - } - Action::StartUpdateWizard => { - let selected_project_id = state - .get_selected_project() - .map(|p| p.path.clone()); - let is_currently_checking_same_project = selected_project_id - .as_ref() - .map(|project_id| { - state.is_checking_updates - && state - .updater - .locked_project_id - .as_deref() - == Some(project_id.as_path()) - }) - .unwrap_or(false); - let is_checking_different_project = - state.is_checking_updates && !is_currently_checking_same_project; - - if is_checking_different_project { - continue; - } - - // Process the action. Directory selections intentionally do nothing: - // update checks are manual and scoped to one selected project. - reducer(state, action.clone()); - - if is_currently_checking_same_project { - // Already processing this project; just keep displaying the wizard. - continue; - } - - if state.updater.locked_project_id.is_some() { - let action_tx_clone = action_tx.clone(); - let state_snapshot = state.clone(); - let dep_cache_clone = dep_cache.clone(); - tokio::spawn(async move { - check_for_updates(&state_snapshot, action_tx_clone, dep_cache_clone).await; - }); - } - } - Action::UpdateDependencies(..) => { - reducer(state, action); - } - Action::UpdateSingleDependency(..) => { - // Update individual dependency and continue - reducer(state, action); - } - Action::UpdateDependenciesStreamStart(_) => { - // Stream has started - reducer(state, action); - } - Action::SaveSettings => { - reducer(state, action.clone()); - } - Action::RunUpdate => { - // Construct the cargo update command for selected dependencies - // Use name@version format to avoid ambiguity when multiple versions exist - let selected_deps: Vec = state - .updater - .selected_dependencies - .iter() - .filter_map(|name| { - // Look up the current version from outdated_dependencies - state - .updater - .outdated_dependencies - .iter() - .find(|d| &d.name == name) - .map(|d| format!("{}@{}", d.name, d.current_version)) - }) - .collect(); - if !selected_deps.is_empty() { - let update_cmd = format!("update -p {}", selected_deps.join(" -p ")); - - // Run update only on the currently highlighted project - // (the one whose dependencies are shown in the update wizard) - if let Some(project_id) = state.updater.locked_project_id.clone() { - // Temporarily clear selected projects and set only the current one - let previous_selection = state.selected_projects.clone(); - state.selected_projects.clear(); - state.selected_projects.insert(project_id.clone()); - - // Set pending reload - will be processed when FinishCommand is received - state.updater.pending_reload_project = Some(project_id); - - // Clear wizard selections (but keep wizard open until command finishes) - state.updater.selected_dependencies.clear(); - - // Run the update command (spawns async task) - run_command(&update_cmd, state, action_tx.clone()).await; - - // Restore previous selection state - state.selected_projects = previous_selection; - - // Exit wizard mode - the reload will happen via FinishCommand - reducer(state, Action::EnterNormalMode); - } - } - } - Action::FinishCommand(tab_index) => { - reducer(state, Action::FinishCommand(*tab_index)); - - // Check if we have a pending dependency reload after update - if let Some(project_id) = state.updater.pending_reload_project.take() - && let Some(all_proj) = state.all_projects.iter_mut().find(|p| p.path == project_id) - && let Ok(()) = all_proj.reload_dependencies() - { - // Successfully reloaded! Now sync to filtered projects list - if let Some(proj) = state.projects.iter_mut().find(|p| p.path == project_id) { - proj.dependencies = all_proj.dependencies.clone(); - } - - // Clear stale wizard state - state.updater.outdated_dependencies.clear(); - - // Now re-check with the FRESH dependencies to get latest versions - let fresh_deps = all_proj.dependencies.clone(); - let proj_id = all_proj.path.clone(); - let cache_duration = state.config.app.cache_duration(); - let action_tx_clone = action_tx.clone(); - let dep_cache_clone = dep_cache.clone(); - - tokio::spawn(async move { - // Re-check with fresh dependencies from disk - check_dependencies_with_cache( - proj_id, - fresh_deps, - action_tx_clone, - false, // Don't use cache - force fresh check - cache_duration, - dep_cache_clone, - ) - .await; - }); - - // Add notification to the tab - let _ = action_tx - .send(Action::AddOutput( - *tab_index, - "Dependencies reloaded. Re-checking for updates...".into(), - )) - .await; - } - } - _ => { - reducer(state, action); - } - } - } - _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => { - // Periodic cache persistence (every 30 seconds) - if last_cache_save.elapsed() > std::time::Duration::from_secs(30) { - let mut cache = dep_cache.write().await; - let _ = cache.save(); - last_cache_save = std::time::Instant::now(); - } - continue; - } - }; - - if state.should_quit { - let mut cache = dep_cache.write().await; - let _ = cache.save(); - return Ok(()); - } - } -} diff --git a/src/project.rs b/src/project.rs deleted file mode 100644 index 0db85c5..0000000 --- a/src/project.rs +++ /dev/null @@ -1,1475 +0,0 @@ -//! Project structure and dependency management -//! -//! This module defines the core types for managing Rust projects and their dependencies. -//! It handles project discovery, metadata parsing, and dependency tracking. - -use cargo_lock::{Lockfile, Package as LockPackage}; -use serde::Deserialize; -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use walkdir::WalkDir; - -/// Status of a project's command execution -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ProjectStatus { - /// Pending execution - Pending, - /// Currently running - Running, - /// Successfully completed - Success, - /// Failed execution - Failed, -} - -/// Status of dependency update checking -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DependencyCheckStatus { - /// Dependencies have not been checked yet - NotChecked, - /// Currently checking for updates - Checking, - /// Dependencies have been checked - Checked, -} - -/// Status of git repository -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum GitStatus { - #[default] - Clean, - Dirty, - Unknown, -} - -/// Visual status of a project's update check state -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum ProjectCheckStatus { - /// Not checked yet or cache invalidated (Gray) - #[default] - Unchecked, - /// Currently being checked for updates (Blue) - Checking, - /// Some dependencies are outdated (Yellow) - HasUpdates, - /// All dependencies up to date (Green) - UpToDate, -} - -/// Represents a single dependency with version information -#[derive(Debug, Clone)] -pub struct Dependency { - /// Name of the dependency - pub name: String, - /// Currently installed version - pub current_version: String, - /// Latest available version, if checked - pub latest_version: Option, - /// Current status of update checking - pub check_status: DependencyCheckStatus, - /// Timestamp of the last check - pub last_checked: Option, -} - -impl From<&LockPackage> for Dependency { - fn from(pkg: &LockPackage) -> Self { - Self { - name: pkg.name.as_str().to_string(), - current_version: pkg.version.to_string(), - latest_version: None, - check_status: DependencyCheckStatus::NotChecked, - last_checked: None, - } - } -} - -impl Dependency { - /// Check if a version string is a pre-release (beta, rc, alpha, etc.) - pub fn is_prerelease(version: &str) -> bool { - semver::Version::parse(version) - .map(|v| !v.pre.is_empty()) - .unwrap_or(false) - } - - /// Check if this dependency has a newer stable version available - /// Returns true only if: - /// - Latest version is available and different from current - /// - If current is stable, latest must also be stable (ignore pre-releases) - /// - If current is pre-release, any newer version counts - pub fn has_stable_update(&self) -> bool { - let Some(ref latest) = self.latest_version else { - return false; - }; - - // If versions are the same, no update - if latest == &self.current_version { - return false; - } - - // Parse both versions - let current_semver = semver::Version::parse(&self.current_version).ok(); - let latest_semver = semver::Version::parse(latest).ok(); - - match (current_semver, latest_semver) { - (Some(current), Some(latest_ver)) => { - // If current is stable but latest is pre-release, don't flag as update - if current.pre.is_empty() && !latest_ver.pre.is_empty() { - return false; - } - // Otherwise, if latest is greater, it's an update - latest_ver > current - } - _ => { - // Fallback: simple string comparison if parsing fails - latest != &self.current_version - } - } - } - - /// Get update type description for display - pub fn update_type(&self) -> Option<&'static str> { - let latest = self.latest_version.as_ref()?; - - if latest == &self.current_version { - return None; - } - - let current_is_pre = Self::is_prerelease(&self.current_version); - let latest_is_pre = Self::is_prerelease(latest); - - match (current_is_pre, latest_is_pre) { - (false, true) => Some("pre-release"), // Stable → pre-release (usually skip) - (true, false) => Some("stable"), // Pre-release → stable (upgrade!) - (false, false) => Some("stable"), // Stable → stable - (true, true) => Some("pre-release"), // Pre-release → pre-release - } - } - - /// Check if the update requires a major version bump - /// Returns true if the latest version has a different major version than current - /// This typically means `cargo update` can't auto-update - requires Cargo.toml change - pub fn is_major_update(&self) -> bool { - let Some(ref latest) = self.latest_version else { - return false; - }; - - let current_semver = semver::Version::parse(&self.current_version).ok(); - let latest_semver = semver::Version::parse(latest).ok(); - - match (current_semver, latest_semver) { - (Some(current), Some(latest_ver)) => { - // For 0.x versions, minor version changes are breaking - if current.major == 0 { - current.minor != latest_ver.minor || current.major != latest_ver.major - } else { - current.major != latest_ver.major - } - } - _ => false, - } - } - - /// Get a note about the update constraint, if any - pub fn update_note(&self) -> Option<&'static str> { - if !self.has_stable_update() { - return None; - } - - if self.is_major_update() { - Some("requires Cargo.toml change") - } else { - None - } - } -} - -/// Represents a Rust project with metadata and dependencies -/// -/// A project is identified by its `Cargo.toml` file and contains metadata about the project -/// such as name, version, authors, and all dependencies. Projects can be standalone or part -/// of a workspace. -#[derive(Debug, Clone)] -pub struct Project { - /// The name of the project from Cargo.toml - pub name: String, - /// The path to the project's root directory - pub path: PathBuf, - /// Current status of command execution - pub status: ProjectStatus, - /// Version of the project - pub version: String, - /// List of project authors - pub authors: Vec, - /// All dependencies of the project - pub dependencies: Vec, - /// If part of a workspace, the path to the workspace root - pub workspace_root: Option, - /// If part of a workspace, the name of the workspace - pub workspace_name: Option, - /// Hash of Cargo.lock file for cache invalidation - pub cargo_lock_hash: Option, - /// Visual status of update checking for UI display - pub check_status: ProjectCheckStatus, - /// Git status of the project - pub git_status: GitStatus, - /// Total size of the project directory in bytes (calculated on demand) - pub total_size: Option, - /// Size of the target/ directory in bytes (potential savings from cargo clean) - pub target_size: Option, -} - -impl Project { - #[allow(dead_code)] // Kept for potential async git checking in future - fn check_git_status(path: &Path) -> GitStatus { - use std::process::Command; - // Run git status --porcelain to check for modifications - // We run it on the specific path to handle monorepos correctly - let output = Command::new("git") - .arg("status") - .arg("--porcelain") - .arg(".") // Check only this directory and subdirectories - .current_dir(path) - .output(); - - match output { - Ok(output) if output.status.success() => { - if output.stdout.is_empty() { - GitStatus::Clean - } else { - GitStatus::Dirty - } - } - _ => GitStatus::Unknown, - } - } - - pub fn from_toml( - path: &Path, - toml: &CargoToml, - workspace_root: Option, - workspace_name: Option, - ) -> Option { - let package = toml.package.as_ref()?; - let project_path = path.parent()?.to_path_buf(); - - let declared_deps = collect_declared_dependency_names(toml); - - // For workspace members, try to load Cargo.lock from workspace root first - let lockfile_path = if let Some(ref ws_root) = workspace_root { - let ws_lockfile = ws_root.join("Cargo.lock"); - if ws_lockfile.exists() { - ws_lockfile - } else { - project_path.join("Cargo.lock") - } - } else { - project_path.join("Cargo.lock") - }; - - let dependencies = if let Ok(lockfile) = Lockfile::load(&lockfile_path) { - lockfile - .packages - .iter() - // Filter to only dependencies declared in this crate's Cargo.toml - .filter(|pkg| { - declared_deps.contains(pkg.name.as_str()) && is_crates_io_package(pkg) - }) - .map(Dependency::from) - .collect() - } else { - Vec::new() - }; - - let authors = package.authors_vec(); - - let check_status = Project::compute_check_status_from_deps(&dependencies); - - Some(Self { - name: package.name.clone(), - path: project_path, - status: ProjectStatus::Pending, - version: package.version_string(), - authors, - dependencies, - workspace_root, - workspace_name, - cargo_lock_hash: None, // No hash available here, will be calculated later - check_status, - git_status: GitStatus::Unknown, // Check git status asynchronously - total_size: None, // Calculate on demand - target_size: None, // Calculate on demand - }) - } - - /// Reload dependencies from Cargo.lock after an update - /// - /// This method re-parses Cargo.toml and Cargo.lock from disk to get the latest - /// dependency versions. It should be called after running `cargo update` to ensure - /// the in-memory state reflects what's on disk. - /// - /// Returns Ok(()) if successful, Err with error message otherwise. - pub fn reload_dependencies(&mut self) -> Result<(), String> { - // Re-parse Cargo.toml to get declared dependencies - let cargo_toml_path = self.path.join("Cargo.toml"); - let toml_content = fs::read_to_string(&cargo_toml_path) - .map_err(|e| format!("Failed to read Cargo.toml: {}", e))?; - - let toml: CargoToml = toml::from_str(&toml_content) - .map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?; - - let declared_deps = collect_declared_dependency_names(&toml); - - // Determine which Cargo.lock to use (workspace or project) - let lockfile_path = if let Some(ref ws_root) = self.workspace_root { - let ws_lockfile = ws_root.join("Cargo.lock"); - if ws_lockfile.exists() { - ws_lockfile - } else { - self.path.join("Cargo.lock") - } - } else { - self.path.join("Cargo.lock") - }; - - // Re-parse Cargo.lock - let lockfile = Lockfile::load(&lockfile_path) - .map_err(|e| format!("Failed to load Cargo.lock: {}", e))?; - - // Extract current versions for declared dependencies - let mut new_deps: Vec = lockfile - .packages - .iter() - .filter(|pkg| declared_deps.contains(pkg.name.as_str()) && is_crates_io_package(pkg)) - .map(|pkg| { - // Try to preserve latest_version, check_status, and last_checked from existing deps - let existing = self - .dependencies - .iter() - .find(|d| d.name == pkg.name.as_str()); - - let mut dep = Dependency::from(pkg); - if let Some(existing_dep) = existing { - // Preserve the cached check results if they exist - dep.latest_version = existing_dep.latest_version.clone(); - dep.check_status = existing_dep.check_status.clone(); - dep.last_checked = existing_dep.last_checked; - } - dep - }) - .collect(); - - // Sort for consistent ordering - new_deps.sort_by(|a, b| a.name.cmp(&b.name)); - - // Update dependencies - self.dependencies = new_deps; - - Ok(()) - } - - /// Compute the project check status based on current dependencies - /// - /// Examines all dependencies to determine if any have available updates. - /// Uses `has_stable_update()` to properly handle pre-release versions - - /// a stable version won't be flagged for update to a pre-release. - /// Returns `HasUpdates` if any dependency has a newer stable version available, - /// otherwise returns `UpToDate`. - pub fn compute_check_status_from_deps(deps: &[Dependency]) -> ProjectCheckStatus { - if deps.is_empty() { - return ProjectCheckStatus::UpToDate; - } - - if deps - .iter() - .any(|d| d.check_status == DependencyCheckStatus::Checking) - { - return ProjectCheckStatus::Checking; - } - - if deps - .iter() - .any(|d| d.check_status != DependencyCheckStatus::Checked || d.latest_version.is_none()) - { - return ProjectCheckStatus::Unchecked; - } - - let has_updates = deps.iter().any(|d| d.has_stable_update()); - - if has_updates { - ProjectCheckStatus::HasUpdates - } else { - ProjectCheckStatus::UpToDate - } - } - - /// Calculate the total size of the project directory - pub fn calculate_total_size(&self) -> Option { - calculate_directory_size(&self.path) - } - - /// Calculate the size of the target/ directory (potential cargo clean savings) - pub fn calculate_target_size(&self) -> Option { - let target_path = self.path.join("target"); - if target_path.exists() && target_path.is_dir() { - calculate_directory_size(&target_path) - } else { - Some(0) - } - } - - /// Format size in human-readable format (KB, MB, GB, TB) - pub fn format_size(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - const TB: u64 = GB * 1024; - - if bytes >= TB { - format!("{:.1}TB", bytes as f64 / TB as f64) - } else if bytes >= GB { - format!("{:.1}GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.1}MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.1}KB", bytes as f64 / KB as f64) - } else { - format!("{}B", bytes) - } - } -} - -/// Calculate the total size of a directory and all its contents -pub fn calculate_directory_size(path: &Path) -> Option { - use walkdir::WalkDir; - - if !path.exists() { - return None; - } - - Some( - WalkDir::new(path) - .follow_links(false) // Don't follow symlinks to avoid infinite loops - .into_iter() - .filter_map(|entry| entry.ok()) - .filter_map(|entry| entry.metadata().ok()) - .filter(|metadata| metadata.is_file()) - .map(|metadata| metadata.len()) - .sum(), - ) -} - -#[derive(Debug, Deserialize)] -pub struct CargoToml { - pub package: Option, - pub workspace: Option, - #[serde(default)] - pub dependencies: HashMap, - #[serde(default, rename = "dev-dependencies")] - pub dev_dependencies: HashMap, - #[serde(default, rename = "build-dependencies")] - pub build_dependencies: HashMap, -} - -#[derive(Debug, Deserialize)] -pub struct Package { - pub name: String, - /// Version can be a string OR workspace-inherited (version.workspace = true) - #[serde(default)] - pub version: Option, - /// Authors can be an array OR workspace-inherited (authors.workspace = true) - #[serde(default)] - pub authors: Option, -} - -impl Package { - /// Extract version as a string, handling workspace inheritance - pub fn version_string(&self) -> String { - match &self.version { - Some(toml::Value::String(s)) => s.clone(), - Some(toml::Value::Table(t)) - if t.get("workspace") == Some(&toml::Value::Boolean(true)) => - { - "workspace".to_string() - } - _ => "0.0.0".to_string(), - } - } - - /// Extract authors as a vector, handling workspace inheritance - pub fn authors_vec(&self) -> Vec { - match &self.authors { - Some(toml::Value::Array(arr)) => arr - .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect(), - Some(toml::Value::Table(t)) - if t.get("workspace") == Some(&toml::Value::Boolean(true)) => - { - vec![] // Workspace-inherited, actual values unknown - } - _ => vec![], - } - } -} - -#[derive(Debug, Deserialize)] -pub struct Workspace { - pub members: Vec, - // Capture all other workspace fields (package, lints, dependencies, etc.) - // This ensures we can parse ANY workspace Cargo.toml without failures - #[serde(flatten)] - pub other: std::collections::HashMap, -} - -fn collect_declared_dependency_names(toml: &CargoToml) -> HashSet { - let mut declared_deps = HashSet::new(); - insert_dependency_names(&toml.dependencies, &mut declared_deps); - insert_dependency_names(&toml.dev_dependencies, &mut declared_deps); - insert_dependency_names(&toml.build_dependencies, &mut declared_deps); - declared_deps -} - -fn insert_dependency_names( - dependencies: &HashMap, - declared_deps: &mut HashSet, -) { - for (dep_name, dep_value) in dependencies { - let package_name = dep_value - .as_table() - .and_then(|table| table.get("package")) - .and_then(toml::Value::as_str) - .unwrap_or(dep_name); - declared_deps.insert(package_name.to_string()); - } -} - -fn is_crates_io_package(pkg: &LockPackage) -> bool { - pkg.source - .as_ref() - .is_some_and(|source| source.is_default_registry()) -} - -/// Recursively finds all Rust projects in the given directory path. -/// -/// This function scans for Cargo.toml files, identifies workspaces, -/// and returns a sorted list of projects with their dependencies. -/// -/// # Arguments -/// -/// * `path` - The directory path to scan for Rust projects -/// -/// # Returns -/// -/// A vector of `Project` structs, sorted by workspace and name. -/// Projects without dependencies in their Cargo.lock are excluded from the results. -/// -/// # Examples -/// -/// ``` -/// use carwash::project::find_rust_projects; -/// -/// let projects = find_rust_projects("."); -/// // Projects will be sorted with workspace members grouped together -/// ``` -pub fn find_rust_projects(path: &str) -> Vec { - let mut projects = HashMap::new(); - let mut workspaces: HashMap)> = HashMap::new(); - - // Convert relative path to absolute path - let base_path = if Path::new(path).is_absolute() { - PathBuf::from(path) - } else { - std::env::current_dir() - .ok() - .map(|cwd| cwd.join(path)) - .unwrap_or_else(|| PathBuf::from(path)) - }; - - // Build gitignore matcher - let mut builder = ignore::WalkBuilder::new(&base_path); - builder - .follow_links(true) - .git_ignore(true) - .git_global(false) - .git_exclude(false) - .hidden(true) // We'll filter hidden ourselves - .filter_entry(|e| { - // Skip common directories that won't contain projects - let file_name = e.file_name().to_string_lossy(); - !file_name.starts_with('.') && file_name != "target" && file_name != "node_modules" - }); - - // First pass: Find all Cargo.toml files and identify workspaces - for entry in builder - .build() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name() == "Cargo.toml") - { - let manifest_path = entry.path(); - - if let Ok(content) = fs::read_to_string(manifest_path) - && let Ok(toml) = toml::from_str::(&content) - { - // Check if this is a workspace root - if let Some(workspace) = &toml.workspace { - let root_path = manifest_path.parent().unwrap().to_path_buf(); - let mut member_paths = Vec::new(); - - // Resolve workspace members - for member in &workspace.members { - let member_path = root_path.join(member).join("Cargo.toml"); - if member_path.exists() { - member_paths.push(member_path); - } - } - - // Get workspace name (use directory name as fallback) - let workspace_name = if let Some(pkg) = &toml.package { - pkg.name.clone() - } else { - root_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("workspace") - .to_string() - }; - - workspaces.insert(root_path, (workspace_name, member_paths)); - } - } - } - - // Second pass: Create projects with workspace information - for entry in WalkDir::new(&base_path) - .follow_links(true) - .into_iter() - .filter_entry(|e| { - let file_name = e.file_name().to_string_lossy(); - !file_name.starts_with('.') && file_name != "target" && file_name != "node_modules" - }) - .filter_map(|e| e.ok()) - .filter(|e| e.file_name() == "Cargo.toml") - { - let manifest_path = entry.path(); - - if let Ok(content) = fs::read_to_string(manifest_path) - && let Ok(toml) = toml::from_str::(&content) - { - // Find if this project belongs to a workspace - let mut workspace_info: Option<(PathBuf, String)> = None; - - for (ws_root, (ws_name, members)) in &workspaces { - if members.contains(&manifest_path.to_path_buf()) { - workspace_info = Some((ws_root.clone(), ws_name.clone())); - break; - } - } - - // Add the project if it has a package section - if let Some(project) = Project::from_toml( - manifest_path, - &toml, - workspace_info.as_ref().map(|(root, _)| root.clone()), - workspace_info.as_ref().map(|(_, name)| name.clone()), - ) { - projects.insert(manifest_path.to_path_buf(), project); - } - } - } - - // Convert to vec and sort by workspace and name - let mut result: Vec = projects.into_values().collect(); - result.sort_by(|a, b| { - match (&a.workspace_name, &b.workspace_name) { - (Some(ws_a), Some(ws_b)) if ws_a == ws_b => { - // Same workspace: sort by project name - a.name.cmp(&b.name) - } - (Some(ws_a), Some(ws_b)) => { - // Different workspaces: sort by workspace name - ws_a.cmp(ws_b) - } - (Some(_), None) => std::cmp::Ordering::Less, // Workspace projects first - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.name.cmp(&b.name), // Standalone projects sorted by name - } - }); - - result -} - -/// Build a hierarchical tree of projects organized by directory structure -/// -/// This function takes a root path and builds a tree where: -/// - Directories are intermediate nodes that can be expanded/collapsed -/// - Projects (Cargo.toml files) are leaf nodes -/// - Subdirectories are only expanded if they contain projects -/// -/// # Arguments -/// -/// * `path` - The root directory path to scan -/// -/// # Returns -/// -/// A `TreeNode` representing the root of the directory tree -pub fn build_project_tree(path: &str) -> crate::tree::TreeNode { - let base_path = if Path::new(path).is_absolute() { - PathBuf::from(path) - } else { - std::env::current_dir() - .ok() - .map(|cwd| cwd.join(path)) - .unwrap_or_else(|| PathBuf::from(path)) - }; - - if let Some(project) = standalone_project_at(&base_path) { - return crate::tree::TreeNode::project(project, 0); - } - - // Build ONLY the root level - no recursive scanning! - // Children will be loaded lazily when directories are expanded - build_tree_level_only(&base_path, 0) -} - -fn standalone_project_at(path: &Path) -> Option { - let cargo_toml_path = path.join("Cargo.toml"); - let content = fs::read_to_string(&cargo_toml_path).ok()?; - let toml = toml::from_str::(&content).ok()?; - - if toml.workspace.is_some() || toml.package.is_none() { - return None; - } - - Project::from_toml(&cargo_toml_path, &toml, None, None) -} - -/// Build a single level of the tree (non-recursive) -fn build_tree_level_only(dir_path: &Path, depth: usize) -> crate::tree::TreeNode { - let dir_name = dir_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("projects") - .to_string(); - - let mut node = - crate::tree::TreeNode::directory(dir_name.clone(), dir_path.to_path_buf(), depth); - node.children_loaded = false; // Mark as not loaded yet - // Expand root by default, and also auto-expand "crates" directories - node.expanded = depth == 0 || dir_name == "crates"; - - node -} - -/// Check if a path should be ignored based on gitignore rules -fn should_ignore_path(path: &Path, parent_dir: &Path) -> bool { - // Build a gitignore matcher for the parent directory - let mut builder = ignore::gitignore::GitignoreBuilder::new(parent_dir); - - // Add .gitignore from parent if it exists - let gitignore_path = parent_dir.join(".gitignore"); - if gitignore_path.exists() { - let _ = builder.add(&gitignore_path); - } - - let gitignore = match builder.build() { - Ok(gi) => gi, - Err(_) => return false, // If we can't build gitignore, don't ignore - }; - - // Check if path is ignored - gitignore.matched(path, path.is_dir()).is_ignore() -} - -/// Check if a directory contains any Rust projects (recursively, but shallow scan) -/// This does a quick check to see if we should show a directory even when show_all_folders=false -fn directory_contains_rust_projects(path: &Path) -> bool { - // Check if this directory itself is a Rust project or workspace - let cargo_toml_path = path.join("Cargo.toml"); - if cargo_toml_path.exists() { - return true; - } - - // Check immediate children for Rust projects - if let Ok(entries) = std::fs::read_dir(path) { - for entry in entries.filter_map(|e| e.ok()) { - let child_path = entry.path(); - - // Skip if ignored by gitignore - if should_ignore_path(&child_path, path) { - continue; - } - - // Skip hidden and common non-project directories - if let Some(name) = child_path.file_name() { - let name_str = name.to_string_lossy(); - if name_str.starts_with('.') - || name_str == "target" - || name_str == "node_modules" - || name_str == "vendor" - || name_str == "third_party" - { - continue; - } - } - - // Check if child directory is a Rust project/workspace OR contains Rust projects - if child_path.is_dir() { - if child_path.join("Cargo.toml").exists() { - return true; - } - // Recursively check one more level for nested projects (e.g., crates/) - if let Ok(nested_entries) = std::fs::read_dir(&child_path) { - for nested in nested_entries.filter_map(|e| e.ok()) { - if nested.path().is_dir() && nested.path().join("Cargo.toml").exists() { - return true; - } - } - } - } - } - } - - false -} - -/// Load children for a directory node (lazy loading) - Async friendly version -/// Returns a vector of children nodes -pub fn load_directory_children_async( - dir_path: &Path, - depth: usize, - show_all_folders: bool, -) -> Vec { - let mut children = Vec::new(); - - // Scan for Rust projects directly in this directory - if let Ok(entries) = std::fs::read_dir(dir_path) { - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - - // Skip if ignored by gitignore - if should_ignore_path(&path, dir_path) { - continue; - } - - let file_name = match path.file_name() { - Some(name) => name.to_string_lossy(), - None => continue, - }; - - // Skip hidden directories and common build directories - if file_name.starts_with('.') - || file_name == "target" - || file_name == "node_modules" - || file_name == "vendor" - || file_name == "third_party" - { - continue; - } - - if path.is_dir() { - // Check if this directory IS a Rust project - let cargo_toml_path = path.join("Cargo.toml"); - if cargo_toml_path.exists() - && let Ok(content) = std::fs::read_to_string(&cargo_toml_path) - && let Ok(toml) = toml::from_str::(&content) - { - // Parse Cargo.toml to check if it's a workspace-only or a real project - // Check if this has a workspace section (with or without package) - // Workspaces should be expandable directories to show their members - if toml.workspace.is_some() { - // It's a workspace root - treat as expandable directory - if show_all_folders || directory_contains_rust_projects(&path) { - let mut dir_node = build_tree_level_only(&path, depth + 1); - // Load children eagerly so "crates" dirs can be auto-expanded - load_directory_children(&mut dir_node, show_all_folders); - load_auto_expanded_descendants(&mut dir_node, show_all_folders); - - // Patch workspace info on child project nodes so they - // resolve deps from the workspace-level Cargo.lock - let ws_name = toml - .package - .as_ref() - .map(|p| p.name.clone()) - .unwrap_or_else(|| { - path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("workspace") - .to_string() - }); - patch_workspace_info(&mut dir_node, &path, &ws_name); - - // Keep the workspace itself collapsed - dir_node.expanded = false; - children.push(dir_node); - } - } else if toml.package.is_some() { - // It's a standalone project — use from_toml to get deps from Cargo.lock - if let Some(project) = - Project::from_toml(&cargo_toml_path, &toml, None, None) - { - let project_node = crate::tree::TreeNode::project(project, depth + 1); - children.push(project_node); - } - } - } else if path.is_dir() { - // No Cargo.toml - check if we should show it as a directory - if show_all_folders || directory_contains_rust_projects(&path) { - let mut dir_node = build_tree_level_only(&path, depth + 1); - // Don't override expanded state - build_tree_level_only sets it correctly - // (e.g., "crates" directories are auto-expanded) - load_auto_expanded_descendants(&mut dir_node, show_all_folders); - children.push(dir_node); - } - } - } - } - } - - // Sort children - children.sort_by(|a, b| match (&a.node_type, &b.node_type) { - ( - crate::tree::TreeNodeType::Directory { name: a_name, .. }, - crate::tree::TreeNodeType::Directory { name: b_name, .. }, - ) => a_name.cmp(b_name), - ( - crate::tree::TreeNodeType::Project(a_proj), - crate::tree::TreeNodeType::Project(b_proj), - ) => a_proj.name.cmp(&b_proj.name), - (crate::tree::TreeNodeType::Directory { .. }, crate::tree::TreeNodeType::Project(_)) => { - std::cmp::Ordering::Less - } - (crate::tree::TreeNodeType::Project(_), crate::tree::TreeNodeType::Directory { .. }) => { - std::cmp::Ordering::Greater - } - }); - - children -} - -/// Recursively patch workspace_root and workspace_name on child project nodes, -/// then reload their dependencies from the workspace-level Cargo.lock. -fn patch_workspace_info( - node: &mut crate::tree::TreeNode, - workspace_root: &Path, - workspace_name: &str, -) { - for child in &mut node.children { - if let crate::tree::TreeNodeType::Project(ref mut project) = child.node_type { - project.workspace_root = Some(workspace_root.to_path_buf()); - project.workspace_name = Some(workspace_name.to_string()); - // Re-load deps now that workspace_root is set (correct Cargo.lock resolution) - let _ = project.reload_dependencies(); - } - // Recurse into subdirectories (e.g., crates/) - patch_workspace_info(child, workspace_root, workspace_name); - } -} - -/// Populate descendants that are expanded by convention, such as workspace -/// `crates/` directories, before the tree is flattened. -fn load_auto_expanded_descendants(node: &mut crate::tree::TreeNode, show_all_folders: bool) { - if node.node_type.is_directory() && node.expanded && !node.children_loaded { - load_directory_children(node, show_all_folders); - } - - for child in &mut node.children { - load_auto_expanded_descendants(child, show_all_folders); - } -} - -/// Load children for a directory node (lazy loading) -/// This scans only the immediate children (1 level deep) -/// -/// If `show_all_folders` is false, only directories containing Rust projects (Cargo.toml) are shown. -/// If `show_all_folders` is true, all directories are shown. -pub fn load_directory_children(node: &mut crate::tree::TreeNode, show_all_folders: bool) { - if node.children_loaded { - return; // Already loaded - } - - let dir_path = match &node.node_type { - crate::tree::TreeNodeType::Directory { path, .. } => path, - _ => return, // Not a directory - }; - - node.children = load_directory_children_async(dir_path, node.depth, show_all_folders); - node.children_loaded = true; -} - -#[cfg(test)] -mod tests { - use super::*; - - fn unique_temp_dir(prefix: &str) -> PathBuf { - let unique = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("{}-{}", prefix, unique)) - } - - #[test] - fn test_project_discovery() { - // Test that the discovery function runs without panicking - let _projects = find_rust_projects("."); - // In CI or clean environments, there might be no projects with dependencies - // This is fine - we just verify the function completes without panic - } - - #[test] - fn test_cargo_toml_parsing() { - // Verify we can parse a basic Cargo.toml structure - let toml_content = r#" -[package] -name = "test-project" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde = "1.0" -"#; - let parsed: Result = toml::from_str(toml_content); - assert!(parsed.is_ok(), "Should parse valid Cargo.toml"); - - let cargo_toml = parsed.unwrap(); - assert!(cargo_toml.package.is_some()); - assert_eq!(cargo_toml.dependencies.len(), 1); - assert!(cargo_toml.dependencies.contains_key("serde")); - } - - #[test] - fn test_cargo_toml_parsing_with_dev_dependencies() { - let toml_content = r#" -[package] -name = "test-project" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde = "1.0" - -[dev-dependencies] -tokio = { version = "1.0", features = ["full"] } -"#; - let parsed: Result = toml::from_str(toml_content); - assert!(parsed.is_ok()); - - let cargo_toml = parsed.unwrap(); - assert_eq!(cargo_toml.dependencies.len(), 1); - assert_eq!(cargo_toml.dev_dependencies.len(), 1); - assert!(cargo_toml.dev_dependencies.contains_key("tokio")); - } - - #[test] - fn test_cargo_toml_parsing_workspace() { - let toml_content = r#" -[workspace] -members = ["crate1", "crate2"] -"#; - let parsed: Result = toml::from_str(toml_content); - assert!(parsed.is_ok()); - - let cargo_toml = parsed.unwrap(); - assert!(cargo_toml.workspace.is_some()); - assert_eq!(cargo_toml.workspace.unwrap().members.len(), 2); - } - - #[test] - fn test_cargo_toml_workspace_inherited_fields() { - // Test parsing Cargo.toml with workspace-inherited fields (like turbovault) - let toml_content = r#" -[package] -name = "test-crate" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -publish = true - -[dependencies] -serde = { workspace = true } -"#; - let cargo_toml: Result = toml::from_str(toml_content); - assert!( - cargo_toml.is_ok(), - "Should parse workspace-inherited fields: {:?}", - cargo_toml.err() - ); - - let parsed = cargo_toml.unwrap(); - assert!(parsed.package.is_some()); - - let package = parsed.package.unwrap(); - assert_eq!(package.name, "test-crate"); - assert_eq!(package.version_string(), "workspace"); - assert_eq!(package.authors_vec(), Vec::::new()); // workspace-inherited, so empty - } - - #[test] - fn test_renamed_dependency_uses_package_name() { - let toml_content = r#" -[package] -name = "test-crate" -version = "0.1.0" - -[dependencies] -serde_renamed = { package = "serde", version = "1" } -tokio = "1" -"#; - let cargo_toml: CargoToml = toml::from_str(toml_content).unwrap(); - let names = collect_declared_dependency_names(&cargo_toml); - - assert!(names.contains("serde")); - assert!(names.contains("tokio")); - assert!(!names.contains("serde_renamed")); - } - - #[test] - fn test_project_status_default() { - let status = ProjectStatus::Pending; - assert_eq!(status, ProjectStatus::Pending); - } - - #[test] - fn test_dependency_check_status() { - let status = DependencyCheckStatus::NotChecked; - assert_eq!(status, DependencyCheckStatus::NotChecked); - - let checking = DependencyCheckStatus::Checking; - assert_eq!(checking, DependencyCheckStatus::Checking); - - let checked = DependencyCheckStatus::Checked; - assert_eq!(checked, DependencyCheckStatus::Checked); - } - - #[test] - fn test_dependency_from_lock_package() { - use cargo_lock::{Package as LockPackage, Version}; - - let pkg = LockPackage { - name: "test-crate".parse().unwrap(), - version: Version::parse("1.2.3").unwrap(), - source: None, - checksum: None, - dependencies: Vec::new(), - replace: None, - }; - - let dep = Dependency::from(&pkg); - assert_eq!(dep.name, "test-crate"); - assert_eq!(dep.current_version, "1.2.3"); - assert_eq!(dep.check_status, DependencyCheckStatus::NotChecked); - assert!(dep.latest_version.is_none()); - } - - #[test] - fn test_crates_io_package_filter() { - use cargo_lock::{Package as LockPackage, SourceId, Version}; - - let crates_io_pkg = LockPackage { - name: "serde".parse().unwrap(), - version: Version::parse("1.0.0").unwrap(), - source: Some( - SourceId::from_url("registry+https://github.com/rust-lang/crates.io-index") - .unwrap(), - ), - checksum: None, - dependencies: Vec::new(), - replace: None, - }; - assert!(is_crates_io_package(&crates_io_pkg)); - - let path_pkg = LockPackage { - source: None, - ..crates_io_pkg - }; - assert!(!is_crates_io_package(&path_pkg)); - } - - #[test] - fn test_is_prerelease() { - // Stable versions - assert!(!Dependency::is_prerelease("1.0.0")); - assert!(!Dependency::is_prerelease("2.3.4")); - assert!(!Dependency::is_prerelease("0.1.0")); - - // Pre-release versions - assert!(Dependency::is_prerelease("1.0.0-beta.1")); - assert!(Dependency::is_prerelease("2.0.0-rc.1")); - assert!(Dependency::is_prerelease("1.0.0-alpha")); - assert!(Dependency::is_prerelease("0.5.0-pre")); - - // Invalid versions (fallback to false) - assert!(!Dependency::is_prerelease("not-a-version")); - } - - #[test] - fn test_has_stable_update_stable_to_stable() { - // Stable → newer stable = update - let dep = Dependency { - name: "test".into(), - current_version: "1.0.0".into(), - latest_version: Some("1.1.0".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert!(dep.has_stable_update()); - } - - #[test] - fn test_has_stable_update_stable_to_prerelease() { - // Stable → pre-release = NO update (user is on stable, don't suggest beta) - let dep = Dependency { - name: "test".into(), - current_version: "1.0.0".into(), - latest_version: Some("2.0.0-beta.1".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert!(!dep.has_stable_update()); - } - - #[test] - fn test_has_stable_update_prerelease_to_stable() { - // Pre-release → stable = update (user should upgrade to stable) - let dep = Dependency { - name: "test".into(), - current_version: "2.0.0-beta.1".into(), - latest_version: Some("2.0.0".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert!(dep.has_stable_update()); - } - - #[test] - fn test_has_stable_update_prerelease_to_newer_prerelease() { - // Pre-release → newer pre-release = update - let dep = Dependency { - name: "test".into(), - current_version: "2.0.0-beta.1".into(), - latest_version: Some("2.0.0-beta.2".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert!(dep.has_stable_update()); - } - - #[test] - fn test_has_stable_update_same_version() { - // Same version = no update - let dep = Dependency { - name: "test".into(), - current_version: "1.0.0".into(), - latest_version: Some("1.0.0".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert!(!dep.has_stable_update()); - } - - #[test] - fn test_has_stable_update_no_latest() { - // No latest version = no update - let dep = Dependency { - name: "test".into(), - current_version: "1.0.0".into(), - latest_version: None, - check_status: DependencyCheckStatus::NotChecked, - last_checked: None, - }; - assert!(!dep.has_stable_update()); - } - - #[test] - fn test_compute_check_status_requires_complete_checked_data() { - let unchecked_dep = Dependency { - name: "unchecked".into(), - current_version: "1.0.0".into(), - latest_version: None, - check_status: DependencyCheckStatus::NotChecked, - last_checked: None, - }; - assert_eq!( - Project::compute_check_status_from_deps(&[unchecked_dep]), - ProjectCheckStatus::Unchecked - ); - - let checked_dep = Dependency { - name: "checked".into(), - current_version: "1.0.0".into(), - latest_version: Some("1.0.0".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert_eq!( - Project::compute_check_status_from_deps(&[checked_dep]), - ProjectCheckStatus::UpToDate - ); - } - - #[test] - fn test_update_type() { - // Stable → stable - let dep1 = Dependency { - name: "test".into(), - current_version: "1.0.0".into(), - latest_version: Some("2.0.0".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert_eq!(dep1.update_type(), Some("stable")); - - // Stable → pre-release - let dep2 = Dependency { - name: "test".into(), - current_version: "1.0.0".into(), - latest_version: Some("2.0.0-beta.1".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert_eq!(dep2.update_type(), Some("pre-release")); - - // Pre-release → stable - let dep3 = Dependency { - name: "test".into(), - current_version: "2.0.0-beta.1".into(), - latest_version: Some("2.0.0".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert_eq!(dep3.update_type(), Some("stable")); - - // Same version = no update type - let dep4 = Dependency { - name: "test".into(), - current_version: "1.0.0".into(), - latest_version: Some("1.0.0".into()), - check_status: DependencyCheckStatus::Checked, - last_checked: None, - }; - assert_eq!(dep4.update_type(), None); - } - - #[test] - fn test_empty_cargo_toml() { - let toml_content = r#" -[dependencies] -"#; - let parsed: Result = toml::from_str(toml_content); - assert!(parsed.is_ok()); - - let cargo_toml = parsed.unwrap(); - assert!(cargo_toml.package.is_none()); - assert!(cargo_toml.dependencies.is_empty()); - } - - #[test] - fn test_relative_path_resolution() { - // Test that relative paths like "." are properly resolved - let cwd = std::env::current_dir().ok().unwrap_or_default(); - let cwd_str = cwd.to_string_lossy(); - - // Both should work - the current implementation should resolve them - let projects_dot = find_rust_projects("."); - let projects_cwd = find_rust_projects(cwd_str.as_ref()); - - // Both should find the same number of projects - assert_eq!(projects_dot.len(), projects_cwd.len()); - - // Verify that projects are properly discovered - // (the exact number depends on the test environment) - let _ = projects_dot; - } - - #[test] - fn test_build_project_tree() { - let root = unique_temp_dir("carwash-tree-test"); - std::fs::create_dir_all(&root).unwrap(); - - let tree = build_project_tree(root.to_string_lossy().as_ref()); - - // Tree should have a root node - assert_eq!( - tree.node_type.name(), - root.file_name().unwrap().to_string_lossy() - ); - - // Root should be a directory - assert!(tree.node_type.is_directory()); - - // Root should be expanded by default - assert!(tree.expanded); - - // Tree structure should be created successfully - let _ = tree; - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn test_build_project_tree_root_standalone_project() { - let root = unique_temp_dir("carwash-root-project-test"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.join("Cargo.toml"), - r#" -[package] -name = "root-project" -version = "0.1.0" -"#, - ) - .unwrap(); - - let tree = build_project_tree(root.to_string_lossy().as_ref()); - assert!(tree.node_type.is_project()); - assert!(tree.children_loaded); - - let crate::tree::TreeNodeType::Project(project) = tree.node_type else { - panic!("expected root project node"); - }; - assert_eq!(project.name, "root-project"); - assert_eq!(project.path, root); - - let _ = std::fs::remove_dir_all(project.path); - } - - #[test] - fn test_tree_navigation() { - // Test tree flattening - let tree = build_project_tree("."); - let flattened = crate::tree::FlattenedTree::from_tree(&tree); - - // Flattened tree should have at least the root node - assert!(!flattened.items.is_empty()); - } - - #[test] - fn test_auto_expanded_crates_directory_loads_projects() { - let root = unique_temp_dir("carwash-crates-test"); - let member = root.join("crates").join("member"); - std::fs::create_dir_all(&member).unwrap(); - std::fs::write( - member.join("Cargo.toml"), - r#" -[package] -name = "member" -version = "0.1.0" -"#, - ) - .unwrap(); - - let mut tree = build_project_tree(root.to_string_lossy().as_ref()); - load_directory_children(&mut tree, false); - let flattened = crate::tree::FlattenedTree::from_tree(&tree); - - assert!(flattened.items.iter().any(|(node, _)| { - matches!( - &node.node_type, - crate::tree::TreeNodeType::Project(project) if project.name == "member" - ) - })); - - let _ = std::fs::remove_dir_all(root); - } -} diff --git a/src/runner.rs b/src/runner.rs deleted file mode 100644 index d379dd0..0000000 --- a/src/runner.rs +++ /dev/null @@ -1,565 +0,0 @@ -//! Task execution and update checking -//! -//! This module handles the execution of cargo commands and dependency update checking. -//! Dependency update checks are user-initiated and scoped to one project at a time. - -use crate::app::AppState; -use crate::events::Action; -use crate::project::{Dependency, DependencyCheckStatus, Project}; -use crates_io_api::AsyncClient; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::Arc; -use std::time::SystemTime; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::Command as TokioCommand, - sync::{Semaphore, mpsc}, -}; - -const PARALLEL_UPDATE_CHECKS: usize = 5; - -/// Check for updates on selected project with proper caching -/// This is called when user presses 'u' or opens update wizard -pub async fn check_for_updates( - state: &AppState, - tx: mpsc::Sender, - dep_cache: Arc>, -) { - // CRITICAL FIX: When called from wizard, use the LOCKED project, not current selection! - // User might have moved cursor after opening wizard - let project_to_check = if state.mode == crate::events::Mode::UpdateWizard { - // Wizard is open - use the locked project - if let Some(ref locked_id) = state.updater.locked_project_id { - state.all_projects.iter().find(|p| &p.path == locked_id) - } else { - state.get_selected_project() - } - } else { - state.get_selected_project() - }; - - if let Some(project) = project_to_check { - let deps = project.dependencies.clone(); - let project_id = project.path.clone(); - - // Send initial action to show we're checking - let _ = tx - .send(Action::UpdateDependenciesStreamStart(project_id.clone())) - .await; - - check_dependencies_with_cache( - project_id, - deps, - tx, - true, - state.config.app.cache_duration(), - dep_cache, - ) - .await; - } -} - -/// Check dependencies with intelligent caching and streaming updates -async fn check_single_dependency( - dep: Dependency, - client: &AsyncClient, - tx: &mpsc::Sender, - project_id: &Path, - use_cache: bool, - cache_duration: std::time::Duration, - now: SystemTime, -) -> Option<(Dependency, bool)> { - let mut updated_dep = dep.clone(); - let mut checked_remote = false; - let should_check = if use_cache { - if let Some(last_checked) = updated_dep.last_checked { - if let Ok(elapsed) = now.duration_since(last_checked) { - elapsed > cache_duration - } else { - true - } - } else { - true - } - } else { - true - }; - - if should_check { - updated_dep.check_status = DependencyCheckStatus::Checking; - - let _ = tx - .send(Action::UpdateDependencyCheckStatus( - project_id.to_path_buf(), - updated_dep.name.clone(), - DependencyCheckStatus::Checking, - )) - .await; - - match tokio::time::timeout( - std::time::Duration::from_secs(5), - client.get_crate(&updated_dep.name), - ) - .await - { - Ok(Ok(crate_info)) => { - updated_dep.latest_version = Some(crate_info.crate_data.max_version); - updated_dep.check_status = DependencyCheckStatus::Checked; - updated_dep.last_checked = Some(SystemTime::now()); - checked_remote = true; - } - _ => { - updated_dep.check_status = DependencyCheckStatus::NotChecked; - } - } - - // Only send update for deps we actually checked (avoid redundant UI updates). - // Failed checks intentionally remain NotChecked so the project is not shown - // as up to date from incomplete data. - let _ = tx - .send(Action::UpdateSingleDependency( - project_id.to_path_buf(), - updated_dep.clone(), - )) - .await; - } - // Skip sending UpdateSingleDependency for cached deps - they're already up to date - - Some((updated_dep, checked_remote)) -} - -pub async fn check_dependencies_with_cache( - project_id: PathBuf, - deps: Vec, - tx: mpsc::Sender, - use_cache: bool, - cache_duration: std::time::Duration, - dep_cache: Arc>, -) { - let mut deps = deps; - if use_cache { - let cache = dep_cache.read().await; - for dep in &mut deps { - if let Some(entry) = cache.lookup(&dep.name, &dep.current_version, cache_duration) { - dep.latest_version = Some(entry.latest.clone()); - dep.check_status = DependencyCheckStatus::Checked; - dep.last_checked = Some( - std::time::SystemTime::UNIX_EPOCH - + std::time::Duration::from_secs(entry.checked_at), - ); - } - } - } - - let semaphore = Arc::new(Semaphore::new(PARALLEL_UPDATE_CHECKS)); - let client = match AsyncClient::new( - "carwash/0.1.0 (https://github.com/epistates/carwash)", - std::time::Duration::from_secs(1), - ) { - Ok(client) => client, - Err(_) => { - let _ = tx.send(Action::UpdateDependencies(project_id, deps)).await; - return; - } - }; - let now = SystemTime::now(); - let mut tasks = Vec::new(); - - for dep in deps { - let semaphore_clone = semaphore.clone(); - let client_clone = client.clone(); - let tx_clone = tx.clone(); - let project_id_clone = project_id.clone(); - - let task = tokio::spawn(async move { - let _permit = semaphore_clone.acquire().await.ok()?; - check_single_dependency( - dep, - &client_clone, - &tx_clone, - &project_id_clone, - use_cache, - cache_duration, - now, - ) - .await - }); - tasks.push(task); - } - - let mut updated_deps = Vec::new(); - let mut remotely_checked_deps = Vec::new(); - for task in tasks { - if let Ok(Some((dep, checked_remote))) = task.await { - if checked_remote { - remotely_checked_deps.push(dep.clone()); - } - updated_deps.push(dep); - } - } - - // Update the global per-dependency version cache - if !remotely_checked_deps.is_empty() { - let mut cache = dep_cache.write().await; - for dep in &remotely_checked_deps { - if dep.check_status == DependencyCheckStatus::Checked - && let Some(ref latest) = dep.latest_version - { - cache.insert(&dep.name, &dep.current_version, latest.clone()); - } - } - let _ = cache.save(); - } - - let _ = tx - .send(Action::UpdateDependencies(project_id, updated_deps)) - .await; -} - -/// Recalculate the size of a single project after a command (e.g., cargo clean) -async fn recalculate_project_size(project: &Project, tx: &mpsc::Sender) { - let project_id = project.path.clone(); - let project_path = project.path.clone(); - let workspace_root = project.workspace_root.clone(); - let tx = tx.clone(); - - tokio::spawn(async move { - // Use spawn_blocking for blocking WalkDir I/O to prevent UI lag - let (total_size, target_size) = tokio::task::spawn_blocking(move || { - let total_size = crate::project::calculate_directory_size(&project_path); - - // For workspace members, look for target/ at workspace root - // For standalone projects, look in the project directory - let target_size = { - let target_path = if let Some(ws_root) = workspace_root { - ws_root.join("target") - } else { - project_path.join("target") - }; - - if target_path.exists() && target_path.is_dir() { - crate::project::calculate_directory_size(&target_path) - } else { - Some(0) - } - }; - - (total_size, target_size) - }) - .await - .unwrap_or((None, None)); - - // Send update back to main thread - let _ = tx - .send(Action::UpdateProjectSize( - project_id, - total_size, - target_size, - )) - .await; - }); -} - -async fn spawn_and_stream_command( - command_str: &str, - project: &Project, - tx: &mpsc::Sender, - tab_index: usize, -) -> anyhow::Result<()> { - let start_time = std::time::Instant::now(); - let args = shlex::split(command_str) - .unwrap_or_else(|| command_str.split_whitespace().map(String::from).collect()); - - if args.is_empty() { - anyhow::bail!("Empty command"); - } - - let _ = tx - .send(Action::AddOutput( - tab_index, - format!("$ cargo {} (in {})", command_str, project.path.display()), - )) - .await; - let _ = tx.send(Action::AddOutput(tab_index, "".to_string())).await; - - let mut cmd = TokioCommand::new("cargo"); - cmd.args(&args) - .current_dir(&project.path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - - let mut child = cmd.spawn()?; - - let stdout = child.stdout.take().expect("stdout was configured as piped"); - let stderr = child.stderr.take().expect("stderr was configured as piped"); - - let mut stdout_reader = BufReader::new(stdout).lines(); - let mut stderr_reader = BufReader::new(stderr).lines(); - - let mut line_count = 0; - loop { - tokio::select! { - Ok(Some(line)) = stdout_reader.next_line() => { - let _ = tx.send(Action::AddOutput(tab_index, line)).await; - line_count += 1; - } - Ok(Some(line)) = stderr_reader.next_line() => { - let _ = tx.send(Action::AddOutput(tab_index, line)).await; - line_count += 1; - } - else => break, - } - } - - let status = child.wait().await?; - let elapsed = start_time.elapsed(); - let _ = tx.send(Action::AddOutput(tab_index, "".to_string())).await; - - if status.success() { - let _ = tx - .send(Action::AddOutput( - tab_index, - format!( - "✓ Finished successfully in {:.2}s ({} lines)", - elapsed.as_secs_f64(), - line_count - ), - )) - .await; - - // Note: Size recalculation for clean commands is handled in run_command() - // with sequential execution for workspace members to avoid race conditions - } else { - let code = status.code().unwrap_or(-1); - let _ = tx - .send(Action::AddOutput( - tab_index, - format!( - "❌ Failed with exit code {} after {:.2}s", - code, - elapsed.as_secs_f64() - ), - )) - .await; - } - - Ok(()) -} - -#[derive(Debug, Clone)] -struct CleanCommandTarget { - tab_index: usize, - command_project: Project, - affected_projects: Vec, -} - -fn clean_target_path(project: &Project) -> PathBuf { - project - .workspace_root - .clone() - .unwrap_or_else(|| project.path.clone()) -} - -fn clean_target_name(first_project: &Project, target_path: &Path) -> String { - first_project.workspace_name.clone().unwrap_or_else(|| { - target_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(first_project.name.as_str()) - .to_string() - }) -} - -fn plan_clean_command_targets( - projects: Vec, - start_tab_count: usize, -) -> Vec { - let mut grouped_projects: Vec<(PathBuf, Vec)> = Vec::new(); - - for project in projects { - let target_path = clean_target_path(&project); - if let Some((_, existing_projects)) = grouped_projects - .iter_mut() - .find(|(path, _)| *path == target_path) - { - existing_projects.push(project); - } else { - grouped_projects.push((target_path, vec![project])); - } - } - - grouped_projects - .into_iter() - .enumerate() - .map(|(offset, (target_path, affected_projects))| { - let mut command_project = affected_projects[0].clone(); - command_project.name = clean_target_name(&command_project, &target_path); - command_project.path = target_path; - command_project.workspace_root = None; - - CleanCommandTarget { - tab_index: start_tab_count + offset, - command_project, - affected_projects, - } - }) - .collect() -} - -pub async fn run_command(command_str: &str, state: &AppState, tx: mpsc::Sender) { - if command_str.is_empty() { - return; - } - - let projects_to_run: Vec = state - .all_projects - .iter() - .filter(|p| state.selected_projects.contains(&p.path)) - .cloned() - .collect(); - - if projects_to_run.is_empty() { - let tab_index = state.tabs.len(); - let _ = tx - .send(Action::CreateTab("Error: No projects selected".to_string())) - .await; - let _ = tx - .send(Action::AddOutput( - tab_index, - "Please select at least one project to run commands.".to_string(), - )) - .await; - let _ = tx.send(Action::FinishCommand(tab_index)).await; - return; - } - - let start_tab_count = state.tabs.len(); - let is_clean_command = shlex::split(command_str) - .unwrap_or_else(|| command_str.split_whitespace().map(String::from).collect()) - .first() - .is_some_and(|command| command == "clean"); - - if is_clean_command { - let clean_targets = plan_clean_command_targets(projects_to_run, start_tab_count); - - for target in &clean_targets { - let tab_title = format!("{}: {}", command_str, target.command_project.name); - let _ = tx.send(Action::CreateTab(tab_title)).await; - } - - // `cargo clean` acts on the workspace target directory for workspace members. - // Run it once per unique target directory, then refresh every affected row. - for target in clean_targets { - let tx = tx.clone(); - let command_str = command_str.to_string(); - - tokio::spawn(async move { - if let Err(e) = spawn_and_stream_command( - &command_str, - &target.command_project, - &tx, - target.tab_index, - ) - .await - { - let _ = tx - .send(Action::AddOutput( - target.tab_index, - format!("❌ Error: {}", e), - )) - .await; - } - - for project in target.affected_projects { - recalculate_project_size(&project, &tx).await; - } - - let _ = tx.send(Action::FinishCommand(target.tab_index)).await; - }); - } - } else { - let projects_with_tabs: Vec<(usize, Project)> = projects_to_run - .into_iter() - .enumerate() - .map(|(i, project)| (start_tab_count + i, project)) - .collect(); - - for (_, project) in &projects_with_tabs { - let tab_title = format!("{}: {}", command_str, project.name); - let _ = tx.send(Action::CreateTab(tab_title)).await; - } - - // Non-clean commands: run in parallel as before - for (tab_index, project) in projects_with_tabs { - let tx = tx.clone(); - let command_str = command_str.to_string(); - - tokio::spawn(async move { - if let Err(e) = - spawn_and_stream_command(&command_str, &project, &tx, tab_index).await - { - let _ = tx - .send(Action::AddOutput(tab_index, format!("❌ Error: {}", e))) - .await; - } - let _ = tx.send(Action::FinishCommand(tab_index)).await; - }); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::project::{GitStatus, ProjectCheckStatus, ProjectStatus}; - - fn project(name: &str, path: &str, workspace_root: Option<&str>) -> Project { - Project { - name: name.to_string(), - path: PathBuf::from(path), - status: ProjectStatus::Pending, - version: "0.1.0".to_string(), - authors: vec![], - dependencies: vec![], - workspace_root: workspace_root.map(PathBuf::from), - workspace_name: workspace_root.map(|_| "workspace".to_string()), - cargo_lock_hash: None, - check_status: ProjectCheckStatus::Unchecked, - git_status: GitStatus::Clean, - total_size: None, - target_size: None, - } - } - - #[test] - fn clean_targets_dedupe_workspace_members() { - let member_a = project("member-a", "/repo/member-a", Some("/repo")); - let member_b = project("member-b", "/repo/member-b", Some("/repo")); - - let targets = plan_clean_command_targets(vec![member_a, member_b], 3); - - assert_eq!(targets.len(), 1); - assert_eq!(targets[0].tab_index, 3); - assert_eq!(targets[0].command_project.path, PathBuf::from("/repo")); - assert_eq!(targets[0].affected_projects.len(), 2); - } - - #[test] - fn clean_targets_keep_standalone_projects_separate() { - let workspace_member = project("member-a", "/repo/member-a", Some("/repo")); - let standalone = project("tool", "/tools/tool", None); - - let targets = plan_clean_command_targets(vec![workspace_member, standalone], 10); - - assert_eq!(targets.len(), 2); - assert_eq!(targets[0].tab_index, 10); - assert_eq!(targets[0].command_project.path, PathBuf::from("/repo")); - assert_eq!(targets[1].tab_index, 11); - assert_eq!( - targets[1].command_project.path, - PathBuf::from("/tools/tool") - ); - } -} diff --git a/src/settings.rs b/src/settings.rs deleted file mode 100644 index e2579fc..0000000 --- a/src/settings.rs +++ /dev/null @@ -1,38 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::time::Duration; - -const DEFAULT_CACHE_TTL_MINUTES: u64 = 5; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppSettings { - #[serde(default = "default_cache_ttl")] - pub cache_ttl_minutes: u64, - #[serde(default)] - pub show_all_folders: bool, -} - -fn default_cache_ttl() -> u64 { - DEFAULT_CACHE_TTL_MINUTES -} - -impl Default for AppSettings { - fn default() -> Self { - Self { - cache_ttl_minutes: DEFAULT_CACHE_TTL_MINUTES, - show_all_folders: false, - } - } -} - -impl AppSettings { - pub fn cache_duration(&self) -> Duration { - Duration::from_secs(self.cache_ttl_minutes.max(1) * 60) - } - - pub fn normalize(mut self) -> Self { - if self.cache_ttl_minutes == 0 { - self.cache_ttl_minutes = DEFAULT_CACHE_TTL_MINUTES; - } - self - } -} diff --git a/src/tree.rs b/src/tree.rs deleted file mode 100644 index f1c4de5..0000000 --- a/src/tree.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Hierarchical project tree structure with lazy loading support -//! -//! This module provides a tree-based view of the file system where: -//! - Directories can be expanded/collapsed -//! - Projects (Cargo.toml files) are leaf nodes -//! - Subdirectories are only scanned when expanded (lazy loading) -//! - The tree state is persisted in AppState - -use crate::project::Project; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -/// A node in the project tree, either a directory or a project -#[derive(Debug, Clone)] -pub enum TreeNodeType { - /// A directory that can be expanded to show subdirectories and projects - Directory { - /// Name of the directory - name: String, - /// Absolute path to the directory - path: PathBuf, - }, - /// A leaf node representing a Rust project - Project(Project), -} - -impl TreeNodeType { - /// Get the display name of this node - pub fn name(&self) -> &str { - match self { - TreeNodeType::Directory { name, .. } => name, - TreeNodeType::Project(p) => &p.name, - } - } - - /// Get the path of this node - pub fn path(&self) -> &Path { - match self { - TreeNodeType::Directory { path, .. } => path, - TreeNodeType::Project(p) => &p.path, - } - } - - /// Check if this is a directory node - pub fn is_directory(&self) -> bool { - matches!(self, TreeNodeType::Directory { .. }) - } - - /// Check if this is a project node - pub fn is_project(&self) -> bool { - matches!(self, TreeNodeType::Project(_)) - } -} - -/// A node in the tree with its metadata and children -#[derive(Debug, Clone)] -pub struct TreeNode { - /// The node content (directory or project) - pub node_type: TreeNodeType, - /// Child nodes (only populated if expanded) - pub children: Vec, - /// Whether this directory node is expanded - pub expanded: bool, - /// Whether children have been loaded from disk (for lazy loading) - pub children_loaded: bool, - /// Whether children are currently being loaded - pub loading: bool, - /// Depth in the tree (for indentation) - pub depth: usize, -} - -impl TreeNode { - /// Create a new directory node - pub fn directory(name: String, path: PathBuf, depth: usize) -> Self { - Self { - node_type: TreeNodeType::Directory { name, path }, - children: Vec::new(), - expanded: depth == 0, // Only expand root directory by default - children_loaded: false, - loading: false, - depth, - } - } - - /// Create a new project node - pub fn project(project: Project, depth: usize) -> Self { - Self { - node_type: TreeNodeType::Project(project), - children: Vec::new(), - expanded: false, - children_loaded: true, // Projects don't have children - loading: false, - depth, - } - } - - /// Toggle the expanded state of this directory node - pub fn toggle_expanded(&mut self) { - if self.node_type.is_directory() { - self.expanded = !self.expanded; - } - } - - /// Get all projects in this subtree - pub fn collect_projects(&self) -> Vec<&Project> { - let mut projects = Vec::new(); - - match &self.node_type { - TreeNodeType::Project(p) => { - projects.push(p); - } - TreeNodeType::Directory { .. } => { - for child in &self.children { - projects.extend(child.collect_projects()); - } - } - } - - projects - } -} - -/// Flattened view of the tree for rendering and navigation -#[derive(Debug, Clone)] -pub struct FlattenedTree { - /// Flat list of nodes in display order - pub items: Vec<(TreeNode, usize)>, // (node, index in flat list) - /// Map from path to flat index for quick lookup - pub path_to_index: HashMap, -} - -impl FlattenedTree { - /// Create an empty flattened tree - pub fn new() -> Self { - Self { - items: Vec::new(), - path_to_index: HashMap::new(), - } - } - - /// Build a flattened view from a tree, respecting expanded state - pub fn from_tree(root: &TreeNode) -> Self { - let mut flattened = FlattenedTree::new(); - let mut index = 0; - flattened.flatten_recursive(root, &mut index, 0); - flattened - } - - fn flatten_recursive(&mut self, node: &TreeNode, index: &mut usize, depth_adjustment: usize) { - // Check if this is a "crates" directory (Rust convention for workspace members) - let is_crates_dir = if let TreeNodeType::Directory { name, .. } = &node.node_type { - name == "crates" - } else { - false - }; - - if is_crates_dir { - // Skip this node in the display, but process children with increased depth adjustment - if node.expanded { - for child in &node.children { - self.flatten_recursive(child, index, depth_adjustment + 1); - } - } - } else { - // Normal node - add to display with adjusted depth - let adjusted_node = TreeNode { - node_type: node.node_type.clone(), - children: Vec::new(), // Do not clone children to avoid O(N^2) memory cloning - expanded: node.expanded, - children_loaded: node.children_loaded, - loading: node.loading, - depth: node.depth.saturating_sub(depth_adjustment), - }; - - self.items.push((adjusted_node, *index)); - self.path_to_index - .insert(node.node_type.path().to_path_buf(), *index); - *index += 1; - - // If this is an expanded directory, add its children - if node.node_type.is_directory() && node.expanded { - for child in &node.children { - self.flatten_recursive(child, index, depth_adjustment); - } - } - } - } - - /// Get the flat index of a node by its path - pub fn get_index(&self, path: &Path) -> Option { - self.path_to_index.get(path).copied() - } -} - -/// Manages the selection state of a tree node -#[derive(Debug, Clone)] -pub struct TreeSelectionState { - /// Index of the currently selected item in the flattened tree - pub selected_index: Option, - /// Set of selected project paths (for multi-select) - pub selected_projects: HashSet, -} - -impl TreeSelectionState { - /// Create a new selection state - pub fn new() -> Self { - Self { - selected_index: Some(0), - selected_projects: HashSet::new(), - } - } - - /// Select the next item - pub fn select_next(&mut self, max_index: usize) { - if let Some(idx) = self.selected_index - && idx < max_index - { - self.selected_index = Some(idx + 1); - } - } - - /// Select the previous item - pub fn select_prev(&mut self) { - if let Some(idx) = self.selected_index - && idx > 0 - { - self.selected_index = Some(idx - 1); - } - } - - /// Toggle selection of a project - pub fn toggle_project(&mut self, path: PathBuf) { - if self.selected_projects.contains(&path) { - self.selected_projects.remove(&path); - } else { - self.selected_projects.insert(path); - } - } - - /// Check if a project is selected - pub fn is_project_selected(&self, path: &Path) -> bool { - self.selected_projects.contains(path) - } -} - -impl Default for TreeSelectionState { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_tree_node_creation() { - let node = TreeNode::directory("test".to_string(), PathBuf::from("/test"), 0); - assert!(node.node_type.is_directory()); - assert_eq!(node.node_type.name(), "test"); - } - - #[test] - fn test_tree_toggle_expanded() { - let mut node = TreeNode::directory("test".to_string(), PathBuf::from("/test"), 0); - assert!(node.expanded); - node.toggle_expanded(); - assert!(!node.expanded); - node.toggle_expanded(); - assert!(node.expanded); - } - - #[test] - fn test_selection_state() { - let mut state = TreeSelectionState::new(); - assert_eq!(state.selected_index, Some(0)); - - state.select_next(5); - assert_eq!(state.selected_index, Some(1)); - - state.select_prev(); - assert_eq!(state.selected_index, Some(0)); - - state.toggle_project(PathBuf::from("proj1")); - assert!(state.is_project_selected(Path::new("proj1"))); - - state.toggle_project(PathBuf::from("proj1")); - assert!(!state.is_project_selected(Path::new("proj1"))); - } -} diff --git a/src/ui/layout.rs b/src/ui/layout.rs deleted file mode 100644 index bd16c47..0000000 --- a/src/ui/layout.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Layout utilities and helpers for responsive UI design -//! -//! Provides layout calculation helpers and modern constraint-based layout patterns. - -use ratatui::layout::{Constraint, Direction, Layout, Rect}; - -/// Layout preferences and responsive sizing -#[derive(Debug, Clone)] -pub struct LayoutPreferences { - /// Left pane width percentage (project list) - pub left_pane_percent: u16, - /// Right pane width percentage (dependencies + output) - pub right_pane_percent: u16, - /// Top right pane height percentage (dependencies) - pub top_right_percent: u16, - /// Bottom right pane height percentage (output) - pub bottom_right_percent: u16, -} - -impl Default for LayoutPreferences { - fn default() -> Self { - Self { - left_pane_percent: 40, - right_pane_percent: 60, - top_right_percent: 40, - bottom_right_percent: 60, - } - } -} - -impl LayoutPreferences { - /// Create layout preferences with custom percentages - pub fn new(left: u16, right: u16, top_right: u16, bottom_right: u16) -> Self { - Self { - left_pane_percent: left, - right_pane_percent: right, - top_right_percent: top_right, - bottom_right_percent: bottom_right, - } - } - - /// Adjust left pane width - pub fn adjust_left_pane(&mut self, percent: u16) { - let percent = percent.clamp(20, 80); // Constrain to reasonable range - self.left_pane_percent = percent; - self.right_pane_percent = 100 - percent; - } - - /// Adjust top-right pane height - pub fn adjust_top_right(&mut self, percent: u16) { - let percent = percent.clamp(20, 80); - self.top_right_percent = percent; - self.bottom_right_percent = 100 - percent; - } -} - -/// Helper to create a centered rectangle for modals -pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] -} - -/// Advanced layout system with Flex support -pub struct ResponsiveLayout; - -impl ResponsiveLayout { - /// Create main vertical layout with flexible content and fixed status bar - pub fn main_layout(area: Rect) -> [Rect; 2] { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(1)]) - .split(area); - [chunks[0], chunks[1]] - } - - /// Create horizontal split for left/right panes - pub fn horizontal_split(area: Rect, left_percent: u16) -> [Rect; 2] { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(left_percent), - Constraint::Percentage(100 - left_percent), - ]) - .split(area); - [chunks[0], chunks[1]] - } - - /// Create vertical split for top/bottom panes - pub fn vertical_split(area: Rect, top_percent: u16) -> [Rect; 2] { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(top_percent), - Constraint::Percentage(100 - top_percent), - ]) - .split(area); - [chunks[0], chunks[1]] - } - - /// Create three-column layout with flexible sizing - pub fn three_column_layout( - area: Rect, - left_percent: u16, - middle_percent: u16, - right_percent: u16, - ) -> [Rect; 3] { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(left_percent), - Constraint::Percentage(middle_percent), - Constraint::Percentage(right_percent), - ]) - .split(area); - [chunks[0], chunks[1], chunks[2]] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_layout_preferences_default() { - let prefs = LayoutPreferences::default(); - assert_eq!(prefs.left_pane_percent, 40); - assert_eq!(prefs.right_pane_percent, 60); - } - - #[test] - fn test_layout_preferences_adjust_left() { - let mut prefs = LayoutPreferences::default(); - prefs.adjust_left_pane(50); - assert_eq!(prefs.left_pane_percent, 50); - assert_eq!(prefs.right_pane_percent, 50); - } - - #[test] - fn test_layout_preferences_clamp() { - let mut prefs = LayoutPreferences::default(); - prefs.adjust_left_pane(95); // Should clamp to 80 - assert_eq!(prefs.left_pane_percent, 80); - } - - #[test] - fn test_centered_rect() { - let area = Rect::new(0, 0, 100, 100); - let centered = centered_rect(50, 50, area); - assert!(centered.width < area.width); - assert!(centered.height < area.height); - } -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs deleted file mode 100644 index 98b5350..0000000 --- a/src/ui/mod.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! UI module for CarWash -//! -//! This module contains all UI-related functionality including styling, theming, -//! layout management, and component rendering orchestration. - -pub mod layout; -pub mod modal; -pub mod styles; -pub mod theme; - -use crate::app::AppState; -use crate::components::{ - Component, dependencies::DependenciesPane, help::Help, output::TabbedOutputPane, - palette::CommandPalette, projects::ProjectList, settings::SettingsModal, spinner::Spinner, - status::StatusBar, text_input::TextInput, updater::UpdateWizard, -}; -use crate::events::Mode; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout}, -}; - -// Re-export commonly used items -pub use styles::{ColorScheme, Colors}; -pub use theme::{Theme, ThemeManager}; - -/// Main UI rendering function -pub fn ui(f: &mut Frame, app: &mut AppState) { - let mut dependencies = DependenciesPane::new(); - let mut output = TabbedOutputPane::new(); - let mut status = StatusBar::new(); - let mut project_list = ProjectList::new(); - - if app.mode == Mode::Loading { - let mut spinner = Spinner::new(); - spinner.draw(f, app, f.area()); - return; - } - - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) - .split(f.area()); - - // Use dynamic layout preferences from config - let left_percent = app.config.layout.left_pane_percent; - let right_percent = 100 - left_percent; - let top_right_percent = app.config.layout.top_right_percent; - let bottom_right_percent = 100 - top_right_percent; - - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage(left_percent), - Constraint::Percentage(right_percent), - ] - .as_ref(), - ) - .split(main_chunks[0]); - - let right_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints( - [ - Constraint::Percentage(top_right_percent), - Constraint::Percentage(bottom_right_percent), - ] - .as_ref(), - ) - .split(top_chunks[1]); - - project_list.draw(f, app, top_chunks[0]); - dependencies.draw(f, app, right_chunks[0]); - output.draw(f, app, right_chunks[1]); - status.draw(f, app, main_chunks[1]); - - if app.mode == Mode::CommandPalette { - let mut palette = CommandPalette::new(); - palette.draw(f, app, f.area()); - } else if app.mode == Mode::UpdateWizard { - let mut updater = UpdateWizard::new(); - updater.draw(f, app, f.area()); - } else if app.mode == Mode::TextInput { - let mut text_input = TextInput::new(); - text_input.draw(f, app, f.area()); - } else if app.mode == Mode::Help { - let mut help = Help::new(); - help.draw(f, app, f.area()); - } else if app.mode == Mode::Settings { - let mut settings = SettingsModal::new(); - settings.draw(f, app, f.area()); - } -} diff --git a/src/ui/modal.rs b/src/ui/modal.rs deleted file mode 100644 index f877080..0000000 --- a/src/ui/modal.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Base modal component with common patterns -//! -//! Provides shared modal rendering utilities and patterns used across -//! command palette, help, settings, and other modals. - -use crate::ui::layout::centered_rect; -use crate::ui::styles::Colors; -use ratatui::{ - Frame, - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -/// Common modal styling and rendering utilities -pub struct ModalRenderer; - -impl ModalRenderer { - /// Render a modal with title and custom content - pub fn render_modal( - f: &mut Frame, - title: &str, - colors: Colors, - percent_x: u16, - percent_y: u16, - ) -> Rect { - let area = f.area(); - let popup_area = centered_rect(percent_x, percent_y, area); - - // Clear background - f.render_widget(Clear, popup_area); - - // Render border - let block = Block::default() - .title(format!(" {} ", title)) - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .style( - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD), - ); - f.render_widget(block, popup_area); - - // Return inner area (excluding border) - Rect { - x: popup_area.x + 1, - y: popup_area.y + 1, - width: popup_area.width.saturating_sub(2), - height: popup_area.height.saturating_sub(2), - } - } - - /// Render help text with key bindings - pub fn render_help_lines(colors: Colors) -> Vec> { - vec![ - Line::from(vec![ - Span::styled( - "Arrows/hjkl", - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" - Navigate"), - ]), - Line::from(vec![ - Span::styled( - "Space", - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" - Select"), - ]), - Line::from(vec![ - Span::styled( - "Enter", - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" - Confirm"), - ]), - Line::from(vec![ - Span::styled( - "Esc", - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" - Cancel"), - ]), - ] - } - - /// Render a loading indicator - pub fn render_loading(f: &mut Frame, colors: Colors) { - let area = f.area(); - let popup_area = centered_rect(40, 20, area); - - f.render_widget(Clear, popup_area); - - let loading = Paragraph::new("Loading...") - .style(Style::default().fg(colors.text)) - .alignment(ratatui::layout::Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .style(Style::default().fg(colors.selection)), - ); - - f.render_widget(loading, popup_area); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_modal_renderer_exists() { - let _ = ModalRenderer; - } -} diff --git a/src/ui/styles.rs b/src/ui/styles.rs deleted file mode 100644 index b0bb08a..0000000 --- a/src/ui/styles.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! Centralized style system for CarWash -//! -//! This module defines all colors, styles, and visual elements used throughout -//! the application. This provides a single source of truth for the visual design -//! and makes theming and customization straightforward. - -use ratatui::style::{Color, Modifier, Style}; - -/// Core color definitions for CarWash -#[derive(Debug, Clone, Copy)] -pub struct Colors { - /// Primary selection/focus color - pub selection: Color, - /// Success/active/completed status - pub success: Color, - /// Warning/attention needed - pub warning: Color, - /// Error/failure status - pub error: Color, - /// Muted/disabled/secondary text - pub muted: Color, - /// Default text color - pub text: Color, - /// Subtle background elements - pub dim: Color, - /// Primary foreground color (directories, headers) - pub primary: Color, -} - -impl Colors { - /// Light theme colors - pub fn light() -> Self { - Self { - selection: Color::Cyan, - success: Color::Green, - warning: Color::Yellow, - error: Color::Red, - muted: Color::DarkGray, - text: Color::White, - dim: Color::Rgb(150, 150, 200), - primary: Color::Cyan, - } - } - - /// Dark theme colors (default) - pub fn dark() -> Self { - Self { - selection: Color::Cyan, - success: Color::Green, - warning: Color::Yellow, - error: Color::Red, - muted: Color::DarkGray, - text: Color::White, - dim: Color::Rgb(150, 150, 200), - primary: Color::Cyan, - } - } - - /// Custom color scheme from RGB values - pub fn custom( - selection: Color, - success: Color, - warning: Color, - error: Color, - muted: Color, - text: Color, - dim: Color, - primary: Color, - ) -> Self { - Self { - selection, - success, - warning, - error, - muted, - text, - dim, - primary, - } - } -} - -impl Default for Colors { - fn default() -> Self { - Self::dark() - } -} - -impl Colors { - /// Nord theme - Arctic, north-bluish color palette - pub fn nord() -> Self { - Self { - selection: Color::Rgb(136, 192, 208), // nord8 - success: Color::Rgb(163, 190, 140), // nord14 - green - warning: Color::Rgb(235, 203, 139), // nord13 - yellow - error: Color::Rgb(191, 97, 106), // nord11 - red - muted: Color::Rgb(76, 86, 106), // nord3 - text: Color::Rgb(236, 239, 244), // nord4 - dim: Color::Rgb(216, 222, 233), // nord6 - primary: Color::Rgb(136, 192, 208), // nord8 - cyan - } - } - - /// Dracula theme - Dark, vampiric colors - pub fn dracula() -> Self { - Self { - selection: Color::Rgb(139, 233, 253), // cyan - success: Color::Rgb(80, 250, 123), // green - warning: Color::Rgb(241, 250, 140), // yellow - error: Color::Rgb(255, 121, 198), // pink - muted: Color::Rgb(98, 114, 164), // comment gray - text: Color::Rgb(248, 248, 242), // foreground - dim: Color::Rgb(68, 71, 90), // background - primary: Color::Rgb(189, 147, 249), // purple - } - } - - /// Cosmic theme - Neon Cyans and Deep Purples - pub fn cosmic() -> Self { - Self { - selection: Color::Rgb(0, 255, 255), // Neon Cyan - success: Color::Rgb(50, 255, 100), // Bright Green - warning: Color::Rgb(255, 200, 0), // Bright Yellow - error: Color::Rgb(255, 50, 80), // Neon Red - muted: Color::Rgb(100, 100, 140), // Muted Purple - text: Color::Rgb(240, 240, 255), // White-ish - dim: Color::Rgb(30, 30, 50), // Dark Purple - primary: Color::Rgb(180, 0, 255), // Neon Purple - } - } - - /// Gestalt theme - Inspired by Catppuccin Mocha - pub fn gestalt() -> Self { - Self { - selection: Color::Rgb(203, 166, 247), // Mauve - success: Color::Rgb(166, 227, 161), // Green - warning: Color::Rgb(249, 226, 175), // Yellow - error: Color::Rgb(243, 139, 168), // Red - muted: Color::Rgb(108, 112, 134), // Overlay0 - text: Color::Rgb(205, 214, 244), // Text - dim: Color::Rgb(69, 71, 90), // Surface1 - primary: Color::Rgb(180, 190, 254), // Lavender - } - } -} - -/// Named color scheme presets -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ColorScheme { - Gestalt, - Dark, - Light, - Nord, - Dracula, - Cosmic, -} - -impl ColorScheme { - pub fn colors(self) -> Colors { - match self { - ColorScheme::Gestalt => Colors::gestalt(), - ColorScheme::Dark => Colors::dark(), - ColorScheme::Light => Colors::light(), - ColorScheme::Nord => Colors::nord(), - ColorScheme::Dracula => Colors::dracula(), - ColorScheme::Cosmic => Colors::cosmic(), - } - } - - pub fn all() -> &'static [ColorScheme] { - &[ - ColorScheme::Gestalt, - ColorScheme::Dark, - ColorScheme::Light, - ColorScheme::Nord, - ColorScheme::Dracula, - ColorScheme::Cosmic, - ] - } -} - -/// Styled components - named style presets for common UI elements -pub struct StyledComponent; - -impl StyledComponent { - // Project/Directory Styles - pub fn directory_selected(colors: Colors) -> Style { - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD) - } - - pub fn directory_unselected(colors: Colors) -> Style { - Style::default().fg(colors.dim).add_modifier(Modifier::BOLD) - } - - pub fn project_selected(colors: Colors) -> Style { - Style::default() - .fg(colors.success) - .add_modifier(Modifier::BOLD) - } - - pub fn project_unselected(colors: Colors) -> Style { - Style::default().fg(colors.text) - } - - // Status Indicators - pub fn status_unchecked(colors: Colors) -> Style { - Style::default().fg(colors.muted) - } - - pub fn status_checking(_colors: Colors) -> Style { - Style::default() - .fg(Color::Blue) - .add_modifier(Modifier::BOLD) - } - - pub fn status_has_updates(colors: Colors) -> Style { - Style::default() - .fg(colors.warning) - .add_modifier(Modifier::BOLD) - } - - pub fn status_up_to_date(colors: Colors) -> Style { - Style::default().fg(colors.success) - } - - // Dependency Styles - pub fn dependency_up_to_date(colors: Colors) -> Style { - Style::default().fg(colors.success) - } - - pub fn dependency_outdated(colors: Colors) -> Style { - Style::default() - .fg(colors.warning) - .add_modifier(Modifier::BOLD) - } - - pub fn dependency_checking(colors: Colors) -> Style { - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD) - } - - pub fn dependency_unchecked(colors: Colors) -> Style { - Style::default().fg(colors.muted) - } - - // UI Component Styles - pub fn block_border(colors: Colors) -> Style { - Style::default().fg(colors.primary) - } - - pub fn block_title(colors: Colors) -> Style { - Style::default() - .fg(colors.primary) - .add_modifier(Modifier::BOLD) - } - - pub fn status_bar_normal(colors: Colors) -> Style { - Style::default().fg(colors.text).bg(Color::Rgb(40, 40, 60)) - } - - pub fn status_bar_mode_indicator(colors: Colors) -> Style { - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD) - .bg(Color::Rgb(40, 40, 60)) - } - - pub fn command_palette_match(colors: Colors) -> Style { - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD) - } - - pub fn modal_background(colors: Colors) -> Style { - Style::default().fg(colors.text).bg(Color::Rgb(30, 30, 50)) - } - - pub fn modal_border(colors: Colors) -> Style { - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD) - } - - pub fn input_field(colors: Colors) -> Style { - Style::default().fg(colors.text).bg(Color::Rgb(20, 20, 40)) - } - - pub fn help_text(colors: Colors) -> Style { - Style::default().fg(colors.text) - } - - pub fn help_key(colors: Colors) -> Style { - Style::default() - .fg(colors.selection) - .add_modifier(Modifier::BOLD) - } - - pub fn warning_text(colors: Colors) -> Style { - Style::default() - .fg(colors.warning) - .add_modifier(Modifier::BOLD) - } - - pub fn error_text(colors: Colors) -> Style { - Style::default() - .fg(colors.error) - .add_modifier(Modifier::BOLD) - } - - pub fn success_text(colors: Colors) -> Style { - Style::default() - .fg(colors.success) - .add_modifier(Modifier::BOLD) - } -} - -/// Status indicator symbols for consistent icon usage -pub struct StatusSymbols; - -impl StatusSymbols { - pub const UNCHECKED: &'static str = "⋯"; - pub const CHECKING: &'static str = "⟳"; - pub const HAS_UPDATES: &'static str = "⚠"; - pub const UP_TO_DATE: &'static str = "✓"; - pub const ARROW_RIGHT: &'static str = "→"; - pub const EXPAND: &'static str = "▾"; - pub const COLLAPSE: &'static str = "▸"; - pub const SELECTION: &'static str = "▶"; - pub const BULLET: &'static str = "•"; - pub const GIT_DIRTY: &'static str = "*"; - pub const GIT_CLEAN: &'static str = ""; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_colors_dark_created() { - let colors = Colors::dark(); - assert_eq!(colors.success, Color::Green); - } - - #[test] - fn test_color_scheme_all() { - assert_eq!(ColorScheme::all().len(), 6); - } - - #[test] - fn test_styled_component_styles() { - let colors = Colors::default(); - let style = StyledComponent::directory_selected(colors); - assert!(style.add_modifier.contains(Modifier::BOLD)); - } -} diff --git a/src/ui/theme.rs b/src/ui/theme.rs deleted file mode 100644 index a791358..0000000 --- a/src/ui/theme.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! Theme management system for CarWash -//! -//! Handles theme switching, persistence, and global theme state. - -use crate::ui::styles::{ColorScheme, Colors}; -use std::sync::{Arc, Mutex}; - -/// Global theme manager using Arc for thread-safe access -pub static THEME_MANAGER: once_cell::sync::Lazy>> = - once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(ThemeManager::new()))); - -/// Theme management for the application -#[derive(Debug, Clone)] -pub struct ThemeManager { - current_scheme: ColorScheme, - current_colors: Colors, -} - -impl Default for ThemeManager { - fn default() -> Self { - Self::new() - } -} - -impl ThemeManager { - /// Create a new theme manager with default theme - pub fn new() -> Self { - let scheme = ColorScheme::Gestalt; - let colors = scheme.colors(); - Self { - current_scheme: scheme, - current_colors: colors, - } - } - - /// Get the current color scheme - pub fn scheme(&self) -> ColorScheme { - self.current_scheme - } - - /// Get the current colors - pub fn colors(&self) -> Colors { - self.current_colors - } - - /// Switch to a different color scheme - pub fn set_scheme(&mut self, scheme: ColorScheme) { - self.current_scheme = scheme; - self.current_colors = scheme.colors(); - } - - /// Switch to the next theme in the cycle - pub fn next_theme(&mut self) { - let schemes = ColorScheme::all(); - let current_idx = schemes - .iter() - .position(|&s| s == self.current_scheme) - .unwrap_or(0); - let next_idx = (current_idx + 1) % schemes.len(); - self.set_scheme(schemes[next_idx]); - } - - /// Switch to the previous theme in the cycle - pub fn previous_theme(&mut self) { - let schemes = ColorScheme::all(); - let current_idx = schemes - .iter() - .position(|&s| s == self.current_scheme) - .unwrap_or(0); - let next_idx = if current_idx == 0 { - schemes.len() - 1 - } else { - current_idx - 1 - }; - self.set_scheme(schemes[next_idx]); - } -} - -/// Theme trait for custom theme implementations -pub struct Theme { - pub name: String, - pub colors: Colors, -} - -impl Theme { - pub fn new(name: impl Into, colors: Colors) -> Self { - Self { - name: name.into(), - colors, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_theme_manager_creation() { - let tm = ThemeManager::new(); - assert_eq!(tm.scheme(), ColorScheme::Gestalt); - } - - #[test] - fn test_next_theme() { - let mut tm = ThemeManager::new(); - tm.next_theme(); - assert_ne!(tm.scheme(), ColorScheme::Gestalt); - } - - #[test] - fn test_previous_theme() { - let mut tm = ThemeManager::new(); - tm.next_theme(); - tm.previous_theme(); - assert_eq!(tm.scheme(), ColorScheme::Gestalt); - } -} From ca29d8e56c487b645dda8ec878070fd21f213d1b Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:17:18 -0400 Subject: [PATCH 04/22] feat(core): polyglot discovery, measurement, git safety and clean engine - Declarative ecosystem rules for 43 ecosystems (builtin.toml), user overridable, compiled into lookup indexes - Parallel discovery that classifies directories by entry names, never descends into artifacts, understands workspaces (Cargo, npm/pnpm, uv, go.work, Dart, Gradle, .NET) and the projects enclosing the scan root - Content detection: CACHEDIR.TAG, pyvenv.cfg, conda-meta, CMakeCache.txt, Meson build dirs, Cargo fingerprints, interrupted-clean leftovers - Allocated, hard-link-aware sizes with a separate reclaimable figure - Batched git inspection: tracked content is protected, generic names need git or content confirmation - Validated deletion (rename-then-remove, or trash), size cache, history --- Cargo.lock | 2389 +++-------------- Cargo.toml | 139 +- crates/carwash-core/Cargo.toml | 35 + crates/carwash-core/src/cache.rs | 115 + crates/carwash-core/src/clean.rs | 334 +++ crates/carwash-core/src/discover.rs | 723 +++++ crates/carwash-core/src/ecosystem/adapters.rs | 421 +++ .../carwash-core/src/ecosystem/builtin.toml | 510 ++++ crates/carwash-core/src/ecosystem/mod.rs | 670 +++++ crates/carwash-core/src/fmt.rs | 120 + crates/carwash-core/src/git.rs | 202 ++ crates/carwash-core/src/history.rs | 100 + crates/carwash-core/src/lib.rs | 54 + crates/carwash-core/src/measure.rs | 266 ++ crates/carwash-core/src/model.rs | 264 ++ crates/carwash-core/src/paths.rs | 76 + crates/carwash-core/src/safety.rs | 90 + crates/carwash-core/src/scan.rs | 308 +++ crates/carwash-core/src/select.rs | 188 ++ crates/carwash-core/tests/discover.rs | 387 +++ 20 files changed, 5375 insertions(+), 2016 deletions(-) create mode 100644 crates/carwash-core/Cargo.toml create mode 100644 crates/carwash-core/src/cache.rs create mode 100644 crates/carwash-core/src/clean.rs create mode 100644 crates/carwash-core/src/discover.rs create mode 100644 crates/carwash-core/src/ecosystem/adapters.rs create mode 100644 crates/carwash-core/src/ecosystem/builtin.toml create mode 100644 crates/carwash-core/src/ecosystem/mod.rs create mode 100644 crates/carwash-core/src/fmt.rs create mode 100644 crates/carwash-core/src/git.rs create mode 100644 crates/carwash-core/src/history.rs create mode 100644 crates/carwash-core/src/lib.rs create mode 100644 crates/carwash-core/src/measure.rs create mode 100644 crates/carwash-core/src/model.rs create mode 100644 crates/carwash-core/src/paths.rs create mode 100644 crates/carwash-core/src/safety.rs create mode 100644 crates/carwash-core/src/scan.rs create mode 100644 crates/carwash-core/src/select.rs create mode 100644 crates/carwash-core/tests/discover.rs diff --git a/Cargo.lock b/Cargo.lock index 66238c0..570c565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,10 +12,13 @@ dependencies = [ ] [[package]] -name = "allocator-api2" -version = "0.2.21" +name = "aligned" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] [[package]] name = "android_system_properties" @@ -62,7 +65,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -73,7 +76,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -83,19 +86,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "atomic" -version = "0.6.1" +name = "as-slice" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" dependencies = [ - "bytemuck", + "stable_deref_trait", ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "assert_cmd" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] [[package]] name = "autocfg" @@ -103,48 +115,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bstr" version = "1.12.1" @@ -152,6 +128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -161,69 +138,50 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cargo-lock" -version = "11.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63585cdf8572aa7adf0e30a253f988f2b77233bfac1973d52efb6dd53a75920e" -dependencies = [ - "semver", - "serde", - "toml 0.9.12+spec-1.1.0", - "url", -] - [[package]] name = "carwash" -version = "0.3.5" +version = "0.4.0" dependencies = [ + "anstream", + "anstyle", "anyhow", - "cargo-lock", - "chrono", + "assert_cmd", + "carwash-core", "clap", - "crates_io_api", - "crossterm", - "directories", - "futures", - "fuzzy-matcher", - "glob", - "ignore", - "once_cell", - "ratatui", - "rayon", - "semver", + "clap_complete", + "crossbeam-channel", + "indicatif", + "predicates", "serde", "serde_json", - "shlex 2.0.1", - "tokio", - "toml 1.1.2+spec-1.1.0", + "tempfile", + "toml", "tracing", "tracing-appender", "tracing-subscriber", - "tui-input", - "tui-tree-widget", - "walkdir", + "unicode-width", ] [[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +name = "carwash-core" +version = "0.4.0" dependencies = [ - "rustversion", + "crossbeam-channel", + "dashmap", + "etcetera", + "filetime", + "fs4", + "globset", + "rayon", + "remove_dir_all", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", + "tracing", + "trash", + "yaml_serde", ] [[package]] @@ -233,7 +191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "shlex 1.3.0", + "shlex", ] [[package]] @@ -255,10 +213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", - "serde", - "wasm-bindgen", "windows-link", ] @@ -282,6 +237,16 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", ] [[package]] @@ -293,7 +258,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -309,36 +274,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" +name = "console" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ - "core-foundation-sys", + "encode_unicode", "libc", + "unicode-width", + "windows-sys 0.61.2", ] [[package]] @@ -347,32 +291,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crates_io_api" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d129ea9f4e6581cdda525c0b939e4b1753a9873543e4b409971b473f5c27db" -dependencies = [ - "chrono", - "futures", - "reqwest", - "serde", - "serde_derive", - "serde_json", - "serde_path_to_error", - "tokio", - "url", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -408,93 +326,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.11.1", - "crossterm_winapi", - "derive_more", - "document-features", - "futures-core", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" +name = "cvt" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "cfg-if", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "dashmap" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - [[package]] name = "deranged" version = "0.5.8" @@ -505,77 +358,10 @@ dependencies = [ ] [[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "document-features" -version = "0.2.12" +name = "difflib" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "either" @@ -583,6 +369,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -596,26 +388,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", + "windows-sys 0.61.2", ] [[package]] -name = "fancy-regex" +name = "etcetera" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ - "bit-set", - "regex", + "cfg-if", + "windows-sys 0.61.2", ] [[package]] @@ -625,14 +408,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "filedescriptor" -version = "0.8.3" +name = "filetime" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ + "cfg-if", "libc", - "thiserror 1.0.69", - "winapi", ] [[package]] @@ -642,22 +424,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "fnv" -version = "1.0.7" +name = "float-cmp" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] [[package]] name = "foldhash" @@ -666,187 +439,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "foldhash" -version = "0.2.0" +name = "fs4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] [[package]] -name = "foreign-types" -version = "0.3.2" +name = "fs_at" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036" dependencies = [ - "foreign-types-shared", + "aligned", + "cfg-if", + "cvt", + "libc", + "nix", + "windows-sys 0.52.0", ] [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "futures-task" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "futures" +name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", - "futures-executor", - "futures-io", - "futures-sink", "futures-task", - "futures-util", + "pin-project-lite", + "slab", ] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "futures-core", - "futures-sink", + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", ] [[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "globset" -version = "0.4.18" +name = "globset" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ @@ -859,22 +514,17 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -889,110 +539,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1017,137 +563,12 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "ignore" -version = "0.4.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1161,48 +582,24 @@ dependencies = [ ] [[package]] -name = "indoc" -version = "2.0.7" +name = "indicatif" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1221,23 +618,6 @@ dependencies = [ "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.18", -] - -[[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" @@ -1257,22 +637,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "libc", -] - -[[package]] -name = "line-clipping" -version = "0.3.7" +name = "libyaml-rs" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.11.1", -] +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" [[package]] name = "linux-raw-sys" @@ -1280,18 +648,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -1308,22 +664,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "mac_address" -version = "1.1.8" +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "nix", - "winapi", + "regex-automata", ] [[package]] @@ -1332,77 +678,31 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys", -] - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cfg-if", "cfg_aliases", "libc", - "memoffset", ] [[package]] -name = "nom" -version = "7.1.3" +name = "normalize-line-endings" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "normpath" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11ce00d2594068e8a27c9146fdc5cf9f3ac38eb42c7cd34d05fea618873ac9f" dependencies = [ - "memchr", - "minimal-lexical", + "windows-sys 0.61.2", ] [[package]] @@ -1411,7 +711,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1420,17 +720,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1441,93 +730,41 @@ dependencies = [ ] [[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openssl" -version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "objc2-encode", ] [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "openssl-sys" -version = "0.9.116" +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "bitflags", + "objc2", ] [[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-float" -version = "4.6.0" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "parking_lot" -version = "0.12.5" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "parking_lot_core" @@ -1549,133 +786,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - -[[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" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "phf_generator" -version = "0.11.3" +name = "portable-atomic" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] -name = "phf_macros" -version = "0.11.3" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "phf_shared" -version = "0.11.3" +name = "predicates" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ - "siphasher", + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" +name = "predicates-core" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "predicates-tree" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ - "zerovec", + "predicates-core", + "termtree", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "prettyplease" version = "0.2.37" @@ -1683,7 +840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -1704,118 +861,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.11.1", - "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools", - "kasuari", - "lru", - "strum", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "strum", - "time", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "rayon" version = "1.12.0" @@ -1837,23 +888,12 @@ dependencies = [ ] [[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "redox_users" -version = "0.5.2" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", + "bitflags", ] [[package]] @@ -1886,50 +926,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" +name = "remove_dir_all" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "808cc0b475acf76adf36f08ca49429b12aad9f678cb56143d5b3cb49b9a1dd08" dependencies = [ - "semver", + "cfg-if", + "cvt", + "fs_at", + "libc", + "normpath", + "rayon", + "windows-sys 0.59.0", ] [[package]] @@ -1938,20 +946,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", + "windows-sys 0.61.2", ] [[package]] @@ -1966,62 +965,17 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys", -] - [[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 2.11.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] [[package]] name = "serde" @@ -2050,7 +1004,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2066,17 +1020,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -2086,29 +1029,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -2124,49 +1044,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - [[package]] name = "slab" version = "0.4.12" @@ -2179,72 +1056,24 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys", -] - [[package]] name = "stable_deref_trait" 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.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "symlink" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -2256,26 +1085,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -2283,83 +1092,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom", "once_cell", "rustix", - "windows-sys", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", + "windows-sys 0.61.2", ] [[package]] -name = "termwiz" -version = "0.23.3" +name = "terminal_size" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "anyhow", - "base64", - "bitflags 2.11.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", + "rustix", + "windows-sys 0.61.2", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "termtree" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "thiserror" @@ -2367,18 +1120,7 @@ version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror-impl", ] [[package]] @@ -2389,7 +1131,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2409,9 +1151,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -2434,69 +1174,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -2506,19 +1183,10 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.3", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", + "winnow", ] [[package]] @@ -2531,64 +1199,19 @@ dependencies = [ ] [[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.3", -] - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" +name = "toml_parser" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] [[package]] -name = "tower-service" -version = "0.3.3" +name = "toml_writer" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tracing" @@ -2609,7 +1232,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror", "time", "tracing-subscriber", ] @@ -2622,7 +1245,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2652,77 +1275,42 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] [[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tui-input" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd014a652e31cf25ea68d11b10a7b09549863449b19387505c9933f11eb05fa" -dependencies = [ - "ratatui", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "tui-tree-widget" -version = "0.24.0" +name = "trash" +version = "5.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deca119555009eee2e0cfb9c020f39f632444dc4579918d5fc009d51d75dff92" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" dependencies = [ - "ratatui-core", - "ratatui-widgets", - "unicode-width", + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", ] -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "unicode-width" version = "0.2.2" @@ -2736,22 +1324,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "url" -version = "2.5.8" +name = "unit-prefix" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] -name = "utf8_iter" -version = "1.0.4" +name = "urlencoding" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "utf8parse" @@ -2759,18 +1341,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "atomic", - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -2778,51 +1348,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" +name = "wait-timeout" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ - "try-lock", + "libc", ] -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -2854,16 +1387,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.121" @@ -2883,7 +1406,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -2924,125 +1447,43 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", ] [[package]] -name = "web-sys" -version = "0.3.98" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - -[[package]] -name = "winapi" -version = "0.3.9" +name = "windows" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-sys", + "windows-core", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -3056,6 +1497,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3064,7 +1516,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3075,7 +1527,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3084,6 +1536,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3102,6 +1564,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -3112,10 +1592,77 @@ dependencies = [ ] [[package]] -name = "winnow" -version = "0.7.15" +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "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]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" @@ -3159,7 +1706,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3175,7 +1722,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3187,7 +1734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags", "indexmap", "log", "serde", @@ -3218,92 +1765,16 @@ dependencies = [ ] [[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" +name = "yaml_serde" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "indexmap", + "itoa", + "libyaml-rs", + "ryu", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index db1dd50..7807894 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,68 +1,93 @@ -[package] -name = "carwash" -version = "0.3.5" +[workspace] +resolver = "3" +members = ["crates/*"] +default-members = ["crates/carwash"] + +[workspace.package] +version = "0.4.0" edition = "2024" +rust-version = "1.88" authors = ["Nicholas Paterno "] -description = "A beautiful TUI for managing multiple Rust projects with workspace support, dependency management, and parallel command execution" -readme = "README.md" +license = "MIT" homepage = "https://github.com/epistates/carwash" repository = "https://github.com/epistates/carwash" -documentation = "https://docs.rs/carwash" -license = "MIT" -keywords = ["cargo", "tui", "workspace", "dependencies", "rust"] -categories = ["command-line-utilities", "development-tools"] -rust-version = "1.93.0" -[lib] -name = "carwash" -path = "src/lib.rs" +[workspace.dependencies] +carwash-core = { version = "0.4.0", path = "crates/carwash-core" } -[[bin]] -name = "carwash" -path = "src/main.rs" +# Engine +rayon = "1.12" +crossbeam-channel = "0.5" +dashmap = "6.1" +globset = "0.4" +remove_dir_all = { version = "1.0", features = ["parallel"] } +trash = "5.2" +fs4 = "1.1" +etcetera = "0.11" +filetime = "0.2" -[profile.release] -opt-level = 3 # Maximum optimization -lto = "fat" # Full link-time optimization -codegen-units = 1 # Better optimization, slower compile -strip = true # Strip symbols for smaller binary -panic = "abort" # Smaller binary, no stack unwinding +# Formats +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "1.1" +toml_edit = "0.25" +yaml_serde = "0.10" +semver = { version = "1.0", features = ["serde"] } +cargo-lock = "11.1" +jiff = { version = "0.2", features = ["serde"] } -[profile.dev] -opt-level = 0 # Fast compilation -debug = true # Full debug info +# Errors, logging +thiserror = "2.0" +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" -[lints.clippy] -# Allow some pedantic lints that don't affect functionality -new_without_default = "allow" -useless_format = "allow" -too_many_arguments = "allow" +# CLI / TUI +clap = { version = "4.6", features = ["derive", "env", "wrap_help"] } +clap_complete = "4.6" +ratatui = "0.30" +crossterm = { version = "0.29", features = ["event-stream"] } +tui-input = "0.15" +nucleo-matcher = "0.3" +unicode-width = "0.2" +humantime = "2.2" +indicatif = "0.18" +anstream = "1.0" +anstyle = "1.0" + +# Async, processes, network +tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs"] } +tokio-util = "0.7" +futures = "0.3" +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "http2"] } +portable-pty = "0.9" +vt100 = "0.16" +tui-term = "0.3" +process-wrap = { version = "10.0", features = ["std"] } +shlex = "2.0" + +# Testing +tempfile = "3.27" +insta = "1.48" +assert_cmd = "2.2" +predicates = "3.1" + +[workspace.lints.rust] +unsafe_code = "deny" +missing_debug_implementations = "warn" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } module_name_repetitions = "allow" +too_many_arguments = "allow" + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +strip = true +panic = "abort" -[dependencies] -cargo-lock = "11.0.1" -clap = { version = "4.6.1", features = ["derive"] } -crates_io_api = "0.12.0" -crossterm = { version = "0.29.0", features = ["event-stream"] } -futures = "0.3.32" -fuzzy-matcher = "0.3.7" -glob = "0.3.3" -ratatui = "0.30.0" -rayon = "1.12.0" -serde = { version = "1.0.228", features = ["derive"] } -tokio = { version = "1.52.3", features = ["full"] } -toml = "1.1.2" -once_cell = "1.21.4" -tui-input = "0.15.3" -tui-tree-widget = "0.24.0" -walkdir = "2.5.0" -chrono = "0.4.44" -anyhow = "1.0.102" -semver = "1.0.28" -directories = "6.0.0" -serde_json = "1.0.149" -ignore = "0.4.25" -tracing-subscriber = "0.3.23" -tracing-appender = "0.2.5" -tracing = "0.1.44" -shlex = "2.0.1" +[profile.dev.package."*"] +opt-level = 2 diff --git a/crates/carwash-core/Cargo.toml b/crates/carwash-core/Cargo.toml new file mode 100644 index 0000000..7ae6a23 --- /dev/null +++ b/crates/carwash-core/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "carwash-core" +description = "Engine for carwash: polyglot project discovery, artifact sizing, safe cleaning, dependency and task inspection" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +keywords = ["cleanup", "disk-usage", "monorepo", "node-modules", "cargo"] +categories = ["development-tools", "filesystem"] + +[dependencies] +rayon.workspace = true +crossbeam-channel.workspace = true +dashmap.workspace = true +globset.workspace = true +remove_dir_all.workspace = true +trash.workspace = true +fs4.workspace = true +etcetera.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +yaml_serde.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true +filetime.workspace = true + +[lints] +workspace = true diff --git a/crates/carwash-core/src/cache.rs b/crates/carwash-core/src/cache.rs new file mode 100644 index 0000000..161a143 --- /dev/null +++ b/crates/carwash-core/src/cache.rs @@ -0,0 +1,115 @@ +//! Persistent cache of measured artifact sizes. +//! +//! Measuring tens of gigabytes of `target/` and `node_modules` takes a while; the cache lets +//! a frontend show last-known sizes instantly and refine them as fresh measurements arrive. + +use crate::model::Size; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const VERSION: u32 = 1; +/// Entries not refreshed for this long are dropped on save. +const MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24 * 60); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedSize { + pub size: Size, + /// Unix seconds. + pub measured_at: u64, +} + +impl CachedSize { + pub fn age(&self) -> Duration { + let now = unix_now(); + Duration::from_secs(now.saturating_sub(self.measured_at)) + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct SizeCache { + version: u32, + entries: HashMap, +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +impl SizeCache { + /// Loads the cache; a missing, unreadable or outdated file yields an empty cache. + pub fn load(path: &Path) -> Self { + std::fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|cache| cache.version == VERSION) + .unwrap_or_else(|| Self { + version: VERSION, + entries: HashMap::new(), + }) + } + + pub fn save(&mut self, path: &Path) -> std::io::Result<()> { + self.version = VERSION; + let cutoff = unix_now().saturating_sub(MAX_AGE.as_secs()); + self.entries.retain(|_, entry| entry.measured_at >= cutoff); + let json = serde_json::to_vec(self).map_err(std::io::Error::other)?; + crate::paths::write_atomic(path, &json) + } + + pub fn get(&self, path: &Path) -> Option<&CachedSize> { + self.entries.get(path) + } + + pub fn insert(&mut self, path: PathBuf, size: Size) { + self.entries.insert( + path, + CachedSize { + size, + measured_at: unix_now(), + }, + ); + } + + pub fn remove(&mut self, path: &Path) { + self.entries.remove(path); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_ignores_garbage() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("sizes.json"); + let mut cache = SizeCache::load(&file); + assert!(cache.is_empty()); + let size = Size { + on_disk: 42, + reclaimable: 40, + ..Size::default() + }; + cache.insert(PathBuf::from("/p/target"), size); + cache.save(&file).unwrap(); + + let loaded = SizeCache::load(&file); + assert_eq!(loaded.get(Path::new("/p/target")).unwrap().size, size); + + std::fs::write(&file, b"{not json").unwrap(); + assert!(SizeCache::load(&file).is_empty()); + } +} diff --git a/crates/carwash-core/src/clean.rs b/crates/carwash-core/src/clean.rs new file mode 100644 index 0000000..5e2ad1c --- /dev/null +++ b/crates/carwash-core/src/clean.rs @@ -0,0 +1,334 @@ +//! Safe deletion of artifact directories. +//! +//! Every target is re-validated immediately before deletion (it may have changed since it +//! was scanned). Permanent deletion first renames the directory to a staging name next to +//! it, which is atomic and instant, then removes the staged tree; an interrupted clean leaves +//! a `.carwash-trash-*` directory that the next scan reports as a leftover artifact. + +use crate::Cancel; +use crate::discover::STAGING_PREFIX; +use crate::model::ArtifactId; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DeleteMode { + /// Remove immediately; frees space right away. + #[default] + Permanent, + /// Move to the platform trash; recoverable, but frees nothing until the trash is emptied. + Trash, +} + +#[derive(Debug, Clone)] +pub struct CleanItem { + pub id: ArtifactId, + pub path: PathBuf, + /// Bytes expected to be freed (the measured reclaimable size), for reporting. + pub expected_bytes: u64, +} + +#[derive(Debug, Clone)] +pub struct CleanOptions { + pub mode: DeleteMode, + /// Targets must lie strictly inside one of these directories. + pub allowed_roots: Vec, + /// Validate and report without deleting anything. + pub dry_run: bool, +} + +#[derive(Debug, Clone)] +pub enum CleanEvent { + Started { id: ArtifactId }, + Removed { id: ArtifactId, bytes: u64 }, + Failed { id: ArtifactId, error: String }, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CleanReport { + pub removed: usize, + pub failed: usize, + pub bytes: u64, + /// Free space on the filesystem of the first target, before and after. + pub free_before: Option, + pub free_after: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum RefusalReason { + #[error("no longer exists")] + Missing, + #[error("is not a directory")] + NotDirectory, + #[error("is a symlink")] + Symlink, + #[error("is outside the scanned roots")] + OutsideRoots, + #[error("is a scan root itself")] + IsRoot, + #[error("contains a git repository")] + ContainsRepository, + #[error(transparent)] + Io(#[from] io::Error), +} + +/// Checks that `path` is still a plain directory strictly inside an allowed root. +pub fn validate(path: &Path, allowed_roots: &[PathBuf]) -> Result<(), RefusalReason> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(RefusalReason::Missing), + Err(e) => return Err(e.into()), + }; + if meta.file_type().is_symlink() { + return Err(RefusalReason::Symlink); + } + if !meta.is_dir() { + return Err(RefusalReason::NotDirectory); + } + let canonical = fs::canonicalize(path)?; + let mut inside = false; + for root in allowed_roots { + let Ok(root) = fs::canonicalize(root) else { + continue; + }; + if canonical == root { + return Err(RefusalReason::IsRoot); + } + if canonical.starts_with(&root) { + inside = true; + } + } + if !inside { + return Err(RefusalReason::OutsideRoots); + } + if fs::symlink_metadata(path.join(".git")).is_ok() { + return Err(RefusalReason::ContainsRepository); + } + Ok(()) +} + +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn staging_path(path: &Path) -> Option { + let parent = path.parent()?; + let name = path.file_name()?.to_string_lossy(); + let n = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed); + Some(parent.join(format!("{STAGING_PREFIX}{}-{n}-{name}", std::process::id()))) +} + +fn remove_permanently(path: &Path) -> io::Result<()> { + // Rename first so the artifact disappears atomically; fall back to deleting in place + // when renaming is not possible (for example on some network filesystems). + let target = match staging_path(path) { + Some(staged) if fs::rename(path, &staged).is_ok() => staged, + _ => path.to_path_buf(), + }; + remove_dir_all::remove_dir_all(&target) +} + +/// Deletes `items` in parallel on the current rayon pool. +pub fn clean( + items: &[CleanItem], + options: &CleanOptions, + cancel: &Cancel, + emit: &(dyn Fn(CleanEvent) + Sync), +) -> CleanReport { + let probe = items + .first() + .and_then(|i| i.path.parent().map(Path::to_path_buf)); + let free_before = probe.as_deref().and_then(|p| fs4::available_space(p).ok()); + + let removed = AtomicU64::new(0); + let failed = AtomicU64::new(0); + let bytes = AtomicU64::new(0); + + items.par_iter().for_each(|item| { + if cancel.is_cancelled() { + return; + } + emit(CleanEvent::Started { id: item.id }); + let result = validate(&item.path, &options.allowed_roots) + .map_err(|e| e.to_string()) + .and_then(|()| { + if options.dry_run { + return Ok(()); + } + match options.mode { + DeleteMode::Permanent => { + remove_permanently(&item.path).map_err(|e| e.to_string()) + } + DeleteMode::Trash => trash::delete(&item.path).map_err(|e| e.to_string()), + } + }); + match result { + Ok(()) => { + removed.fetch_add(1, Ordering::Relaxed); + bytes.fetch_add(item.expected_bytes, Ordering::Relaxed); + emit(CleanEvent::Removed { + id: item.id, + bytes: item.expected_bytes, + }); + } + Err(error) => { + failed.fetch_add(1, Ordering::Relaxed); + emit(CleanEvent::Failed { id: item.id, error }); + } + } + }); + + CleanReport { + removed: removed.into_inner() as usize, + failed: failed.into_inner() as usize, + bytes: bytes.into_inner(), + free_before, + free_after: probe.as_deref().and_then(|p| fs4::available_space(p).ok()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn item(id: u32, path: PathBuf) -> CleanItem { + CleanItem { + id: ArtifactId(id), + path, + expected_bytes: 100, + } + } + + fn run( + items: &[CleanItem], + roots: &[PathBuf], + dry_run: bool, + ) -> (CleanReport, Vec) { + let events = Mutex::new(Vec::new()); + let report = clean( + items, + &CleanOptions { + mode: DeleteMode::Permanent, + allowed_roots: roots.to_vec(), + dry_run, + }, + &Cancel::new(), + &|e| events.lock().unwrap().push(e), + ); + (report, events.into_inner().unwrap()) + } + + #[test] + fn deletes_directories_and_leaves_no_staging() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("project/target"); + fs::create_dir_all(target.join("debug/deps")).unwrap(); + fs::write(target.join("debug/deps/lib.rlib"), "x").unwrap(); + + let (report, _) = run( + &[item(0, target.clone())], + &[root.path().to_path_buf()], + false, + ); + assert_eq!((report.removed, report.failed, report.bytes), (1, 0, 100)); + assert!(!target.exists()); + let leftovers: Vec<_> = fs::read_dir(root.path().join("project")).unwrap().collect(); + assert!(leftovers.is_empty(), "staging directory removed"); + } + + #[test] + fn dry_run_touches_nothing() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("node_modules"); + fs::create_dir(&target).unwrap(); + let (report, _) = run( + &[item(0, target.clone())], + &[root.path().to_path_buf()], + true, + ); + assert_eq!(report.removed, 1); + assert!(target.exists()); + } + + #[test] + fn refuses_outside_roots_the_root_itself_and_repositories() { + let root = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let repo = root.path().join("vendored"); + fs::create_dir_all(repo.join(".git")).unwrap(); + let outside = other.path().join("target"); + fs::create_dir(&outside).unwrap(); + + let roots = [root.path().to_path_buf()]; + assert!(matches!( + validate(&outside, &roots), + Err(RefusalReason::OutsideRoots) + )); + assert!(matches!( + validate(root.path(), &roots), + Err(RefusalReason::IsRoot) + )); + assert!(matches!( + validate(&repo, &roots), + Err(RefusalReason::ContainsRepository) + )); + assert!(matches!( + validate(&root.path().join("missing"), &roots), + Err(RefusalReason::Missing) + )); + + let (report, events) = run(&[item(0, outside.clone())], &roots, false); + assert_eq!(report.failed, 1); + assert!(outside.exists()); + assert!( + events + .iter() + .any(|e| matches!(e, CleanEvent::Failed { .. })) + ); + } + + #[cfg(unix)] + #[test] + fn symlinked_artifacts_are_refused_and_targets_survive() { + let root = tempfile::tempdir().unwrap(); + let precious = root.path().join("src"); + fs::create_dir(&precious).unwrap(); + fs::write(precious.join("main.rs"), "fn main() {}").unwrap(); + let link = root.path().join("node_modules"); + std::os::unix::fs::symlink(&precious, &link).unwrap(); + + let (report, _) = run( + &[item(0, link.clone())], + &[root.path().to_path_buf()], + false, + ); + assert_eq!(report.failed, 1); + assert!(precious.join("main.rs").exists()); + } + + #[cfg(unix)] + #[test] + fn symlinks_inside_artifacts_are_removed_without_following() { + let root = tempfile::tempdir().unwrap(); + let precious = root.path().join("shared"); + fs::create_dir(&precious).unwrap(); + fs::write(precious.join("keep"), "data").unwrap(); + let modules = root.path().join("app/node_modules"); + fs::create_dir_all(&modules).unwrap(); + std::os::unix::fs::symlink(&precious, modules.join("linked-pkg")).unwrap(); + std::os::unix::fs::symlink(root.path().join("dangling"), modules.join("broken")).unwrap(); + + let (report, _) = run( + &[item(0, modules.clone())], + &[root.path().to_path_buf()], + false, + ); + assert_eq!(report.removed, 1); + assert!(!modules.exists()); + assert!(precious.join("keep").exists(), "symlink target untouched"); + } +} diff --git a/crates/carwash-core/src/discover.rs b/crates/carwash-core/src/discover.rs new file mode 100644 index 0000000..3b3044e --- /dev/null +++ b/crates/carwash-core/src/discover.rs @@ -0,0 +1,723 @@ +//! Phase 1: parallel discovery of projects and artifact directories. +//! +//! Every directory is read exactly once. Its entry names decide, without any `stat`, whether +//! it is a project (marker files) and which children are artifacts (ecosystem rules). +//! Artifacts are never descended into, so discovery cost is proportional to source trees, +//! not to `node_modules` or `target/`. + +use crate::Cancel; +use crate::ecosystem::adapters::WorkspaceSpec; +use crate::ecosystem::{EcoId, Registry, RuleId}; +use crate::model::{Artifact, ArtifactId, ArtifactKind, Detection, GitState, Project, ProjectId}; +use serde::Serialize; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::SystemTime; + +/// Directory-name prefix used while a clean is in flight; leftovers are reported as artifacts. +pub const STAGING_PREFIX: &str = ".carwash-trash-"; + +const CACHEDIR_TAG_SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55"; +/// Enclosing directories inspected above the scan root. +const MAX_ENCLOSING_LEVELS: usize = 16; +/// Version-control internals: never entered, never artifacts. +const VCS_DIRS: &[&str] = &[".git", ".hg", ".svn", ".jj", ".bzr", "_darcs"]; + +/// Which hidden directories (that are not artifacts) discovery enters. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Hidden { + Never, + /// Only inside projects and repositories, where they hold things like agent worktrees + /// (`.claude/worktrees`) rather than tool homes such as `~/.cargo` or `~/.npm`. + #[default] + InsideProjects, + Always, +} + +#[derive(Debug, Clone)] +pub struct DiscoverOptions { + /// Maximum directory depth below the root. + pub max_depth: Option, + /// Hidden directories to descend into when they are not artifacts. + pub hidden: Hidden, + /// Do not cross filesystem boundaries. + pub same_filesystem: bool, + /// Absolute paths never entered. + pub exclude: Vec, + /// Report artifacts of projects enclosing the root, e.g. a workspace `target/` when the + /// scan starts in a member crate. + pub include_enclosing: bool, +} + +impl Default for DiscoverOptions { + fn default() -> Self { + Self { + max_depth: None, + hidden: Hidden::default(), + same_filesystem: true, + exclude: Vec::new(), + include_enclosing: true, + } + } +} + +/// Something discovery found. +#[derive(Debug, Clone)] +pub enum Found { + Project(Project), + Artifact(Artifact), + Warning(Warning), +} + +#[derive(Debug, Clone, Serialize)] +pub struct Warning { + pub path: PathBuf, + pub message: String, +} + +/// Live progress counters, readable while discovery runs. +#[derive(Debug, Default)] +pub struct Counters { + pub dirs: AtomicU64, + pub projects: AtomicU32, + pub artifacts: AtomicU32, + pub warnings: AtomicU32, + /// Artifacts measured so far. + pub measured: AtomicU32, +} + +/// Runs discovery on the current rayon pool, calling `emit` from worker threads. +/// +/// Projects are emitted before anything below them, so consumers can resolve `parent` +/// and `member_of` as events arrive. +pub fn discover( + root: &Path, + registry: &Registry, + options: &DiscoverOptions, + counters: &Counters, + cancel: &Cancel, + emit: &(dyn Fn(Found) + Sync), +) { + let walker = Walker { + registry, + options, + counters, + cancel, + emit, + root_dev: device(root), + }; + let ctx = walker.enclosing_context(root); + rayon::scope(|scope| walker.visit(scope, root.to_path_buf(), ctx, true)); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EntryKind { + Dir, + Other, +} + +#[derive(Debug)] +struct Entry { + name: OsString, + kind: EntryKind, +} + +/// Reads a directory without following symlinks; symlinked directories are `Other`. +fn read_entries(dir: &Path) -> io::Result> { + let mut entries = Vec::new(); + for entry in fs::read_dir(dir)? { + let Ok(entry) = entry else { continue }; + let Ok(file_type) = entry.file_type() else { + continue; + }; + entries.push(Entry { + name: entry.file_name(), + kind: if file_type.is_dir() { + EntryKind::Dir + } else { + EntryKind::Other + }, + }); + } + Ok(entries) +} + +fn utf8_names(entries: &[Entry]) -> Vec<&str> { + entries.iter().filter_map(|e| e.name.to_str()).collect() +} + +#[cfg(unix)] +fn device(path: &Path) -> Option { + use std::os::unix::fs::MetadataExt; + fs::symlink_metadata(path).ok().map(|m| m.dev()) +} + +#[cfg(not(unix))] +fn device(_path: &Path) -> Option { + None +} + +fn modified(path: &Path) -> Option { + fs::symlink_metadata(path).and_then(|m| m.modified()).ok() +} + +fn is_cachedir_tag(path: &Path) -> bool { + use std::io::Read; + let mut buf = [0u8; CACHEDIR_TAG_SIGNATURE.len()]; + fs::File::open(path) + .and_then(|mut f| f.read_exact(&mut buf)) + .is_ok_and(|()| buf == CACHEDIR_TAG_SIGNATURE) +} + +#[derive(Debug, Clone)] +struct Owner { + id: ProjectId, + ecos: Arc<[EcoId]>, +} + +#[derive(Debug, Clone, Copy)] +struct Pending { + rule: RuleId, + /// Index of the segment the next directory level must match. + next: usize, + owner: Option, +} + +#[derive(Debug, Clone)] +struct WorkspaceCtx { + id: ProjectId, + ecos: Arc<[EcoId]>, + root: Arc, + spec: Arc, +} + +/// State inherited from ancestors. +#[derive(Debug, Clone)] +struct Ctx { + depth: usize, + owner: Option, + /// Ecosystems of every enclosing project (suppresses nested weak markers). + inside: Arc<[EcoId]>, + /// Enclosing ecosystems that have `anywhere` rules. + anywhere: Arc<[EcoId]>, + /// Multi-segment rules partially matched by ancestors. + pending: Vec, + repo: Option>, + workspaces: Arc<[WorkspaceCtx]>, + /// Entered only to finish a pending rule (hidden directories such as `.nx`). + pending_only: bool, + outside_root: bool, +} + +impl Ctx { + fn root() -> Self { + Self { + depth: 0, + owner: None, + inside: Arc::from([]), + anywhere: Arc::from([]), + pending: Vec::new(), + repo: None, + workspaces: Arc::from([]), + pending_only: false, + outside_root: false, + } + } +} + +/// A child directory matched by at least one rule, awaiting content checks. +struct Candidate { + rule: RuleId, + owner: Option, +} + +struct Walker<'a> { + registry: &'a Registry, + options: &'a DiscoverOptions, + counters: &'a Counters, + cancel: &'a Cancel, + emit: &'a (dyn Fn(Found) + Sync), + root_dev: Option, +} + +impl<'a> Walker<'a> { + fn warn(&self, path: &Path, error: &io::Error) { + self.counters.warnings.fetch_add(1, Ordering::Relaxed); + (self.emit)(Found::Warning(Warning { + path: path.to_path_buf(), + message: error.to_string(), + })); + } + + fn visit<'s>(&'s self, scope: &rayon::Scope<'s>, dir: PathBuf, mut ctx: Ctx, is_root: bool) + where + 'a: 's, + { + if self.cancel.is_cancelled() { + return; + } + self.counters.dirs.fetch_add(1, Ordering::Relaxed); + let entries = match read_entries(&dir) { + Ok(entries) => entries, + Err(error) => { + self.warn(&dir, &error); + return; + } + }; + let names = utf8_names(&entries); + + let mut here: Option = None; + if !ctx.pending_only { + if names.contains(&".git") { + ctx.repo = Some(Arc::from(dir.as_path())); + } + let ecos = self.project_ecosystems(&dir, &names, &ctx.inside); + if ecos.is_empty() { + if !is_root + && !names.iter().any(|n| VCS_DIRS.contains(n)) + && let Some((detection, kind, eco)) = self.content_detection(&dir, &names) + { + let owner = ctx.owner.as_ref().map(|o| o.id); + self.emit_artifact(dir, owner, eco, kind, detection, false, &ctx); + return; + } + } else { + let owner = self.enter_project(&dir, &names, ecos, &mut ctx); + here = Some(owner); + } + } + + let nearest = ctx.owner.as_ref().map(|o| o.id); + let mut candidates: Vec = Vec::new(); + let mut matched: Vec = Vec::new(); + for entry in entries.iter().filter(|e| e.kind == EntryKind::Dir) { + let child = dir.join(&entry.name); + if self.options.exclude.iter().any(|ex| child.starts_with(ex)) { + continue; + } + let Some(name) = entry.name.to_str() else { + // Non-UTF-8 names never match a rule but may contain projects. + if !ctx.pending_only { + self.descend(scope, child, &ctx, Vec::new(), false); + } + continue; + }; + if VCS_DIRS.contains(&name) { + continue; + } + + candidates.clear(); + let mut child_pending = Vec::new(); + if !ctx.pending_only { + if name.starts_with(STAGING_PREFIX) { + self.emit_artifact( + child, + nearest, + None, + ArtifactKind::Leftover, + Detection::CarwashLeftover, + false, + &ctx, + ); + continue; + } + if let Some(owner) = &here { + for &eco in owner.ecos.iter() { + matched.clear(); + self.registry.root_rules_for(eco, name, &mut matched); + for &rule in &matched { + if self.registry.rule(rule).segment_count() == 1 { + candidates.push(Candidate { + rule, + owner: Some(owner.id), + }); + } else { + child_pending.push(Pending { + rule, + next: 1, + owner: Some(owner.id), + }); + } + } + } + } + matched.clear(); + for &eco in ctx.anywhere.iter() { + self.registry.anywhere_rules_for(eco, name, &mut matched); + } + self.registry.global_rules_for(name, &mut matched); + candidates.extend(matched.iter().map(|&rule| Candidate { + rule, + owner: nearest, + })); + } + for pending in &ctx.pending { + let rule = self.registry.rule(pending.rule); + if rule.segment_matches(pending.next, name) { + if pending.next + 1 == rule.segment_count() { + candidates.push(Candidate { + rule: pending.rule, + owner: pending.owner, + }); + } else { + child_pending.push(Pending { + next: pending.next + 1, + ..*pending + }); + } + } + } + + if !candidates.is_empty() + && let Some((rule, confirmed, owner)) = self.evaluate(&child, &candidates) + { + let spec = self.registry.rule(rule); + self.emit_artifact( + child, + owner, + Some(spec.eco), + spec.kind, + Detection::Rule { rule, confirmed }, + spec.ambiguous, + &ctx, + ); + continue; + } + + let enter_hidden = match self.options.hidden { + Hidden::Never => false, + Hidden::InsideProjects => ctx.owner.is_some() || ctx.repo.is_some(), + Hidden::Always => true, + }; + let normal = !ctx.pending_only && (!name.starts_with('.') || enter_hidden); + if normal || !child_pending.is_empty() { + self.descend(scope, child, &ctx, child_pending, !normal); + } + } + } + + fn descend<'s>( + &'s self, + scope: &rayon::Scope<'s>, + child: PathBuf, + ctx: &Ctx, + pending: Vec, + pending_only: bool, + ) where + 'a: 's, + { + if self.options.max_depth.is_some_and(|max| ctx.depth >= max) { + return; + } + if self.options.same_filesystem + && self.root_dev.is_some() + && device(&child) != self.root_dev + { + return; + } + let child_ctx = Ctx { + depth: ctx.depth + 1, + pending, + pending_only, + ..ctx.clone() + }; + scope.spawn(move |scope| self.visit(scope, child, child_ctx, false)); + } + + /// Ecosystems whose markers are present, dropping weak markers inside a project of the + /// same ecosystem. + fn project_ecosystems(&self, dir: &Path, names: &[&str], inside: &[EcoId]) -> Vec { + let markers = self.registry.detect_markers(dir, names.iter().copied()); + let mut ecos = markers.strong; + ecos.extend(markers.weak.into_iter().filter(|eco| !inside.contains(eco))); + ecos.sort_unstable(); + ecos + } + + /// Recognises artifact directories by content rather than by name. + fn content_detection( + &self, + dir: &Path, + names: &[&str], + ) -> Option<(Detection, ArtifactKind, Option)> { + let has = |name: &str| names.contains(&name); + if has("pyvenv.cfg") { + return Some(( + Detection::PythonVenv, + ArtifactKind::Environment, + self.registry.find("python"), + )); + } + if has("conda-meta") { + return Some(( + Detection::CondaEnv, + ArtifactKind::Environment, + self.registry.find("python"), + )); + } + if has("CMakeCache.txt") { + return Some(( + Detection::CMakeBuild, + ArtifactKind::Build, + self.registry.find("cmake"), + )); + } + if has("meson-private") && has("build.ninja") { + return Some(( + Detection::MesonBuild, + ArtifactKind::Build, + self.registry.find("meson"), + )); + } + if has("CACHEDIR.TAG") && is_cachedir_tag(&dir.join("CACHEDIR.TAG")) { + let is_cargo_target = has(".rustc_info.json"); + let (kind, eco) = if is_cargo_target { + (ArtifactKind::Build, self.registry.find("rust")) + } else { + (ArtifactKind::Cache, None) + }; + return Some((Detection::CacheDirTag, kind, eco)); + } + // A Cargo target directory whose manifest is gone and that predates CACHEDIR.TAG. + if dir.file_name().is_some_and(|n| n == "target") + && ["debug", "release"] + .iter() + .any(|profile| has(profile) && dir.join(profile).join(".fingerprint").is_dir()) + { + return Some(( + Detection::CargoFingerprint, + ArtifactKind::Build, + self.registry.find("rust"), + )); + } + None + } + + /// Checks a rule-matched child's content and picks the most convincing rule. + fn evaluate( + &self, + child: &Path, + candidates: &[Candidate], + ) -> Option<(RuleId, bool, Option)> { + let entries = read_entries(child).ok()?; + let names = utf8_names(&entries); + if names.iter().any(|n| VCS_DIRS.contains(n)) { + return None; + } + let is_project = !self + .registry + .detect_markers(child, names.iter().copied()) + .strong + .is_empty(); + + let mut best: Option<(u8, RuleId, bool, Option)> = None; + for candidate in candidates { + let rule = self.registry.rule(candidate.rule); + let confirmed = rule.has_confirm() && names.iter().any(|n| rule.confirms(n)); + if rule.require_confirm && !confirmed { + continue; + } + if rule.ambiguous && is_project { + continue; + } + let score = u8::from(confirmed) * 2 + u8::from(!rule.ambiguous); + if best.as_ref().is_none_or(|(s, ..)| score > *s) { + best = Some((score, candidate.rule, confirmed, candidate.owner)); + } + } + best.map(|(_, rule, confirmed, owner)| (rule, confirmed, owner)) + } + + /// Creates the project for `dir` and updates `ctx` to describe its subtree. + fn enter_project(&self, dir: &Path, names: &[&str], ecos: Vec, ctx: &mut Ctx) -> Owner { + let id = ProjectId(self.counters.projects.fetch_add(1, Ordering::Relaxed)); + + let mut name = None; + let mut workspace = None; + for &eco in &ecos { + if let Some(adapter) = self.registry.ecosystem(eco).adapter { + let info = adapter.inspect(dir, names); + name = name.or(info.name); + workspace = workspace.or(info.workspace); + } + } + let name = name.unwrap_or_else(|| { + dir.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| dir.display().to_string()) + }); + + let member_of = ctx.workspaces.iter().rev().find_map(|ws| { + let shares_ecosystem = ws.ecos.iter().any(|e| ecos.contains(e)); + let relative = dir.strip_prefix(&*ws.root).ok()?; + let relative = relative + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + (shares_ecosystem && !relative.is_empty() && ws.spec.contains(&relative)) + .then_some(ws.id) + }); + + let last_activity = ecos + .iter() + .flat_map(|&eco| &self.registry.ecosystem(eco).activity_files) + .filter(|file| names.contains(&file.as_str())) + .filter_map(|file| modified(&dir.join(file))) + .max(); + + let ecos: Arc<[EcoId]> = Arc::from(ecos); + (self.emit)(Found::Project(Project { + id, + path: dir.to_path_buf(), + name, + ecosystems: ecos.to_vec(), + parent: ctx.owner.as_ref().map(|o| o.id), + member_of, + is_workspace: workspace.is_some(), + repo: ctx.repo.as_deref().map(Path::to_path_buf), + last_activity, + outside_root: ctx.outside_root, + })); + + let owner = Owner { + id, + ecos: ecos.clone(), + }; + let mut inside = ctx.inside.to_vec(); + for &eco in ecos.iter() { + if !inside.contains(&eco) { + inside.push(eco); + } + } + ctx.anywhere = inside + .iter() + .copied() + .filter(|&eco| self.registry.has_anywhere_rules(eco)) + .collect(); + ctx.inside = Arc::from(inside); + if let Some(spec) = workspace { + let mut workspaces = ctx.workspaces.to_vec(); + workspaces.push(WorkspaceCtx { + id, + ecos, + root: Arc::from(dir), + spec: Arc::new(spec), + }); + ctx.workspaces = Arc::from(workspaces); + } + ctx.owner = Some(owner.clone()); + owner + } + + #[allow(clippy::too_many_arguments)] + fn emit_artifact( + &self, + path: PathBuf, + project: Option, + ecosystem: Option, + kind: ArtifactKind, + detection: Detection, + ambiguous: bool, + ctx: &Ctx, + ) { + let id = ArtifactId(self.counters.artifacts.fetch_add(1, Ordering::Relaxed)); + (self.emit)(Found::Artifact(Artifact { + id, + modified: modified(&path), + path, + project, + ecosystem, + kind, + detection, + ambiguous, + git: GitState::Unknown, + repo: ctx.repo.as_deref().map(Path::to_path_buf), + size: None, + outside_root: ctx.outside_root, + })); + } + + /// Walks from the root's parent upwards (to the enclosing repository root, stopping + /// below the home directory) so a scan started inside a project knows about it. + fn enclosing_context(&self, root: &Path) -> Ctx { + let home = etcetera::home_dir().ok(); + let mut chain = Vec::new(); + let mut current = root.parent(); + while let Some(dir) = current { + if home.as_deref() == Some(dir) || chain.len() >= MAX_ENCLOSING_LEVELS { + break; + } + chain.push(dir); + if dir.join(".git").exists() { + break; + } + current = dir.parent(); + } + + let mut ctx = Ctx::root(); + ctx.outside_root = true; + for (index, dir) in chain.iter().enumerate().rev() { + let Ok(entries) = read_entries(dir) else { + continue; + }; + let names = utf8_names(&entries); + if names.contains(&".git") { + ctx.repo = Some(Arc::from(*dir)); + } + let ecos = self.project_ecosystems(dir, &names, &ctx.inside); + if ecos.is_empty() { + continue; + } + let owner = self.enter_project(dir, &names, ecos, &mut ctx); + if !self.options.include_enclosing { + continue; + } + // The child leading back towards the scan root is not a candidate. + let towards_root = if index == 0 { root } else { chain[index - 1] }; + let mut matched = Vec::new(); + for entry in entries.iter().filter(|e| e.kind == EntryKind::Dir) { + let Some(name) = entry.name.to_str() else { + continue; + }; + let child = dir.join(name); + if child == towards_root { + continue; + } + matched.clear(); + for &eco in owner.ecos.iter() { + self.registry.root_rules_for(eco, name, &mut matched); + } + let candidates: Vec = matched + .iter() + .filter(|&&rule| self.registry.rule(rule).segment_count() == 1) + .map(|&rule| Candidate { + rule, + owner: Some(owner.id), + }) + .collect(); + if candidates.is_empty() { + continue; + } + if let Some((rule, confirmed, owner)) = self.evaluate(&child, &candidates) { + let spec = self.registry.rule(rule); + self.emit_artifact( + child, + owner, + Some(spec.eco), + spec.kind, + Detection::Rule { rule, confirmed }, + spec.ambiguous, + &ctx, + ); + } + } + } + ctx.outside_root = false; + ctx + } +} diff --git a/crates/carwash-core/src/ecosystem/adapters.rs b/crates/carwash-core/src/ecosystem/adapters.rs new file mode 100644 index 0000000..bb6d16e --- /dev/null +++ b/crates/carwash-core/src/ecosystem/adapters.rs @@ -0,0 +1,421 @@ +//! Content-aware hooks for ecosystems whose manifests carry names and workspace layouts. +//! +//! Adapters only read small manifest files and never fail discovery: unreadable or +//! malformed manifests simply yield no information. + +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use std::fs; +use std::path::Path; + +/// Built-in adapter selected by an ecosystem's `adapter` field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Adapter { + Cargo, + Node, + Python, + Go, + Gradle, + Dotnet, + Dart, +} + +/// How a workspace root declares its members. +#[derive(Debug, Clone)] +pub enum WorkspaceSpec { + /// Member paths relative to the root, as globs. + Globs { include: GlobSet, exclude: GlobSet }, + /// Every nested project of the same ecosystem is a member. + Nested, +} + +impl WorkspaceSpec { + /// `relative` uses `/` separators and is relative to the workspace root. + pub fn contains(&self, relative: &str) -> bool { + match self { + WorkspaceSpec::Globs { include, exclude } => { + include.is_match(relative) && !exclude.is_match(relative) + } + WorkspaceSpec::Nested => true, + } + } +} + +/// What an adapter learned about a project directory. +#[derive(Debug, Clone, Default)] +pub struct ProjectInfo { + pub name: Option, + pub workspace: Option, +} + +impl Adapter { + pub fn parse(name: &str) -> Option { + Some(match name { + "cargo" => Self::Cargo, + "node" => Self::Node, + "python" => Self::Python, + "go" => Self::Go, + "gradle" => Self::Gradle, + "dotnet" => Self::Dotnet, + "dart" => Self::Dart, + _ => return None, + }) + } + + /// Inspects `dir`, whose entry names are `names`. + pub fn inspect(self, dir: &Path, names: &[&str]) -> ProjectInfo { + match self { + Adapter::Cargo => cargo(dir), + Adapter::Node => node(dir, names), + Adapter::Python => python(dir), + Adapter::Go => go(dir, names), + Adapter::Gradle => gradle(dir, names), + Adapter::Dotnet => dotnet(names), + Adapter::Dart => dart(dir), + } + } +} + +/// Manifests larger than this are not parsed; real ones are a few kilobytes. +const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024; + +fn read(path: &Path) -> Option { + let meta = fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_MANIFEST_BYTES { + return None; + } + fs::read_to_string(path).ok() +} + +fn read_toml(path: &Path) -> Option { + read(path)?.parse::().ok() +} + +/// Builds a workspace spec from glob patterns; `!pattern` entries are exclusions. +fn globs_spec<'a>( + patterns: impl IntoIterator, + excludes: impl IntoIterator, +) -> Option { + let mut include = GlobSetBuilder::new(); + let mut exclude = GlobSetBuilder::new(); + let mut any = false; + let add = |builder: &mut GlobSetBuilder, raw: &str| { + let pattern = raw.trim().trim_start_matches("./").trim_end_matches('/'); + if pattern.is_empty() || pattern == "." { + return false; + } + match GlobBuilder::new(pattern).literal_separator(true).build() { + Ok(glob) => { + builder.add(glob); + true + } + Err(_) => false, + } + }; + for raw in patterns { + if let Some(negated) = raw.strip_prefix('!') { + add(&mut exclude, negated); + } else if add(&mut include, raw) { + any = true; + } + } + for raw in excludes { + add(&mut exclude, raw); + } + if !any { + return None; + } + Some(WorkspaceSpec::Globs { + include: include.build().ok()?, + exclude: exclude.build().ok()?, + }) +} + +fn str_array(value: Option<&toml::Value>) -> Vec<&str> { + value + .and_then(toml::Value::as_array) + .map(|items| items.iter().filter_map(toml::Value::as_str).collect()) + .unwrap_or_default() +} + +fn cargo(dir: &Path) -> ProjectInfo { + let Some(manifest) = read_toml(&dir.join("Cargo.toml")) else { + return ProjectInfo::default(); + }; + let name = manifest + .get("package") + .and_then(|p| p.get("name")) + .and_then(toml::Value::as_str) + .map(str::to_owned); + let workspace = manifest.get("workspace").map(|ws| { + let members = str_array(ws.get("members")); + let exclude = str_array(ws.get("exclude")); + globs_spec(members, exclude).unwrap_or(WorkspaceSpec::Nested) + }); + ProjectInfo { name, workspace } +} + +fn node(dir: &Path, names: &[&str]) -> ProjectInfo { + let manifest: Option = + read(&dir.join("package.json")).and_then(|s| serde_json::from_str(&s).ok()); + let name = manifest + .as_ref() + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .map(str::to_owned); + + let mut patterns: Vec = Vec::new(); + if let Some(workspaces) = manifest.as_ref().and_then(|m| m.get("workspaces")) { + let list = workspaces.as_array().or_else(|| { + workspaces + .get("packages") + .and_then(serde_json::Value::as_array) + }); + patterns.extend( + list.into_iter() + .flatten() + .filter_map(|v| v.as_str().map(str::to_owned)), + ); + } + if names.contains(&"pnpm-workspace.yaml") + && let Some(yaml) = read(&dir.join("pnpm-workspace.yaml")) + && let Ok(doc) = yaml_serde::from_str::(&yaml) + && let Some(packages) = doc.get("packages").and_then(|p| p.as_sequence()) + { + patterns.extend( + packages + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)), + ); + } + let workspace = globs_spec(patterns.iter().map(String::as_str), []); + ProjectInfo { name, workspace } +} + +fn python(dir: &Path) -> ProjectInfo { + let Some(pyproject) = read_toml(&dir.join("pyproject.toml")) else { + return ProjectInfo::default(); + }; + let name = pyproject + .get("project") + .and_then(|p| p.get("name")) + .or_else(|| { + pyproject + .get("tool") + .and_then(|t| t.get("poetry")) + .and_then(|p| p.get("name")) + }) + .and_then(toml::Value::as_str) + .map(str::to_owned); + let workspace = pyproject + .get("tool") + .and_then(|t| t.get("uv")) + .and_then(|uv| uv.get("workspace")) + .and_then(|ws| globs_spec(str_array(ws.get("members")), str_array(ws.get("exclude")))); + ProjectInfo { name, workspace } +} + +fn go(dir: &Path, names: &[&str]) -> ProjectInfo { + let name = read(&dir.join("go.mod")).and_then(|gomod| { + gomod.lines().find_map(|line| { + let module = line.trim().strip_prefix("module")?.trim(); + let module = module.trim_matches('"'); + module.rsplit('/').next().map(str::to_owned) + }) + }); + let workspace = if names.contains(&"go.work") { + read(&dir.join("go.work")) + .and_then(|work| globs_spec(go_work_uses(&work).iter().map(String::as_str), [])) + } else { + None + }; + ProjectInfo { name, workspace } +} + +/// Extracts `use` directives from a go.work file, both single-line and block forms. +fn go_work_uses(source: &str) -> Vec { + let mut uses = Vec::new(); + let mut in_block = false; + for line in source.lines() { + let line = line.split("//").next().unwrap_or_default().trim(); + if in_block { + if line.starts_with(')') { + in_block = false; + } else if !line.is_empty() { + uses.push(line.trim_matches('"').to_owned()); + } + } else if let Some(rest) = line.strip_prefix("use") { + let rest = rest.trim(); + if rest.starts_with('(') { + in_block = true; + } else if !rest.is_empty() { + uses.push(rest.trim_matches('"').to_owned()); + } + } + } + uses +} + +fn gradle(dir: &Path, names: &[&str]) -> ProjectInfo { + let settings = ["settings.gradle.kts", "settings.gradle"] + .into_iter() + .find(|s| names.contains(s)); + let Some(settings) = settings else { + return ProjectInfo::default(); + }; + let name = read(&dir.join(settings)).and_then(|source| { + source.lines().find_map(|line| { + let rest = line.trim().strip_prefix("rootProject.name")?; + let value = rest.trim_start().strip_prefix('=')?.trim(); + let value = value.trim_matches(|c| c == '"' || c == '\''); + (!value.is_empty()).then(|| value.to_owned()) + }) + }); + ProjectInfo { + name, + workspace: Some(WorkspaceSpec::Nested), + } +} + +fn dotnet(names: &[&str]) -> ProjectInfo { + let solution = names + .iter() + .find(|n| n.ends_with(".sln") || n.ends_with(".slnx")); + let project = solution.or_else(|| { + names + .iter() + .find(|n| n.ends_with(".csproj") || n.ends_with(".fsproj") || n.ends_with(".vbproj")) + }); + ProjectInfo { + name: project + .and_then(|n| Path::new(n).file_stem()) + .and_then(|s| s.to_str()) + .map(str::to_owned), + workspace: solution.map(|_| WorkspaceSpec::Nested), + } +} + +fn dart(dir: &Path) -> ProjectInfo { + let Some(doc) = read(&dir.join("pubspec.yaml")) + .and_then(|s| yaml_serde::from_str::(&s).ok()) + else { + return ProjectInfo::default(); + }; + let name = doc.get("name").and_then(|n| n.as_str()).map(str::to_owned); + let workspace = doc + .get("workspace") + .and_then(|w| w.as_sequence()) + .and_then(|members| globs_spec(members.iter().filter_map(|m| m.as_str()), [])); + ProjectInfo { name, workspace } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &Path, name: &str, contents: &str) { + fs::write(dir.join(name), contents).unwrap(); + } + + #[test] + fn cargo_workspace_members_and_excludes() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\", \"tools/cli\"]\nexclude = [\"crates/legacy\"]\n", + ); + let info = Adapter::Cargo.inspect(dir.path(), &["Cargo.toml"]); + let ws = info.workspace.expect("workspace"); + assert!(ws.contains("crates/core")); + assert!(ws.contains("tools/cli")); + assert!(!ws.contains("crates/legacy")); + assert!(!ws.contains("crates/core/nested")); + assert!(info.name.is_none()); + } + + #[test] + fn cargo_package_name() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ); + let info = Adapter::Cargo.inspect(dir.path(), &["Cargo.toml"]); + assert_eq!(info.name.as_deref(), Some("demo")); + assert!(info.workspace.is_none()); + } + + #[test] + fn node_workspaces_from_package_json_and_pnpm() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "package.json", + r#"{"name":"mono","workspaces":{"packages":["apps/*"]}}"#, + ); + write( + dir.path(), + "pnpm-workspace.yaml", + "packages:\n - 'packages/**'\n - '!packages/private'\n", + ); + let info = Adapter::Node.inspect(dir.path(), &["package.json", "pnpm-workspace.yaml"]); + assert_eq!(info.name.as_deref(), Some("mono")); + let ws = info.workspace.unwrap(); + assert!(ws.contains("apps/web")); + assert!(ws.contains("packages/ui/button")); + assert!(!ws.contains("packages/private")); + assert!(!ws.contains("docs")); + } + + #[test] + fn python_names_and_uv_workspace() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "pyproject.toml", + "[project]\nname = \"svc\"\n[tool.uv.workspace]\nmembers = [\"libs/*\"]\n", + ); + let info = Adapter::Python.inspect(dir.path(), &["pyproject.toml"]); + assert_eq!(info.name.as_deref(), Some("svc")); + assert!(info.workspace.unwrap().contains("libs/core")); + } + + #[test] + fn go_module_name_and_work_uses() { + assert_eq!( + go_work_uses("go 1.22\n\nuse (\n ./api // comment\n \"./cli\"\n)\nuse ./tools\n"), + vec!["./api", "./cli", "./tools"] + ); + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "go.mod", + "module github.com/acme/widget\n\ngo 1.22\n", + ); + let info = Adapter::Go.inspect(dir.path(), &["go.mod"]); + assert_eq!(info.name.as_deref(), Some("widget")); + } + + #[test] + fn gradle_root_project_name() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "settings.gradle.kts", + "rootProject.name = \"shop\"\ninclude(\":app\")\n", + ); + let info = Adapter::Gradle.inspect(dir.path(), &["settings.gradle.kts"]); + assert_eq!(info.name.as_deref(), Some("shop")); + assert!(matches!(info.workspace, Some(WorkspaceSpec::Nested))); + } + + #[test] + fn malformed_manifests_yield_nothing() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "Cargo.toml", "not = [valid"); + write(dir.path(), "package.json", "{"); + assert!(Adapter::Cargo.inspect(dir.path(), &[]).name.is_none()); + assert!(Adapter::Node.inspect(dir.path(), &[]).name.is_none()); + } +} diff --git a/crates/carwash-core/src/ecosystem/builtin.toml b/crates/carwash-core/src/ecosystem/builtin.toml new file mode 100644 index 0000000..a3a1af1 --- /dev/null +++ b/crates/carwash-core/src/ecosystem/builtin.toml @@ -0,0 +1,510 @@ +# Built-in ecosystem rules. +# +# Users can add or override ecosystems in ~/.config/carwash/ecosystems.toml +# using the same schema; an entry with an existing `id` replaces the built-in. +# +# Ecosystem fields: +# id, name, badge identity; `badge` is a short label for narrow/ASCII UIs +# markers entry names (globs allowed, `a/b` paths allowed) that make a directory a project +# weak_markers like markers, but ignored inside an existing project of the same ecosystem +# (e.g. nested CMakeLists.txt or requirements.txt do not start new projects) +# lockfiles names of lockfiles, used for activity and dependency tooling +# adapter optional built-in content hook (names, workspaces, custom target dirs) +# +# Artifact fields: +# path relative to the project root; `/` separates segments; segments may be globs +# kind build | dependencies | cache | environment | other +# anywhere match at any depth below the project, not only at the root +# global also match outside any project (only for unmistakable names) +# ambiguous generic name (build, dist, bin, vendor...) that some repos commit; only +# deleted without review when git reports it ignored and untracked +# confirm globs; if any child entry matches, the directory is confirmed as an artifact +# require_confirm without a confirming child the directory is not treated as an artifact +# regenerate hint shown to the user about how the artifact comes back + +schema = 1 + +[[ecosystem]] +id = "rust" +name = "Rust" +badge = "rs" +markers = ["Cargo.toml"] +lockfiles = ["Cargo.lock"] +adapter = "cargo" +artifacts = [ + { path = "target", kind = "build", confirm = ["CACHEDIR.TAG", ".rustc_info.json", "debug", "release"], regenerate = "cargo build" }, +] + +[[ecosystem]] +id = "node" +name = "JavaScript/TypeScript" +badge = "js" +markers = ["package.json"] +lockfiles = ["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"] +adapter = "node" +artifacts = [ + { path = "node_modules", kind = "dependencies", global = true, regenerate = "reinstall with your package manager" }, + { path = ".next", kind = "build", regenerate = "next build / next dev" }, + { path = ".nuxt", kind = "build", regenerate = "nuxt build" }, + { path = ".output", kind = "build", confirm = ["nitro.json", "server", "public"], require_confirm = true, regenerate = "nuxt/nitro build" }, + { path = ".svelte-kit", kind = "build", regenerate = "vite build" }, + { path = ".turbo", kind = "cache", regenerate = "turbo run" }, + { path = ".nx/cache", kind = "cache", regenerate = "nx run" }, + { path = ".nx/workspace-data", kind = "cache", regenerate = "nx run" }, + { path = ".parcel-cache", kind = "cache", regenerate = "parcel build" }, + { path = ".angular/cache", kind = "cache", regenerate = "ng build" }, + { path = ".expo", kind = "cache", regenerate = "expo start" }, + { path = ".docusaurus", kind = "build", regenerate = "docusaurus build" }, + { path = ".astro", kind = "cache", regenerate = "astro build" }, + { path = ".vercel/output", kind = "build", regenerate = "vercel build" }, + { path = ".pnpm-store", kind = "cache", regenerate = "pnpm install" }, + { path = ".yarn/cache", kind = "cache", ambiguous = true, regenerate = "yarn install" }, + { path = ".yarn/unplugged", kind = "dependencies", ambiguous = true, regenerate = "yarn install" }, + { path = ".cache", kind = "cache", ambiguous = true }, + { path = "storybook-static", kind = "build", regenerate = "build-storybook" }, + { path = "dist", kind = "build", ambiguous = true, regenerate = "your build script" }, + { path = "build", kind = "build", ambiguous = true, regenerate = "your build script" }, + { path = "out", kind = "build", ambiguous = true, regenerate = "your build script" }, + { path = "coverage", kind = "other", ambiguous = true, regenerate = "your test script" }, +] + +[[ecosystem]] +id = "deno" +name = "Deno" +badge = "dn" +markers = ["deno.json", "deno.jsonc"] +lockfiles = ["deno.lock"] +artifacts = [ + { path = "node_modules", kind = "dependencies", regenerate = "deno install" }, +] + +[[ecosystem]] +id = "python" +name = "Python" +badge = "py" +markers = ["pyproject.toml", "setup.py", "Pipfile", "uv.lock", "poetry.lock", "pdm.lock", "pixi.toml", "hatch.toml"] +weak_markers = ["requirements*.txt", "setup.cfg", "tox.ini", "noxfile.py", "environment.yml"] +lockfiles = ["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock", "pixi.lock"] +adapter = "python" +artifacts = [ + { path = ".venv", kind = "environment", confirm = ["pyvenv.cfg", "conda-meta"], require_confirm = true, regenerate = "uv sync / poetry install / python -m venv" }, + { path = "__pycache__", kind = "cache", anywhere = true, global = true, regenerate = "automatic" }, + { path = ".pytest_cache", kind = "cache", anywhere = true, global = true, regenerate = "automatic" }, + { path = ".mypy_cache", kind = "cache", anywhere = true, global = true, regenerate = "mypy" }, + { path = ".ruff_cache", kind = "cache", anywhere = true, global = true, regenerate = "ruff" }, + { path = ".pytype", kind = "cache", regenerate = "pytype" }, + { path = ".pyre", kind = "cache", regenerate = "pyre" }, + { path = ".hypothesis", kind = "cache", anywhere = true, regenerate = "hypothesis" }, + { path = ".tox", kind = "environment", regenerate = "tox" }, + { path = ".nox", kind = "environment", regenerate = "nox" }, + { path = ".pixi", kind = "environment", regenerate = "pixi install" }, + { path = "__pypackages__", kind = "dependencies", regenerate = "pdm install" }, + { path = ".eggs", kind = "build", regenerate = "setuptools" }, + { path = "*.egg-info", kind = "build", anywhere = true, regenerate = "pip install -e ." }, + { path = "htmlcov", kind = "other", regenerate = "coverage html" }, + { path = ".ipynb_checkpoints", kind = "cache", anywhere = true, ambiguous = true, regenerate = "jupyter autosave" }, + { path = "build", kind = "build", ambiguous = true, confirm = ["lib", "lib.*", "bdist.*", "temp.*"], regenerate = "python -m build" }, + { path = "dist", kind = "build", ambiguous = true, confirm = ["*.whl", "*.tar.gz"], regenerate = "python -m build" }, +] + +[[ecosystem]] +id = "go" +name = "Go" +badge = "go" +markers = ["go.mod", "go.work"] +lockfiles = ["go.sum", "go.work.sum"] +adapter = "go" +artifacts = [ + { path = "vendor", kind = "dependencies", ambiguous = true, confirm = ["modules.txt"], regenerate = "go mod vendor" }, +] + +[[ecosystem]] +id = "maven" +name = "Java (Maven)" +badge = "mv" +markers = ["pom.xml"] +artifacts = [ + { path = "target", kind = "build", ambiguous = true, confirm = ["classes", "maven-status", "maven-archiver", "surefire-reports", "generated-sources", "*.jar"], regenerate = "mvn package" }, +] + +[[ecosystem]] +id = "gradle" +name = "JVM (Gradle)" +badge = "gr" +markers = ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"] +lockfiles = ["gradle.lockfile"] +adapter = "gradle" +artifacts = [ + { path = "build", kind = "build", ambiguous = true, confirm = ["tmp", "intermediates", "classes", "libs", "kotlin", "generated", "reports", "outputs"], regenerate = "gradle build" }, + { path = ".gradle", kind = "cache", regenerate = "gradle build" }, + { path = ".kotlin", kind = "cache", regenerate = "gradle build" }, + { path = ".cxx", kind = "build", regenerate = "gradle build" }, + { path = ".externalNativeBuild", kind = "build", regenerate = "gradle build" }, + { path = "out", kind = "build", ambiguous = true, confirm = ["production", "test"], regenerate = "IntelliJ build" }, +] + +[[ecosystem]] +id = "scala" +name = "Scala" +badge = "sc" +markers = ["build.sbt", "build.mill", "build.sc"] +artifacts = [ + { path = "target", kind = "build", ambiguous = true, confirm = ["scala-*", "streams", "global-logging", "task-temp-directory"], regenerate = "sbt compile" }, + { path = "project/target", kind = "build", regenerate = "sbt compile" }, + { path = "project/project", kind = "build", regenerate = "sbt compile" }, + { path = ".bloop", kind = "cache", regenerate = "bloop" }, + { path = ".metals", kind = "cache", regenerate = "metals" }, + { path = ".bsp", kind = "cache", regenerate = "sbt bspConfig" }, + { path = ".scala-build", kind = "build", regenerate = "scala-cli" }, + { path = "out", kind = "build", ambiguous = true, confirm = ["mill-*"], regenerate = "mill" }, +] + +[[ecosystem]] +id = "bazel" +name = "Bazel" +badge = "bz" +markers = ["MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"] +artifacts = [] + +[[ecosystem]] +id = "dotnet" +name = ".NET" +badge = "cs" +markers = ["*.csproj", "*.fsproj", "*.vbproj", "*.sln", "*.slnx"] +lockfiles = ["packages.lock.json"] +adapter = "dotnet" +artifacts = [ + { path = "bin", kind = "build", ambiguous = true, confirm = ["Debug", "Release"], regenerate = "dotnet build" }, + { path = "obj", kind = "build", ambiguous = true, confirm = ["project.assets.json", "*.nuget.*", "Debug", "Release"], regenerate = "dotnet restore" }, + { path = "TestResults", kind = "other", regenerate = "dotnet test" }, + { path = ".vs", kind = "cache", ambiguous = true, regenerate = "Visual Studio" }, +] + +[[ecosystem]] +id = "swift" +name = "Swift Package" +badge = "sw" +markers = ["Package.swift"] +lockfiles = ["Package.resolved"] +artifacts = [ + { path = ".build", kind = "build", regenerate = "swift build" }, + { path = ".swiftpm", kind = "cache", ambiguous = true, regenerate = "swift package" }, +] + +[[ecosystem]] +id = "xcode" +name = "Xcode" +badge = "xc" +markers = ["*.xcodeproj", "*.xcworkspace"] +artifacts = [ + { path = "DerivedData", kind = "build", regenerate = "xcodebuild" }, + { path = "build", kind = "build", ambiguous = true, confirm = ["*.build", "Release*", "Debug*", "XCBuildData"], regenerate = "xcodebuild" }, +] + +[[ecosystem]] +id = "cocoapods" +name = "CocoaPods" +badge = "po" +markers = ["Podfile"] +lockfiles = ["Podfile.lock"] +artifacts = [ + { path = "Pods", kind = "dependencies", ambiguous = true, confirm = ["Manifest.lock"], regenerate = "pod install" }, +] + +[[ecosystem]] +id = "carthage" +name = "Carthage" +badge = "ct" +markers = ["Cartfile"] +lockfiles = ["Cartfile.resolved"] +artifacts = [ + { path = "Carthage/Build", kind = "build", regenerate = "carthage build" }, + { path = "Carthage/Checkouts", kind = "dependencies", ambiguous = true, regenerate = "carthage bootstrap" }, +] + +[[ecosystem]] +id = "dart" +name = "Dart/Flutter" +badge = "dt" +markers = ["pubspec.yaml"] +lockfiles = ["pubspec.lock"] +adapter = "dart" +artifacts = [ + { path = ".dart_tool", kind = "cache", regenerate = "dart pub get" }, + { path = "build", kind = "build", ambiguous = true, confirm = ["flutter_assets", "app", "ios", "web", "native_assets", "*.cache", "test_cache"], regenerate = "flutter build" }, + { path = "ios/.symlinks", kind = "dependencies", regenerate = "flutter pub get" }, + { path = "ios/Flutter/ephemeral", kind = "build", regenerate = "flutter pub get" }, + { path = "macos/Flutter/ephemeral", kind = "build", regenerate = "flutter pub get" }, + { path = "linux/flutter/ephemeral", kind = "build", regenerate = "flutter pub get" }, + { path = "windows/flutter/ephemeral", kind = "build", regenerate = "flutter pub get" }, +] + +[[ecosystem]] +id = "elixir" +name = "Elixir" +badge = "ex" +markers = ["mix.exs"] +lockfiles = ["mix.lock"] +artifacts = [ + { path = "_build", kind = "build", regenerate = "mix compile" }, + { path = "deps", kind = "dependencies", regenerate = "mix deps.get" }, + { path = ".elixir_ls", kind = "cache", regenerate = "ElixirLS" }, + { path = ".elixir-tools", kind = "cache", regenerate = "Next LS" }, + { path = ".lexical", kind = "cache", regenerate = "Lexical" }, + { path = "cover", kind = "other", ambiguous = true, regenerate = "mix test --cover" }, +] + +[[ecosystem]] +id = "erlang" +name = "Erlang" +badge = "er" +markers = ["rebar.config"] +lockfiles = ["rebar.lock"] +artifacts = [ + { path = "_build", kind = "build", regenerate = "rebar3 compile" }, +] + +[[ecosystem]] +id = "gleam" +name = "Gleam" +badge = "gl" +markers = ["gleam.toml"] +lockfiles = ["manifest.toml"] +artifacts = [ + { path = "build", kind = "build", ambiguous = true, confirm = ["packages", "dev", "prod", "lsp"], regenerate = "gleam build" }, +] + +[[ecosystem]] +id = "haskell" +name = "Haskell" +badge = "hs" +markers = ["*.cabal", "cabal.project", "stack.yaml", "package.yaml"] +lockfiles = ["cabal.project.freeze", "stack.yaml.lock"] +artifacts = [ + { path = "dist-newstyle", kind = "build", regenerate = "cabal build" }, + { path = ".stack-work", kind = "build", regenerate = "stack build" }, + { path = "dist", kind = "build", ambiguous = true, confirm = ["setup-config", "build"], regenerate = "cabal build" }, +] + +[[ecosystem]] +id = "ocaml" +name = "OCaml" +badge = "ml" +markers = ["dune-project", "*.opam"] +artifacts = [ + { path = "_build", kind = "build", regenerate = "dune build" }, + { path = "_opam", kind = "environment", regenerate = "opam switch create ." }, +] + +[[ecosystem]] +id = "zig" +name = "Zig" +badge = "zg" +markers = ["build.zig", "build.zig.zon"] +artifacts = [ + { path = ".zig-cache", kind = "cache", regenerate = "zig build" }, + { path = "zig-cache", kind = "cache", regenerate = "zig build" }, + { path = "zig-out", kind = "build", regenerate = "zig build" }, +] + +[[ecosystem]] +id = "cmake" +name = "C/C++ (CMake)" +badge = "c+" +weak_markers = ["CMakeLists.txt", "CMakePresets.json"] +artifacts = [ + { path = "cmake-build-*", kind = "build", regenerate = "cmake --build" }, + { path = "build", kind = "build", ambiguous = true, confirm = ["CMakeCache.txt", "CMakeFiles", "build.ninja"], regenerate = "cmake --build" }, + { path = "out", kind = "build", ambiguous = true, confirm = ["build", "CMakeCache.txt"], regenerate = "cmake --build" }, + { path = ".cache/clangd", kind = "cache", regenerate = "clangd" }, + { path = "vcpkg_installed", kind = "dependencies", regenerate = "vcpkg install" }, +] + +[[ecosystem]] +id = "meson" +name = "C/C++ (Meson)" +badge = "me" +weak_markers = ["meson.build"] +artifacts = [ + { path = "builddir", kind = "build", confirm = ["meson-private", "build.ninja"], require_confirm = true, regenerate = "meson setup" }, + { path = "build", kind = "build", ambiguous = true, confirm = ["meson-private", "build.ninja"], require_confirm = true, regenerate = "meson setup" }, +] + +[[ecosystem]] +id = "xmake" +name = "C/C++ (xmake)" +badge = "xm" +markers = ["xmake.lua"] +artifacts = [ + { path = ".xmake", kind = "cache", regenerate = "xmake" }, + { path = "build", kind = "build", ambiguous = true, regenerate = "xmake" }, +] + +[[ecosystem]] +id = "php" +name = "PHP" +badge = "ph" +markers = ["composer.json"] +lockfiles = ["composer.lock"] +artifacts = [ + { path = "vendor", kind = "dependencies", ambiguous = true, confirm = ["autoload.php", "composer"], regenerate = "composer install" }, +] + +[[ecosystem]] +id = "ruby" +name = "Ruby" +badge = "rb" +markers = ["Gemfile", "*.gemspec"] +lockfiles = ["Gemfile.lock"] +artifacts = [ + { path = "vendor/bundle", kind = "dependencies", regenerate = "bundle install" }, + { path = ".yardoc", kind = "cache", regenerate = "yard" }, + { path = "tmp", kind = "cache", ambiguous = true, confirm = ["cache", "pids", "sockets"], regenerate = "rails tmp:create" }, + { path = "coverage", kind = "other", ambiguous = true, regenerate = "your test suite" }, +] + +[[ecosystem]] +id = "terraform" +name = "Terraform/OpenTofu" +badge = "tf" +weak_markers = ["*.tf", "*.tofu"] +lockfiles = [".terraform.lock.hcl"] +artifacts = [ + { path = ".terraform", kind = "dependencies", regenerate = "terraform init" }, + { path = ".terragrunt-cache", kind = "cache", regenerate = "terragrunt init" }, +] + +[[ecosystem]] +id = "unity" +name = "Unity" +badge = "un" +markers = ["ProjectSettings/ProjectVersion.txt"] +artifacts = [ + { path = "Library", kind = "cache", regenerate = "reopen in Unity (slow reimport)" }, + { path = "Temp", kind = "cache", regenerate = "Unity" }, + { path = "Obj", kind = "build", regenerate = "Unity" }, + { path = "Logs", kind = "other", regenerate = "Unity" }, + { path = "MemoryCaptures", kind = "other", ambiguous = true }, +] + +[[ecosystem]] +id = "unreal" +name = "Unreal Engine" +badge = "ue" +markers = ["*.uproject"] +artifacts = [ + { path = "Binaries", kind = "build", regenerate = "rebuild in Unreal" }, + { path = "Intermediate", kind = "build", regenerate = "rebuild in Unreal" }, + { path = "DerivedDataCache", kind = "cache", regenerate = "Unreal (shader recompile)" }, + { path = "Plugins/*/Binaries", kind = "build", regenerate = "rebuild in Unreal" }, + { path = "Plugins/*/Intermediate", kind = "build", regenerate = "rebuild in Unreal" }, + { path = "Saved", kind = "other", ambiguous = true, confirm = ["Logs", "Crashes"], regenerate = "autosaves and config live here" }, +] + +[[ecosystem]] +id = "godot" +name = "Godot" +badge = "gd" +markers = ["project.godot"] +artifacts = [ + { path = ".godot", kind = "cache", regenerate = "reopen in Godot" }, + { path = ".import", kind = "cache", regenerate = "reopen in Godot" }, + { path = ".mono", kind = "build", regenerate = "Godot build" }, +] + +[[ecosystem]] +id = "r" +name = "R" +badge = "r" +markers = ["DESCRIPTION", "*.Rproj", "renv.lock"] +lockfiles = ["renv.lock"] +artifacts = [ + { path = "renv/library", kind = "dependencies", regenerate = "renv::restore()" }, + { path = "renv/staging", kind = "cache", regenerate = "renv" }, + { path = "packrat/lib", kind = "dependencies", regenerate = "packrat::restore()" }, + { path = ".Rproj.user", kind = "cache", regenerate = "RStudio" }, +] + +[[ecosystem]] +id = "julia" +name = "Julia" +badge = "jl" +markers = ["Project.toml", "JuliaProject.toml"] +lockfiles = ["Manifest.toml"] +artifacts = [] + +[[ecosystem]] +id = "nix" +name = "Nix" +badge = "nx" +markers = ["flake.nix", "shell.nix", "default.nix", "devenv.nix"] +lockfiles = ["flake.lock", "devenv.lock"] +artifacts = [ + { path = ".devenv", kind = "environment", regenerate = "devenv shell" }, +] + +[[ecosystem]] +id = "direnv" +name = "direnv" +badge = "de" +weak_markers = [".envrc"] +artifacts = [ + { path = ".direnv", kind = "environment", regenerate = "direnv allow" }, +] + +[[ecosystem]] +id = "jekyll" +name = "Jekyll" +badge = "jk" +markers = ["_config.yml"] +artifacts = [ + { path = "_site", kind = "build", regenerate = "jekyll build" }, + { path = ".jekyll-cache", kind = "cache", regenerate = "jekyll build" }, + { path = ".sass-cache", kind = "cache", regenerate = "jekyll build" }, +] + +[[ecosystem]] +id = "hugo" +name = "Hugo" +badge = "hu" +markers = ["hugo.toml", "hugo.yaml", "hugo.json"] +artifacts = [ + { path = "public", kind = "build", ambiguous = true, confirm = ["index.html", "sitemap.xml"], regenerate = "hugo" }, + { path = "resources/_gen", kind = "cache", regenerate = "hugo" }, +] + +[[ecosystem]] +id = "mkdocs" +name = "MkDocs" +badge = "md" +markers = ["mkdocs.yml", "mkdocs.yaml"] +artifacts = [ + { path = "site", kind = "build", ambiguous = true, confirm = ["sitemap.xml", "search"], regenerate = "mkdocs build" }, +] + +[[ecosystem]] +id = "aws-cdk" +name = "AWS CDK" +badge = "cd" +markers = ["cdk.json"] +artifacts = [ + { path = "cdk.out", kind = "build", regenerate = "cdk synth" }, +] + +[[ecosystem]] +id = "serverless" +name = "Serverless Framework" +badge = "sl" +markers = ["serverless.yml", "serverless.yaml", "serverless.ts"] +artifacts = [ + { path = ".serverless", kind = "build", regenerate = "serverless package" }, +] + +[[ecosystem]] +id = "aws-sam" +name = "AWS SAM" +badge = "sa" +markers = ["samconfig.toml"] +artifacts = [ + { path = ".aws-sam", kind = "build", regenerate = "sam build" }, +] diff --git a/crates/carwash-core/src/ecosystem/mod.rs b/crates/carwash-core/src/ecosystem/mod.rs new file mode 100644 index 0000000..1bbf432 --- /dev/null +++ b/crates/carwash-core/src/ecosystem/mod.rs @@ -0,0 +1,670 @@ +//! Declarative ecosystem rules and the compiled registry used during discovery. + +pub mod adapters; + +use crate::model::ArtifactKind; +use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +pub use adapters::Adapter; + +const BUILTIN_RULES: &str = include_str!("builtin.toml"); +const SCHEMA_VERSION: u32 = 1; + +/// Index of an ecosystem in its [`Registry`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EcoId(pub u16); + +/// Index of an artifact rule in its [`Registry`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RuleId(pub u32); + +#[derive(Debug, thiserror::Error)] +pub enum RulesError { + #[error("invalid rules file: {0}")] + Parse(#[from] toml::de::Error), + #[error("unsupported rules schema {found} (expected {SCHEMA_VERSION})")] + Schema { found: u32 }, + #[error("ecosystem `{ecosystem}`: invalid pattern `{pattern}`: {source}")] + Pattern { + ecosystem: String, + pattern: String, + source: globset::Error, + }, + #[error("ecosystem `{ecosystem}`: unknown adapter `{adapter}`")] + Adapter { ecosystem: String, adapter: String }, + #[error("ecosystem `{ecosystem}`: {message}")] + Invalid { ecosystem: String, message: String }, +} + +/// On-disk rules file. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RulesFile { + pub schema: u32, + #[serde(default)] + pub ecosystem: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EcosystemSpec { + pub id: String, + pub name: String, + #[serde(default)] + pub badge: Option, + #[serde(default)] + pub markers: Vec, + #[serde(default)] + pub weak_markers: Vec, + #[serde(default)] + pub lockfiles: Vec, + #[serde(default)] + pub adapter: Option, + #[serde(default)] + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactSpec { + pub path: String, + pub kind: ArtifactKind, + #[serde(default)] + pub anywhere: bool, + /// Also matches outside projects of this ecosystem; for names specific enough to be + /// unmistakable (`node_modules`, `__pycache__`). + #[serde(default)] + pub global: bool, + #[serde(default)] + pub ambiguous: bool, + #[serde(default)] + pub confirm: Vec, + #[serde(default)] + pub require_confirm: bool, + #[serde(default)] + pub regenerate: Option, +} + +/// One path segment of a marker or artifact pattern. +#[derive(Debug, Clone)] +enum Segment { + Exact(String), + Glob(GlobMatcher), +} + +impl Segment { + fn parse(ecosystem: &str, raw: &str) -> Result { + if raw.contains(['*', '?', '[', '{']) { + let glob = glob(raw).map_err(|source| RulesError::Pattern { + ecosystem: ecosystem.to_owned(), + pattern: raw.to_owned(), + source, + })?; + Ok(Segment::Glob(glob.compile_matcher())) + } else { + Ok(Segment::Exact(raw.to_owned())) + } + } + + fn matches(&self, name: &str) -> bool { + match self { + Segment::Exact(exact) => exact == name, + Segment::Glob(glob) => glob.is_match(name), + } + } +} + +fn glob(raw: &str) -> Result { + GlobBuilder::new(raw).literal_separator(true).build() +} + +fn split_segments(ecosystem: &str, raw: &str) -> Result, RulesError> { + let segments: Vec<&str> = raw.split('/').filter(|s| !s.is_empty()).collect(); + if segments.is_empty() || segments.iter().any(|s| *s == "." || *s == "..") { + return Err(RulesError::Invalid { + ecosystem: ecosystem.to_owned(), + message: format!("path `{raw}` must be relative and non-empty"), + }); + } + segments + .into_iter() + .map(|s| Segment::parse(ecosystem, s)) + .collect() +} + +/// A compiled ecosystem. +#[derive(Debug)] +pub struct Ecosystem { + pub id: EcoId, + pub key: String, + pub name: String, + pub badge: String, + /// Strong and weak markers as written in the rules. + pub markers: Vec, + pub lockfiles: Vec, + pub adapter: Option, + pub rules: Vec, + /// Marker and lockfile names checked for activity timestamps. + pub(crate) activity_files: Vec, +} + +/// A compiled artifact rule. +#[derive(Debug)] +pub struct ArtifactRule { + pub id: RuleId, + pub eco: EcoId, + pub path: String, + segments: Vec, + pub kind: ArtifactKind, + pub anywhere: bool, + pub global: bool, + pub ambiguous: bool, + confirm: Option, + pub require_confirm: bool, + pub regenerate: Option, +} + +impl ArtifactRule { + pub(crate) fn segment_count(&self) -> usize { + self.segments.len() + } + + pub(crate) fn segment_matches(&self, index: usize, name: &str) -> bool { + self.segments.get(index).is_some_and(|s| s.matches(name)) + } + + pub(crate) fn has_confirm(&self) -> bool { + self.confirm.is_some() + } + + pub(crate) fn confirms(&self, child_name: &str) -> bool { + self.confirm + .as_ref() + .is_some_and(|c| c.is_match(child_name)) + } +} + +#[derive(Debug, Clone)] +struct MarkerRef { + eco: EcoId, + weak: bool, + /// Remaining segments for nested markers such as `ProjectSettings/ProjectVersion.txt`. + rest: Option, +} + +/// Index from a directory entry name to the patterns whose first segment it matches. +#[derive(Debug)] +struct SegmentIndex { + exact: HashMap>, + globs: GlobSet, + glob_targets: Vec, +} + +impl SegmentIndex { + fn build(entries: Vec<(Segment, String, T)>) -> Self { + let mut exact: HashMap> = HashMap::new(); + let mut builder = GlobSetBuilder::new(); + let mut glob_targets = Vec::new(); + for (segment, raw, target) in entries { + match segment { + Segment::Exact(name) => exact.entry(name).or_default().push(target), + Segment::Glob(_) => { + builder.add(glob(&raw).expect("validated when the segment was parsed")); + glob_targets.push(target); + } + } + } + Self { + exact, + globs: builder.build().expect("validated globs"), + glob_targets, + } + } + + fn lookup(&self, name: &str, out: &mut Vec) { + if let Some(targets) = self.exact.get(name) { + out.extend(targets.iter().cloned()); + } + if !self.glob_targets.is_empty() { + for i in self.globs.matches(name) { + out.push(self.glob_targets[i].clone()); + } + } + } +} + +/// Result of checking a directory's entries for project markers. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MarkerMatch { + /// Ecosystems with a strong marker present. + pub strong: Vec, + /// Ecosystems with only weak markers present. + pub weak: Vec, +} + +impl MarkerMatch { + pub fn is_empty(&self) -> bool { + self.strong.is_empty() && self.weak.is_empty() + } +} + +/// Compiled set of ecosystem rules. +#[derive(Debug)] +pub struct Registry { + ecosystems: Vec, + rules: Vec, + markers: SegmentIndex, + /// Root-level rules per ecosystem, indexed by first segment. + root_rules: Vec>, + /// Rules matching at any depth below a project, per ecosystem. + anywhere_rules: Vec>, + /// Rules matching anywhere, even outside projects. + global_rules: SegmentIndex, + by_key: HashMap, +} + +impl Registry { + /// The built-in rules only. + pub fn builtin() -> Self { + Self::from_specs(builtin_specs()).expect("built-in rules are valid") + } + + /// Built-in rules with user overrides applied: an override with an existing `id` + /// replaces that ecosystem, a new `id` is appended. + pub fn with_overrides(user_rules: &str) -> Result { + let file = parse_rules(user_rules)?; + let mut specs = builtin_specs(); + for spec in file.ecosystem { + match specs.iter_mut().find(|s| s.id == spec.id) { + Some(existing) => *existing = spec, + None => specs.push(spec), + } + } + Self::from_specs(specs) + } + + pub fn from_specs(specs: Vec) -> Result { + let mut ecosystems = Vec::with_capacity(specs.len()); + let mut rules = Vec::new(); + let mut marker_entries = Vec::new(); + let mut root_entries: Vec> = Vec::new(); + let mut anywhere_entries: Vec> = Vec::new(); + let mut global_entries = Vec::new(); + let mut by_key = HashMap::new(); + + for (index, spec) in specs.into_iter().enumerate() { + let eco = EcoId(u16::try_from(index).map_err(|_| RulesError::Invalid { + ecosystem: spec.id.clone(), + message: "too many ecosystems".into(), + })?); + if by_key.insert(spec.id.clone(), eco).is_some() { + return Err(RulesError::Invalid { + ecosystem: spec.id, + message: "duplicate id".into(), + }); + } + if spec.markers.is_empty() && spec.weak_markers.is_empty() { + return Err(RulesError::Invalid { + ecosystem: spec.id, + message: "needs at least one marker".into(), + }); + } + let adapter = spec + .adapter + .as_deref() + .map(|name| { + Adapter::parse(name).ok_or_else(|| RulesError::Adapter { + ecosystem: spec.id.clone(), + adapter: name.to_owned(), + }) + }) + .transpose()?; + + for (raw, weak) in spec + .markers + .iter() + .map(|m| (m, false)) + .chain(spec.weak_markers.iter().map(|m| (m, true))) + { + let (first, rest) = match raw.split_once('/') { + Some((first, rest)) => (first, Some(rest.to_owned())), + None => (raw.as_str(), None), + }; + marker_entries.push(( + Segment::parse(&spec.id, first)?, + first.to_owned(), + MarkerRef { eco, weak, rest }, + )); + } + + let mut roots = Vec::new(); + let mut anywhere = Vec::new(); + let mut eco_rules = Vec::new(); + for artifact in &spec.artifacts { + let id = RuleId(rules.len() as u32); + let segments = split_segments(&spec.id, &artifact.path)?; + if (artifact.anywhere || artifact.global) && segments.len() != 1 { + return Err(RulesError::Invalid { + ecosystem: spec.id.clone(), + message: format!( + "`anywhere`/`global` artifacts must be a single segment: `{}`", + artifact.path + ), + }); + } + let first_raw = artifact + .path + .split('/') + .next() + .unwrap_or_default() + .to_owned(); + let entry = (segments[0].clone(), first_raw, id); + if artifact.global { + global_entries.push(entry.clone()); + } + if artifact.anywhere { + anywhere.push(entry); + } else { + roots.push(entry); + } + let confirm = if artifact.confirm.is_empty() { + None + } else { + let mut builder = GlobSetBuilder::new(); + for pattern in &artifact.confirm { + builder.add(glob(pattern).map_err(|source| RulesError::Pattern { + ecosystem: spec.id.clone(), + pattern: pattern.clone(), + source, + })?); + } + Some(builder.build().map_err(|source| RulesError::Pattern { + ecosystem: spec.id.clone(), + pattern: artifact.confirm.join(", "), + source, + })?) + }; + if artifact.require_confirm && confirm.is_none() { + return Err(RulesError::Invalid { + ecosystem: spec.id.clone(), + message: format!( + "`{}` requires confirmation but lists none", + artifact.path + ), + }); + } + rules.push(ArtifactRule { + id, + eco, + path: artifact.path.clone(), + segments, + kind: artifact.kind, + anywhere: artifact.anywhere, + global: artifact.global, + ambiguous: artifact.ambiguous, + confirm, + require_confirm: artifact.require_confirm, + regenerate: artifact.regenerate.clone(), + }); + eco_rules.push(id); + } + root_entries.push(roots); + anywhere_entries.push(anywhere); + + let activity_files = spec + .markers + .iter() + .chain(&spec.lockfiles) + .filter(|m| !m.contains(['*', '?', '[', '{', '/'])) + .cloned() + .collect(); + let badge = spec + .badge + .clone() + .unwrap_or_else(|| spec.id.chars().take(2).collect()); + ecosystems.push(Ecosystem { + id: eco, + key: spec.id, + name: spec.name, + badge, + markers: spec + .markers + .iter() + .chain(&spec.weak_markers) + .cloned() + .collect(), + lockfiles: spec.lockfiles, + adapter, + rules: eco_rules, + activity_files, + }); + } + + Ok(Self { + ecosystems, + rules, + markers: SegmentIndex::build(marker_entries), + root_rules: root_entries.into_iter().map(SegmentIndex::build).collect(), + anywhere_rules: anywhere_entries + .into_iter() + .map(SegmentIndex::build) + .collect(), + global_rules: SegmentIndex::build(global_entries), + by_key, + }) + } + + pub fn ecosystems(&self) -> &[Ecosystem] { + &self.ecosystems + } + + pub fn ecosystem(&self, id: EcoId) -> &Ecosystem { + &self.ecosystems[usize::from(id.0)] + } + + pub fn find(&self, key: &str) -> Option { + self.by_key.get(key).copied() + } + + pub fn rule(&self, id: RuleId) -> &ArtifactRule { + &self.rules[id.0 as usize] + } + + pub fn rules(&self) -> &[ArtifactRule] { + &self.rules + } + + /// Checks directory entry names for project markers. `dir` is used to resolve nested + /// markers, which cost one `stat` each and only when their first segment is present. + pub fn detect_markers<'a>( + &self, + dir: &Path, + names: impl IntoIterator, + ) -> MarkerMatch { + let mut refs = Vec::new(); + for name in names { + let start = refs.len(); + self.markers.lookup(name, &mut refs); + // Resolve nested markers relative to the matching entry. + let mut i = start; + while i < refs.len() { + let keep = match &refs[i].rest { + Some(rest) => dir.join(name).join(rest).exists(), + None => true, + }; + if keep { + i += 1; + } else { + refs.swap_remove(i); + } + } + } + let mut result = MarkerMatch::default(); + for marker in &refs { + if !marker.weak && !result.strong.contains(&marker.eco) { + result.strong.push(marker.eco); + } + } + for marker in &refs { + if marker.weak + && !result.strong.contains(&marker.eco) + && !result.weak.contains(&marker.eco) + { + result.weak.push(marker.eco); + } + } + result.strong.sort_unstable(); + result.weak.sort_unstable(); + result + } + + /// Root-level rules of `eco` whose first segment matches `name`. + pub(crate) fn root_rules_for(&self, eco: EcoId, name: &str, out: &mut Vec) { + self.root_rules[usize::from(eco.0)].lookup(name, out); + } + + /// `anywhere` rules of `eco` matching `name`. + pub(crate) fn anywhere_rules_for(&self, eco: EcoId, name: &str, out: &mut Vec) { + self.anywhere_rules[usize::from(eco.0)].lookup(name, out); + } + + /// `global` rules of any ecosystem matching `name`. + pub(crate) fn global_rules_for(&self, name: &str, out: &mut Vec) { + self.global_rules.lookup(name, out); + } + + pub(crate) fn has_anywhere_rules(&self, eco: EcoId) -> bool { + let index = &self.anywhere_rules[usize::from(eco.0)]; + !index.exact.is_empty() || !index.glob_targets.is_empty() + } +} + +impl Default for Registry { + fn default() -> Self { + Self::builtin() + } +} + +fn parse_rules(source: &str) -> Result { + let file: RulesFile = toml::from_str(source)?; + if file.schema != SCHEMA_VERSION { + return Err(RulesError::Schema { found: file.schema }); + } + Ok(file) +} + +/// The built-in ecosystem specifications. +pub fn builtin_specs() -> Vec { + parse_rules(BUILTIN_RULES) + .expect("built-in rules parse") + .ecosystem +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_rules_compile() { + let registry = Registry::builtin(); + assert!(registry.ecosystems().len() >= 40); + assert!(registry.find("rust").is_some()); + assert!(registry.find("node").is_some()); + } + + #[test] + fn markers_match_exact_glob_and_weak() { + let registry = Registry::builtin(); + let rust = registry.find("rust").unwrap(); + let dotnet = registry.find("dotnet").unwrap(); + let cmake = registry.find("cmake").unwrap(); + let found = registry.detect_markers( + Path::new("/nonexistent"), + ["Cargo.toml", "App.csproj", "CMakeLists.txt", "README.md"], + ); + assert_eq!(found.strong, vec![rust, dotnet]); + assert_eq!(found.weak, vec![cmake]); + } + + #[test] + fn nested_markers_require_the_full_path() { + let dir = tempfile::tempdir().unwrap(); + let registry = Registry::builtin(); + std::fs::create_dir(dir.path().join("ProjectSettings")).unwrap(); + assert!( + registry + .detect_markers(dir.path(), ["ProjectSettings"]) + .is_empty() + ); + std::fs::write(dir.path().join("ProjectSettings/ProjectVersion.txt"), "").unwrap(); + let found = registry.detect_markers(dir.path(), ["ProjectSettings"]); + assert_eq!(found.strong, vec![registry.find("unity").unwrap()]); + } + + #[test] + fn root_rules_lookup_by_first_segment() { + let registry = Registry::builtin(); + let node = registry.find("node").unwrap(); + let mut out = Vec::new(); + registry.root_rules_for(node, "node_modules", &mut out); + assert_eq!(out.len(), 1); + assert_eq!(registry.rule(out[0]).path, "node_modules"); + out.clear(); + registry.root_rules_for(node, ".nx", &mut out); + assert_eq!( + out.len(), + 2, + "both .nx/cache and .nx/workspace-data start with .nx" + ); + } + + #[test] + fn overrides_replace_and_extend() { + let registry = Registry::with_overrides( + r#" + schema = 1 + [[ecosystem]] + id = "rust" + name = "Rust (custom)" + markers = ["Cargo.toml"] + artifacts = [{ path = "target", kind = "build" }, { path = "out", kind = "build" }] + + [[ecosystem]] + id = "acme" + name = "Acme" + markers = ["acme.yml"] + artifacts = [{ path = ".acme", kind = "cache" }] + "#, + ) + .unwrap(); + let rust = registry.find("rust").unwrap(); + assert_eq!(registry.ecosystem(rust).name, "Rust (custom)"); + assert_eq!(registry.ecosystem(rust).rules.len(), 2); + assert!(registry.find("acme").is_some()); + } + + #[test] + fn invalid_rules_are_rejected() { + let bad_path = r#" + schema = 1 + [[ecosystem]] + id = "x" + name = "X" + markers = ["x"] + artifacts = [{ path = "../escape", kind = "build" }] + "#; + assert!(matches!( + Registry::with_overrides(bad_path), + Err(RulesError::Invalid { .. }) + )); + assert!(matches!( + Registry::with_overrides("schema = 2"), + Err(RulesError::Schema { found: 2 }) + )); + } +} diff --git a/crates/carwash-core/src/fmt.rs b/crates/carwash-core/src/fmt.rs new file mode 100644 index 0000000..15c0e52 --- /dev/null +++ b/crates/carwash-core/src/fmt.rs @@ -0,0 +1,120 @@ +//! Human-readable sizes and ages, and parsers for their CLI forms. + +use std::time::Duration; + +const UNITS: [&str; 6] = ["B", "kB", "MB", "GB", "TB", "PB"]; + +/// Formats bytes with decimal (SI) units, matching Finder and most disk tools: `1.5 GB`. +pub fn bytes(n: u64) -> String { + if n < 1000 { + return format!("{n} B"); + } + let mut value = n as f64; + let mut unit = 0; + while value >= 1000.0 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + if value >= 100.0 { + format!("{value:.0} {}", UNITS[unit]) + } else if value >= 10.0 { + format!("{value:.1} {}", UNITS[unit]) + } else { + format!("{value:.2} {}", UNITS[unit]) + } +} + +/// Parses `500`, `100MB`, `1.5 GiB`, `2g`... Decimal units are powers of 1000, `*iB` of 1024. +pub fn parse_bytes(input: &str) -> Option { + let input = input.trim(); + let split = input + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(input.len()); + let (number, unit) = input.split_at(split); + let number: f64 = number.parse().ok()?; + let multiplier: f64 = match unit.trim().to_ascii_lowercase().as_str() { + "" | "b" => 1.0, + "k" | "kb" => 1e3, + "m" | "mb" => 1e6, + "g" | "gb" => 1e9, + "t" | "tb" => 1e12, + "kib" => 1024.0, + "mib" => 1024.0 * 1024.0, + "gib" => 1024.0 * 1024.0 * 1024.0, + "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0, + _ => return None, + }; + let value = number * multiplier; + (value.is_finite() && value >= 0.0).then_some(value as u64) +} + +/// Compact age: `now`, `5m`, `3h`, `12d`, `8w`, `4mo`, `2y`. +pub fn age(duration: Duration) -> String { + let secs = duration.as_secs(); + const MIN: u64 = 60; + const HOUR: u64 = 60 * MIN; + const DAY: u64 = 24 * HOUR; + match secs { + s if s < MIN => "now".into(), + s if s < HOUR => format!("{}m", s / MIN), + s if s < DAY => format!("{}h", s / HOUR), + s if s < 14 * DAY => format!("{}d", s / DAY), + s if s < 60 * DAY => format!("{}w", s / (7 * DAY)), + s if s < 365 * DAY => format!("{}mo", s / (30 * DAY)), + s => format!("{}y", s / (365 * DAY)), + } +} + +/// Parses `30d`, `2w`, `6mo`, `1y`, `12h`, `90m`. +pub fn parse_age(input: &str) -> Option { + let input = input.trim().to_ascii_lowercase(); + let split = input.find(|c: char| !c.is_ascii_digit())?; + let (number, unit) = input.split_at(split); + let number: u64 = number.parse().ok()?; + let secs = match unit { + "m" | "min" | "mins" => 60, + "h" | "hr" | "hrs" | "hour" | "hours" => 3600, + "d" | "day" | "days" => 86_400, + "w" | "wk" | "week" | "weeks" => 7 * 86_400, + "mo" | "month" | "months" => 30 * 86_400, + "y" | "yr" | "year" | "years" => 365 * 86_400, + _ => return None, + }; + number.checked_mul(secs).map(Duration::from_secs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_bytes() { + assert_eq!(bytes(0), "0 B"); + assert_eq!(bytes(999), "999 B"); + assert_eq!(bytes(1_500), "1.50 kB"); + assert_eq!(bytes(52_300_000_000), "52.3 GB"); + assert_eq!(bytes(140_000_000_000), "140 GB"); + } + + #[test] + fn parses_bytes() { + assert_eq!(parse_bytes("500"), Some(500)); + assert_eq!(parse_bytes("100MB"), Some(100_000_000)); + assert_eq!(parse_bytes("1.5 gb"), Some(1_500_000_000)); + assert_eq!(parse_bytes("1GiB"), Some(1 << 30)); + assert_eq!(parse_bytes("2x"), None); + assert_eq!(parse_bytes("abc"), None); + } + + #[test] + fn formats_and_parses_ages() { + assert_eq!(age(Duration::from_secs(5)), "now"); + assert_eq!(age(Duration::from_secs(3 * 86_400)), "3d"); + assert_eq!(age(Duration::from_secs(21 * 86_400)), "3w"); + assert_eq!(age(Duration::from_secs(400 * 86_400)), "1y"); + assert_eq!(parse_age("30d"), Some(Duration::from_secs(30 * 86_400))); + assert_eq!(parse_age("6mo"), Some(Duration::from_secs(180 * 86_400))); + assert_eq!(parse_age("2W"), Some(Duration::from_secs(14 * 86_400))); + assert_eq!(parse_age("soon"), None); + } +} diff --git a/crates/carwash-core/src/git.rs b/crates/carwash-core/src/git.rs new file mode 100644 index 0000000..263a7ca --- /dev/null +++ b/crates/carwash-core/src/git.rs @@ -0,0 +1,202 @@ +//! Batched git inspection of artifact directories. +//! +//! One `git ls-files` and one `git check-ignore` per repository answer, for every +//! candidate at once, whether it contains tracked files and whether it is ignored. + +use crate::model::GitState; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Pathspecs per `git ls-files` invocation, to stay well under argument-length limits. +const CHUNK: usize = 256; + +/// Inspects `paths` (absolute, all inside `repo`) and returns their git state in order. +/// Returns `GitState::Unknown` for every path when git is unavailable or fails. +pub fn inspect(repo: &Path, paths: &[PathBuf]) -> Vec { + let relative: Vec> = paths + .iter() + .map(|p| { + p.strip_prefix(repo) + .ok() + .and_then(|r| r.to_str()) + .map(|r| r.replace(std::path::MAIN_SEPARATOR, "/")) + .filter(|r| !r.is_empty()) + }) + .collect(); + let valid: Vec<&str> = relative.iter().flatten().map(String::as_str).collect(); + if valid.is_empty() { + return vec![GitState::Unknown; paths.len()]; + } + + let Some(tracked) = tracked_counts(repo, &valid) else { + return vec![GitState::Unknown; paths.len()]; + }; + let untracked: Vec<&str> = valid + .iter() + .copied() + .filter(|r| !tracked.contains_key(*r)) + .collect(); + let ignored = ignored_set(repo, &untracked); + + relative + .iter() + .map(|rel| match rel { + None => GitState::Unknown, + Some(rel) => match tracked.get(rel.as_str()) { + Some(&count) => GitState::Tracked(count), + None => match &ignored { + Some(ignored) if ignored.contains_key(rel.as_str()) => GitState::Ignored, + Some(_) => GitState::Untracked, + None => GitState::Unknown, + }, + }, + }) + .collect() +} + +fn git(repo: &Path) -> Command { + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(repo) + .env("GIT_OPTIONAL_LOCKS", "0") + .stdin(Stdio::null()) + .stderr(Stdio::null()); + cmd +} + +/// Number of tracked files below each relative directory that has any. +fn tracked_counts<'a>(repo: &Path, relative: &[&'a str]) -> Option> { + let mut counts: HashMap<&'a str, u64> = HashMap::new(); + for chunk in relative.chunks(CHUNK) { + // Literal pathspecs so names containing `*` or `?` are not treated as globs. + // (`check-ignore` rejects this setting, so it is scoped to `ls-files`.) + let output = git(repo) + .env("GIT_LITERAL_PATHSPECS", "1") + .args(["ls-files", "-z", "--cached", "--"]) + .args(chunk) + .output() + .ok()?; + if !output.status.success() { + return None; + } + for file in output.stdout.split(|&b| b == 0).filter(|f| !f.is_empty()) { + let Ok(file) = std::str::from_utf8(file) else { + continue; + }; + if let Some(dir) = chunk.iter().find(|dir| { + file.len() > dir.len() + && file.starts_with(**dir) + && file.as_bytes()[dir.len()] == b'/' + }) { + *counts.entry(dir).or_default() += 1; + } else if let Some(dir) = chunk.iter().find(|dir| file == **dir) { + // A submodule (gitlink) is listed as the directory itself. + *counts.entry(dir).or_default() += 1; + } + } + } + Some(counts) +} + +/// Relative directories matched by an ignore rule. +fn ignored_set<'a>(repo: &Path, relative: &[&'a str]) -> Option> { + if relative.is_empty() { + return Some(HashMap::new()); + } + let mut child = git(repo) + .args(["check-ignore", "-z", "--stdin", "--no-index"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .ok()?; + let mut stdin = child.stdin.take()?; + // Write on a separate thread while this one drains stdout, so neither pipe can fill up + // and deadlock. + let output = std::thread::scope(|s| { + s.spawn(move || -> std::io::Result<()> { + for rel in relative { + // A trailing slash lets directory-only patterns such as `target/` match. + stdin.write_all(rel.as_bytes())?; + stdin.write_all(b"/\0")?; + } + Ok(()) + }); + child.wait_with_output() + }) + .ok()?; + // Exit status 1 means "nothing ignored"; 128 is a fatal error. + if output.status.code() == Some(128) { + return None; + } + let mut ignored = HashMap::new(); + for path in output.stdout.split(|&b| b == 0).filter(|p| !p.is_empty()) { + let Ok(path) = std::str::from_utf8(path) else { + continue; + }; + let path = path.trim_end_matches('/'); + if let Some(rel) = relative.iter().find(|r| **r == path) { + ignored.insert(*rel, ()); + } + } + Some(ignored) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn git_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) + } + + fn run(repo: &Path, args: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git {args:?}"); + } + + #[test] + fn classifies_tracked_ignored_and_untracked() { + if !git_available() { + return; + } + let repo = tempfile::tempdir().unwrap(); + let root = repo.path(); + run(root, &["init", "-q"]); + fs::write(root.join(".gitignore"), "target/\n").unwrap(); + for dir in ["target", "dist", "build"] { + fs::create_dir(root.join(dir)).unwrap(); + fs::write(root.join(dir).join("file"), dir).unwrap(); + } + run(root, &["add", "dist/file", ".gitignore"]); + + let states = inspect( + root, + &[root.join("target"), root.join("dist"), root.join("build")], + ); + assert_eq!( + states, + vec![GitState::Ignored, GitState::Tracked(1), GitState::Untracked] + ); + } + + #[test] + fn outside_paths_are_unknown() { + let states = inspect(Path::new("/repo"), &[PathBuf::from("/elsewhere/target")]); + assert_eq!(states, vec![GitState::Unknown]); + } +} diff --git a/crates/carwash-core/src/history.rs b/crates/carwash-core/src/history.rs new file mode 100644 index 0000000..a3ff40c --- /dev/null +++ b/crates/carwash-core/src/history.rs @@ -0,0 +1,100 @@ +//! Append-only journal of reclaimed space. + +use crate::clean::DeleteMode; +use crate::model::ArtifactKind; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Record { + /// Unix seconds. + pub at: u64, + pub path: PathBuf, + pub bytes: u64, + pub kind: ArtifactKind, + #[serde(default)] + pub ecosystem: Option, + pub mode: DeleteMode, +} + +impl Record { + pub fn now( + path: PathBuf, + bytes: u64, + kind: ArtifactKind, + ecosystem: Option, + mode: DeleteMode, + ) -> Self { + Self { + at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + path, + bytes, + kind, + ecosystem, + mode, + } + } +} + +pub fn append(file: &Path, records: &[Record]) -> std::io::Result<()> { + if records.is_empty() { + return Ok(()); + } + if let Some(dir) = file.parent() { + std::fs::create_dir_all(dir)?; + } + let mut out = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(file)?; + let mut buf = Vec::new(); + for record in records { + serde_json::to_writer(&mut buf, record).map_err(std::io::Error::other)?; + buf.push(b'\n'); + } + out.write_all(&buf) +} + +/// Reads every well-formed record; malformed lines are skipped. +pub fn read(file: &Path) -> Vec { + let Ok(text) = std::fs::read_to_string(file) else { + return Vec::new(); + }; + text.lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_and_reads_back() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("h/history.jsonl"); + let record = Record::now( + PathBuf::from("/p/target"), + 1_000, + ArtifactKind::Build, + Some("rust".into()), + DeleteMode::Permanent, + ); + append(&file, std::slice::from_ref(&record)).unwrap(); + append(&file, &[record]).unwrap(); + std::fs::OpenOptions::new() + .append(true) + .open(&file) + .unwrap() + .write_all(b"garbage\n") + .unwrap(); + let records = read(&file); + assert_eq!(records.len(), 2); + assert_eq!(records[0].bytes, 1_000); + } +} diff --git a/crates/carwash-core/src/lib.rs b/crates/carwash-core/src/lib.rs new file mode 100644 index 0000000..a1743fc --- /dev/null +++ b/crates/carwash-core/src/lib.rs @@ -0,0 +1,54 @@ +//! Engine behind [carwash](https://github.com/epistates/carwash): finds projects of every +//! major ecosystem under a directory, recognises their build outputs, dependency installs, +//! caches and environments, measures what deleting them would really free, and deletes them +//! safely. +//! +//! The pipeline, run by [`Engine::scan`]: +//! +//! 1. [`discover`]: a parallel walk that classifies directories by entry names only and +//! never descends into artifacts. +//! 2. [`git`] and [`measure`] in parallel: tracked/ignored status per artifact, and +//! allocated, hard-link-aware sizes. +//! +//! Ecosystems are data ([`ecosystem::builtin_specs`]); users extend or override them with a +//! TOML file in the same format. + +pub mod cache; +pub mod clean; +pub mod discover; +pub mod ecosystem; +pub mod fmt; +pub mod git; +pub mod history; +pub mod measure; +pub mod model; +pub mod paths; +pub mod safety; +pub mod scan; +pub mod select; + +pub use discover::{Counters, DiscoverOptions}; +pub use ecosystem::{EcoId, Registry, RuleId}; +pub use model::*; +pub use scan::{Engine, ScanEvent, ScanOptions, Snapshot, Target}; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Cooperative cancellation shared between a frontend and engine work. +#[derive(Debug, Clone, Default)] +pub struct Cancel(Arc); + +impl Cancel { + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} diff --git a/crates/carwash-core/src/measure.rs b/crates/carwash-core/src/measure.rs new file mode 100644 index 0000000..1db918a --- /dev/null +++ b/crates/carwash-core/src/measure.rs @@ -0,0 +1,266 @@ +//! Phase 2: parallel size measurement of artifact directories. +//! +//! Sizes are allocated bytes (`st_blocks * 512` on Unix), not apparent lengths. Hard-linked +//! inodes are counted once, and only count towards `reclaimable` when every link lives +//! inside the measured tree: deleting a pnpm or uv `node_modules`/`.venv` whose files are +//! hard links into a global store frees almost nothing, and the numbers say so. + +use crate::Cancel; +use crate::model::Size; +use dashmap::DashMap; +use std::fs; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// A hard-linked inode seen during measurement. +#[derive(Debug, Clone, Copy)] +struct Linked { + nlink: u64, + seen: u64, + bytes: u64, +} + +#[derive(Debug, Default)] +struct Totals { + single_link_bytes: AtomicU64, + apparent: AtomicU64, + files: AtomicU64, + dirs: AtomicU64, + errors: AtomicU64, + newest_secs: AtomicU64, +} + +/// File identity for hard-link accounting: `(device, inode)`. +type Identity = (u64, u64); + +struct Measurer<'a> { + totals: Totals, + linked: DashMap, + cancel: &'a Cancel, +} + +/// Measures `path` on the current rayon pool. Unreadable entries are counted in +/// [`Size::errors`] and otherwise skipped; symlinks are measured, never followed. +pub fn measure(path: &Path, cancel: &Cancel) -> Size { + let measurer = Measurer { + totals: Totals::default(), + linked: DashMap::new(), + cancel, + }; + match fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => { + measurer.account(&meta); + rayon::scope(|scope| measurer.walk(scope, path.to_path_buf())); + } + Ok(meta) => measurer.account(&meta), + Err(_) => { + measurer.totals.errors.fetch_add(1, Ordering::Relaxed); + } + } + measurer.finish() +} + +impl Measurer<'_> { + fn walk<'s>(&'s self, scope: &rayon::Scope<'s>, dir: std::path::PathBuf) { + if self.cancel.is_cancelled() { + return; + } + let Ok(read_dir) = fs::read_dir(&dir) else { + self.totals.errors.fetch_add(1, Ordering::Relaxed); + return; + }; + for entry in read_dir { + let Ok(entry) = entry else { + self.totals.errors.fetch_add(1, Ordering::Relaxed); + continue; + }; + // `DirEntry::metadata` does not traverse symlinks. + let Ok(meta) = entry.metadata() else { + self.totals.errors.fetch_add(1, Ordering::Relaxed); + continue; + }; + self.account(&meta); + if meta.is_dir() { + let child = entry.path(); + scope.spawn(move |scope| self.walk(scope, child)); + } + } + } + + fn account(&self, meta: &fs::Metadata) { + let totals = &self.totals; + if meta.is_dir() { + totals.dirs.fetch_add(1, Ordering::Relaxed); + } else { + totals.files.fetch_add(1, Ordering::Relaxed); + totals.apparent.fetch_add(meta.len(), Ordering::Relaxed); + } + if let Ok(modified) = meta.modified() + && let Ok(since_epoch) = modified.duration_since(UNIX_EPOCH) + { + totals + .newest_secs + .fetch_max(since_epoch.as_secs(), Ordering::Relaxed); + } + let bytes = allocated_bytes(meta); + match link_identity(meta) { + Some((identity, nlink)) if nlink > 1 && !meta.is_dir() => { + self.linked + .entry(identity) + .and_modify(|l| l.seen += 1) + .or_insert(Linked { + nlink, + seen: 1, + bytes, + }); + } + _ => { + totals.single_link_bytes.fetch_add(bytes, Ordering::Relaxed); + } + } + } + + fn finish(self) -> Size { + let totals = self.totals; + let single = totals.single_link_bytes.into_inner(); + let mut on_disk = single; + let mut reclaimable = single; + for (_, linked) in self.linked { + on_disk += linked.bytes; + if linked.seen >= linked.nlink { + reclaimable += linked.bytes; + } + } + let newest = totals.newest_secs.into_inner(); + Size { + on_disk, + reclaimable, + apparent: totals.apparent.into_inner(), + files: totals.files.into_inner(), + dirs: totals.dirs.into_inner(), + newest: (newest > 0).then(|| UNIX_EPOCH + Duration::from_secs(newest)), + errors: totals.errors.into_inner(), + } + } +} + +#[cfg(unix)] +fn allocated_bytes(meta: &fs::Metadata) -> u64 { + use std::os::unix::fs::MetadataExt; + meta.blocks() * 512 +} + +#[cfg(not(unix))] +fn allocated_bytes(meta: &fs::Metadata) -> u64 { + // Round up to a 4 KiB cluster; there is no portable allocation query. + meta.len().div_ceil(4096) * 4096 +} + +#[cfg(unix)] +fn link_identity(meta: &fs::Metadata) -> Option<(Identity, u64)> { + use std::os::unix::fs::MetadataExt; + Some(((meta.dev(), meta.ino()), meta.nlink())) +} + +#[cfg(not(unix))] +fn link_identity(_meta: &fs::Metadata) -> Option<(Identity, u64)> { + None +} + +/// Newest of two optional timestamps. +pub fn newest(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(path: &Path, bytes: usize) { + fs::write(path, vec![7u8; bytes]).unwrap(); + } + + #[test] + fn counts_files_dirs_and_allocation() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("a/b")).unwrap(); + write(&dir.path().join("a/one"), 10_000); + write(&dir.path().join("a/b/two"), 20_000); + let size = measure(dir.path(), &Cancel::new()); + assert_eq!(size.files, 2); + assert_eq!(size.dirs, 3); + assert_eq!(size.apparent, 30_000); + assert!( + size.on_disk >= 30_000, + "allocation covers content: {size:?}" + ); + assert_eq!(size.on_disk, size.reclaimable); + assert_eq!(size.errors, 0); + assert!(size.newest.is_some()); + } + + #[cfg(unix)] + #[test] + fn hard_links_count_once_and_shared_links_are_not_reclaimable() { + let root = tempfile::tempdir().unwrap(); + let store = root.path().join("store"); + let project = root.path().join("project"); + fs::create_dir_all(&store).unwrap(); + fs::create_dir_all(project.join("node_modules")).unwrap(); + write(&store.join("pkg"), 64 * 1024); + fs::hard_link(store.join("pkg"), project.join("node_modules/pkg")).unwrap(); + // Two links inside the tree to one inode. + write(&project.join("node_modules/own"), 64 * 1024); + fs::hard_link( + project.join("node_modules/own"), + project.join("node_modules/own2"), + ) + .unwrap(); + + let size = measure(&project.join("node_modules"), &Cancel::new()); + assert_eq!(size.files, 3); + let dir_bytes = measure_dir_only(&project.join("node_modules")); + // `own` counted once; `pkg` counted in on_disk but not reclaimable. + assert_eq!(size.shared(), allocated(&store.join("pkg"))); + assert_eq!( + size.reclaimable, + allocated(&project.join("node_modules/own")) + dir_bytes + ); + } + + #[cfg(unix)] + fn allocated(path: &Path) -> u64 { + allocated_bytes(&fs::symlink_metadata(path).unwrap()) + } + + #[cfg(unix)] + fn measure_dir_only(path: &Path) -> u64 { + allocated(path) + } + + #[cfg(unix)] + #[test] + fn symlinks_are_not_followed() { + let root = tempfile::tempdir().unwrap(); + let outside = root.path().join("outside"); + let tree = root.path().join("tree"); + fs::create_dir_all(&outside).unwrap(); + fs::create_dir_all(&tree).unwrap(); + write(&outside.join("big"), 1024 * 1024); + std::os::unix::fs::symlink(&outside, tree.join("link")).unwrap(); + let size = measure(&tree, &Cancel::new()); + assert!(size.apparent < 1024 * 1024); + assert_eq!(size.files, 1, "the symlink itself"); + } + + #[test] + fn missing_paths_report_an_error() { + let size = measure(Path::new("/definitely/not/here"), &Cancel::new()); + assert_eq!(size.errors, 1); + assert_eq!(size.on_disk, 0); + } +} diff --git a/crates/carwash-core/src/model.rs b/crates/carwash-core/src/model.rs new file mode 100644 index 0000000..6508b47 --- /dev/null +++ b/crates/carwash-core/src/model.rs @@ -0,0 +1,264 @@ +//! Domain model shared by every carwash frontend. + +use crate::ecosystem::{EcoId, RuleId}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::SystemTime; + +/// Stable identifier of a project within one scan. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProjectId(pub u32); + +/// Stable identifier of an artifact within one scan. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ArtifactId(pub u32); + +/// A directory recognised as a project by one or more ecosystems. +#[derive(Debug, Clone, Serialize)] +pub struct Project { + pub id: ProjectId, + pub path: PathBuf, + /// Manifest name when an adapter could read it, otherwise the directory name. + pub name: String, + pub ecosystems: Vec, + /// Nearest enclosing project, if any. + pub parent: Option, + /// Workspace/monorepo this project is a declared member of. + pub member_of: Option, + /// True when the project declares workspace members (Cargo workspace, pnpm workspace, go.work...). + pub is_workspace: bool, + /// Root of the git repository containing the project. + pub repo: Option, + /// Newest modification time among manifests and lockfiles. + #[serde(with = "unix_secs_opt")] + pub last_activity: Option, + /// Project lies above the scan root (the scan started inside it). + pub outside_root: bool, +} + +/// What an artifact directory contains, which drives how costly it is to regenerate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ArtifactKind { + /// Compiler and bundler output. + Build, + /// Installed third-party packages. + Dependencies, + /// Tool caches. + Cache, + /// Virtual environments and toolchain switches. + Environment, + /// Reports, logs, coverage and other generated output. + Other, + /// Half-deleted directories left behind by an interrupted carwash clean. + Leftover, +} + +impl ArtifactKind { + pub const ALL: [Self; 6] = [ + Self::Build, + Self::Dependencies, + Self::Cache, + Self::Environment, + Self::Other, + Self::Leftover, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Build => "build", + Self::Dependencies => "deps", + Self::Cache => "cache", + Self::Environment => "env", + Self::Other => "other", + Self::Leftover => "leftover", + } + } + + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "build" => Self::Build, + "deps" | "dependencies" => Self::Dependencies, + "cache" => Self::Cache, + "env" | "environment" => Self::Environment, + "other" => Self::Other, + "leftover" => Self::Leftover, + _ => return None, + }) + } +} + +/// How an artifact was recognised. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Detection { + /// Matched an ecosystem rule; `confirmed` when a confirming child entry was present. + Rule { rule: RuleId, confirmed: bool }, + /// Contains a `CACHEDIR.TAG` with a valid signature. + CacheDirTag, + /// Contains `pyvenv.cfg`. + PythonVenv, + /// Contains `conda-meta/`. + CondaEnv, + /// Contains `CMakeCache.txt`. + CMakeBuild, + /// Contains `meson-private/` and `build.ninja`. + MesonBuild, + /// A `target/` holding Cargo's `debug/.fingerprint` or `release/.fingerprint`. + CargoFingerprint, + /// Named like a carwash staging directory. + CarwashLeftover, +} + +impl Detection { + /// Detected by content rather than by a possibly-generic name. + pub fn is_certain(self) -> bool { + match self { + Detection::Rule { confirmed, .. } => confirmed, + _ => true, + } + } +} + +/// Git's view of an artifact directory. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "snake_case", tag = "state", content = "files")] +pub enum GitState { + /// Not yet inspected, or git is unavailable. + #[default] + Unknown, + NotInRepo, + /// Matched by an ignore rule and contains no tracked files. + Ignored, + /// Neither ignored nor tracked; it would show up in `git status`. + Untracked, + /// Contains this many tracked files. + Tracked(u64), +} + +/// A deletable directory. +#[derive(Debug, Clone, Serialize)] +pub struct Artifact { + pub id: ArtifactId, + pub path: PathBuf, + /// Owning project; `None` for content-detected directories outside any project. + pub project: Option, + pub ecosystem: Option, + pub kind: ArtifactKind, + pub detection: Detection, + /// Generic name that some repositories commit (`build`, `dist`, `vendor`...). + pub ambiguous: bool, + /// Modification time of the directory itself; refined by measurement. + #[serde(with = "unix_secs_opt")] + pub modified: Option, + pub git: GitState, + /// Root of the git repository containing the artifact. + pub repo: Option, + pub size: Option, + /// Lies above the scan root, e.g. a workspace `target/` when scanning a member. + pub outside_root: bool, +} + +impl Artifact { + pub fn name(&self) -> &str { + self.path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + } + + /// Newest known modification time, preferring the measured value. + pub fn last_modified(&self) -> Option { + self.size.and_then(|s| s.newest).or(self.modified) + } + + pub fn safety(&self) -> Safety { + crate::safety::assess(self) + } +} + +/// Sizes of an artifact directory tree. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Size { + /// Allocated bytes, counting each hard-linked inode once. + pub on_disk: u64, + /// Bytes freed by deleting the directory: hard-linked files are only counted when + /// every link lives inside it. + pub reclaimable: u64, + /// Sum of file lengths. + pub apparent: u64, + pub files: u64, + pub dirs: u64, + /// Newest modification time anywhere in the tree. + #[serde(with = "unix_secs_opt")] + pub newest: Option, + /// Entries that could not be read; the totals are lower bounds when non-zero. + pub errors: u64, +} + +impl Size { + /// Bytes shared with something outside the directory (for example a pnpm store). + pub fn shared(&self) -> u64 { + self.on_disk.saturating_sub(self.reclaimable) + } +} + +/// Whether an artifact may be deleted without extra confirmation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case", tag = "level", content = "reason")] +pub enum Safety { + Safe, + /// Deletable, but only after the user looks at it. + Review(ReviewReason), + /// Never deleted by carwash. + Protected(ProtectReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewReason { + /// Generic name without confirming content, and git does not say it is ignored. + GenericName, + /// Visible to git as untracked: it may be work in progress. + UntrackedInGit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProtectReason { + /// Contains files tracked by git. + TrackedFiles, +} + +impl Safety { + pub fn describe(self) -> &'static str { + match self { + Safety::Safe => "safe", + Safety::Review(ReviewReason::GenericName) => "generic name, not confirmed by git", + Safety::Review(ReviewReason::UntrackedInGit) => "not ignored by git", + Safety::Protected(ProtectReason::TrackedFiles) => "contains git-tracked files", + } + } + + pub fn is_deletable(self) -> bool { + !matches!(self, Safety::Protected(_)) + } +} + +pub(crate) mod unix_secs_opt { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + pub fn serialize(t: &Option, s: S) -> Result { + match t.and_then(|t| t.duration_since(UNIX_EPOCH).ok()) { + Some(d) => s.serialize_some(&d.as_secs()), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Option::::deserialize(d)?.map(|secs| UNIX_EPOCH + Duration::from_secs(secs))) + } +} diff --git a/crates/carwash-core/src/paths.rs b/crates/carwash-core/src/paths.rs new file mode 100644 index 0000000..1e07533 --- /dev/null +++ b/crates/carwash-core/src/paths.rs @@ -0,0 +1,76 @@ +//! Locations of carwash's own files. +//! +//! XDG layout on every Unix, including macOS (`~/.config/carwash`, `~/.cache/carwash`...), +//! which is what developers expect from CLI tools. `CARWASH_HOME` puts everything under one +//! directory, which tests and portable installs use. + +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct Dirs { + pub config: PathBuf, + pub cache: PathBuf, + pub data: PathBuf, + pub state: PathBuf, +} + +impl Dirs { + pub fn discover() -> Option { + if let Some(home) = std::env::var_os("CARWASH_HOME").filter(|v| !v.is_empty()) { + let home = PathBuf::from(home); + return Some(Self { + config: home.join("config"), + cache: home.join("cache"), + data: home.join("data"), + state: home.join("state"), + }); + } + use etcetera::BaseStrategy; + let base = etcetera::choose_base_strategy().ok()?; + let app = "carwash"; + Some(Self { + config: base.config_dir().join(app), + cache: base.cache_dir().join(app), + data: base.data_dir().join(app), + state: base + .state_dir() + .unwrap_or_else(|| base.data_dir()) + .join(app), + }) + } + + pub fn config_file(&self) -> PathBuf { + self.config.join("config.toml") + } + + pub fn rules_file(&self) -> PathBuf { + self.config.join("ecosystems.toml") + } + + pub fn size_cache_file(&self) -> PathBuf { + self.cache.join("sizes.json") + } + + pub fn history_file(&self) -> PathBuf { + self.data.join("history.jsonl") + } + + pub fn log_dir(&self) -> PathBuf { + self.state.join("logs") + } +} + +/// Writes `contents` to `path` atomically (temporary file in the same directory, then rename). +pub fn write_atomic(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { + let dir = path + .parent() + .ok_or_else(|| std::io::Error::other("path has no parent"))?; + std::fs::create_dir_all(dir)?; + let tmp = dir.join(format!( + ".{}.{}.tmp", + path.file_name().unwrap_or_default().to_string_lossy(), + std::process::id() + )); + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path) +} diff --git a/crates/carwash-core/src/safety.rs b/crates/carwash-core/src/safety.rs new file mode 100644 index 0000000..f393a8b --- /dev/null +++ b/crates/carwash-core/src/safety.rs @@ -0,0 +1,90 @@ +//! Deletion safety policy. +//! +//! Evidence, strongest first: +//! 1. Git-tracked content is never deleted. +//! 2. A generic name (`build`, `dist`, `vendor`...) needs either confirming content or git +//! reporting the directory as ignored; otherwise the user must review it. +//! 3. An ambiguous directory that git sees as untracked (not ignored) may be work in progress. + +use crate::model::{Artifact, GitState, ProtectReason, ReviewReason, Safety}; + +pub fn assess(artifact: &Artifact) -> Safety { + if let GitState::Tracked(files) = artifact.git + && files > 0 + { + return Safety::Protected(ProtectReason::TrackedFiles); + } + if !artifact.ambiguous { + return Safety::Safe; + } + match artifact.git { + GitState::Ignored => Safety::Safe, + GitState::Untracked => Safety::Review(ReviewReason::UntrackedInGit), + _ if artifact.detection.is_certain() => Safety::Safe, + _ => Safety::Review(ReviewReason::GenericName), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ecosystem::RuleId; + use crate::model::{ArtifactId, ArtifactKind, Detection}; + use std::path::PathBuf; + + fn artifact(ambiguous: bool, confirmed: bool, git: GitState) -> Artifact { + Artifact { + id: ArtifactId(0), + path: PathBuf::from("/p/build"), + project: None, + ecosystem: None, + kind: ArtifactKind::Build, + detection: Detection::Rule { + rule: RuleId(0), + confirmed, + }, + ambiguous, + modified: None, + git, + repo: None, + size: None, + outside_root: false, + } + } + + #[test] + fn tracked_content_is_always_protected() { + for (ambiguous, confirmed) in [(false, false), (true, true), (true, false)] { + let a = artifact(ambiguous, confirmed, GitState::Tracked(3)); + assert_eq!(assess(&a), Safety::Protected(ProtectReason::TrackedFiles)); + } + } + + #[test] + fn unique_names_are_safe_without_git() { + assert_eq!( + assess(&artifact(false, false, GitState::NotInRepo)), + Safety::Safe + ); + } + + #[test] + fn generic_names_need_confirmation_or_git() { + assert_eq!( + assess(&artifact(true, false, GitState::NotInRepo)), + Safety::Review(ReviewReason::GenericName) + ); + assert_eq!( + assess(&artifact(true, true, GitState::NotInRepo)), + Safety::Safe + ); + assert_eq!( + assess(&artifact(true, false, GitState::Ignored)), + Safety::Safe + ); + assert_eq!( + assess(&artifact(true, true, GitState::Untracked)), + Safety::Review(ReviewReason::UntrackedInGit) + ); + } +} diff --git a/crates/carwash-core/src/scan.rs b/crates/carwash-core/src/scan.rs new file mode 100644 index 0000000..39d7340 --- /dev/null +++ b/crates/carwash-core/src/scan.rs @@ -0,0 +1,308 @@ +//! The scan pipeline: discovery, then git inspection and measurement in parallel, as one +//! stream of events. + +use crate::Cancel; +use crate::clean::{self, CleanEvent, CleanItem, CleanOptions, CleanReport}; +use crate::discover::{self, Counters, DiscoverOptions, Found, Warning}; +use crate::ecosystem::Registry; +use crate::git; +use crate::measure; +use crate::model::{Artifact, ArtifactId, GitState, Project, Size}; +use rayon::prelude::*; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub struct ScanOptions { + pub discover: DiscoverOptions, + /// Ask git whether artifacts are ignored or contain tracked files. + pub git: bool, + /// Measure artifact sizes. + pub measure: bool, +} + +impl Default for ScanOptions { + fn default() -> Self { + Self { + discover: DiscoverOptions::default(), + git: true, + measure: true, + } + } +} + +#[derive(Debug, Clone)] +pub enum ScanEvent { + Project(Project), + Artifact(Artifact), + Warning(Warning), + DiscoveryFinished { dirs: u64, elapsed: Duration }, + Git { id: ArtifactId, state: GitState }, + Measured { id: ArtifactId, size: Size }, + Finished { elapsed: Duration }, +} + +/// Artifact location needed by the git and measurement phases. +#[derive(Debug, Clone)] +pub struct Target { + pub id: ArtifactId, + pub path: PathBuf, + pub repo: Option, +} + +impl From<&Artifact> for Target { + fn from(a: &Artifact) -> Self { + Self { + id: a.id, + path: a.path.clone(), + repo: a.repo.clone(), + } + } +} + +/// Owns the rule registry and the I/O thread pool. +#[derive(Debug, Clone)] +pub struct Engine { + registry: Arc, + pool: Arc, +} + +/// Directory walking is latency-bound, so more threads than cores keeps the disk queue full. +fn default_threads() -> usize { + let cores = std::thread::available_parallelism().map_or(4, usize::from); + (cores * 2).clamp(4, 32) +} + +impl Engine { + pub fn new(registry: Registry) -> Self { + Self::with_threads(registry, None).expect("thread pool") + } + + pub fn with_threads( + registry: Registry, + threads: Option, + ) -> Result { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(threads.unwrap_or_else(default_threads)) + .thread_name(|i| format!("carwash-io-{i}")) + .build()?; + Ok(Self { + registry: Arc::new(registry), + pool: Arc::new(pool), + }) + } + + pub fn registry(&self) -> &Arc { + &self.registry + } + + /// Runs the full pipeline, blocking the calling thread. `counters` may be read + /// concurrently for progress display. + pub fn scan( + &self, + root: &Path, + options: &ScanOptions, + counters: &Counters, + cancel: &Cancel, + sink: &(dyn Fn(ScanEvent) + Sync), + ) { + let started = Instant::now(); + let targets = Mutex::new(Vec::new()); + self.pool.install(|| { + discover::discover( + root, + &self.registry, + &options.discover, + counters, + cancel, + &|found| match found { + Found::Project(p) => sink(ScanEvent::Project(p)), + Found::Artifact(a) => { + targets.lock().expect("targets lock").push(Target::from(&a)); + sink(ScanEvent::Artifact(a)); + } + Found::Warning(w) => sink(ScanEvent::Warning(w)), + }, + ); + }); + sink(ScanEvent::DiscoveryFinished { + dirs: counters.dirs.load(Ordering::Relaxed), + elapsed: started.elapsed(), + }); + let targets = targets.into_inner().expect("targets lock"); + if !cancel.is_cancelled() { + self.pool.install(|| { + rayon::join( + || { + if options.git { + inspect_git(&targets, sink); + } + }, + || { + if options.measure { + measure_all(&targets, counters, cancel, sink); + } + }, + ); + }); + } + sink(ScanEvent::Finished { + elapsed: started.elapsed(), + }); + } + + /// Re-measures specific artifacts, emitting `ScanEvent::Measured`. + pub fn measure( + &self, + targets: &[Target], + counters: &Counters, + cancel: &Cancel, + sink: &(dyn Fn(ScanEvent) + Sync), + ) { + self.pool + .install(|| measure_all(targets, counters, cancel, sink)); + } + + /// Deletes artifacts; see [`clean::clean`]. + pub fn clean( + &self, + items: &[CleanItem], + options: &CleanOptions, + cancel: &Cancel, + emit: &(dyn Fn(CleanEvent) + Sync), + ) -> CleanReport { + self.pool + .install(|| clean::clean(items, options, cancel, emit)) + } +} + +fn inspect_git(targets: &[Target], sink: &(dyn Fn(ScanEvent) + Sync)) { + let mut by_repo: HashMap<&Path, Vec<&Target>> = HashMap::new(); + for target in targets { + match &target.repo { + Some(repo) => by_repo.entry(repo.as_path()).or_default().push(target), + None => sink(ScanEvent::Git { + id: target.id, + state: GitState::NotInRepo, + }), + } + } + by_repo.into_par_iter().for_each(|(repo, targets)| { + let paths: Vec = targets.iter().map(|t| t.path.clone()).collect(); + for (target, state) in targets.iter().zip(git::inspect(repo, &paths)) { + sink(ScanEvent::Git { + id: target.id, + state, + }); + } + }); +} + +fn measure_all( + targets: &[Target], + counters: &Counters, + cancel: &Cancel, + sink: &(dyn Fn(ScanEvent) + Sync), +) { + targets.par_iter().for_each(|target| { + if cancel.is_cancelled() { + return; + } + let size = measure::measure(&target.path, cancel); + if cancel.is_cancelled() { + return; + } + counters.measured.fetch_add(1, Ordering::Relaxed); + sink(ScanEvent::Measured { + id: target.id, + size, + }); + }); +} + +/// Everything a scan produced, assembled from its events. +#[derive(Debug, Default, Clone)] +pub struct Snapshot { + pub projects: Vec, + pub artifacts: Vec, + pub warnings: Vec, + pub dirs: u64, + pub elapsed: Duration, + project_index: HashMap, + artifact_index: HashMap, +} + +impl Snapshot { + pub fn apply(&mut self, event: ScanEvent) { + match event { + ScanEvent::Project(p) => { + self.project_index.insert(p.id, self.projects.len()); + self.projects.push(p); + } + ScanEvent::Artifact(a) => { + self.artifact_index.insert(a.id, self.artifacts.len()); + self.artifacts.push(a); + } + ScanEvent::Warning(w) => self.warnings.push(w), + ScanEvent::DiscoveryFinished { dirs, .. } => self.dirs = dirs, + ScanEvent::Git { id, state } => { + if let Some(a) = self.artifact_mut(id) { + a.git = state; + } + } + ScanEvent::Measured { id, size } => { + if let Some(a) = self.artifact_mut(id) { + a.size = Some(size); + } + } + ScanEvent::Finished { elapsed } => self.elapsed = elapsed, + } + } + + pub fn project(&self, id: crate::model::ProjectId) -> Option<&Project> { + self.project_index.get(&id).map(|&i| &self.projects[i]) + } + + pub fn artifact(&self, id: ArtifactId) -> Option<&Artifact> { + self.artifact_index.get(&id).map(|&i| &self.artifacts[i]) + } + + pub fn artifact_mut(&mut self, id: ArtifactId) -> Option<&mut Artifact> { + self.artifact_index + .get(&id) + .copied() + .map(|i| &mut self.artifacts[i]) + } + + /// Runs a scan to completion and collects it. + pub fn collect(engine: &Engine, root: &Path, options: &ScanOptions, cancel: &Cancel) -> Self { + let snapshot = Mutex::new(Snapshot::default()); + engine.scan(root, options, &Counters::default(), cancel, &|event| { + snapshot.lock().expect("snapshot lock").apply(event); + }); + let mut snapshot = snapshot.into_inner().expect("snapshot lock"); + snapshot.sort(); + snapshot + } + + /// Orders projects and artifacts by path for stable output. + pub fn sort(&mut self) { + self.projects.sort_by(|a, b| a.path.cmp(&b.path)); + self.artifacts.sort_by(|a, b| a.path.cmp(&b.path)); + self.project_index = self + .projects + .iter() + .enumerate() + .map(|(i, p)| (p.id, i)) + .collect(); + self.artifact_index = self + .artifacts + .iter() + .enumerate() + .map(|(i, a)| (a.id, i)) + .collect(); + } +} diff --git a/crates/carwash-core/src/select.rs b/crates/carwash-core/src/select.rs new file mode 100644 index 0000000..5d9e8b7 --- /dev/null +++ b/crates/carwash-core/src/select.rs @@ -0,0 +1,188 @@ +//! Filters and the default selection policy, shared by every frontend. + +use crate::ecosystem::EcoId; +use crate::model::{Artifact, ArtifactKind, Safety}; +use std::time::{Duration, SystemTime}; + +/// Narrows which artifacts are considered at all. +#[derive(Debug, Clone, Default)] +pub struct Filter { + /// Minimum reclaimable bytes; unmeasured artifacts never pass. + pub min_size: Option, + /// Only artifacts whose newest content is at least this old. + pub older_than: Option, + /// Empty means every kind. + pub kinds: Vec, + /// Empty means every ecosystem. + pub ecosystems: Vec, +} + +impl Filter { + pub fn matches(&self, artifact: &Artifact, now: SystemTime) -> bool { + if let Some(min) = self.min_size + && artifact.size.is_none_or(|s| s.reclaimable < min) + { + return false; + } + if let Some(older_than) = self.older_than + && age(artifact, now).is_some_and(|age| age < older_than) + { + return false; + } + if !self.kinds.is_empty() && !self.kinds.contains(&artifact.kind) { + return false; + } + if !self.ecosystems.is_empty() + && !artifact + .ecosystem + .is_some_and(|eco| self.ecosystems.contains(&eco)) + { + return false; + } + true + } +} + +/// Time since the artifact's newest known modification. +pub fn age(artifact: &Artifact, now: SystemTime) -> Option { + artifact + .last_modified() + .map(|t| now.duration_since(t).unwrap_or_default()) +} + +/// Why the default policy leaves an artifact unselected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Hold { + /// Never deletable. + Protected, + /// Needs a human look first. + Review, + /// Modified recently; probably in active use. + Recent, +} + +impl Hold { + pub fn describe(self) -> &'static str { + match self { + Hold::Protected => "protected", + Hold::Review => "needs review", + Hold::Recent => "recently used", + } + } +} + +/// Which artifacts are selected by default. +#[derive(Debug, Clone)] +pub struct Policy { + pub include_review: bool, + /// Artifacts modified within this window are held back; `None` disables the check. + pub recent: Option, +} + +impl Default for Policy { + fn default() -> Self { + Self { + include_review: false, + recent: Some(Duration::from_secs(7 * 86_400)), + } + } +} + +impl Policy { + pub fn hold(&self, artifact: &Artifact, now: SystemTime) -> Option { + match artifact.safety() { + Safety::Protected(_) => return Some(Hold::Protected), + Safety::Review(_) if !self.include_review => return Some(Hold::Review), + _ => {} + } + if let Some(recent) = self.recent + && age(artifact, now).is_some_and(|age| age < recent) + { + return Some(Hold::Recent); + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ecosystem::RuleId; + use crate::model::{ArtifactId, Detection, GitState, Size}; + use std::path::PathBuf; + + fn artifact(reclaimable: u64, age_days: u64, ambiguous: bool) -> (Artifact, SystemTime) { + let now = SystemTime::now(); + let modified = now - Duration::from_secs(age_days * 86_400); + let artifact = Artifact { + id: ArtifactId(1), + path: PathBuf::from("/p/target"), + project: None, + ecosystem: Some(EcoId(0)), + kind: ArtifactKind::Build, + detection: Detection::Rule { + rule: RuleId(0), + confirmed: false, + }, + ambiguous, + modified: Some(modified), + git: GitState::NotInRepo, + repo: None, + size: Some(Size { + reclaimable, + on_disk: reclaimable, + ..Size::default() + }), + outside_root: false, + }; + (artifact, now) + } + + #[test] + fn filter_by_size_age_kind_and_ecosystem() { + let (a, now) = artifact(500, 40, false); + assert!(Filter::default().matches(&a, now)); + let big = Filter { + min_size: Some(1_000), + ..Filter::default() + }; + assert!(!big.matches(&a, now)); + let old = Filter { + older_than: Some(Duration::from_secs(30 * 86_400)), + ..Filter::default() + }; + assert!(old.matches(&a, now)); + let very_old = Filter { + older_than: Some(Duration::from_secs(60 * 86_400)), + ..Filter::default() + }; + assert!(!very_old.matches(&a, now)); + let deps = Filter { + kinds: vec![ArtifactKind::Dependencies], + ..Filter::default() + }; + assert!(!deps.matches(&a, now)); + let other_eco = Filter { + ecosystems: vec![EcoId(9)], + ..Filter::default() + }; + assert!(!other_eco.matches(&a, now)); + } + + #[test] + fn policy_holds_review_and_recent() { + let policy = Policy::default(); + let (old_safe, now) = artifact(1, 30, false); + assert_eq!(policy.hold(&old_safe, now), None); + let (recent, now) = artifact(1, 2, false); + assert_eq!(policy.hold(&recent, now), Some(Hold::Recent)); + let (generic, now) = artifact(1, 30, true); + assert_eq!(policy.hold(&generic, now), Some(Hold::Review)); + let lenient = Policy { + include_review: true, + recent: None, + }; + assert_eq!(lenient.hold(&generic, now), None); + assert_eq!(lenient.hold(&recent, now), None); + } +} diff --git a/crates/carwash-core/tests/discover.rs b/crates/carwash-core/tests/discover.rs new file mode 100644 index 0000000..e821934 --- /dev/null +++ b/crates/carwash-core/tests/discover.rs @@ -0,0 +1,387 @@ +//! Discovery against a fixture tree shaped like a real `~/work`. + +use carwash_core::discover::Hidden; +use carwash_core::{ + ArtifactKind, Cancel, Detection, DiscoverOptions, Engine, Registry, Safety, ScanOptions, + Snapshot, +}; +use std::fs; +use std::path::{Path, PathBuf}; + +struct Fixture { + dir: tempfile::TempDir, +} + +impl Fixture { + fn new() -> Self { + Self { + dir: tempfile::tempdir().unwrap(), + } + } + + fn root(&self) -> PathBuf { + // Canonical so assertions compare equal on macOS (/var -> /private/var). + fs::canonicalize(self.dir.path()).unwrap() + } + + fn file(&self, rel: &str, contents: &str) -> &Self { + let path = self.dir.path().join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + self + } + + fn dir(&self, rel: &str) -> &Self { + fs::create_dir_all(self.dir.path().join(rel)).unwrap(); + self + } +} + +const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n"; + +fn build_fixture() -> Fixture { + let f = Fixture::new(); + // Standalone Rust crate. + f.file( + "rust-app/Cargo.toml", + "[package]\nname = \"rust-app\"\nversion = \"0.1.0\"\n", + ) + .file("rust-app/Cargo.lock", "") + .file("rust-app/src/main.rs", "fn main() {}") + .file("rust-app/target/CACHEDIR.TAG", CACHEDIR_TAG) + .file("rust-app/target/.rustc_info.json", "{}") + .file("rust-app/target/debug/app", &"x".repeat(10_000)); + // Cargo workspace: one shared target at the root. + f.file("ws/Cargo.toml", "[workspace]\nmembers = [\"crates/*\"]\n") + .file("ws/target/CACHEDIR.TAG", CACHEDIR_TAG) + .file("ws/crates/a/Cargo.toml", "[package]\nname = \"a\"\n") + .file("ws/crates/b/Cargo.toml", "[package]\nname = \"b\"\n"); + // pnpm monorepo. + f.file( + "web/package.json", + r#"{"name":"web","workspaces":["packages/*"]}"#, + ) + .file("web/pnpm-lock.yaml", "") + .file("web/node_modules/react/package.json", r#"{"name":"react"}"#) + .file("web/node_modules/react/node_modules/x/package.json", "{}") + .file("web/.next/BUILD_ID", "1") + .file("web/dist/app.js", "") + .file("web/.nx/cache/abc", "") + .file("web/.nx/installation/keep", "") + .file("web/packages/ui/package.json", r#"{"name":"@web/ui"}"#) + .file("web/packages/ui/node_modules/.modules.yaml", ""); + // Python project with a venv and caches deep in the source tree. + f.file("py/pyproject.toml", "[project]\nname = \"svc\"\n") + .file("py/.venv/pyvenv.cfg", "home = /usr/bin") + .file("py/src/svc/__pycache__/mod.cpython-312.pyc", "") + .file("py/src/svc/mod.py", "") + .file("py/build/notes.txt", ""); + // Loose scripts: a venv and caches outside any project. + f.file("scripts/venv/pyvenv.cfg", "home = /usr/bin") + .file("scripts/tool.py", "") + .file("scripts/__pycache__/tool.pyc", "") + .file("stray/node_modules/left-pad/package.json", "{}"); + // CMake with nested CMakeLists and an out-of-source build directory. + f.file("cpp/CMakeLists.txt", "") + .file("cpp/lib/CMakeLists.txt", "") + .file("cpp/build/CMakeCache.txt", "") + .file("cpp/custom-out/CMakeCache.txt", ""); + // A monorepo package literally named `build` is a project, not an artifact. + f.file("tools/package.json", r#"{"name":"tools"}"#) + .file("tools/build/package.json", r#"{"name":"@tools/build"}"#); + // Hidden directories are skipped. + f.file(".hidden/package.json", "{}") + .dir(".hidden/node_modules"); + // Unity. + f.file( + "game/ProjectSettings/ProjectVersion.txt", + "m_EditorVersion: 6000.0", + ) + .dir("game/Assets") + .file("game/Library/ArtifactDB", ""); + // Gradle multi-project. + f.file("jvm/settings.gradle.kts", "rootProject.name = \"shop\"\n") + .file("jvm/app/build.gradle.kts", "") + .file("jvm/app/build/intermediates/x", ""); + // A leftover from an interrupted clean. + f.file("rust-app/.carwash-trash-99-0-target/f", ""); + // Hidden directories inside projects are entered: agent worktrees live there. + f.file( + "rust-app/.claude/worktrees/agent-1/.git", + "gitdir: ../../../.git/worktrees/agent-1", + ) + .file( + "rust-app/.claude/worktrees/agent-1/Cargo.toml", + "[package]\nname = \"rust-app\"\n", + ) + .file( + "rust-app/.claude/worktrees/agent-1/target/CACHEDIR.TAG", + CACHEDIR_TAG, + ); + // A Cargo target whose manifest was deleted, recognised by its fingerprints. + f.dir("orphan/target/debug/.fingerprint"); + // A target directory that is actually a nested repository is never an artifact. + f.file("vendored/Cargo.toml", "[package]\nname = \"vendored\"\n") + .dir("vendored/target/.git"); + f +} + +fn scan(root: &Path) -> Snapshot { + let engine = Engine::with_threads(Registry::builtin(), Some(4)).unwrap(); + let options = ScanOptions { + discover: DiscoverOptions::default(), + git: false, + measure: true, + }; + Snapshot::collect(&engine, root, &options, &Cancel::new()) +} + +fn rel(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/") +} + +#[test] +fn finds_projects_across_ecosystems() { + let fixture = build_fixture(); + let root = fixture.root(); + let snapshot = scan(&root); + let registry = Registry::builtin(); + + let mut projects: Vec<(String, Vec)> = snapshot + .projects + .iter() + .map(|p| { + ( + rel(&root, &p.path), + p.ecosystems + .iter() + .map(|&e| registry.ecosystem(e).key.clone()) + .collect(), + ) + }) + .collect(); + projects.sort(); + let expected: Vec<(String, Vec)> = [ + ("cpp", vec!["cmake"]), + ("game", vec!["unity"]), + ("jvm", vec!["gradle"]), + ("jvm/app", vec!["gradle"]), + ("py", vec!["python"]), + ("rust-app", vec!["rust"]), + ("rust-app/.claude/worktrees/agent-1", vec!["rust"]), + ("tools", vec!["node"]), + ("tools/build", vec!["node"]), + ("vendored", vec!["rust"]), + ("web", vec!["node"]), + ("web/packages/ui", vec!["node"]), + ("ws", vec!["rust"]), + ("ws/crates/a", vec!["rust"]), + ("ws/crates/b", vec!["rust"]), + ] + .into_iter() + .map(|(p, e)| (p.to_owned(), e.into_iter().map(str::to_owned).collect())) + .collect(); + assert_eq!(projects, expected); +} + +#[test] +fn finds_artifacts_and_never_descends_into_them() { + let fixture = build_fixture(); + let root = fixture.root(); + let snapshot = scan(&root); + + let mut artifacts: Vec<(String, ArtifactKind)> = snapshot + .artifacts + .iter() + .map(|a| (rel(&root, &a.path), a.kind)) + .collect(); + artifacts.sort(); + use ArtifactKind::*; + let expected: Vec<(String, ArtifactKind)> = [ + ("cpp/build", Build), + ("cpp/custom-out", Build), + ("game/Library", Cache), + ("jvm/app/build", Build), + ("orphan/target", Build), + ("py/.venv", Environment), + ("py/build", Build), + ("py/src/svc/__pycache__", Cache), + ("rust-app/.carwash-trash-99-0-target", Leftover), + ("rust-app/.claude/worktrees/agent-1/target", Build), + ("rust-app/target", Build), + ("scripts/__pycache__", Cache), + ("scripts/venv", Environment), + ("stray/node_modules", Dependencies), + ("web/.next", Build), + ("web/.nx/cache", Cache), + ("web/dist", Build), + ("web/node_modules", Dependencies), + ("web/packages/ui/node_modules", Dependencies), + ("ws/target", Build), + ] + .into_iter() + .map(|(p, k)| (p.to_owned(), k)) + .collect(); + assert_eq!(artifacts, expected); +} + +#[test] +fn classifies_safety_by_evidence() { + let fixture = build_fixture(); + let root = fixture.root(); + let snapshot = scan(&root); + let by_path = |p: &str| { + snapshot + .artifacts + .iter() + .find(|a| rel(&root, &a.path) == p) + .unwrap_or_else(|| panic!("missing {p}")) + }; + + // Generic names without confirming content need review (no git in the fixture). + assert!(matches!(by_path("web/dist").safety(), Safety::Review(_))); + assert!(matches!(by_path("py/build").safety(), Safety::Review(_))); + // Confirmed by content. + assert_eq!(by_path("jvm/app/build").safety(), Safety::Safe); + assert!(matches!( + by_path("jvm/app/build").detection, + Detection::Rule { + confirmed: true, + .. + } + )); + assert_eq!(by_path("rust-app/target").safety(), Safety::Safe); + assert_eq!(by_path("scripts/venv").detection, Detection::PythonVenv); + assert_eq!(by_path("cpp/custom-out").detection, Detection::CMakeBuild); +} + +#[test] +fn attributes_owners_and_workspace_membership() { + let fixture = build_fixture(); + let root = fixture.root(); + let snapshot = scan(&root); + let project = |p: &str| { + snapshot + .projects + .iter() + .find(|x| rel(&root, &x.path) == p) + .unwrap() + }; + let ws = project("ws"); + assert!(ws.is_workspace); + assert_eq!(project("ws/crates/a").member_of, Some(ws.id)); + assert_eq!(project("ws/crates/a").parent, Some(ws.id)); + assert_eq!( + project("web/packages/ui").member_of, + Some(project("web").id) + ); + assert_eq!(project("jvm/app").member_of, Some(project("jvm").id)); + assert_eq!(project("rust-app").name, "rust-app"); + assert_eq!(project("py").name, "svc"); + assert_eq!(project("jvm").name, "shop"); + + let pycache = snapshot + .artifacts + .iter() + .find(|a| rel(&root, &a.path) == "py/src/svc/__pycache__") + .unwrap(); + assert_eq!(pycache.project, Some(project("py").id)); + let stray = snapshot + .artifacts + .iter() + .find(|a| rel(&root, &a.path) == "stray/node_modules") + .unwrap(); + assert_eq!(stray.project, None); +} + +#[test] +fn measures_every_artifact() { + let fixture = build_fixture(); + let root = fixture.root(); + let snapshot = scan(&root); + for artifact in &snapshot.artifacts { + let size = artifact.size.expect("measured"); + assert_eq!(size.errors, 0, "{}", artifact.path.display()); + } + let target = snapshot + .artifacts + .iter() + .find(|a| rel(&root, &a.path) == "rust-app/target") + .unwrap(); + assert!(target.size.unwrap().apparent >= 10_000); +} + +#[test] +fn scanning_inside_a_member_reports_the_enclosing_workspace() { + let fixture = build_fixture(); + let root = fixture.root(); + let member = root.join("ws/crates/a"); + let snapshot = scan(&member); + + let ws = snapshot + .projects + .iter() + .find(|p| p.path == root.join("ws")) + .expect("enclosing workspace reported"); + assert!(ws.outside_root); + let a = snapshot.projects.iter().find(|p| p.path == member).unwrap(); + assert!(!a.outside_root); + assert_eq!(a.member_of, Some(ws.id)); + + let target = snapshot + .artifacts + .iter() + .find(|t| t.path == root.join("ws/target")) + .expect("shared target reported"); + assert!(target.outside_root); + assert_eq!(target.project, Some(ws.id)); + // Siblings of the member are not scanned. + assert!( + !snapshot + .projects + .iter() + .any(|p| p.path.ends_with("crates/b")) + ); +} + +#[test] +fn exclusions_depth_and_hidden_options() { + let fixture = build_fixture(); + let root = fixture.root(); + let engine = Engine::with_threads(Registry::builtin(), Some(2)).unwrap(); + let options = ScanOptions { + discover: DiscoverOptions { + exclude: vec![root.join("web")], + hidden: Hidden::Always, + max_depth: Some(1), + ..DiscoverOptions::default() + }, + git: false, + measure: false, + }; + let snapshot = Snapshot::collect(&engine, &root, &options, &Cancel::new()); + assert!( + !snapshot + .projects + .iter() + .any(|p| p.path.starts_with(root.join("web"))) + ); + assert!( + snapshot + .projects + .iter() + .any(|p| p.path == root.join(".hidden")) + ); + // Depth 1 reaches `ws` but not `ws/crates/a`. + assert!(snapshot.projects.iter().any(|p| p.path == root.join("ws"))); + assert!( + !snapshot + .projects + .iter() + .any(|p| p.path == root.join("ws/crates/a")) + ); + assert!(snapshot.artifacts.iter().all(|a| a.size.is_none())); +} From a8e11d240d1105eb994c549e788582da14506dc1 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:17:18 -0400 Subject: [PATCH 05/22] feat(cli): scan, clean, ecosystems, history and completions scan prints a table (or --json) sorted by reclaimable bytes, with safety status and notes; clean shows the plan, holds back recently used and review-needing directories by default, confirms, deletes and records the reclaimed space. XDG config at ~/.config/carwash/config.toml. --- crates/carwash/Cargo.toml | 42 ++++ crates/carwash/src/cli.rs | 256 ++++++++++++++++++++ crates/carwash/src/commands/clean.rs | 343 +++++++++++++++++++++++++++ crates/carwash/src/commands/info.rs | 144 +++++++++++ crates/carwash/src/commands/mod.rs | 142 +++++++++++ crates/carwash/src/commands/scan.rs | 136 +++++++++++ crates/carwash/src/config.rs | 149 ++++++++++++ crates/carwash/src/context.rs | 215 +++++++++++++++++ crates/carwash/src/main.rs | 90 +++++++ crates/carwash/src/report.rs | 187 +++++++++++++++ crates/carwash/src/table.rs | 101 ++++++++ 11 files changed, 1805 insertions(+) create mode 100644 crates/carwash/Cargo.toml create mode 100644 crates/carwash/src/cli.rs create mode 100644 crates/carwash/src/commands/clean.rs create mode 100644 crates/carwash/src/commands/info.rs create mode 100644 crates/carwash/src/commands/mod.rs create mode 100644 crates/carwash/src/commands/scan.rs create mode 100644 crates/carwash/src/config.rs create mode 100644 crates/carwash/src/context.rs create mode 100644 crates/carwash/src/main.rs create mode 100644 crates/carwash/src/report.rs create mode 100644 crates/carwash/src/table.rs diff --git a/crates/carwash/Cargo.toml b/crates/carwash/Cargo.toml new file mode 100644 index 0000000..17bab18 --- /dev/null +++ b/crates/carwash/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "carwash" +description = "Reclaim disk space across every project on your machine: finds build outputs, dependency installs and caches for 40+ ecosystems, then cleans, updates and runs tasks from one TUI" +readme = "../../README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +keywords = ["cleanup", "disk-space", "node-modules", "monorepo", "tui"] +categories = ["command-line-utilities", "development-tools", "filesystem"] + +[[bin]] +name = "carwash" +path = "src/main.rs" + +[dependencies] +carwash-core.workspace = true +anyhow.workspace = true +clap.workspace = true +clap_complete.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +crossbeam-channel.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +tracing-appender.workspace = true +unicode-width.workspace = true +indicatif.workspace = true +anstream.workspace = true +anstyle.workspace = true + +[dev-dependencies] +tempfile.workspace = true +assert_cmd.workspace = true +predicates.workspace = true + +[lints] +workspace = true diff --git a/crates/carwash/src/cli.rs b/crates/carwash/src/cli.rs new file mode 100644 index 0000000..2e9bf58 --- /dev/null +++ b/crates/carwash/src/cli.rs @@ -0,0 +1,256 @@ +//! Command-line interface definition. + +use carwash_core::ArtifactKind; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; +use std::time::Duration; + +#[derive(Debug, Parser)] +#[command( + name = "carwash", + version, + about = "Reclaim disk space from build outputs, dependency installs and caches across all your projects", + long_about = "Reclaim disk space from build outputs, dependency installs and caches across all your projects.\n\n\ + Run without a subcommand to open the interactive UI on PATH (default: current directory).", + args_conflicts_with_subcommands = true, + arg_required_else_help = false +)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + /// Directory to open in the interactive UI. + pub path: Option, + + #[command(flatten)] + pub global: GlobalArgs, +} + +#[derive(Debug, Args)] +pub struct GlobalArgs { + /// Configuration file (default: ~/.config/carwash/config.toml). + #[arg(long, global = true, value_name = "FILE", env = "CARWASH_CONFIG")] + pub config: Option, + + /// I/O threads (default: derived from the CPU count). + #[arg(long, global = true, value_name = "N")] + pub threads: Option, + + /// When to use colors. + #[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)] + pub color: ColorChoice, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum ColorChoice { + Auto, + Always, + Never, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// List reclaimable artifacts without deleting anything. + Scan(ScanArgs), + /// Delete artifacts, after showing the plan and asking for confirmation. + Clean(CleanArgs), + /// List the ecosystems carwash recognises. + Ecosystems(JsonArgs), + /// Show how much space carwash has reclaimed. + History(JsonArgs), + /// Print shell completions. + Completions { + #[arg(value_enum)] + shell: clap_complete::Shell, + }, +} + +#[derive(Debug, Args)] +pub struct WalkArgs { + /// Directory to scan. + #[arg(default_value = ".")] + pub path: PathBuf, + + /// Never enter this path (repeatable). + #[arg(long, value_name = "PATH")] + pub exclude: Vec, + + /// Also descend into hidden directories. + #[arg(long)] + pub hidden: bool, + + /// Maximum directory depth below PATH. + #[arg(long, value_name = "N")] + pub max_depth: Option, + + /// Cross filesystem boundaries (mount points). + #[arg(long)] + pub cross_fs: bool, + + /// Ignore projects enclosing PATH (e.g. a workspace's shared target/ when PATH is a member). + #[arg(long)] + pub no_enclosing: bool, +} + +#[derive(Debug, Clone, Args)] +pub struct FilterArgs { + /// Only artifacts freeing at least this much, e.g. 100MB, 1.5GB. + #[arg(long, value_name = "SIZE", value_parser = parse_size)] + pub min_size: Option, + + /// Only artifacts not modified for this long, e.g. 30d, 6w, 3mo, 1y. + #[arg(long, value_name = "AGE", value_parser = parse_age)] + pub older_than: Option, + + /// Only these kinds (build, deps, cache, env, other, leftover). + #[arg(long, value_name = "KIND", value_delimiter = ',', value_parser = parse_kind)] + pub kind: Vec, + + /// Only these ecosystems, by id (see `carwash ecosystems`). + #[arg( + long = "ecosystem", + short = 'e', + value_name = "ID", + value_delimiter = ',' + )] + pub ecosystems: Vec, +} + +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +pub enum SortKey { + /// Largest reclaimable first. + #[default] + Size, + /// Oldest first. + Age, + Path, +} + +#[derive(Debug, Args)] +pub struct ScanArgs { + #[command(flatten)] + pub walk: WalkArgs, + + #[command(flatten)] + pub filter: FilterArgs, + + /// Machine-readable output. + #[arg(long)] + pub json: bool, + + /// Skip git inspection (faster; ambiguous directories stay "needs review"). + #[arg(long)] + pub no_git: bool, + + /// Skip size measurement. + #[arg(long)] + pub no_size: bool, + + #[arg(long, value_enum, default_value_t)] + pub sort: SortKey, + + /// Show at most N rows. + #[arg(long, short = 'n', value_name = "N")] + pub limit: Option, +} + +#[derive(Debug, Args)] +pub struct CleanArgs { + #[command(flatten)] + pub walk: WalkArgs, + + #[command(flatten)] + pub filter: FilterArgs, + + /// Show what would be deleted, then stop. + #[arg(long)] + pub dry_run: bool, + + /// Do not ask for confirmation. + #[arg(long, short = 'y')] + pub yes: bool, + + /// Move to the trash instead of deleting (frees nothing until the trash is emptied). + #[arg(long)] + pub trash: bool, + + /// Include directories with generic names that git does not confirm as ignored. + #[arg(long)] + pub include_review: bool, + + /// Include artifacts modified recently (default window: 7 days). + #[arg(long)] + pub include_recent: bool, + + /// Machine-readable output. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct JsonArgs { + /// Machine-readable output. + #[arg(long)] + pub json: bool, +} + +fn parse_size(s: &str) -> Result { + carwash_core::fmt::parse_bytes(s) + .ok_or_else(|| format!("invalid size `{s}` (try 100MB or 1.5GB)")) +} + +fn parse_age(s: &str) -> Result { + carwash_core::fmt::parse_age(s) + .ok_or_else(|| format!("invalid age `{s}` (try 30d, 6w, 3mo or 1y)")) +} + +fn parse_kind(s: &str) -> Result { + ArtifactKind::parse(s).ok_or_else(|| { + format!("unknown kind `{s}` (expected build, deps, cache, env, other or leftover)") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + #[test] + fn parses_filters() { + let cli = Cli::try_parse_from([ + "carwash", + "scan", + "~/work", + "--min-size", + "1GB", + "--older-than", + "30d", + "--kind", + "build,deps", + "-e", + "rust,node", + ]) + .unwrap(); + let Some(Command::Scan(scan)) = cli.command else { + panic!("expected scan"); + }; + assert_eq!(scan.filter.min_size, Some(1_000_000_000)); + assert_eq!( + scan.filter.kind, + vec![ArtifactKind::Build, ArtifactKind::Dependencies] + ); + assert_eq!(scan.filter.ecosystems, vec!["rust", "node"]); + } + + #[test] + fn bare_path_opens_the_ui() { + let cli = Cli::try_parse_from(["carwash", "/tmp"]).unwrap(); + assert!(cli.command.is_none()); + assert_eq!(cli.path, Some(PathBuf::from("/tmp"))); + } +} diff --git a/crates/carwash/src/commands/clean.rs b/crates/carwash/src/commands/clean.rs new file mode 100644 index 0000000..dc073a9 --- /dev/null +++ b/crates/carwash/src/commands/clean.rs @@ -0,0 +1,343 @@ +//! `carwash clean`: delete selected artifacts. + +use super::{artifact_table, count}; +use crate::cli::{CleanArgs, SortKey}; +use crate::context::Context; +use anstyle::{AnsiColor, Style}; +use anyhow::{Result, bail}; +use carwash_core::clean::{CleanEvent, CleanItem, CleanOptions, DeleteMode}; +use carwash_core::history::{self, Record}; +use carwash_core::select::Hold; +use carwash_core::{Artifact, ArtifactId, Cancel, fmt}; +use indicatif::{ProgressBar, ProgressStyle}; +use serde::Serialize; +use std::collections::{BTreeMap, HashMap}; +use std::io::{BufRead, IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::Mutex; +use std::time::SystemTime; + +const PLAN_ROWS: usize = 30; + +#[derive(Serialize)] +struct CleanOutput<'a> { + version: u32, + root: &'a Path, + dry_run: bool, + mode: DeleteMode, + selected: Vec>, + held: BTreeMap<&'static str, usize>, + removed: usize, + failed: Vec, + bytes: u64, + free_before: Option, + free_after: Option, +} + +#[derive(Serialize)] +struct Planned<'a> { + id: u32, + path: &'a Path, + bytes: u64, +} + +#[derive(Serialize)] +struct Failure { + path: PathBuf, + error: String, +} + +pub fn run(ctx: &Context, args: &CleanArgs) -> Result { + let (root, options) = ctx.scan_options(&args.walk, true, true)?; + let snapshot = ctx.run_scan(&root, &options, !args.json); + let filter = ctx.filter(&args.filter)?; + // An explicit --older-than replaces the default recent-activity window. + let policy = ctx.policy( + args.include_review, + args.include_recent || args.filter.older_than.is_some(), + ); + let registry = ctx.registry(); + let now = SystemTime::now(); + let mode = if args.trash { + DeleteMode::Trash + } else { + ctx.config.clean.mode + }; + + let mut selected: Vec<&Artifact> = Vec::new(); + let mut held: BTreeMap<&'static str, usize> = BTreeMap::new(); + for artifact in snapshot.artifacts.iter().filter(|a| filter.matches(a, now)) { + match policy.hold(artifact, now) { + None if artifact.size.is_some() => selected.push(artifact), + None => *held.entry("unmeasured").or_default() += 1, + Some(hold) => *held.entry(hold.describe()).or_default() += 1, + } + } + super::scan::sort(&mut selected, SortKey::Size, now); + let total: u64 = selected + .iter() + .filter_map(|a| a.size) + .map(|s| s.reclaimable) + .sum(); + + let mut out = anstream::stdout().lock(); + if !args.json { + print_plan(&mut out, &selected, &held, total, &root, ctx, now, &policy)?; + } + let mut output = CleanOutput { + version: crate::report::VERSION, + root: &root, + dry_run: args.dry_run, + mode, + selected: selected + .iter() + .map(|a| Planned { + id: a.id.0, + path: &a.path, + bytes: a.size.map_or(0, |s| s.reclaimable), + }) + .collect(), + held: held.clone(), + removed: 0, + failed: Vec::new(), + bytes: 0, + free_before: None, + free_after: None, + }; + + if selected.is_empty() || args.dry_run { + if args.json { + serde_json::to_writer_pretty(&mut out, &output)?; + writeln!(out)?; + } else if args.dry_run && !selected.is_empty() { + writeln!(out, "Dry run: nothing was deleted.")?; + } + return Ok(ExitCode::SUCCESS); + } + + if !args.yes { + if !std::io::stdin().is_terminal() { + bail!( + "refusing to delete without confirmation in a non-interactive session; pass --yes" + ); + } + let verb = match mode { + DeleteMode::Permanent => "Delete", + DeleteMode::Trash => "Move to trash", + }; + write!( + out, + "{verb} {} directories, freeing {}? [y/N] ", + count(u64::try_from(selected.len()).unwrap_or(u64::MAX)), + fmt::bytes(total) + )?; + out.flush()?; + let mut answer = String::new(); + std::io::stdin().lock().read_line(&mut answer)?; + if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { + writeln!(out, "Nothing deleted.")?; + return Ok(ExitCode::SUCCESS); + } + } + + // Enclosing projects (above the scan root) own artifacts the user was shown. + let mut allowed_roots = vec![root.clone()]; + allowed_roots.extend( + snapshot + .projects + .iter() + .filter(|p| p.outside_root) + .map(|p| p.path.clone()), + ); + let items: Vec = selected + .iter() + .map(|a| CleanItem { + id: a.id, + path: a.path.clone(), + expected_bytes: a.size.map_or(0, |s| s.reclaimable), + }) + .collect(); + let by_id: HashMap = selected.iter().map(|a| (a.id, *a)).collect(); + + let bar = if !args.json && std::io::stderr().is_terminal() { + let bar = ProgressBar::new(total); + bar.set_style( + ProgressStyle::with_template( + "{bar:30.green/dim} {binary_bytes}/{binary_total_bytes} {msg}", + ) + .expect("valid template") + .progress_chars("━━─"), + ); + bar + } else { + ProgressBar::hidden() + }; + let failures = Mutex::new(Vec::new()); + let records = Mutex::new(Vec::new()); + let report = ctx.engine.clean( + &items, + &CleanOptions { + mode, + allowed_roots, + dry_run: false, + }, + &Cancel::new(), + &|event| match event { + CleanEvent::Started { id } => { + if let Some(a) = by_id.get(&id) { + bar.set_message(super::display_path(&a.path, &root)); + } + } + CleanEvent::Removed { id, bytes } => { + bar.inc(bytes); + if let Some(a) = by_id.get(&id) { + records.lock().expect("records lock").push(Record::now( + a.path.clone(), + bytes, + a.kind, + a.ecosystem.map(|e| registry.ecosystem(e).key.clone()), + mode, + )); + } + } + CleanEvent::Failed { id, error } => { + if let Some(a) = by_id.get(&id) { + failures.lock().expect("failures lock").push(Failure { + path: a.path.clone(), + error, + }); + } + } + }, + ); + bar.finish_and_clear(); + + let records = records.into_inner().expect("records lock"); + if let Some(dirs) = &ctx.dirs + && let Err(error) = history::append(&dirs.history_file(), &records) + { + tracing::warn!(%error, "cannot write history"); + } + ctx.update_size_cache(|cache| { + for record in &records { + cache.remove(&record.path); + } + }); + + output.removed = report.removed; + output.failed = failures.into_inner().expect("failures lock"); + output.bytes = report.bytes; + output.free_before = report.free_before; + output.free_after = report.free_after; + + if args.json { + serde_json::to_writer_pretty(&mut out, &output)?; + writeln!(out)?; + } else { + let green = AnsiColor::Green.on_default().bold(); + writeln!( + out, + "{}Freed {}{} from {} directories.", + green.render(), + fmt::bytes(report.bytes), + green.render_reset(), + count(u64::try_from(report.removed).unwrap_or(u64::MAX)) + )?; + if let (Some(before), Some(after)) = (report.free_before, report.free_after) { + writeln!( + out, + "Free space: {} → {}", + fmt::bytes(before), + fmt::bytes(after) + )?; + } + if mode == DeleteMode::Trash { + writeln!( + out, + "Items are in the trash; empty it to actually free the space." + )?; + } + for failure in &output.failed { + let red = AnsiColor::Red.on_default(); + writeln!( + out, + "{}failed{} {}: {}", + red.render(), + red.render_reset(), + failure.path.display(), + failure.error + )?; + } + } + Ok(if output.failed.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} + +#[allow(clippy::too_many_arguments)] +fn print_plan( + out: &mut impl Write, + selected: &[&Artifact], + held: &BTreeMap<&'static str, usize>, + total: u64, + root: &Path, + ctx: &Context, + now: SystemTime, + policy: &carwash_core::select::Policy, +) -> Result<()> { + if selected.is_empty() { + writeln!(out, "Nothing to clean under {}.", root.display())?; + } else { + let shown: Vec<&Artifact> = selected.iter().take(PLAN_ROWS).copied().collect(); + artifact_table(&shown, root, ctx.registry(), |a| policy.hold(a, now), now).write(out)?; + if selected.len() > shown.len() { + let rest: u64 = selected[shown.len()..] + .iter() + .filter_map(|a| a.size) + .map(|s| s.reclaimable) + .sum(); + writeln!( + out, + "{}… and {} more ({}){}", + Style::new().dimmed().render(), + selected.len() - shown.len(), + fmt::bytes(rest), + Style::new().dimmed().render_reset() + )?; + } + writeln!(out)?; + let bold = Style::new().bold(); + writeln!( + out, + "Selected {} directories, {}{}{} to free.", + count(u64::try_from(selected.len()).unwrap_or(u64::MAX)), + bold.render(), + fmt::bytes(total), + bold.render_reset() + )?; + } + if !held.is_empty() { + let parts: Vec = held + .iter() + .map(|(label, n)| format!("{n} {label}")) + .collect(); + let hint = if held.contains_key(Hold::Review.describe()) + || held.contains_key(Hold::Recent.describe()) + { + " (see --include-review, --include-recent)" + } else { + "" + }; + writeln!( + out, + "{}Held back: {}{hint}{}", + Style::new().dimmed().render(), + parts.join(", "), + Style::new().dimmed().render_reset() + )?; + } + Ok(()) +} diff --git a/crates/carwash/src/commands/info.rs b/crates/carwash/src/commands/info.rs new file mode 100644 index 0000000..b314d3a --- /dev/null +++ b/crates/carwash/src/commands/info.rs @@ -0,0 +1,144 @@ +//! `carwash ecosystems` and `carwash history`. + +use super::count; +use crate::cli::JsonArgs; +use crate::context::Context; +use crate::table::{Align, Cell, Table}; +use anstyle::Style; +use anyhow::Result; +use carwash_core::{fmt, history}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::io::Write; +use std::process::ExitCode; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Serialize)] +struct EcosystemOut<'a> { + id: &'a str, + name: &'a str, + markers: &'a [String], + lockfiles: &'a [String], + artifacts: Vec<&'a str>, +} + +pub fn ecosystems(ctx: &Context, args: &JsonArgs) -> Result { + let registry = ctx.registry(); + let list: Vec = registry + .ecosystems() + .iter() + .map(|eco| EcosystemOut { + id: &eco.key, + name: &eco.name, + markers: &eco.markers, + lockfiles: &eco.lockfiles, + artifacts: eco + .rules + .iter() + .map(|&r| registry.rule(r).path.as_str()) + .collect(), + }) + .collect(); + let mut out = anstream::stdout().lock(); + if args.json { + serde_json::to_writer_pretty(&mut out, &list)?; + writeln!(out)?; + return Ok(ExitCode::SUCCESS); + } + let mut table = Table::new(&[ + ("ID", Align::Left), + ("NAME", Align::Left), + ("MARKERS", Align::Left), + ("ARTIFACTS", Align::Left), + ]); + for eco in &list { + table.push(vec![ + Cell::styled(eco.id, Style::new().bold()), + Cell::new(eco.name), + Cell::styled(eco.markers.join(" "), Style::new().dimmed()), + Cell::new(eco.artifacts.join(" ")), + ]); + } + table.write(&mut out)?; + writeln!(out, "\n{} ecosystems", list.len())?; + Ok(ExitCode::SUCCESS) +} + +#[derive(Serialize)] +struct HistoryOut { + total_bytes: u64, + cleans: usize, + by_ecosystem: BTreeMap, + records: Vec, +} + +pub fn history(ctx: &Context, args: &JsonArgs) -> Result { + let records = ctx + .dirs + .as_ref() + .map(|d| history::read(&d.history_file())) + .unwrap_or_default(); + let total: u64 = records.iter().map(|r| r.bytes).sum(); + let mut by_ecosystem: BTreeMap = BTreeMap::new(); + for record in &records { + *by_ecosystem + .entry(record.ecosystem.clone().unwrap_or_else(|| "other".into())) + .or_default() += record.bytes; + } + let mut out = anstream::stdout().lock(); + if args.json { + let output = HistoryOut { + total_bytes: total, + cleans: records.len(), + by_ecosystem, + records, + }; + serde_json::to_writer_pretty(&mut out, &output)?; + writeln!(out)?; + return Ok(ExitCode::SUCCESS); + } + if records.is_empty() { + writeln!(out, "Nothing reclaimed yet. Try `carwash clean --dry-run`.")?; + return Ok(ExitCode::SUCCESS); + } + let bold = Style::new().bold(); + writeln!( + out, + "{}{}{} reclaimed across {} directories.\n", + bold.render(), + fmt::bytes(total), + bold.render_reset(), + count(u64::try_from(records.len()).unwrap_or(u64::MAX)) + )?; + let mut ranked: Vec<(&String, &u64)> = by_ecosystem.iter().collect(); + ranked.sort_by_key(|(_, bytes)| std::cmp::Reverse(**bytes)); + let mut table = Table::new(&[("ECOSYSTEM", Align::Left), ("FREED", Align::Right)]); + for (eco, bytes) in ranked { + table.push(vec![Cell::new(eco.as_str()), Cell::new(fmt::bytes(*bytes))]); + } + table.write(&mut out)?; + + writeln!(out, "\nRecent:")?; + let now = SystemTime::now(); + let mut recent = Table::new(&[ + ("WHEN", Align::Right), + ("FREED", Align::Right), + ("PATH", Align::Left), + ]); + for record in records.iter().rev().take(10) { + let when = UNIX_EPOCH + Duration::from_secs(record.at); + recent.push(vec![ + Cell::styled( + format!( + "{} ago", + fmt::age(now.duration_since(when).unwrap_or_default()) + ), + Style::new().dimmed(), + ), + Cell::new(fmt::bytes(record.bytes)), + Cell::new(record.path.display().to_string()), + ]); + } + recent.write(&mut out)?; + Ok(ExitCode::SUCCESS) +} diff --git a/crates/carwash/src/commands/mod.rs b/crates/carwash/src/commands/mod.rs new file mode 100644 index 0000000..1895b2c --- /dev/null +++ b/crates/carwash/src/commands/mod.rs @@ -0,0 +1,142 @@ +//! Non-interactive subcommands. + +pub mod clean; +pub mod info; +pub mod scan; + +use anstyle::{AnsiColor, Style}; +use carwash_core::select::Hold; +use carwash_core::{Artifact, GitState}; +use std::path::Path; +use std::time::SystemTime; + +use crate::table::{Align, Cell, Table}; + +const GB: u64 = 1_000_000_000; + +pub(crate) fn size_style(bytes: u64) -> Style { + if bytes >= 10 * GB { + AnsiColor::Red.on_default().bold() + } else if bytes >= GB { + AnsiColor::Yellow.on_default().bold() + } else if bytes >= 100_000_000 { + Style::new() + } else { + Style::new().dimmed() + } +} + +pub(crate) fn display_path(path: &Path, root: &Path) -> String { + match path.strip_prefix(root) { + Ok(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(), + _ => path.display().to_string(), + } +} + +/// Status word and style for an artifact row. +pub(crate) fn status(hold: Option) -> Cell { + match hold { + None => Cell::styled("ready", AnsiColor::Green.on_default()), + Some(Hold::Recent) => Cell::styled("recent", AnsiColor::Blue.on_default()), + Some(Hold::Review) => Cell::styled("review", AnsiColor::Yellow.on_default()), + Some(Hold::Protected) => Cell::styled("protected", AnsiColor::Red.on_default()), + } +} + +/// Short notes explaining sizes or safety. +pub(crate) fn notes(artifact: &Artifact) -> String { + let mut notes = Vec::new(); + if let Some(size) = artifact.size { + if size.on_disk > 0 && size.shared() * 2 >= size.on_disk { + notes.push(format!("shared {}%", size.shared() * 100 / size.on_disk)); + } + if size.errors > 0 { + notes.push(format!("{} unreadable", size.errors)); + } + } + if let GitState::Tracked(files) = artifact.git { + notes.push(format!("{files} tracked")); + } + if artifact.outside_root { + notes.push("enclosing".into()); + } + notes.join(", ") +} + +pub(crate) fn artifact_table( + artifacts: &[&Artifact], + root: &Path, + registry: &carwash_core::Registry, + hold: impl Fn(&Artifact) -> Option, + now: SystemTime, +) -> Table { + let mut table = Table::new(&[ + ("FREED", Align::Right), + ("ON DISK", Align::Right), + ("AGE", Align::Right), + ("KIND", Align::Left), + ("ECOSYSTEM", Align::Left), + ("STATUS", Align::Left), + ("NOTES", Align::Left), + ("PATH", Align::Left), + ]); + for artifact in artifacts { + let (freed, on_disk) = match artifact.size { + Some(size) => ( + Cell::styled( + carwash_core::fmt::bytes(size.reclaimable), + size_style(size.reclaimable), + ), + Cell::styled( + carwash_core::fmt::bytes(size.on_disk), + Style::new().dimmed(), + ), + ), + None => (Cell::new("-"), Cell::new("-")), + }; + let age = carwash_core::select::age(artifact, now) + .map(carwash_core::fmt::age) + .unwrap_or_else(|| "-".into()); + let ecosystem = artifact + .ecosystem + .map(|e| registry.ecosystem(e).key.clone()) + .unwrap_or_default(); + table.push(vec![ + freed, + on_disk, + Cell::styled(age, Style::new().dimmed()), + Cell::new(artifact.kind.label()), + Cell::new(ecosystem), + status(hold(artifact)), + Cell::styled(notes(artifact), Style::new().dimmed()), + Cell::new(display_path(&artifact.path, root)), + ]); + } + table +} + +/// Formats a count with thousands separators. +pub(crate) fn count(n: impl Into) -> String { + let digits = n.into().to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_have_separators() { + assert_eq!(count(0u64), "0"); + assert_eq!(count(999u64), "999"); + assert_eq!(count(1_234u64), "1,234"); + assert_eq!(count(1_234_567u64), "1,234,567"); + } +} diff --git a/crates/carwash/src/commands/scan.rs b/crates/carwash/src/commands/scan.rs new file mode 100644 index 0000000..1868b47 --- /dev/null +++ b/crates/carwash/src/commands/scan.rs @@ -0,0 +1,136 @@ +//! `carwash scan`: report reclaimable artifacts. + +use super::{artifact_table, count}; +use crate::cli::{ScanArgs, SortKey}; +use crate::context::Context; +use crate::report::ScanReport; +use anstyle::Style; +use anyhow::Result; +use carwash_core::select::Hold; +use carwash_core::{Artifact, fmt}; +use std::collections::BTreeMap; +use std::io::Write; +use std::process::ExitCode; +use std::time::SystemTime; + +const DEFAULT_ROWS: usize = 50; + +pub fn run(ctx: &Context, args: &ScanArgs) -> Result { + let (root, options) = ctx.scan_options(&args.walk, !args.no_git, !args.no_size)?; + let snapshot = ctx.run_scan(&root, &options, !args.json); + let filter = ctx.filter(&args.filter)?; + let policy = ctx.policy(false, false); + let registry = ctx.registry(); + let mut out = anstream::stdout().lock(); + + if args.json { + let report = ScanReport::new(&root, &snapshot, registry, &filter, &policy); + serde_json::to_writer_pretty(&mut out, &report)?; + writeln!(out)?; + return Ok(ExitCode::SUCCESS); + } + + let now = SystemTime::now(); + let mut artifacts: Vec<&Artifact> = snapshot + .artifacts + .iter() + .filter(|a| filter.matches(a, now)) + .collect(); + sort(&mut artifacts, args.sort, now); + + let limit = match args.limit { + Some(0) => usize::MAX, + Some(n) => n, + None => DEFAULT_ROWS, + }; + let shown: Vec<&Artifact> = artifacts.iter().take(limit).copied().collect(); + let table = artifact_table(&shown, &root, registry, |a| policy.hold(a, now), now); + if table.is_empty() { + writeln!(out, "No artifacts found under {}.", root.display())?; + } else { + table.write(&mut out)?; + if artifacts.len() > shown.len() { + writeln!( + out, + "{}… {} more (use --limit 0 to show all){}", + Style::new().dimmed().render(), + count(u64::try_from(artifacts.len() - shown.len()).unwrap_or(u64::MAX)), + Style::new().dimmed().render_reset() + )?; + } + } + + let mut total = 0u64; + let mut on_disk = 0u64; + let mut ready = 0u64; + let mut held: BTreeMap<&'static str, (usize, u64)> = BTreeMap::new(); + for artifact in &artifacts { + let size = artifact.size.unwrap_or_default(); + total += size.reclaimable; + on_disk += size.on_disk; + match policy.hold(artifact, now) { + None => ready += size.reclaimable, + Some(hold) => { + let entry = held.entry(hold_label(hold)).or_default(); + entry.0 += 1; + entry.1 += size.reclaimable; + } + } + } + let bold = Style::new().bold(); + let project_count = snapshot.projects.iter().filter(|p| !p.outside_root).count(); + writeln!(out)?; + writeln!( + out, + "{}{} reclaimable{} from {} artifacts in {} projects ({} on disk)", + bold.render(), + fmt::bytes(total), + bold.render_reset(), + count(u64::try_from(artifacts.len()).unwrap_or(u64::MAX)), + count(u64::try_from(project_count).unwrap_or(u64::MAX)), + fmt::bytes(on_disk), + )?; + let mut parts = vec![format!("ready to clean {}", fmt::bytes(ready))]; + for (label, (n, bytes)) in &held { + parts.push(format!("{n} {label} ({})", fmt::bytes(*bytes))); + } + writeln!(out, "{}", parts.join(" · "))?; + writeln!( + out, + "{}Scanned {} directories in {:.1}s{}", + Style::new().dimmed().render(), + count(snapshot.dirs), + snapshot.elapsed.as_secs_f64(), + Style::new().dimmed().render_reset() + )?; + if !snapshot.warnings.is_empty() { + writeln!( + out, + "{}{} directories could not be read{}", + Style::new().dimmed().render(), + snapshot.warnings.len(), + Style::new().dimmed().render_reset() + )?; + } + Ok(ExitCode::SUCCESS) +} + +fn hold_label(hold: Hold) -> &'static str { + match hold { + Hold::Recent => "recently used", + Hold::Review => "need review", + Hold::Protected => "protected", + } +} + +pub(crate) fn sort(artifacts: &mut [&Artifact], key: SortKey, now: SystemTime) { + match key { + SortKey::Size => { + artifacts.sort_by_key(|a| std::cmp::Reverse(a.size.map_or(0, |s| s.reclaimable))) + } + SortKey::Age => artifacts.sort_by_key(|a| { + std::cmp::Reverse(carwash_core::select::age(a, now).unwrap_or_default()) + }), + SortKey::Path => artifacts.sort_by(|a, b| a.path.cmp(&b.path)), + } +} diff --git a/crates/carwash/src/config.rs b/crates/carwash/src/config.rs new file mode 100644 index 0000000..cae274c --- /dev/null +++ b/crates/carwash/src/config.rs @@ -0,0 +1,149 @@ +//! User configuration: `~/.config/carwash/config.toml`. + +use anyhow::{Context, Result}; +use carwash_core::clean::DeleteMode; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub scan: ScanConfig, + pub clean: CleanConfig, + pub ui: UiConfig, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct ScanConfig { + /// Extra paths never scanned. `~` expands to the home directory. + pub exclude: Vec, + /// Also skip well-known non-project locations under the home directory. + pub default_excludes: bool, + /// I/O threads; `0` picks a value from the CPU count. + pub threads: usize, + /// Enter hidden directories everywhere, not only inside projects and repositories. + pub include_hidden: bool, + pub same_filesystem: bool, + pub max_depth: Option, +} + +impl Default for ScanConfig { + fn default() -> Self { + Self { + exclude: Vec::new(), + default_excludes: true, + threads: 0, + include_hidden: false, + same_filesystem: true, + max_depth: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct CleanConfig { + pub mode: DeleteMode, + /// Artifacts modified within this many days are not selected by default. + pub recent_days: u64, +} + +impl Default for CleanConfig { + fn default() -> Self { + Self { + mode: DeleteMode::Permanent, + recent_days: 7, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct UiConfig { + pub theme: String, + /// `unicode`, `nerd` (Nerd Font glyphs) or `ascii`. + pub icons: String, +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + theme: "gestalt".into(), + icons: "unicode".into(), + } + } +} + +/// Home-relative locations that hold no projects but plenty of look-alikes. +const HOME_EXCLUDES: &[&str] = &[ + "Library", + "Applications", + "Pictures", + "Music", + "Movies", + "go/pkg/mod", + "snap", +]; + +impl Config { + /// Loads `path`; a missing file yields defaults, a malformed one is an error. + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => toml::from_str(&text) + .with_context(|| format!("invalid configuration in {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())), + } + } + + /// Absolute exclusion paths: configured ones plus, if enabled, the home defaults. + pub fn excludes(&self) -> Vec { + let home = std::env::var_os("HOME").map(PathBuf::from); + let mut out: Vec = self + .scan + .exclude + .iter() + .map(|raw| expand_home(raw, home.as_deref())) + .collect(); + if self.scan.default_excludes + && let Some(home) = &home + { + out.extend(HOME_EXCLUDES.iter().map(|rel| home.join(rel))); + } + out + } +} + +pub fn expand_home(raw: &str, home: Option<&Path>) -> PathBuf { + match (raw.strip_prefix("~/").or((raw == "~").then_some("")), home) { + (Some(rest), Some(home)) => home.join(rest), + _ => PathBuf::from(raw), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_and_partial_files() { + let config: Config = toml::from_str("[clean]\nrecent_days = 3\n").unwrap(); + assert_eq!(config.clean.recent_days, 3); + assert_eq!(config.clean.mode, DeleteMode::Permanent); + assert!(config.scan.same_filesystem); + } + + #[test] + fn unknown_keys_are_errors() { + assert!(toml::from_str::("[scan]\nexclud = []\n").is_err()); + } + + #[test] + fn home_expansion() { + let home = Path::new("/home/me"); + assert_eq!(expand_home("~/x", Some(home)), PathBuf::from("/home/me/x")); + assert_eq!(expand_home("~", Some(home)), PathBuf::from("/home/me")); + assert_eq!(expand_home("/abs", Some(home)), PathBuf::from("/abs")); + } +} diff --git a/crates/carwash/src/context.rs b/crates/carwash/src/context.rs new file mode 100644 index 0000000..96fb9b5 --- /dev/null +++ b/crates/carwash/src/context.rs @@ -0,0 +1,215 @@ +//! Shared setup for every command: configuration, rules, engine, and scan helpers. + +use crate::cli::{FilterArgs, GlobalArgs, WalkArgs}; +use crate::config::Config; +use anyhow::{Context as _, Result, bail}; +use carwash_core::cache::SizeCache; +use carwash_core::discover::Hidden; +use carwash_core::paths::Dirs; +use carwash_core::select::{Filter, Policy}; +use carwash_core::{ + Cancel, Counters, DiscoverOptions, Engine, Registry, ScanEvent, ScanOptions, Snapshot, +}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +pub struct Context { + pub config: Config, + pub dirs: Option, + pub engine: Engine, +} + +impl Context { + pub fn load(global: &GlobalArgs) -> Result { + let dirs = Dirs::discover(); + let config_path = global + .config + .clone() + .or_else(|| dirs.as_ref().map(Dirs::config_file)); + let config = match &config_path { + Some(path) => Config::load(path)?, + None => Config::default(), + }; + let registry = match dirs.as_ref().map(Dirs::rules_file) { + Some(rules) if rules.exists() => { + let text = std::fs::read_to_string(&rules) + .with_context(|| format!("cannot read {}", rules.display()))?; + Registry::with_overrides(&text) + .with_context(|| format!("invalid ecosystem rules in {}", rules.display()))? + } + _ => Registry::builtin(), + }; + let threads = global + .threads + .or((config.scan.threads > 0).then_some(config.scan.threads)); + let engine = Engine::with_threads(registry, threads).context("cannot start I/O threads")?; + Ok(Self { + config, + dirs, + engine, + }) + } + + pub fn registry(&self) -> &Registry { + self.engine.registry() + } + + /// Resolves the scan root and builds scan options from config and flags. + pub fn scan_options( + &self, + walk: &WalkArgs, + git: bool, + measure: bool, + ) -> Result<(PathBuf, ScanOptions)> { + let root = std::fs::canonicalize(&walk.path) + .with_context(|| format!("cannot open {}", walk.path.display()))?; + if !root.is_dir() { + bail!("{} is not a directory", root.display()); + } + let mut exclude = self.config.excludes(); + exclude.extend( + walk.exclude + .iter() + .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone())), + ); + // Scanning inside an excluded location is an explicit request: honour it. + exclude.retain(|ex| !root.starts_with(ex)); + let scan = &self.config.scan; + Ok(( + root, + ScanOptions { + discover: DiscoverOptions { + max_depth: walk.max_depth.or(scan.max_depth), + hidden: if walk.hidden || scan.include_hidden { + Hidden::Always + } else { + Hidden::InsideProjects + }, + same_filesystem: !walk.cross_fs && scan.same_filesystem, + exclude, + include_enclosing: !walk.no_enclosing, + }, + git, + measure, + }, + )) + } + + pub fn filter(&self, args: &FilterArgs) -> Result { + let ecosystems = args + .ecosystems + .iter() + .map(|key| { + self.registry().find(key).with_context(|| { + format!("unknown ecosystem `{key}` (see `carwash ecosystems`)") + }) + }) + .collect::>()?; + Ok(Filter { + min_size: args.min_size, + older_than: args.older_than, + kinds: args.kind.clone(), + ecosystems, + }) + } + + pub fn policy(&self, include_review: bool, include_recent: bool) -> Policy { + Policy { + include_review, + recent: (!include_recent && self.config.clean.recent_days > 0) + .then(|| Duration::from_secs(self.config.clean.recent_days * 86_400)), + } + } + + /// Runs a scan, showing progress on stderr when it is a terminal, and records measured + /// sizes in the size cache. + pub fn run_scan(&self, root: &Path, options: &ScanOptions, show_progress: bool) -> Snapshot { + let counters = Counters::default(); + let snapshot = Mutex::new(Snapshot::default()); + let discovered = AtomicBool::new(false); + let cancel = Cancel::new(); + + let bar = if show_progress && std::io::stderr().is_terminal() { + let bar = ProgressBar::new_spinner(); + bar.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .expect("valid template") + .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "), + ); + bar.enable_steady_tick(Duration::from_millis(80)); + bar + } else { + ProgressBar::hidden() + }; + + std::thread::scope(|scope| { + let worker = scope.spawn(|| { + self.engine + .scan(root, options, &counters, &cancel, &|event| { + if matches!(event, ScanEvent::DiscoveryFinished { .. }) { + discovered.store(true, Ordering::Relaxed); + } + snapshot.lock().expect("snapshot lock").apply(event); + }); + }); + while !worker.is_finished() { + let artifacts = counters.artifacts.load(Ordering::Relaxed); + let message = if discovered.load(Ordering::Relaxed) { + let found: u64 = snapshot + .lock() + .expect("snapshot lock") + .artifacts + .iter() + .filter_map(|a| a.size) + .map(|s| s.reclaimable) + .sum(); + format!( + "Measuring {}/{} artifacts · {} reclaimable so far", + counters.measured.load(Ordering::Relaxed), + artifacts, + carwash_core::fmt::bytes(found) + ) + } else { + format!( + "Scanning {} dirs · {} projects · {} artifacts", + counters.dirs.load(Ordering::Relaxed), + counters.projects.load(Ordering::Relaxed), + artifacts + ) + }; + bar.set_message(message); + std::thread::sleep(Duration::from_millis(80)); + } + }); + bar.finish_and_clear(); + + let mut snapshot = snapshot.into_inner().expect("snapshot lock"); + snapshot.sort(); + if options.measure { + self.update_size_cache(|cache| { + for artifact in &snapshot.artifacts { + if let Some(size) = artifact.size { + cache.insert(artifact.path.clone(), size); + } + } + }); + } + snapshot + } + + /// Loads, edits and saves the size cache; failures only cost a warm start. + pub fn update_size_cache(&self, edit: impl FnOnce(&mut SizeCache)) { + let Some(path) = self.dirs.as_ref().map(Dirs::size_cache_file) else { + return; + }; + let mut cache = SizeCache::load(&path); + edit(&mut cache); + if let Err(error) = cache.save(&path) { + tracing::warn!(%error, "cannot save size cache"); + } + } +} diff --git a/crates/carwash/src/main.rs b/crates/carwash/src/main.rs new file mode 100644 index 0000000..1800248 --- /dev/null +++ b/crates/carwash/src/main.rs @@ -0,0 +1,90 @@ +//! carwash: reclaim disk space across every project on your machine. + +mod cli; +mod commands; +mod config; +mod context; +mod report; +mod table; + +use clap::{CommandFactory, Parser}; +use cli::{Cli, ColorChoice, Command}; +use context::Context; +use std::process::ExitCode; + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.global.color { + ColorChoice::Always => anstream::ColorChoice::Always.write_global(), + ColorChoice::Never => anstream::ColorChoice::Never.write_global(), + ColorChoice::Auto => {} + } + init_logging(); + match run(cli) { + Ok(code) => code, + Err(error) => { + anstream::eprintln!( + "{}error:{} {error:#}", + anstyle::AnsiColor::Red.on_default().bold().render(), + anstyle::Reset.render() + ); + ExitCode::from(2) + } + } +} + +/// CLI logging goes to stderr and is off unless `CARWASH_LOG` is set (e.g. `debug`). +fn init_logging() { + if let Ok(filter) = tracing_subscriber::EnvFilter::try_from_env("CARWASH_LOG") { + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); + } +} + +fn run(cli: Cli) -> anyhow::Result { + if let Some(Command::Completions { shell }) = cli.command { + clap_complete::generate( + shell, + &mut Cli::command(), + "carwash", + &mut std::io::stdout(), + ); + return Ok(ExitCode::SUCCESS); + } + let ctx = Context::load(&cli.global)?; + match &cli.command { + Some(Command::Scan(args)) => commands::scan::run(&ctx, args), + Some(Command::Clean(args)) => commands::clean::run(&ctx, args), + Some(Command::Ecosystems(args)) => commands::info::ecosystems(&ctx, args), + Some(Command::History(args)) => commands::info::history(&ctx, args), + Some(Command::Completions { .. }) => unreachable!("handled above"), + None => { + // The interactive UI lands in the next phase; scan meanwhile. + let path = cli.path.clone().unwrap_or_else(|| ".".into()); + let args = cli::ScanArgs { + walk: cli::WalkArgs { + path, + exclude: Vec::new(), + hidden: false, + max_depth: None, + cross_fs: false, + no_enclosing: false, + }, + filter: cli::FilterArgs { + min_size: None, + older_than: None, + kind: Vec::new(), + ecosystems: Vec::new(), + }, + json: false, + no_git: false, + no_size: false, + sort: cli::SortKey::Size, + limit: None, + }; + commands::scan::run(&ctx, &args) + } + } +} diff --git a/crates/carwash/src/report.rs b/crates/carwash/src/report.rs new file mode 100644 index 0000000..ec2ef9f --- /dev/null +++ b/crates/carwash/src/report.rs @@ -0,0 +1,187 @@ +//! JSON output shapes. Kept separate from the engine model so the CLI contract can stay +//! stable while internals change; `version` is bumped on breaking changes. + +use carwash_core::select::{Filter, Policy}; +use carwash_core::{ + Artifact, ArtifactKind, Detection, GitState, Project, Registry, Safety, Size, Snapshot, +}; +use serde::Serialize; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const VERSION: u32 = 1; + +fn unix(t: Option) -> Option { + t.and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) +} + +#[derive(Serialize)] +pub struct ProjectOut<'a> { + pub id: u32, + pub path: &'a Path, + pub name: &'a str, + pub ecosystems: Vec<&'a str>, + pub parent: Option, + pub member_of: Option, + pub is_workspace: bool, + pub repo: Option<&'a Path>, + pub last_activity: Option, + pub outside_root: bool, +} + +impl<'a> ProjectOut<'a> { + pub fn new(project: &'a Project, registry: &'a Registry) -> Self { + Self { + id: project.id.0, + path: &project.path, + name: &project.name, + ecosystems: project + .ecosystems + .iter() + .map(|&e| registry.ecosystem(e).key.as_str()) + .collect(), + parent: project.parent.map(|p| p.0), + member_of: project.member_of.map(|p| p.0), + is_workspace: project.is_workspace, + repo: project.repo.as_deref(), + last_activity: unix(project.last_activity), + outside_root: project.outside_root, + } + } +} + +#[derive(Serialize)] +pub struct ArtifactOut<'a> { + pub id: u32, + pub path: &'a Path, + pub project: Option, + pub ecosystem: Option<&'a str>, + pub kind: ArtifactKind, + pub detected_by: &'static str, + /// Rule path, for rule-based detections. + pub rule: Option<&'a str>, + pub confirmed: bool, + pub ambiguous: bool, + pub git: GitState, + pub safety: Safety, + /// Why the default policy would not select it, if it would not. + pub hold: Option<&'static str>, + pub size: Option, + pub modified: Option, + pub outside_root: bool, + pub regenerate: Option<&'a str>, +} + +impl<'a> ArtifactOut<'a> { + pub fn new( + artifact: &'a Artifact, + registry: &'a Registry, + policy: &Policy, + now: SystemTime, + ) -> Self { + let (detected_by, rule) = match artifact.detection { + Detection::Rule { rule, .. } => ("rule", Some(registry.rule(rule))), + Detection::CacheDirTag => ("cachedir_tag", None), + Detection::PythonVenv => ("pyvenv_cfg", None), + Detection::CondaEnv => ("conda_meta", None), + Detection::CMakeBuild => ("cmake_cache", None), + Detection::MesonBuild => ("meson_private", None), + Detection::CargoFingerprint => ("cargo_fingerprint", None), + Detection::CarwashLeftover => ("carwash_leftover", None), + }; + Self { + id: artifact.id.0, + path: &artifact.path, + project: artifact.project.map(|p| p.0), + ecosystem: artifact + .ecosystem + .map(|e| registry.ecosystem(e).key.as_str()), + kind: artifact.kind, + detected_by, + rule: rule.map(|r| r.path.as_str()), + confirmed: artifact.detection.is_certain(), + ambiguous: artifact.ambiguous, + git: artifact.git, + safety: artifact.safety(), + hold: policy.hold(artifact, now).map(|h| h.describe()), + size: artifact.size, + modified: unix(artifact.last_modified()), + outside_root: artifact.outside_root, + regenerate: rule.and_then(|r| r.regenerate.as_deref()), + } + } +} + +#[derive(Serialize, Default)] +pub struct Totals { + pub artifacts: usize, + pub on_disk: u64, + pub reclaimable: u64, +} + +impl Totals { + pub fn add(&mut self, artifact: &Artifact) { + self.artifacts += 1; + if let Some(size) = artifact.size { + self.on_disk += size.on_disk; + self.reclaimable += size.reclaimable; + } + } +} + +#[derive(Serialize)] +pub struct ScanReport<'a> { + pub version: u32, + pub root: &'a Path, + pub elapsed_ms: u128, + pub directories: u64, + /// Every artifact matching the filters. + pub total: Totals, + /// The subset the default policy would clean. + pub selectable: Totals, + pub projects: Vec>, + pub artifacts: Vec>, + pub warnings: usize, +} + +impl<'a> ScanReport<'a> { + pub fn new( + root: &'a Path, + snapshot: &'a Snapshot, + registry: &'a Registry, + filter: &Filter, + policy: &Policy, + ) -> Self { + let now = SystemTime::now(); + let mut total = Totals::default(); + let mut selectable = Totals::default(); + let artifacts: Vec> = snapshot + .artifacts + .iter() + .filter(|a| filter.matches(a, now)) + .inspect(|a| { + total.add(a); + if policy.hold(a, now).is_none() { + selectable.add(a); + } + }) + .map(|a| ArtifactOut::new(a, registry, policy, now)) + .collect(); + Self { + version: VERSION, + root, + elapsed_ms: snapshot.elapsed.as_millis(), + directories: snapshot.dirs, + total, + selectable, + projects: snapshot + .projects + .iter() + .map(|p| ProjectOut::new(p, registry)) + .collect(), + artifacts, + warnings: snapshot.warnings.len(), + } + } +} diff --git a/crates/carwash/src/table.rs b/crates/carwash/src/table.rs new file mode 100644 index 0000000..9da0306 --- /dev/null +++ b/crates/carwash/src/table.rs @@ -0,0 +1,101 @@ +//! Minimal aligned, styled table output for the CLI. + +use anstyle::Style; +use std::io::{self, Write}; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + Left, + Right, +} + +#[derive(Debug, Clone, Default)] +pub struct Cell { + pub text: String, + pub style: Style, +} + +impl Cell { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + style: Style::new(), + } + } + + pub fn styled(text: impl Into, style: Style) -> Self { + Self { + text: text.into(), + style, + } + } +} + +#[derive(Debug)] +pub struct Table { + headers: Vec<&'static str>, + align: Vec, + rows: Vec>, +} + +impl Table { + pub fn new(columns: &[(&'static str, Align)]) -> Self { + Self { + headers: columns.iter().map(|(h, _)| *h).collect(), + align: columns.iter().map(|(_, a)| *a).collect(), + rows: Vec::new(), + } + } + + pub fn push(&mut self, row: Vec) { + debug_assert_eq!(row.len(), self.headers.len()); + self.rows.push(row); + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + pub fn write(&self, out: &mut impl Write) -> io::Result<()> { + let mut widths: Vec = self.headers.iter().map(|h| h.width()).collect(); + for row in &self.rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.text.width()); + } + } + let header = Style::new().bold().dimmed(); + let headers: Vec = self + .headers + .iter() + .map(|h| Cell::styled(*h, header)) + .collect(); + self.write_row(out, &headers, &widths)?; + for row in &self.rows { + self.write_row(out, row, &widths)?; + } + Ok(()) + } + + fn write_row(&self, out: &mut impl Write, row: &[Cell], widths: &[usize]) -> io::Result<()> { + let last = row.len().saturating_sub(1); + for (i, cell) in row.iter().enumerate() { + let pad = widths[i].saturating_sub(cell.text.width()); + let styled = format!( + "{}{}{}", + cell.style.render(), + cell.text, + cell.style.render_reset() + ); + match self.align[i] { + Align::Right => write!(out, "{}{styled}", " ".repeat(pad))?, + Align::Left if i == last => write!(out, "{styled}")?, + Align::Left => write!(out, "{styled}{}", " ".repeat(pad))?, + } + if i != last { + write!(out, " ")?; + } + } + writeln!(out) + } +} From 9fdec3fea06fb8e810f1d31635796fc022a083d6 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:18:12 -0400 Subject: [PATCH 06/22] test(cli): end-to-end scan, clean, dry-run, confirmation and rules tests --- crates/carwash/Cargo.toml | 1 + crates/carwash/tests/cli.rs | 212 ++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 crates/carwash/tests/cli.rs diff --git a/crates/carwash/Cargo.toml b/crates/carwash/Cargo.toml index 17bab18..d3f9b31 100644 --- a/crates/carwash/Cargo.toml +++ b/crates/carwash/Cargo.toml @@ -37,6 +37,7 @@ anstyle.workspace = true tempfile.workspace = true assert_cmd.workspace = true predicates.workspace = true +filetime.workspace = true [lints] workspace = true diff --git a/crates/carwash/tests/cli.rs b/crates/carwash/tests/cli.rs new file mode 100644 index 0000000..d386a57 --- /dev/null +++ b/crates/carwash/tests/cli.rs @@ -0,0 +1,212 @@ +//! End-to-end tests of the `carwash` binary against fixture trees. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use std::path::Path; + +const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n"; + +fn write(root: &Path, rel: &str, contents: &str) { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); +} + +/// Two old artifacts, one recent one, and one with a generic name. +fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, "app/Cargo.toml", "[package]\nname = \"app\"\n"); + write(root, "app/target/CACHEDIR.TAG", CACHEDIR_TAG); + write(root, "app/target/debug/bin", &"x".repeat(50_000)); + write(root, "web/package.json", "{\"name\":\"web\"}"); + write(root, "web/node_modules/pkg/index.js", &"y".repeat(20_000)); + write(root, "web/dist/bundle.js", "z"); + write(root, "fresh/package.json", "{}"); + write(root, "fresh/node_modules/pkg/index.js", "new"); + let old = filetime::FileTime::from_unix_time(1_600_000_000, 0); + for rel in [ + "app/target/CACHEDIR.TAG", + "app/target/debug/bin", + "app/target/debug", + "app/target", + "web/node_modules/pkg/index.js", + "web/node_modules/pkg", + "web/node_modules", + "web/dist/bundle.js", + "web/dist", + ] { + filetime::set_file_mtime(root.join(rel), old).unwrap(); + } + dir +} + +fn carwash(home: &Path) -> Command { + let mut cmd = Command::cargo_bin("carwash").unwrap(); + cmd.env("CARWASH_HOME", home) + .env_remove("CARWASH_CONFIG") + .env("NO_COLOR", "1"); + cmd +} + +#[test] +fn scan_json_reports_artifacts_and_policy() { + let tree = fixture(); + let home = tempfile::tempdir().unwrap(); + let output = carwash(home.path()) + .args(["scan", "--json"]) + .arg(tree.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["version"], 1); + let artifacts = report["artifacts"].as_array().unwrap(); + let find = |suffix: &str| { + artifacts + .iter() + .find(|a| a["path"].as_str().unwrap().ends_with(suffix)) + .unwrap_or_else(|| panic!("{suffix} missing")) + }; + assert_eq!(find("app/target")["kind"], "build"); + assert_eq!(find("app/target")["ecosystem"], "rust"); + assert!(find("app/target")["hold"].is_null()); + assert_eq!(find("web/dist")["hold"], "needs review"); + assert_eq!(find("fresh/node_modules")["hold"], "recently used"); + assert!(find("app/target")["size"]["reclaimable"].as_u64().unwrap() >= 50_000); +} + +#[test] +fn scan_table_summarises() { + let tree = fixture(); + let home = tempfile::tempdir().unwrap(); + carwash(home.path()) + .arg("scan") + .arg(tree.path()) + .assert() + .success() + .stdout(predicate::str::contains("app/target")) + .stdout(predicate::str::contains("reclaimable from 4 artifacts")); +} + +#[test] +fn dry_run_deletes_nothing() { + let tree = fixture(); + let home = tempfile::tempdir().unwrap(); + carwash(home.path()) + .args(["clean", "--dry-run"]) + .arg(tree.path()) + .assert() + .success() + .stdout(predicate::str::contains("Dry run")); + assert!(tree.path().join("app/target").exists()); + assert!(tree.path().join("web/node_modules").exists()); +} + +#[test] +fn refuses_without_confirmation_when_not_interactive() { + let tree = fixture(); + let home = tempfile::tempdir().unwrap(); + carwash(home.path()) + .arg("clean") + .arg(tree.path()) + .write_stdin("") + .assert() + .failure() + .stderr(predicate::str::contains("--yes")); + assert!(tree.path().join("app/target").exists()); +} + +#[test] +fn clean_removes_only_selected_and_records_history() { + let tree = fixture(); + let home = tempfile::tempdir().unwrap(); + carwash(home.path()) + .args(["clean", "--yes"]) + .arg(tree.path()) + .assert() + .success() + .stdout(predicate::str::contains("Freed")); + let root = tree.path(); + assert!(!root.join("app/target").exists()); + assert!(!root.join("web/node_modules").exists()); + // Held back by default: generic name and recently used. + assert!(root.join("web/dist").exists()); + assert!(root.join("fresh/node_modules").exists()); + // Sources untouched. + assert!(root.join("app/Cargo.toml").exists()); + + let history = carwash(home.path()) + .args(["history", "--json"]) + .output() + .unwrap(); + let history: serde_json::Value = serde_json::from_slice(&history.stdout).unwrap(); + assert_eq!(history["cleans"], 2); + assert!(history["total_bytes"].as_u64().unwrap() >= 70_000); +} + +#[test] +fn filters_narrow_the_selection() { + let tree = fixture(); + let home = tempfile::tempdir().unwrap(); + carwash(home.path()) + .args(["clean", "--yes", "--kind", "deps", "--include-recent"]) + .arg(tree.path()) + .assert() + .success(); + let root = tree.path(); + assert!( + root.join("app/target").exists(), + "build artifacts untouched" + ); + assert!(!root.join("web/node_modules").exists()); + assert!(!root.join("fresh/node_modules").exists()); +} + +#[test] +fn rejects_unknown_ecosystems_and_bad_sizes() { + let tree = fixture(); + let home = tempfile::tempdir().unwrap(); + carwash(home.path()) + .args(["scan", "-e", "cobol"]) + .arg(tree.path()) + .assert() + .failure() + .stderr(predicate::str::contains("unknown ecosystem")); + carwash(home.path()) + .args(["scan", "--min-size", "lots"]) + .arg(tree.path()) + .assert() + .failure(); +} + +#[test] +fn user_rules_extend_the_registry() { + let tree = fixture(); + write(tree.path(), "acme/acme.yml", ""); + write(tree.path(), "acme/.acme-cache/blob", "b"); + let home = tempfile::tempdir().unwrap(); + write( + home.path(), + "config/ecosystems.toml", + "schema = 1\n[[ecosystem]]\nid = \"acme\"\nname = \"Acme\"\nmarkers = [\"acme.yml\"]\nartifacts = [{ path = \".acme-cache\", kind = \"cache\" }]\n", + ); + carwash(home.path()) + .args(["scan", "-e", "acme"]) + .arg(tree.path()) + .assert() + .success() + .stdout(predicate::str::contains("acme/.acme-cache")); +} + +#[test] +fn ecosystems_lists_builtins() { + let home = tempfile::tempdir().unwrap(); + carwash(home.path()) + .arg("ecosystems") + .assert() + .success() + .stdout(predicate::str::contains("node_modules")) + .stdout(predicate::str::contains("40 ecosystems")); +} From f598ec2ec115fe9ba846173805a8ab42ba54fe7e Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:24:55 -0400 Subject: [PATCH 07/22] feat(tui): interactive reclaim view Elm-style app (pure update, effects executed by a small runtime) over a single normalized store. Tree, projects and artifacts groupings with aggregated sizes and compressed directory chains; fuzzy + facet filter (eco:, kind:, size>, age>, is:); marking that follows the safety policy; review, live progress and summary for cleaning; details panel; help generated from the keymap; themes (Catppuccin-based default, ansi fallback without truecolor); mouse support; instant start from the size cache; redraws only on change. --- crates/carwash/Cargo.toml | 4 + crates/carwash/src/cli.rs | 14 + crates/carwash/src/commands/clean.rs | 14 +- crates/carwash/src/commands/info.rs | 5 +- crates/carwash/src/commands/mod.rs | 11 + crates/carwash/src/main.rs | 68 +- crates/carwash/src/tui/app.rs | 1068 ++++++++++++++++++++++++++ crates/carwash/src/tui/keymap.rs | 312 ++++++++ crates/carwash/src/tui/mod.rs | 257 +++++++ crates/carwash/src/tui/query.rs | 278 +++++++ crates/carwash/src/tui/store.rs | 782 +++++++++++++++++++ crates/carwash/src/tui/theme.rs | 328 ++++++++ crates/carwash/src/tui/view.rs | 1024 ++++++++++++++++++++++++ 13 files changed, 4123 insertions(+), 42 deletions(-) create mode 100644 crates/carwash/src/tui/app.rs create mode 100644 crates/carwash/src/tui/keymap.rs create mode 100644 crates/carwash/src/tui/mod.rs create mode 100644 crates/carwash/src/tui/query.rs create mode 100644 crates/carwash/src/tui/store.rs create mode 100644 crates/carwash/src/tui/theme.rs create mode 100644 crates/carwash/src/tui/view.rs diff --git a/crates/carwash/Cargo.toml b/crates/carwash/Cargo.toml index d3f9b31..91fdb49 100644 --- a/crates/carwash/Cargo.toml +++ b/crates/carwash/Cargo.toml @@ -32,6 +32,10 @@ unicode-width.workspace = true indicatif.workspace = true anstream.workspace = true anstyle.workspace = true +ratatui.workspace = true +tui-input.workspace = true +nucleo-matcher.workspace = true +fs4.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/carwash/src/cli.rs b/crates/carwash/src/cli.rs index 2e9bf58..4a9454e 100644 --- a/crates/carwash/src/cli.rs +++ b/crates/carwash/src/cli.rs @@ -92,6 +92,20 @@ pub struct WalkArgs { pub no_enclosing: bool, } +impl WalkArgs { + /// Defaults for `path`, as when no walk flags are given. + pub fn for_path(path: PathBuf) -> Self { + Self { + path, + exclude: Vec::new(), + hidden: false, + max_depth: None, + cross_fs: false, + no_enclosing: false, + } + } +} + #[derive(Debug, Clone, Args)] pub struct FilterArgs { /// Only artifacts freeing at least this much, e.g. 100MB, 1.5GB. diff --git a/crates/carwash/src/commands/clean.rs b/crates/carwash/src/commands/clean.rs index dc073a9..5dd6698 100644 --- a/crates/carwash/src/commands/clean.rs +++ b/crates/carwash/src/commands/clean.rs @@ -1,6 +1,6 @@ //! `carwash clean`: delete selected artifacts. -use super::{artifact_table, count}; +use super::artifact_table; use crate::cli::{CleanArgs, SortKey}; use crate::context::Context; use anstyle::{AnsiColor, Style}; @@ -128,8 +128,8 @@ pub fn run(ctx: &Context, args: &CleanArgs) -> Result { }; write!( out, - "{verb} {} directories, freeing {}? [y/N] ", - count(u64::try_from(selected.len()).unwrap_or(u64::MAX)), + "{verb} {}, freeing {}? [y/N] ", + super::directories(selected.len()), fmt::bytes(total) )?; out.flush()?; @@ -238,11 +238,11 @@ pub fn run(ctx: &Context, args: &CleanArgs) -> Result { let green = AnsiColor::Green.on_default().bold(); writeln!( out, - "{}Freed {}{} from {} directories.", + "{}Freed {}{} from {}.", green.render(), fmt::bytes(report.bytes), green.render_reset(), - count(u64::try_from(report.removed).unwrap_or(u64::MAX)) + super::directories(report.removed) )?; if let (Some(before), Some(after)) = (report.free_before, report.free_after) { writeln!( @@ -312,8 +312,8 @@ fn print_plan( let bold = Style::new().bold(); writeln!( out, - "Selected {} directories, {}{}{} to free.", - count(u64::try_from(selected.len()).unwrap_or(u64::MAX)), + "Selected {}, {}{}{} to free.", + super::directories(selected.len()), bold.render(), fmt::bytes(total), bold.render_reset() diff --git a/crates/carwash/src/commands/info.rs b/crates/carwash/src/commands/info.rs index b314d3a..36faf4b 100644 --- a/crates/carwash/src/commands/info.rs +++ b/crates/carwash/src/commands/info.rs @@ -1,6 +1,5 @@ //! `carwash ecosystems` and `carwash history`. -use super::count; use crate::cli::JsonArgs; use crate::context::Context; use crate::table::{Align, Cell, Table}; @@ -104,11 +103,11 @@ pub fn history(ctx: &Context, args: &JsonArgs) -> Result { let bold = Style::new().bold(); writeln!( out, - "{}{}{} reclaimed across {} directories.\n", + "{}{}{} reclaimed across {}.\n", bold.render(), fmt::bytes(total), bold.render_reset(), - count(u64::try_from(records.len()).unwrap_or(u64::MAX)) + super::directories(records.len()) )?; let mut ranked: Vec<(&String, &u64)> = by_ecosystem.iter().collect(); ranked.sort_by_key(|(_, bytes)| std::cmp::Reverse(**bytes)); diff --git a/crates/carwash/src/commands/mod.rs b/crates/carwash/src/commands/mod.rs index 1895b2c..daabea4 100644 --- a/crates/carwash/src/commands/mod.rs +++ b/crates/carwash/src/commands/mod.rs @@ -115,6 +115,17 @@ pub(crate) fn artifact_table( table } +/// `1 directory`, `1,234 directories`. +pub(crate) fn directories(n: usize) -> String { + match n { + 1 => "1 directory".into(), + n => format!( + "{} directories", + count(u64::try_from(n).unwrap_or(u64::MAX)) + ), + } +} + /// Formats a count with thousands separators. pub(crate) fn count(n: impl Into) -> String { let digits = n.into().to_string(); diff --git a/crates/carwash/src/main.rs b/crates/carwash/src/main.rs index 1800248..fcd5416 100644 --- a/crates/carwash/src/main.rs +++ b/crates/carwash/src/main.rs @@ -6,10 +6,13 @@ mod config; mod context; mod report; mod table; +mod tui; +use anyhow::bail; use clap::{CommandFactory, Parser}; use cli::{Cli, ColorChoice, Command}; use context::Context; +use std::io::IsTerminal; use std::process::ExitCode; fn main() -> ExitCode { @@ -19,7 +22,6 @@ fn main() -> ExitCode { ColorChoice::Never => anstream::ColorChoice::Never.write_global(), ColorChoice::Auto => {} } - init_logging(); match run(cli) { Ok(code) => code, Err(error) => { @@ -33,13 +35,28 @@ fn main() -> ExitCode { } } -/// CLI logging goes to stderr and is off unless `CARWASH_LOG` is set (e.g. `debug`). -fn init_logging() { - if let Ok(filter) = tracing_subscriber::EnvFilter::try_from_env("CARWASH_LOG") { - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::io::stderr) - .init(); +/// Logging is off unless `CARWASH_LOG` is set (e.g. `debug`). Commands log to stderr; the +/// interactive UI logs to a file, since stderr would corrupt the screen. +fn init_logging(ctx: Option<&Context>) -> Option { + let filter = tracing_subscriber::EnvFilter::try_from_env("CARWASH_LOG").ok()?; + match ctx.and_then(|c| c.dirs.as_ref()) { + Some(dirs) => { + let appender = tracing_appender::rolling::daily(dirs.log_dir(), "carwash.log"); + let (writer, guard) = tracing_appender::non_blocking(appender); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_ansi(false) + .with_writer(writer) + .init(); + Some(guard) + } + None => { + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .init(); + None + } } } @@ -54,6 +71,8 @@ fn run(cli: Cli) -> anyhow::Result { return Ok(ExitCode::SUCCESS); } let ctx = Context::load(&cli.global)?; + let interactive = cli.command.is_none(); + let _log_guard = init_logging(interactive.then_some(&ctx)); match &cli.command { Some(Command::Scan(args)) => commands::scan::run(&ctx, args), Some(Command::Clean(args)) => commands::clean::run(&ctx, args), @@ -61,30 +80,15 @@ fn run(cli: Cli) -> anyhow::Result { Some(Command::History(args)) => commands::info::history(&ctx, args), Some(Command::Completions { .. }) => unreachable!("handled above"), None => { - // The interactive UI lands in the next phase; scan meanwhile. - let path = cli.path.clone().unwrap_or_else(|| ".".into()); - let args = cli::ScanArgs { - walk: cli::WalkArgs { - path, - exclude: Vec::new(), - hidden: false, - max_depth: None, - cross_fs: false, - no_enclosing: false, - }, - filter: cli::FilterArgs { - min_size: None, - older_than: None, - kind: Vec::new(), - ecosystems: Vec::new(), - }, - json: false, - no_git: false, - no_size: false, - sort: cli::SortKey::Size, - limit: None, - }; - commands::scan::run(&ctx, &args) + if !std::io::stdout().is_terminal() || !std::io::stdin().is_terminal() { + bail!( + "the interactive UI needs a terminal; use `carwash scan` or `carwash clean` in scripts" + ); + } + let walk = cli::WalkArgs::for_path(cli.path.clone().unwrap_or_else(|| ".".into())); + let (root, options) = ctx.scan_options(&walk, true, true)?; + tui::run(&ctx, root, options)?; + Ok(ExitCode::SUCCESS) } } } diff --git a/crates/carwash/src/tui/app.rs b/crates/carwash/src/tui/app.rs new file mode 100644 index 0000000..5892e8f --- /dev/null +++ b/crates/carwash/src/tui/app.rs @@ -0,0 +1,1068 @@ +//! Application state and the update function (Elm architecture: messages in, effects out). + +use super::keymap::{self, Action}; +use super::query::{Fuzzy, Query}; +use super::store::{self, EntryStatus, Grouping, RowKey, Rows, SortKey, Store, View}; +use super::theme::{Glyphs, Theme}; +use carwash_core::cache::SizeCache; +use carwash_core::clean::{CleanEvent, CleanItem, CleanReport, DeleteMode}; +use carwash_core::history::Record; +use carwash_core::select::{Hold, Policy}; +use carwash_core::{ArtifactId, Counters, Registry, ScanEvent, fmt}; +use ratatui::crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, MouseEvent, MouseEventKind, +}; +use ratatui::layout::Rect; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant, SystemTime}; +use tui_input::Input; +use tui_input::backend::crossterm::EventHandler; + +#[derive(Debug)] +pub enum Msg { + Key(KeyEvent), + Mouse(MouseEvent), + Resize, + Tick, + ScanStarted(Arc), + Scan(ScanEvent), + Clean(CleanEvent), + CleanFinished(CleanReport), + Disk { free: u64, total: u64 }, +} + +#[derive(Debug)] +pub enum Effect { + Scan, + CancelScan, + Clean { + items: Vec, + mode: DeleteMode, + allowed_roots: Vec, + }, + CancelClean, + RecordHistory(Vec), + Reveal(PathBuf), + RefreshDisk, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + Info, + Warn, +} + +#[derive(Debug)] +pub struct Toast { + pub text: String, + pub level: Level, + until: Instant, +} + +#[derive(Debug)] +pub enum ReviewPhase { + Confirm, + Running { + done_bytes: u64, + done: usize, + failed: usize, + current: Option, + }, + Done { + report: CleanReport, + failures: Vec<(PathBuf, String)>, + }, +} + +#[derive(Debug)] +pub struct Review { + pub items: Vec, + pub bytes: u64, + pub mode: DeleteMode, + pub phase: ReviewPhase, + pub scroll: usize, + /// Held-back artifacts the user marked explicitly, by reason. + pub held: Vec<(Hold, usize)>, + pub outside_root: usize, + removed: Vec<(ArtifactId, u64)>, + failures: Vec<(PathBuf, String)>, +} + +#[derive(Debug)] +pub enum Mode { + Browse, + Search, + Review(Box), + Help, +} + +#[derive(Debug, Default)] +pub struct ScanStatus { + pub running: bool, + pub discovered: bool, + pub counters: Option>, + pub elapsed: Option, +} + +/// Geometry of the last rendered table, for mouse hit-testing. +#[derive(Debug, Default, Clone, Copy)] +pub struct TableGeometry { + pub area: Rect, + pub first_row_y: u16, + pub offset: usize, +} + +pub struct App { + pub store: Store, + pub registry: Arc, + pub cache: SizeCache, + pub policy: Policy, + pub delete_mode: DeleteMode, + pub mode: Mode, + pub grouping: Grouping, + pub sort: SortKey, + pub input: Input, + pub query: Query, + fuzzy: Fuzzy, + pub marked: HashSet, + pub expanded: HashSet, + pub rows: Rows, + rows_built_for: Option<(u64, u64)>, + view_revision: u64, + pub selected: usize, + selected_key: Option, + pub page: usize, + pub geometry: TableGeometry, + pub scan: ScanStatus, + pub toast: Option, + pub theme: Theme, + pub glyphs: Glyphs, + pub show_details: bool, + pub now: SystemTime, + pub disk: Option<(u64, u64)>, + pub spinner: usize, + pub quit: bool, + pub dirty: bool, +} + +impl std::fmt::Debug for App { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("App") + .field("root", &self.store.root) + .field("mode", &self.mode) + .field("rows", &self.rows.rows.len()) + .finish_non_exhaustive() + } +} + +const TOAST_DURATION: Duration = Duration::from_secs(4); + +impl App { + pub fn new( + root: PathBuf, + registry: Arc, + cache: SizeCache, + policy: Policy, + delete_mode: DeleteMode, + theme: Theme, + glyphs: Glyphs, + ) -> Self { + Self { + store: Store::new(root), + registry, + cache, + policy, + delete_mode, + mode: Mode::Browse, + grouping: Grouping::Tree, + sort: SortKey::Size, + input: Input::default(), + query: Query::default(), + fuzzy: Fuzzy::default(), + marked: HashSet::new(), + expanded: HashSet::new(), + rows: Rows::default(), + rows_built_for: None, + view_revision: 0, + selected: 0, + selected_key: None, + page: 10, + geometry: TableGeometry::default(), + scan: ScanStatus::default(), + toast: None, + theme, + glyphs, + show_details: true, + now: SystemTime::now(), + disk: None, + spinner: 0, + quit: false, + dirty: true, + } + } + + /// Something is moving on screen, so the runtime should tick quickly. + pub fn animating(&self) -> bool { + self.scan.running + || matches!(&self.mode, Mode::Review(r) if matches!(r.phase, ReviewPhase::Running { .. })) + || self.toast.is_some() + } + + fn toast(&mut self, text: impl Into, level: Level) { + self.toast = Some(Toast { + text: text.into(), + level, + until: Instant::now() + TOAST_DURATION, + }); + } + + fn view_changed(&mut self) { + self.view_revision += 1; + } + + /// Rebuilds rows if the store or the view changed, keeping the cursor on the same row. + pub fn ensure_rows(&mut self) { + let stamp = (self.store.revision, self.view_revision); + if self.rows_built_for == Some(stamp) { + return; + } + let mut view = View { + grouping: self.grouping, + sort: self.sort, + query: &self.query, + fuzzy: &mut self.fuzzy, + marked: &self.marked, + expanded: &self.expanded, + policy: &self.policy, + now: self.now, + }; + self.rows = store::build(&self.store, &mut view); + self.rows_built_for = Some(stamp); + if let Some(position) = self + .selected_key + .as_ref() + .and_then(|key| self.rows.position(key)) + { + self.selected = position; + } + self.clamp_selection(); + } + + fn clamp_selection(&mut self) { + self.selected = self.selected.min(self.rows.rows.len().saturating_sub(1)); + } + + /// Moves the cursor on the user's behalf; from then on it follows that row when rows + /// are re-sorted by streaming sizes. Until the user moves, it stays on the first row. + fn select(&mut self, index: usize) { + self.selected = index; + self.clamp_selection(); + self.selected_key = self.rows.rows.get(self.selected).map(|r| r.key.clone()); + } + + pub fn selected_row(&self) -> Option<&store::Row> { + self.rows.rows.get(self.selected) + } + + pub fn update(&mut self, msg: Msg) -> Vec { + self.dirty = true; + match msg { + Msg::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key), + Msg::Key(_) => { + self.dirty = false; + Vec::new() + } + Msg::Mouse(mouse) => self.on_mouse(mouse), + Msg::Resize => Vec::new(), + Msg::Tick => { + self.spinner = self.spinner.wrapping_add(1); + self.now = SystemTime::now(); + if self + .toast + .as_ref() + .is_some_and(|t| Instant::now() >= t.until) + { + self.toast = None; + } + Vec::new() + } + Msg::ScanStarted(counters) => { + self.scan = ScanStatus { + running: true, + discovered: false, + counters: Some(counters), + elapsed: None, + }; + Vec::new() + } + Msg::Scan(event) => self.on_scan(event), + Msg::Clean(event) => self.on_clean(event), + Msg::CleanFinished(report) => self.on_clean_finished(report), + Msg::Disk { free, total } => { + self.disk = Some((free, total)); + Vec::new() + } + } + } + + fn on_scan(&mut self, event: ScanEvent) -> Vec { + match &event { + ScanEvent::DiscoveryFinished { .. } => self.scan.discovered = true, + ScanEvent::Measured { id, size } => { + if let Some(entry) = self.store.entry(*id) { + let path = entry.artifact.path.clone(); + self.cache.insert(path, *size); + } + } + ScanEvent::Finished { elapsed } => { + self.scan.running = false; + self.scan.elapsed = Some(*elapsed); + } + _ => {} + } + self.store.apply(event, &self.cache); + Vec::new() + } + + fn on_key(&mut self, key: KeyEvent) -> Vec { + match &mut self.mode { + Mode::Help => { + self.mode = Mode::Browse; + Vec::new() + } + Mode::Search => self.on_search_key(key), + Mode::Review(_) => self.on_review_key(key), + Mode::Browse => match keymap::action_for(&key) { + Some(action) => self.on_action(action), + None => { + self.dirty = false; + Vec::new() + } + }, + } + } + + fn on_search_key(&mut self, key: KeyEvent) -> Vec { + match key.code { + KeyCode::Esc => { + self.input.reset(); + self.set_query(String::new()); + self.mode = Mode::Browse; + } + KeyCode::Enter => self.mode = Mode::Browse, + KeyCode::Up => self.move_by(-1), + KeyCode::Down => self.move_by(1), + _ => { + if self.input.handle_event(&Event::Key(key)).is_some() { + let value = self.input.value().to_owned(); + self.set_query(value); + } + } + } + Vec::new() + } + + fn set_query(&mut self, raw: String) { + self.query = Query::parse(&raw, &self.registry); + self.selected = 0; + self.selected_key = None; + self.view_changed(); + } + + fn on_review_key(&mut self, key: KeyEvent) -> Vec { + let Mode::Review(review) = &mut self.mode else { + return Vec::new(); + }; + match &review.phase { + ReviewPhase::Confirm => match key.code { + KeyCode::Enter | KeyCode::Char('y') => { + let items: Vec = review + .items + .iter() + .filter_map(|id| self.store.entry(*id)) + .map(|entry| CleanItem { + id: entry.artifact.id, + path: entry.artifact.path.clone(), + expected_bytes: entry.size().map_or(0, |s| s.reclaimable), + }) + .collect(); + let mode = review.mode; + review.phase = ReviewPhase::Running { + done_bytes: 0, + done: 0, + failed: 0, + current: None, + }; + let mut allowed_roots = vec![self.store.root.clone()]; + allowed_roots.extend( + self.store + .projects + .iter() + .filter(|p| p.outside_root) + .map(|p| p.path.clone()), + ); + return vec![Effect::Clean { + items, + mode, + allowed_roots, + }]; + } + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => self.mode = Mode::Browse, + KeyCode::Char('t') => { + review.mode = match review.mode { + DeleteMode::Permanent => DeleteMode::Trash, + DeleteMode::Trash => DeleteMode::Permanent, + }; + } + KeyCode::Down | KeyCode::Char('j') => { + review.scroll = (review.scroll + 1).min(review.items.len().saturating_sub(1)); + } + KeyCode::Up | KeyCode::Char('k') => review.scroll = review.scroll.saturating_sub(1), + _ => self.dirty = false, + }, + ReviewPhase::Running { .. } => { + if key.code == KeyCode::Esc { + self.toast("Stopping after the directories in progress…", Level::Warn); + return vec![Effect::CancelClean]; + } + self.dirty = false; + } + ReviewPhase::Done { .. } => self.mode = Mode::Browse, + } + Vec::new() + } + + fn on_mouse(&mut self, mouse: MouseEvent) -> Vec { + if !matches!(self.mode, Mode::Browse) { + self.dirty = false; + return Vec::new(); + } + match mouse.kind { + MouseEventKind::ScrollDown => self.move_by(3), + MouseEventKind::ScrollUp => self.move_by(-3), + MouseEventKind::Down(_) => { + let g = self.geometry; + let inside = mouse.column >= g.area.x + && mouse.column < g.area.x + g.area.width + && mouse.row >= g.first_row_y + && mouse.row < g.area.y + g.area.height; + if inside { + let index = g.offset + usize::from(mouse.row - g.first_row_y); + if index < self.rows.rows.len() { + if index == self.selected { + return self.on_action(Action::ToggleExpand); + } + self.select(index); + } + } + } + _ => self.dirty = false, + } + Vec::new() + } + + fn move_by(&mut self, delta: isize) { + if self.rows.rows.is_empty() { + return; + } + let last = self.rows.rows.len() - 1; + let target = self.selected.saturating_add_signed(delta).min(last); + self.select(target); + } + + fn on_action(&mut self, action: Action) -> Vec { + self.ensure_rows(); + match action { + Action::Up => self.move_by(-1), + Action::Down => self.move_by(1), + Action::PageUp => self.move_by(-(self.page.max(1) as isize)), + Action::PageDown => self.move_by(self.page.max(1) as isize), + Action::Top => self.select(0), + Action::Bottom => self.select(self.rows.rows.len().saturating_sub(1)), + Action::Expand => { + if let Some(row) = self.selected_row().cloned() { + if row.expandable && !row.expanded { + self.expanded.insert(row.key); + self.view_changed(); + } else if row.expanded { + self.move_by(1); + } + } + } + Action::Collapse => { + if let Some(row) = self.selected_row().cloned() { + if row.expanded && self.query.is_empty() { + self.expanded.remove(&row.key); + self.view_changed(); + } else if let Some(parent) = self.rows.rows[..self.selected] + .iter() + .rposition(|r| r.depth < row.depth) + { + self.select(parent); + } + } + } + Action::ToggleExpand => { + if let Some(row) = self.selected_row().cloned() + && row.expandable + { + if !self.expanded.remove(&row.key) { + self.expanded.insert(row.key); + } + self.view_changed(); + } + } + Action::ExpandAll => { + // Expanding reveals new expandable rows; repeat until stable. + loop { + self.ensure_rows(); + let before = self.expanded.len(); + let keys: Vec = self + .rows + .rows + .iter() + .filter(|r| r.expandable) + .map(|r| r.key.clone()) + .collect(); + self.expanded.extend(keys); + if self.expanded.len() == before { + break; + } + self.view_changed(); + } + } + Action::CollapseAll => { + self.expanded.clear(); + self.view_changed(); + } + Action::Mark => self.mark_selected(), + Action::MarkAll => self.mark_all(), + Action::Unmark => { + let n = self.marked.len(); + self.marked.clear(); + self.view_changed(); + if n > 0 { + self.toast(format!("Unmarked {n}"), Level::Info); + } + } + Action::Clean => self.open_review(), + Action::Search => { + self.input = Input::new(self.query.raw.clone()); + self.mode = Mode::Search; + } + Action::Sort => { + self.sort = self.sort.next(); + self.view_changed(); + } + Action::Group => { + self.grouping = self.grouping.next(); + self.view_changed(); + } + Action::Details => self.show_details = !self.show_details, + Action::Theme => { + self.theme = self.theme.next(); + self.toast(format!("Theme: {}", self.theme.name), Level::Info); + } + Action::Rescan => { + if self.scan.running { + self.toast("A scan is already running", Level::Warn); + } else { + self.store = Store::new(self.store.root.clone()); + self.marked.clear(); + self.rows_built_for = None; + return vec![Effect::Scan, Effect::RefreshDisk]; + } + } + Action::Open => { + if let Some(path) = self.selected_path() { + return vec![Effect::Reveal(path)]; + } + } + Action::Help => self.mode = Mode::Help, + Action::Quit => { + self.quit = true; + return vec![Effect::CancelScan]; + } + } + Vec::new() + } + + pub fn selected_path(&self) -> Option { + match &self.selected_row()?.key { + RowKey::Dir(path) => Some(path.clone()), + RowKey::Project(id) => self.store.project(*id).map(|p| p.path.clone()), + RowKey::Artifact(id) => self.store.entry(*id).map(|e| e.artifact.path.clone()), + RowKey::Orphans => None, + } + } + + fn hold(&self, id: ArtifactId) -> Option { + self.store + .entry(id) + .and_then(|e| store::hold_for(e, &self.policy, self.now)) + } + + fn markable(&self, id: ArtifactId) -> bool { + self.store + .entry(id) + .is_some_and(|e| e.status == EntryStatus::Present) + && self.hold(id) != Some(Hold::Protected) + } + + fn mark_selected(&mut self) { + let Some(row) = self.selected_row().cloned() else { + return; + }; + let ids: Vec = self.rows.artifacts(&row).to_vec(); + if let RowKey::Artifact(id) = row.key { + if self.marked.remove(&id) { + } else if self.markable(id) { + self.marked.insert(id); + } else { + let reason = self + .store + .entry(id) + .map(|e| e.artifact.safety().describe()) + .unwrap_or("unavailable"); + self.toast(format!("Protected: {reason}"), Level::Warn); + return; + } + } else { + let ready: Vec = ids + .iter() + .copied() + .filter(|&id| self.markable(id) && self.hold(id).is_none()) + .collect(); + let held = ids + .iter() + .filter(|&&id| self.markable(id) && self.hold(id).is_some()) + .count(); + if !ready.is_empty() && ready.iter().all(|id| self.marked.contains(id)) { + for id in &ids { + self.marked.remove(id); + } + } else { + self.marked.extend(ready.iter().copied()); + if ready.is_empty() && held > 0 { + self.toast( + format!( + "Nothing ready here: {held} held back (expand to mark individually)" + ), + Level::Warn, + ); + } else if held > 0 { + self.toast( + format!( + "Marked {} · {held} held back (recent or needs review)", + ready.len() + ), + Level::Info, + ); + } + } + } + self.view_changed(); + self.move_by(1); + } + + fn mark_all(&mut self) { + let ready: Vec = self + .rows + .order + .iter() + .copied() + .filter(|&id| self.markable(id) && self.hold(id).is_none()) + .collect(); + let bytes: u64 = ready + .iter() + .filter_map(|id| self.store.entry(*id)) + .filter_map(|e| e.size()) + .map(|s| s.reclaimable) + .sum(); + self.marked.extend(ready.iter().copied()); + self.view_changed(); + self.toast( + format!( + "Marked {} ready artifacts · {}", + ready.len(), + fmt::bytes(bytes) + ), + Level::Info, + ); + } + + fn open_review(&mut self) { + let mut items: Vec = self + .marked + .iter() + .copied() + .filter(|id| self.markable(*id)) + .collect(); + if items.is_empty() { + self.toast( + "Nothing marked: press Space to mark, a to mark everything ready", + Level::Warn, + ); + return; + } + let size = |id: &ArtifactId| { + self.store + .entry(*id) + .and_then(|e| e.size()) + .map_or(0, |s| s.reclaimable) + }; + items.sort_by_key(|id| std::cmp::Reverse(size(id))); + let bytes = items.iter().map(size).sum(); + let mut held: Vec<(Hold, usize)> = Vec::new(); + for id in &items { + if let Some(hold) = self.hold(*id) { + match held.iter_mut().find(|(h, _)| *h == hold) { + Some((_, n)) => *n += 1, + None => held.push((hold, 1)), + } + } + } + let outside_root = items + .iter() + .filter(|id| { + self.store + .entry(**id) + .is_some_and(|e| e.artifact.outside_root) + }) + .count(); + self.mode = Mode::Review(Box::new(Review { + items, + bytes, + mode: self.delete_mode, + phase: ReviewPhase::Confirm, + scroll: 0, + held, + outside_root, + removed: Vec::new(), + failures: Vec::new(), + })); + } + + fn on_clean(&mut self, event: CleanEvent) -> Vec { + match event { + CleanEvent::Started { id } => { + self.store.set_status(id, EntryStatus::Deleting); + let path = self.store.entry(id).map(|e| e.artifact.path.clone()); + if let Mode::Review(review) = &mut self.mode + && let ReviewPhase::Running { current, .. } = &mut review.phase + { + *current = path; + } + } + CleanEvent::Removed { id, bytes } => { + self.store.set_status(id, EntryStatus::Deleted); + self.marked.remove(&id); + if let Some(entry) = self.store.entry(id) { + let path = entry.artifact.path.clone(); + self.cache.remove(&path); + } + if let Mode::Review(review) = &mut self.mode { + review.removed.push((id, bytes)); + if let ReviewPhase::Running { + done_bytes, done, .. + } = &mut review.phase + { + *done_bytes += bytes; + *done += 1; + } + } + self.view_changed(); + } + CleanEvent::Failed { id, error } => { + self.store + .set_status(id, EntryStatus::Failed(error.clone())); + let path = self + .store + .entry(id) + .map(|e| e.artifact.path.clone()) + .unwrap_or_default(); + if let Mode::Review(review) = &mut self.mode { + review.failures.push((path, error)); + if let ReviewPhase::Running { failed, .. } = &mut review.phase { + *failed += 1; + } + } + } + } + Vec::new() + } + + fn on_clean_finished(&mut self, report: CleanReport) -> Vec { + let mut records = Vec::new(); + if let Mode::Review(review) = &mut self.mode { + for (id, bytes) in &review.removed { + if let Some(entry) = self.store.entry(*id) { + records.push(Record::now( + entry.artifact.path.clone(), + *bytes, + entry.artifact.kind, + entry + .artifact + .ecosystem + .map(|e| self.registry.ecosystem(e).key.clone()), + review.mode, + )); + } + } + let failures = std::mem::take(&mut review.failures); + review.phase = ReviewPhase::Done { report, failures }; + } + // Items interrupted by a cancel are back to normal. + let deleting: Vec = self + .store + .entries + .iter() + .filter(|e| e.status == EntryStatus::Deleting) + .map(|e| e.artifact.id) + .collect(); + for id in deleting { + self.store.set_status(id, EntryStatus::Present); + } + self.view_changed(); + vec![Effect::RecordHistory(records), Effect::RefreshDisk] + } + + /// Scan progress text for the header. + pub fn scan_progress(&self) -> Option { + let counters = self.scan.counters.as_ref()?; + if !self.scan.running { + return None; + } + let artifacts = counters.artifacts.load(Ordering::Relaxed); + Some(if self.scan.discovered { + format!( + "measuring {}/{}", + counters.measured.load(Ordering::Relaxed), + artifacts + ) + } else { + format!( + "scanning {} dirs · {} projects · {} artifacts", + crate::commands::count(counters.dirs.load(Ordering::Relaxed)), + crate::commands::count(counters.projects.load(Ordering::Relaxed)), + artifacts + ) + }) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use carwash_core::{ + Artifact, ArtifactKind, Detection, EcoId, GitState, Project, ProjectId, RuleId, Size, + }; + use ratatui::crossterm::event::KeyModifiers; + + /// An app with two projects: a ready target, a recent node_modules, a protected vendor. + pub(crate) fn app() -> App { + let mut app = App::new( + PathBuf::from("/w"), + Arc::new(Registry::builtin()), + SizeCache::default(), + Policy::default(), + DeleteMode::Permanent, + Theme::ANSI, + Glyphs::UNICODE, + ); + let old = SystemTime::now() - Duration::from_secs(90 * 86_400); + let project = |id: u32, path: &str| Project { + id: ProjectId(id), + path: PathBuf::from(path), + name: path.rsplit('/').next().unwrap().into(), + ecosystems: vec![EcoId(0)], + parent: None, + member_of: None, + is_workspace: false, + repo: None, + last_activity: None, + outside_root: false, + }; + let artifact = + |id: u32, project: u32, path: &str, bytes: u64, modified: SystemTime, git| Artifact { + id: ArtifactId(id), + path: PathBuf::from(path), + project: Some(ProjectId(project)), + ecosystem: Some(EcoId(0)), + kind: ArtifactKind::Build, + detection: Detection::Rule { + rule: RuleId(0), + confirmed: true, + }, + ambiguous: false, + modified: Some(modified), + git, + repo: None, + size: Some(Size { + reclaimable: bytes, + on_disk: bytes, + newest: Some(modified), + ..Size::default() + }), + outside_root: false, + }; + for event in [ + ScanEvent::Project(project(0, "/w/a")), + ScanEvent::Project(project(1, "/w/b")), + ScanEvent::Artifact(artifact(0, 0, "/w/a/target", 500, old, GitState::Ignored)), + ScanEvent::Artifact(artifact( + 1, + 0, + "/w/a/node_modules", + 300, + SystemTime::now(), + GitState::Ignored, + )), + ScanEvent::Artifact(artifact( + 2, + 1, + "/w/b/vendor", + 200, + old, + GitState::Tracked(4), + )), + ] { + app.update(Msg::Scan(event)); + } + app.ensure_rows(); + app + } + + fn press(app: &mut App, code: KeyCode) -> Vec { + let effects = app.update(Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))); + app.ensure_rows(); + effects + } + + #[test] + fn marking_a_project_marks_only_ready_artifacts() { + let mut app = app(); + assert_eq!(app.rows.rows[0].label, "a"); + press(&mut app, KeyCode::Char(' ')); + assert!(app.marked.contains(&ArtifactId(0))); + assert!( + !app.marked.contains(&ArtifactId(1)), + "recently used is held back" + ); + // Marking again unmarks. + press(&mut app, KeyCode::Char('k')); + press(&mut app, KeyCode::Char(' ')); + assert!(app.marked.is_empty()); + } + + #[test] + fn protected_artifacts_cannot_be_marked() { + let mut app = app(); + press(&mut app, KeyCode::Char('E')); + let vendor = app + .rows + .position(&RowKey::Artifact(ArtifactId(2))) + .expect("vendor row"); + app.select(vendor); + press(&mut app, KeyCode::Char(' ')); + assert!(app.marked.is_empty()); + assert!(app.toast.as_ref().unwrap().text.contains("tracked")); + } + + #[test] + fn held_artifacts_can_be_marked_individually() { + let mut app = app(); + press(&mut app, KeyCode::Char('E')); + let modules = app.rows.position(&RowKey::Artifact(ArtifactId(1))).unwrap(); + app.select(modules); + press(&mut app, KeyCode::Char(' ')); + assert!(app.marked.contains(&ArtifactId(1))); + } + + #[test] + fn review_flow_emits_clean_and_records_history() { + let mut app = app(); + press(&mut app, KeyCode::Char('a')); + assert_eq!(app.marked.len(), 1); + press(&mut app, KeyCode::Char('d')); + let Mode::Review(review) = &app.mode else { + panic!("review expected"); + }; + assert_eq!(review.bytes, 500); + let effects = press(&mut app, KeyCode::Enter); + let Some(Effect::Clean { + items, + mode, + allowed_roots, + }) = effects.into_iter().next() + else { + panic!("clean effect expected"); + }; + assert_eq!(items.len(), 1); + assert_eq!(mode, DeleteMode::Permanent); + assert_eq!(allowed_roots, vec![PathBuf::from("/w")]); + + app.update(Msg::Clean(CleanEvent::Started { id: ArtifactId(0) })); + app.update(Msg::Clean(CleanEvent::Removed { + id: ArtifactId(0), + bytes: 500, + })); + let effects = app.update(Msg::CleanFinished(CleanReport { + removed: 1, + bytes: 500, + ..CleanReport::default() + })); + assert!(matches!(&effects[0], Effect::RecordHistory(r) if r.len() == 1)); + assert!(app.marked.is_empty()); + app.ensure_rows(); + assert!( + app.rows + .position(&RowKey::Artifact(ArtifactId(0))) + .is_none() + ); + press(&mut app, KeyCode::Enter); + assert!(matches!(app.mode, Mode::Browse)); + } + + #[test] + fn nothing_marked_shows_a_hint() { + let mut app = app(); + press(&mut app, KeyCode::Char('d')); + assert!(matches!(app.mode, Mode::Browse)); + assert!(app.toast.is_some()); + } + + #[test] + fn search_filters_and_escape_restores() { + let mut app = app(); + press(&mut app, KeyCode::Char('/')); + for c in "vendor".chars() { + press(&mut app, KeyCode::Char(c)); + } + assert_eq!(app.rows.totals.artifacts, 1); + press(&mut app, KeyCode::Esc); + assert_eq!(app.rows.totals.artifacts, 3); + } + + #[test] + fn selection_follows_its_row_across_rebuilds() { + let mut app = app(); + press(&mut app, KeyCode::Char('j')); + assert_eq!(app.selected_row().unwrap().label, "b"); + // A size change that reorders rows keeps the cursor on "b". + app.update(Msg::Scan(ScanEvent::Measured { + id: ArtifactId(2), + size: Size { + reclaimable: 10_000, + on_disk: 10_000, + ..Size::default() + }, + })); + app.ensure_rows(); + assert_eq!(app.selected_row().unwrap().label, "b"); + assert_eq!(app.selected, 0); + } +} diff --git a/crates/carwash/src/tui/keymap.rs b/crates/carwash/src/tui/keymap.rs new file mode 100644 index 0000000..e1ff0dd --- /dev/null +++ b/crates/carwash/src/tui/keymap.rs @@ -0,0 +1,312 @@ +//! Key bindings: one table drives both dispatch and the help screen. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Up, + Down, + PageUp, + PageDown, + Top, + Bottom, + Expand, + Collapse, + ToggleExpand, + ExpandAll, + CollapseAll, + Mark, + MarkAll, + Unmark, + Clean, + Search, + Sort, + Group, + Details, + Rescan, + Open, + Theme, + Help, + Quit, +} + +/// Where a binding is listed in the help screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Section { + Navigate, + Select, + View, + General, +} + +impl Section { + pub const ALL: [Section; 4] = [Self::Navigate, Self::Select, Self::View, Self::General]; + + pub fn title(self) -> &'static str { + match self { + Self::Navigate => "Navigate", + Self::Select => "Select & clean", + Self::View => "View", + Self::General => "General", + } + } +} + +pub struct Binding { + pub keys: &'static [(KeyCode, KeyModifiers)], + pub label: &'static str, + pub action: Action, + pub section: Section, + pub description: &'static str, +} + +const NONE: KeyModifiers = KeyModifiers::NONE; +const CTRL: KeyModifiers = KeyModifiers::CONTROL; + +pub const BINDINGS: &[Binding] = &[ + Binding { + keys: &[(KeyCode::Up, NONE), (KeyCode::Char('k'), NONE)], + label: "↑ k", + action: Action::Up, + section: Section::Navigate, + description: "previous row", + }, + Binding { + keys: &[(KeyCode::Down, NONE), (KeyCode::Char('j'), NONE)], + label: "↓ j", + action: Action::Down, + section: Section::Navigate, + description: "next row", + }, + Binding { + keys: &[(KeyCode::PageUp, NONE), (KeyCode::Char('u'), CTRL)], + label: "PgUp ^u", + action: Action::PageUp, + section: Section::Navigate, + description: "page up", + }, + Binding { + keys: &[(KeyCode::PageDown, NONE), (KeyCode::Char('d'), CTRL)], + label: "PgDn ^d", + action: Action::PageDown, + section: Section::Navigate, + description: "page down", + }, + Binding { + keys: &[(KeyCode::Home, NONE), (KeyCode::Char('g'), NONE)], + label: "Home g", + action: Action::Top, + section: Section::Navigate, + description: "first row", + }, + Binding { + keys: &[(KeyCode::End, NONE), (KeyCode::Char('G'), NONE)], + label: "End G", + action: Action::Bottom, + section: Section::Navigate, + description: "last row", + }, + Binding { + keys: &[(KeyCode::Right, NONE), (KeyCode::Char('l'), NONE)], + label: "→ l", + action: Action::Expand, + section: Section::Navigate, + description: "expand", + }, + Binding { + keys: &[(KeyCode::Left, NONE), (KeyCode::Char('h'), NONE)], + label: "← h", + action: Action::Collapse, + section: Section::Navigate, + description: "collapse / go to parent", + }, + Binding { + keys: &[(KeyCode::Enter, NONE)], + label: "Enter", + action: Action::ToggleExpand, + section: Section::Navigate, + description: "toggle expand", + }, + Binding { + keys: &[(KeyCode::Char('E'), NONE)], + label: "E", + action: Action::ExpandAll, + section: Section::Navigate, + description: "expand all", + }, + Binding { + keys: &[(KeyCode::Char('C'), NONE)], + label: "C", + action: Action::CollapseAll, + section: Section::Navigate, + description: "collapse all", + }, + Binding { + keys: &[(KeyCode::Char(' '), NONE)], + label: "Space", + action: Action::Mark, + section: Section::Select, + description: "mark / unmark (directories mark their ready artifacts)", + }, + Binding { + keys: &[(KeyCode::Char('a'), NONE)], + label: "a", + action: Action::MarkAll, + section: Section::Select, + description: "mark every ready artifact shown", + }, + Binding { + keys: &[(KeyCode::Char('A'), NONE)], + label: "A", + action: Action::Unmark, + section: Section::Select, + description: "unmark everything", + }, + Binding { + keys: &[ + (KeyCode::Char('d'), NONE), + (KeyCode::Char('x'), NONE), + (KeyCode::Delete, NONE), + ], + label: "d x", + action: Action::Clean, + section: Section::Select, + description: "review and clean marked artifacts", + }, + Binding { + keys: &[(KeyCode::Char('/'), NONE)], + label: "/", + action: Action::Search, + section: Section::View, + description: "filter: text, eco:rust kind:deps size>1g age>30d is:ready", + }, + Binding { + keys: &[(KeyCode::Char('s'), NONE)], + label: "s", + action: Action::Sort, + section: Section::View, + description: "sort by size, age, name", + }, + Binding { + keys: &[(KeyCode::Tab, NONE)], + label: "Tab", + action: Action::Group, + section: Section::View, + description: "group as tree, projects, artifacts", + }, + Binding { + keys: &[(KeyCode::Char('i'), NONE)], + label: "i", + action: Action::Details, + section: Section::View, + description: "toggle details panel", + }, + Binding { + keys: &[(KeyCode::Char('t'), NONE)], + label: "t", + action: Action::Theme, + section: Section::View, + description: "next theme", + }, + Binding { + keys: &[(KeyCode::Char('r'), NONE)], + label: "r", + action: Action::Rescan, + section: Section::General, + description: "rescan", + }, + Binding { + keys: &[(KeyCode::Char('o'), NONE)], + label: "o", + action: Action::Open, + section: Section::General, + description: "reveal in file manager", + }, + Binding { + keys: &[(KeyCode::Char('?'), NONE), (KeyCode::F(1), NONE)], + label: "?", + action: Action::Help, + section: Section::General, + description: "help", + }, + Binding { + keys: &[(KeyCode::Char('q'), NONE), (KeyCode::Char('c'), CTRL)], + label: "q ^c", + action: Action::Quit, + section: Section::General, + description: "quit", + }, +]; + +pub fn action_for(key: &KeyEvent) -> Option { + // Terminals disagree on whether shifted characters carry SHIFT; ignore it for chars. + let modifiers = match key.code { + KeyCode::Char(_) => key.modifiers - KeyModifiers::SHIFT, + _ => key.modifiers, + }; + BINDINGS.iter().find_map(|binding| { + binding + .keys + .iter() + .any(|&(code, mods)| { + let mods = match code { + KeyCode::Char(_) => mods - KeyModifiers::SHIFT, + _ => mods, + }; + code == key.code && mods == modifiers + }) + .then_some(binding.action) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + #[test] + fn dispatch() { + assert_eq!( + action_for(&key(KeyCode::Char('j'), NONE)), + Some(Action::Down) + ); + assert_eq!( + action_for(&key(KeyCode::Char('G'), KeyModifiers::SHIFT)), + Some(Action::Bottom) + ); + assert_eq!( + action_for(&key(KeyCode::Char('G'), NONE)), + Some(Action::Bottom) + ); + assert_eq!( + action_for(&key(KeyCode::Char('c'), CTRL)), + Some(Action::Quit) + ); + assert_eq!( + action_for(&key(KeyCode::Char('d'), CTRL)), + Some(Action::PageDown) + ); + assert_eq!( + action_for(&key(KeyCode::Char('d'), NONE)), + Some(Action::Clean) + ); + assert_eq!(action_for(&key(KeyCode::Char('z'), NONE)), None); + } + + #[test] + fn no_key_is_bound_twice() { + let mut seen = std::collections::HashSet::new(); + for binding in BINDINGS { + for &(code, mods) in binding.keys { + let mods = match code { + KeyCode::Char(_) => mods - KeyModifiers::SHIFT, + _ => mods, + }; + assert!(seen.insert((code, mods)), "{code:?} {mods:?} bound twice"); + } + } + } +} diff --git a/crates/carwash/src/tui/mod.rs b/crates/carwash/src/tui/mod.rs new file mode 100644 index 0000000..65b274a --- /dev/null +++ b/crates/carwash/src/tui/mod.rs @@ -0,0 +1,257 @@ +//! The interactive UI. +//! +//! [`app::App`] holds all state and turns messages into effects; this module is the only +//! place with side effects: terminal I/O, engine threads, the filesystem. + +mod app; +mod keymap; +mod query; +mod store; +mod theme; +mod view; + +use crate::context::Context; +use anyhow::Result; +use app::{App, Effect, Msg}; +use carwash_core::clean::CleanOptions; +use carwash_core::{Cancel, Counters, ScanOptions, history}; +use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; +use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event}; +use ratatui::crossterm::execute; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +/// Messages processed before a redraw, so bursts of engine events cost one frame. +const MAX_BATCH: usize = 2_000; +const FAST_TICK: Duration = Duration::from_millis(90); +const IDLE_TICK: Duration = Duration::from_secs(1); + +pub fn run(ctx: &Context, root: PathBuf, options: ScanOptions) -> Result<()> { + let cache = ctx + .dirs + .as_ref() + .map(|d| carwash_core::cache::SizeCache::load(&d.size_cache_file())) + .unwrap_or_default(); + let mut app = App::new( + root.clone(), + ctx.engine.registry().clone(), + cache, + ctx.policy(false, false), + ctx.config.clean.mode, + theme::Theme::named(&ctx.config.ui.theme), + theme::Glyphs::named(&ctx.config.ui.icons), + ); + + let mut terminal = ratatui::init(); + let _ = execute!(std::io::stdout(), EnableMouseCapture); + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = execute!(std::io::stdout(), DisableMouseCapture); + previous_hook(info); + })); + + let (tx, rx) = crossbeam_channel::unbounded(); + let stop = Arc::new(AtomicBool::new(false)); + let input = spawn_input(tx.clone(), stop.clone()); + let mut runtime = Runtime { + ctx, + root, + options, + tx, + scan_cancel: None, + clean_cancel: None, + }; + runtime.execute(Effect::Scan); + runtime.execute(Effect::RefreshDisk); + + let result = event_loop(&mut terminal, &mut app, &mut runtime, &rx); + + stop.store(true, Ordering::Relaxed); + runtime.cancel_all(); + let _ = execute!(std::io::stdout(), DisableMouseCapture); + ratatui::restore(); + let _ = input.join(); + if let Some(dirs) = &ctx.dirs + && let Err(error) = app.cache.save(&dirs.size_cache_file()) + { + tracing::warn!(%error, "cannot save size cache"); + } + result +} + +fn event_loop( + terminal: &mut ratatui::DefaultTerminal, + app: &mut App, + runtime: &mut Runtime<'_>, + rx: &Receiver, +) -> Result<()> { + loop { + if app.dirty { + terminal.draw(|frame| view::render(frame, app))?; + app.dirty = false; + } + let timeout = if app.animating() { + FAST_TICK + } else { + IDLE_TICK + }; + let first = match rx.recv_timeout(timeout) { + Ok(msg) => msg, + Err(RecvTimeoutError::Timeout) => Msg::Tick, + Err(RecvTimeoutError::Disconnected) => return Ok(()), + }; + let mut effects = app.update(first); + for msg in rx.try_iter().take(MAX_BATCH) { + effects.extend(app.update(msg)); + } + if app.animating() { + // Keep spinners moving even while events stream in. + effects.extend(app.update(Msg::Tick)); + } + for effect in effects { + runtime.execute(effect); + } + if app.quit { + return Ok(()); + } + } +} + +fn spawn_input(tx: Sender, stop: Arc) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + match event::poll(Duration::from_millis(50)) { + Ok(true) => { + let msg = match event::read() { + Ok(Event::Key(key)) => Msg::Key(key), + Ok(Event::Mouse(mouse)) => Msg::Mouse(mouse), + Ok(Event::Resize(..)) => Msg::Resize, + Ok(_) => continue, + Err(_) => break, + }; + if tx.send(msg).is_err() { + break; + } + } + Ok(false) => {} + Err(_) => break, + } + } + }) +} + +struct Runtime<'a> { + ctx: &'a Context, + root: PathBuf, + options: ScanOptions, + tx: Sender, + scan_cancel: Option, + clean_cancel: Option, +} + +impl Runtime<'_> { + fn cancel_all(&mut self) { + for cancel in [self.scan_cancel.take(), self.clean_cancel.take()] + .into_iter() + .flatten() + { + cancel.cancel(); + } + } + + fn execute(&mut self, effect: Effect) { + match effect { + Effect::Scan => { + if let Some(previous) = self.scan_cancel.take() { + previous.cancel(); + } + let cancel = Cancel::new(); + self.scan_cancel = Some(cancel.clone()); + let counters = Arc::new(Counters::default()); + let _ = self.tx.send(Msg::ScanStarted(counters.clone())); + let engine = self.ctx.engine.clone(); + let (root, options, tx) = + (self.root.clone(), self.options.clone(), self.tx.clone()); + std::thread::spawn(move || { + engine.scan(&root, &options, &counters, &cancel, &|event| { + let _ = tx.send(Msg::Scan(event)); + }); + }); + } + Effect::CancelScan => { + if let Some(cancel) = self.scan_cancel.take() { + cancel.cancel(); + } + } + Effect::Clean { + items, + mode, + allowed_roots, + } => { + let cancel = Cancel::new(); + self.clean_cancel = Some(cancel.clone()); + let engine = self.ctx.engine.clone(); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let options = CleanOptions { + mode, + allowed_roots, + dry_run: false, + }; + let report = engine.clean(&items, &options, &cancel, &|event| { + let _ = tx.send(Msg::Clean(event)); + }); + let _ = tx.send(Msg::CleanFinished(report)); + }); + } + Effect::CancelClean => { + if let Some(cancel) = self.clean_cancel.take() { + cancel.cancel(); + } + } + Effect::RecordHistory(records) => { + if let Some(dirs) = &self.ctx.dirs + && let Err(error) = history::append(&dirs.history_file(), &records) + { + tracing::warn!(%error, "cannot write history"); + } + } + Effect::Reveal(path) => reveal(&path), + Effect::RefreshDisk => { + let (root, tx) = (self.root.clone(), self.tx.clone()); + std::thread::spawn(move || { + if let (Ok(free), Ok(total)) = + (fs4::available_space(&root), fs4::total_space(&root)) + { + let _ = tx.send(Msg::Disk { free, total }); + } + }); + } + } + } +} + +/// Shows `path` in the platform file manager, detached from the terminal. +fn reveal(path: &Path) { + use std::process::{Command, Stdio}; + let mut command = if cfg!(target_os = "macos") { + let mut c = Command::new("open"); + c.arg("-R").arg(path); + c + } else if cfg!(windows) { + let mut c = Command::new("explorer"); + c.arg(format!("/select,{}", path.display())); + c + } else { + let mut c = Command::new("xdg-open"); + c.arg(path.parent().unwrap_or(path)); + c + }; + let _ = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); +} diff --git a/crates/carwash/src/tui/query.rs b/crates/carwash/src/tui/query.rs new file mode 100644 index 0000000..9df9086 --- /dev/null +++ b/crates/carwash/src/tui/query.rs @@ -0,0 +1,278 @@ +//! The filter language: fuzzy text plus facets. +//! +//! `turbo eco:rust,node kind:deps size>1g age>30d is:ready` keeps artifacts whose path +//! fuzzy-matches `turbo`, from Rust or Node projects, installed dependencies, freeing more +//! than 1 GB, untouched for 30 days and selectable by default. Unknown tokens are search text. + +use carwash_core::select::Hold; +use carwash_core::{ArtifactKind, EcoId, Registry}; +use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StateFacet { + Ready, + Review, + Recent, + Protected, + Marked, +} + +#[derive(Debug, Default)] +pub struct Query { + pub raw: String, + pattern: Option, + ecosystems: Vec, + kinds: Vec, + min_size: Option, + max_size: Option, + older_than: Option, + newer_than: Option, + states: Vec, +} + +/// What a query is evaluated against. +pub struct Candidate<'a> { + pub haystack: &'a str, + pub ecosystem: Option, + pub kind: ArtifactKind, + pub reclaimable: Option, + pub age: Option, + pub hold: Option, + pub marked: bool, +} + +impl Query { + pub fn parse(raw: &str, registry: &Registry) -> Self { + let mut query = Query { + raw: raw.to_owned(), + ..Query::default() + }; + let mut text = Vec::new(); + for token in raw.split_whitespace() { + if !query.facet(token, registry) { + text.push(token); + } + } + if !text.is_empty() { + query.pattern = Some(Pattern::parse( + &text.join(" "), + CaseMatching::Smart, + Normalization::Smart, + )); + } + query + } + + /// Applies `token` if it is a facet; returns false for plain search text. + fn facet(&mut self, token: &str, registry: &Registry) -> bool { + if let Some((key, values)) = token.split_once(':') { + let values = values.split(',').filter(|v| !v.is_empty()); + match key { + "eco" | "lang" | "e" => { + let ecos: Vec = values.filter_map(|v| registry.find(v)).collect(); + if ecos.is_empty() { + return false; + } + self.ecosystems.extend(ecos); + } + "kind" | "k" => { + let kinds: Vec = values.filter_map(ArtifactKind::parse).collect(); + if kinds.is_empty() { + return false; + } + self.kinds.extend(kinds); + } + "is" => { + let states: Vec = values + .filter_map(|v| match v { + "ready" => Some(StateFacet::Ready), + "review" => Some(StateFacet::Review), + "recent" => Some(StateFacet::Recent), + "protected" => Some(StateFacet::Protected), + "marked" => Some(StateFacet::Marked), + _ => None, + }) + .collect(); + if states.is_empty() { + return false; + } + self.states.extend(states); + } + _ => return false, + } + return true; + } + for (key, is_size) in [("size", true), ("age", false)] { + let Some(rest) = token.strip_prefix(key) else { + continue; + }; + let (greater, value) = + if let Some(v) = rest.strip_prefix(">=").or(rest.strip_prefix('>')) { + (true, v) + } else if let Some(v) = rest.strip_prefix("<=").or(rest.strip_prefix('<')) { + (false, v) + } else { + return false; + }; + if is_size { + let Some(bytes) = carwash_core::fmt::parse_bytes(value) else { + return false; + }; + if greater { + self.min_size = Some(bytes); + } else { + self.max_size = Some(bytes); + } + } else { + let Some(age) = carwash_core::fmt::parse_age(value) else { + return false; + }; + if greater { + self.older_than = Some(age); + } else { + self.newer_than = Some(age); + } + } + return true; + } + false + } + + pub fn is_empty(&self) -> bool { + self.raw.trim().is_empty() + } + + pub fn matches(&self, candidate: &Candidate<'_>, fuzzy: &mut Fuzzy) -> bool { + if !self.ecosystems.is_empty() + && !candidate + .ecosystem + .is_some_and(|e| self.ecosystems.contains(&e)) + { + return false; + } + if !self.kinds.is_empty() && !self.kinds.contains(&candidate.kind) { + return false; + } + if let Some(min) = self.min_size + && candidate.reclaimable.is_none_or(|r| r < min) + { + return false; + } + if let Some(max) = self.max_size + && candidate.reclaimable.is_none_or(|r| r > max) + { + return false; + } + if let Some(older) = self.older_than + && candidate.age.is_none_or(|a| a < older) + { + return false; + } + if let Some(newer) = self.newer_than + && candidate.age.is_none_or(|a| a > newer) + { + return false; + } + if !self.states.is_empty() + && !self.states.iter().any(|state| match state { + StateFacet::Ready => candidate.hold.is_none(), + StateFacet::Review => candidate.hold == Some(Hold::Review), + StateFacet::Recent => candidate.hold == Some(Hold::Recent), + StateFacet::Protected => candidate.hold == Some(Hold::Protected), + StateFacet::Marked => candidate.marked, + }) + { + return false; + } + match &self.pattern { + Some(pattern) => fuzzy.matches(pattern, candidate.haystack), + None => true, + } + } +} + +/// Reusable fuzzy matcher state (allocation-free after warm-up). +pub struct Fuzzy { + matcher: Matcher, + buf: Vec, +} + +impl Default for Fuzzy { + fn default() -> Self { + Self { + matcher: Matcher::new(Config::DEFAULT.match_paths()), + buf: Vec::new(), + } + } +} + +impl std::fmt::Debug for Fuzzy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Fuzzy") + } +} + +impl Fuzzy { + fn matches(&mut self, pattern: &Pattern, haystack: &str) -> bool { + pattern + .score(Utf32Str::new(haystack, &mut self.buf), &mut self.matcher) + .is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidate<'a>(haystack: &'a str, registry: &Registry) -> Candidate<'a> { + Candidate { + haystack, + ecosystem: registry.find("rust"), + kind: ArtifactKind::Build, + reclaimable: Some(2_000_000_000), + age: Some(Duration::from_secs(40 * 86_400)), + hold: None, + marked: false, + } + } + + #[test] + fn facets_and_text() { + let registry = Registry::builtin(); + let mut fuzzy = Fuzzy::default(); + let c = candidate("work/turbomcp/target", ®istry); + for (raw, expected) in [ + ("", true), + ("turbo", true), + ("tmcp", true), + ("nomatch", false), + ("eco:rust", true), + ("eco:node", false), + ("lang:node,rust", true), + ("kind:deps", false), + ("size>1g", true), + ("size>5gb", false), + ("size<3g", true), + ("age>30d", true), + ("age<7d", false), + ("is:ready", true), + ("is:review", false), + ("turbo eco:rust size>1g is:ready", true), + ] { + let query = Query::parse(raw, ®istry); + assert_eq!(query.matches(&c, &mut fuzzy), expected, "{raw}"); + } + } + + #[test] + fn malformed_facets_become_text() { + let registry = Registry::builtin(); + let query = Query::parse("eco:cobol", ®istry); + assert!(query.pattern.is_some()); + assert!(query.ecosystems.is_empty()); + let query = Query::parse("size>lots", ®istry); + assert!(query.min_size.is_none()); + } +} diff --git a/crates/carwash/src/tui/store.rs b/crates/carwash/src/tui/store.rs new file mode 100644 index 0000000..30bce7c --- /dev/null +++ b/crates/carwash/src/tui/store.rs @@ -0,0 +1,782 @@ +//! The TUI's single source of truth and the rows derived from it. + +use super::query::{Candidate, Fuzzy, Query}; +use carwash_core::cache::SizeCache; +use carwash_core::select::{Hold, Policy}; +use carwash_core::{Artifact, ArtifactId, EcoId, Project, ProjectId, ScanEvent, Size}; +use std::collections::{HashMap, HashSet}; +use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryStatus { + Present, + Deleting, + Deleted, + Failed(String), +} + +#[derive(Debug, Clone)] +pub struct Entry { + pub artifact: Artifact, + /// Last known size from the cache, shown until a fresh measurement arrives. + pub cached: Option, + pub status: EntryStatus, +} + +impl Entry { + pub fn size(&self) -> Option { + self.artifact.size.or(self.cached) + } + + pub fn is_estimate(&self) -> bool { + self.artifact.size.is_none() && self.cached.is_some() + } + + /// Newest modification time, including a cached measurement's. + pub fn last_modified(&self) -> Option { + self.size() + .and_then(|s| s.newest) + .or(self.artifact.modified) + } + + pub fn is_live(&self) -> bool { + self.status != EntryStatus::Deleted + } +} + +#[derive(Debug, Default)] +pub struct Store { + pub root: PathBuf, + pub projects: Vec, + project_index: HashMap, + pub entries: Vec, + entry_index: HashMap, + pub warnings: usize, + pub dirs: u64, + /// Bumped on every change, so derived rows know when to rebuild. + pub revision: u64, +} + +impl Store { + pub fn new(root: PathBuf) -> Self { + Self { + root, + ..Self::default() + } + } + + pub fn apply(&mut self, event: ScanEvent, cache: &SizeCache) { + match event { + ScanEvent::Project(project) => { + self.project_index.insert(project.id, self.projects.len()); + self.projects.push(project); + } + ScanEvent::Artifact(artifact) => { + let cached = cache.get(&artifact.path).map(|c| c.size); + self.entry_index.insert(artifact.id, self.entries.len()); + self.entries.push(Entry { + artifact, + cached, + status: EntryStatus::Present, + }); + } + ScanEvent::Warning(_) => self.warnings += 1, + ScanEvent::DiscoveryFinished { dirs, .. } => self.dirs = dirs, + ScanEvent::Git { id, state } => { + if let Some(entry) = self.entry_mut(id) { + entry.artifact.git = state; + } + } + ScanEvent::Measured { id, size } => { + if let Some(entry) = self.entry_mut(id) { + entry.artifact.size = Some(size); + } + } + ScanEvent::Finished { .. } => {} + } + self.revision += 1; + } + + pub fn project(&self, id: ProjectId) -> Option<&Project> { + self.project_index.get(&id).map(|&i| &self.projects[i]) + } + + pub fn entry(&self, id: ArtifactId) -> Option<&Entry> { + self.entry_index.get(&id).map(|&i| &self.entries[i]) + } + + pub fn entry_mut(&mut self, id: ArtifactId) -> Option<&mut Entry> { + self.revision += 1; + self.entry_index + .get(&id) + .copied() + .map(|i| &mut self.entries[i]) + } + + pub fn set_status(&mut self, id: ArtifactId, status: EntryStatus) { + if let Some(entry) = self.entry_mut(id) { + entry.status = status; + } + } + + pub fn relative<'a>(&self, path: &'a Path) -> std::borrow::Cow<'a, str> { + match path.strip_prefix(&self.root) { + Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy(), + _ => path.to_string_lossy(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Grouping { + Tree, + Projects, + Artifacts, +} + +impl Grouping { + pub fn next(self) -> Self { + match self { + Self::Tree => Self::Projects, + Self::Projects => Self::Artifacts, + Self::Artifacts => Self::Tree, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Tree => "tree", + Self::Projects => "projects", + Self::Artifacts => "artifacts", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortKey { + Size, + Age, + Name, +} + +impl SortKey { + pub fn next(self) -> Self { + match self { + Self::Size => Self::Age, + Self::Age => Self::Name, + Self::Name => Self::Size, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Size => "size", + Self::Age => "age", + Self::Name => "name", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RowKey { + Dir(PathBuf), + Project(ProjectId), + Artifact(ArtifactId), + /// Artifacts not owned by any project (projects grouping). + Orphans, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Totals { + pub reclaimable: u64, + pub on_disk: u64, + pub artifacts: u32, + /// Artifacts that may be marked (live, not protected). + pub markable: u32, + /// Markable artifacts the default policy would select. + pub ready: u32, + pub marked: u32, + pub marked_bytes: u64, + pub unmeasured: u32, + pub estimated: bool, +} + +impl Totals { + fn add(&mut self, other: &Totals) { + self.reclaimable += other.reclaimable; + self.on_disk += other.on_disk; + self.artifacts += other.artifacts; + self.markable += other.markable; + self.ready += other.ready; + self.marked += other.marked; + self.marked_bytes += other.marked_bytes; + self.unmeasured += other.unmeasured; + self.estimated |= other.estimated; + } +} + +#[derive(Debug, Clone)] +pub struct Row { + pub key: RowKey, + pub depth: u16, + pub label: String, + pub expandable: bool, + pub expanded: bool, + pub totals: Totals, + pub newest: Option, + pub ecosystems: Vec, + /// This row's artifacts as a range of [`Rows::order`]. + pub span: Range, +} + +/// Rows for display plus the artifact order they index into. +#[derive(Debug, Default)] +pub struct Rows { + pub rows: Vec, + pub order: Vec, + pub totals: Totals, + /// Largest reclaimable size among top-level rows, for proportional bars. + pub max_top: u64, +} + +impl Rows { + pub fn artifacts(&self, row: &Row) -> &[ArtifactId] { + &self.order[row.span.clone()] + } + + pub fn position(&self, key: &RowKey) -> Option { + self.rows.iter().position(|r| &r.key == key) + } +} + +/// Inputs to row derivation. +pub struct View<'a> { + pub grouping: Grouping, + pub sort: SortKey, + pub query: &'a Query, + pub fuzzy: &'a mut Fuzzy, + pub marked: &'a HashSet, + pub expanded: &'a HashSet, + pub policy: &'a Policy, + pub now: SystemTime, +} + +#[derive(Debug)] +struct Node { + key: RowKey, + label: String, + children: Vec, + totals: Totals, + newest: Option, + /// Reclaimable bytes per ecosystem in this subtree; badges are ordered by it. + eco_bytes: Vec<(EcoId, u64)>, +} + +impl Node { + fn new(key: RowKey, label: String) -> Self { + Self { + key, + label, + children: Vec::new(), + totals: Totals::default(), + newest: None, + eco_bytes: Vec::new(), + } + } + + fn add_eco(&mut self, eco: EcoId, bytes: u64) { + match self.eco_bytes.iter_mut().find(|(e, _)| *e == eco) { + Some((_, total)) => *total += bytes, + None => self.eco_bytes.push((eco, bytes)), + } + } + + fn ecosystems(&self) -> Vec { + let mut ranked = self.eco_bytes.clone(); + ranked.sort_by_key(|&(_, bytes)| std::cmp::Reverse(bytes)); + ranked.into_iter().map(|(eco, _)| eco).collect() + } +} + +pub fn hold_for(entry: &Entry, policy: &Policy, now: SystemTime) -> Option { + let mut artifact = entry.artifact.clone(); + if artifact.size.is_none() { + artifact.size = entry.cached; + } + policy.hold(&artifact, now) +} + +fn artifact_totals(entry: &Entry, hold: Option, marked: bool) -> Totals { + let size = entry.size(); + let reclaimable = size.map_or(0, |s| s.reclaimable); + let markable = entry.status == EntryStatus::Present && hold != Some(Hold::Protected); + Totals { + reclaimable, + on_disk: size.map_or(0, |s| s.on_disk), + artifacts: 1, + markable: u32::from(markable), + ready: u32::from(markable && hold.is_none()), + marked: u32::from(marked), + marked_bytes: if marked { reclaimable } else { 0 }, + unmeasured: u32::from(size.is_none()), + estimated: entry.is_estimate(), + } +} + +/// Derives display rows from the store. +pub fn build(store: &Store, view: &mut View<'_>) -> Rows { + let mut nodes: Vec = vec![Node::new(RowKey::Dir(store.root.clone()), String::new())]; + let mut by_path: HashMap = HashMap::new(); + by_path.insert(store.root.clone(), 0); + let mut orphans: Option = None; + + for entry in store.entries.iter().filter(|e| e.is_live()) { + let artifact = &entry.artifact; + let hold = hold_for(entry, view.policy, view.now); + let marked = view.marked.contains(&artifact.id); + let project = artifact.project.and_then(|id| store.project(id)); + let rel = store.relative(&artifact.path); + let haystack = match project { + Some(p) => format!("{rel} {}", p.name), + None => rel.to_string(), + }; + let candidate = Candidate { + haystack: &haystack, + ecosystem: artifact.ecosystem, + kind: artifact.kind, + reclaimable: entry.size().map(|s| s.reclaimable), + age: entry + .last_modified() + .map(|t| view.now.duration_since(t).unwrap_or_default()), + hold, + marked, + }; + if !view.query.matches(&candidate, view.fuzzy) { + continue; + } + + let parent = match view.grouping { + Grouping::Artifacts => 0, + Grouping::Projects => match project { + Some(p) => *by_path.entry(p.path.clone()).or_insert_with(|| { + let index = nodes.len(); + nodes.push(Node::new( + RowKey::Project(p.id), + store.relative(&p.path).into_owned(), + )); + nodes[0].children.push(index); + index + }), + None => *orphans.get_or_insert_with(|| { + let index = nodes.len(); + nodes.push(Node::new(RowKey::Orphans, "(outside projects)".into())); + nodes[0].children.push(index); + index + }), + }, + Grouping::Tree => { + let (container, key) = match project { + Some(p) => (p.path.clone(), RowKey::Project(p.id)), + None => { + let dir = artifact.path.parent().unwrap_or(&store.root).to_path_buf(); + (dir.clone(), RowKey::Dir(dir)) + } + }; + tree_container(&mut nodes, &mut by_path, &store.root, &container, key) + } + }; + + let label = match (view.grouping, project) { + (Grouping::Artifacts, _) => rel.into_owned(), + (_, Some(p)) if artifact.path.starts_with(&p.path) && artifact.path != p.path => { + artifact + .path + .strip_prefix(&p.path) + .map(|r| r.to_string_lossy().into_owned()) + .unwrap_or_else(|_| rel.into_owned()) + } + _ => artifact + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| rel.into_owned()), + }; + let mut node = Node::new(RowKey::Artifact(artifact.id), label); + node.totals = artifact_totals(entry, hold, marked); + node.newest = entry.last_modified(); + if let Some(eco) = artifact.ecosystem { + node.add_eco(eco, node.totals.reclaimable); + } + nodes.push(node); + let index = nodes.len() - 1; + nodes[parent].children.push(index); + } + + aggregate(&mut nodes, 0); + for node in &mut nodes { + if let RowKey::Project(id) = node.key + && let Some(project) = store.project(id) + { + // Ecosystems without artifacts still identify the project, after the others. + for &eco in &project.ecosystems { + node.add_eco(eco, 0); + } + } + } + if view.grouping == Grouping::Tree { + compress(&mut nodes, 0); + } + sort(&mut nodes, 0, view.sort); + + let mut rows = Rows { + totals: nodes[0].totals, + max_top: nodes[0] + .children + .iter() + .map(|&c| nodes[c].totals.reclaimable) + .max() + .unwrap_or(0), + ..Rows::default() + }; + let expand_all = !view.query.is_empty(); + let children = nodes[0].children.clone(); + for child in children { + flatten(&nodes, child, 0, true, expand_all, view.expanded, &mut rows); + } + rows +} + +/// Finds or creates the tree node for `container`, creating directory nodes along the way. +fn tree_container( + nodes: &mut Vec, + by_path: &mut HashMap, + root: &Path, + container: &Path, + key: RowKey, +) -> usize { + if let Some(&index) = by_path.get(container) { + if matches!(key, RowKey::Project(_)) { + nodes[index].key = key; + } + return index; + } + let Ok(relative) = container.strip_prefix(root) else { + // Above the scan root (an enclosing workspace): its own top-level node. + nodes.push(Node::new(key, format!("↑ {}", container.display()))); + let index = nodes.len() - 1; + nodes[0].children.push(index); + by_path.insert(container.to_path_buf(), index); + return index; + }; + let mut parent = 0; + let mut path = root.to_path_buf(); + let components: Vec<_> = relative.components().collect(); + for (i, component) in components.iter().enumerate() { + path.push(component); + let last = i + 1 == components.len(); + parent = match by_path.get(&path) { + Some(&index) => { + if last && matches!(key, RowKey::Project(_)) { + nodes[index].key = key.clone(); + } + index + } + None => { + let node_key = if last { + key.clone() + } else { + RowKey::Dir(path.clone()) + }; + nodes.push(Node::new( + node_key, + component.as_os_str().to_string_lossy().into_owned(), + )); + let index = nodes.len() - 1; + nodes[parent].children.push(index); + by_path.insert(path.clone(), index); + index + } + }; + } + parent +} + +fn aggregate(nodes: &mut [Node], index: usize) { + let children = nodes[index].children.clone(); + for &child in &children { + aggregate(nodes, child); + } + if children.is_empty() { + return; + } + let mut totals = Totals::default(); + let mut newest: Option = None; + let mut eco_bytes: Vec<(EcoId, u64)> = Vec::new(); + for &child in &children { + totals.add(&nodes[child].totals); + newest = carwash_core::measure::newest(newest, nodes[child].newest); + eco_bytes.extend(nodes[child].eco_bytes.iter().copied()); + } + let node = &mut nodes[index]; + node.totals.add(&totals); + node.newest = carwash_core::measure::newest(node.newest, newest); + for (eco, bytes) in eco_bytes { + node.add_eco(eco, bytes); + } +} + +/// Merges directory chains with a single child (`a` → `b` → `c` becomes `a/b/c`). +fn compress(nodes: &mut Vec, index: usize) { + let children = nodes[index].children.clone(); + for child in children { + loop { + let node = &nodes[child]; + let mergeable = matches!(node.key, RowKey::Dir(_)) + && node.children.len() == 1 + && !matches!(nodes[node.children[0]].key, RowKey::Artifact(_)); + if !mergeable { + break; + } + let only = nodes[child].children[0]; + let label = format!("{}/{}", nodes[child].label, nodes[only].label); + let key = nodes[only].key.clone(); + let grandchildren = std::mem::take(&mut nodes[only].children); + let node = &mut nodes[child]; + node.label = label; + node.key = key; + node.children = grandchildren; + } + compress(nodes, child); + } +} + +fn sort(nodes: &mut [Node], index: usize, key: SortKey) { + let mut children = std::mem::take(&mut nodes[index].children); + match key { + SortKey::Size => children.sort_by(|&a, &b| { + nodes[b] + .totals + .reclaimable + .cmp(&nodes[a].totals.reclaimable) + .then_with(|| nodes[a].label.cmp(&nodes[b].label)) + }), + SortKey::Age => children.sort_by(|&a, &b| { + let age = |n: &Node| n.newest.unwrap_or(SystemTime::UNIX_EPOCH); + age(&nodes[a]) + .cmp(&age(&nodes[b])) + .then_with(|| nodes[a].label.cmp(&nodes[b].label)) + }), + SortKey::Name => children.sort_by(|&a, &b| nodes[a].label.cmp(&nodes[b].label)), + } + for &child in &children { + sort(nodes, child, key); + } + nodes[index].children = children; +} + +fn flatten( + nodes: &[Node], + index: usize, + depth: u16, + visible: bool, + expand_all: bool, + expanded: &HashSet, + rows: &mut Rows, +) { + let node = &nodes[index]; + let start = rows.order.len(); + let is_expanded = expand_all || expanded.contains(&node.key); + let row_index = if visible { + rows.rows.push(Row { + key: node.key.clone(), + depth, + label: node.label.clone(), + expandable: !node.children.is_empty(), + expanded: is_expanded && !node.children.is_empty(), + totals: node.totals, + newest: node.newest, + ecosystems: node.ecosystems(), + span: 0..0, + }); + Some(rows.rows.len() - 1) + } else { + None + }; + if let RowKey::Artifact(id) = node.key { + rows.order.push(id); + } + for &child in &node.children { + flatten( + nodes, + child, + depth + 1, + visible && is_expanded, + expand_all, + expanded, + rows, + ); + } + if let Some(row) = row_index { + rows.rows[row].span = start..rows.order.len(); + } +} + +/// Relative age helper for display. +pub fn age(newest: Option, now: SystemTime) -> Option { + newest.map(|t| now.duration_since(t).unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use carwash_core::{ArtifactKind, Detection, GitState, Registry, RuleId}; + + fn project(id: u32, path: &str) -> Project { + Project { + id: ProjectId(id), + path: PathBuf::from(path), + name: Path::new(path) + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(), + ecosystems: vec![EcoId(0)], + parent: None, + member_of: None, + is_workspace: false, + repo: None, + last_activity: None, + outside_root: false, + } + } + + fn artifact(id: u32, project: Option, path: &str, bytes: u64) -> Artifact { + Artifact { + id: ArtifactId(id), + path: PathBuf::from(path), + project: project.map(ProjectId), + ecosystem: Some(EcoId(0)), + kind: ArtifactKind::Build, + detection: Detection::Rule { + rule: RuleId(0), + confirmed: true, + }, + ambiguous: false, + modified: Some(SystemTime::UNIX_EPOCH), + git: GitState::NotInRepo, + repo: None, + size: Some(Size { + reclaimable: bytes, + on_disk: bytes, + ..Size::default() + }), + outside_root: false, + } + } + + fn store() -> Store { + let mut store = Store::new(PathBuf::from("/w")); + let cache = SizeCache::default(); + for event in [ + ScanEvent::Project(project(0, "/w/big")), + ScanEvent::Project(project(1, "/w/group/deep/small")), + ScanEvent::Project(project(2, "/w/group/other")), + ScanEvent::Artifact(artifact(0, Some(0), "/w/big/target", 1000)), + ScanEvent::Artifact(artifact(1, Some(1), "/w/group/deep/small/node_modules", 10)), + ScanEvent::Artifact(artifact(2, Some(1), "/w/group/deep/small/.next", 5)), + ScanEvent::Artifact(artifact(3, Some(2), "/w/group/other/dist", 50)), + ScanEvent::Artifact(artifact(4, None, "/w/loose/__pycache__", 1)), + ] { + store.apply(event, &cache); + } + store + } + + fn rows(store: &Store, grouping: Grouping, expanded: &HashSet, query: &str) -> Rows { + let registry = Registry::builtin(); + let query = Query::parse(query, ®istry); + let mut fuzzy = Fuzzy::default(); + let marked = HashSet::new(); + let policy = Policy { + include_review: false, + recent: None, + }; + build( + store, + &mut View { + grouping, + sort: SortKey::Size, + query: &query, + fuzzy: &mut fuzzy, + marked: &marked, + expanded, + policy: &policy, + now: SystemTime::now(), + }, + ) + } + + fn labels(rows: &Rows) -> Vec<(u16, String)> { + rows.rows + .iter() + .map(|r| (r.depth, r.label.clone())) + .collect() + } + + #[test] + fn tree_groups_sorts_and_aggregates() { + let store = store(); + let collapsed = rows(&store, Grouping::Tree, &HashSet::new(), ""); + assert_eq!( + labels(&collapsed), + vec![(0, "big".into()), (0, "group".into()), (0, "loose".into())] + ); + assert_eq!(collapsed.totals.reclaimable, 1066); + assert_eq!(collapsed.max_top, 1000); + let group = &collapsed.rows[1]; + assert_eq!(group.totals.reclaimable, 65); + assert_eq!(collapsed.artifacts(group).len(), 3); + } + + #[test] + fn single_child_directories_are_compressed() { + let store = store(); + let mut expanded = HashSet::new(); + expanded.insert(RowKey::Dir(PathBuf::from("/w/group"))); + let rows = rows(&store, Grouping::Tree, &expanded, ""); + let labels = labels(&rows); + assert!(labels.contains(&(1, "other".into()))); + assert!(labels.contains(&(1, "deep/small".into())), "{labels:?}"); + } + + #[test] + fn search_expands_matches_and_filters() { + let store = store(); + let rows = rows(&store, Grouping::Tree, &HashSet::new(), "next"); + let labels = labels(&rows); + assert_eq!(labels.last().unwrap().1, ".next"); + assert_eq!(rows.totals.artifacts, 1); + } + + #[test] + fn projects_and_artifacts_groupings() { + let store = store(); + let projects = rows(&store, Grouping::Projects, &HashSet::new(), ""); + assert_eq!(projects.rows[0].label, "big"); + assert!(projects.rows.iter().any(|r| r.key == RowKey::Orphans)); + let flat = rows(&store, Grouping::Artifacts, &HashSet::new(), ""); + assert_eq!(flat.rows.len(), 5); + assert_eq!(flat.rows[0].label, "big/target"); + } + + #[test] + fn deleted_entries_disappear() { + let mut store = store(); + store.set_status(ArtifactId(0), EntryStatus::Deleted); + let rows = rows(&store, Grouping::Artifacts, &HashSet::new(), ""); + assert_eq!(rows.rows.len(), 4); + assert_eq!(rows.totals.reclaimable, 66); + } +} diff --git a/crates/carwash/src/tui/theme.rs b/crates/carwash/src/tui/theme.rs new file mode 100644 index 0000000..a2c1fcc --- /dev/null +++ b/crates/carwash/src/tui/theme.rs @@ -0,0 +1,328 @@ +//! Color themes and glyph sets. + +use ratatui::style::{Color, Modifier, Style}; + +/// Semantic colors. Backgrounds stay the terminal's own, so themes blend with any setup. +#[derive(Debug, Clone, Copy)] +pub struct Theme { + pub name: &'static str, + pub text: Color, + pub subtle: Color, + pub muted: Color, + /// Highlighted row background. + pub surface: Color, + pub border: Color, + pub accent: Color, + pub accent2: Color, + pub success: Color, + pub warning: Color, + pub error: Color, + pub info: Color, + pub truecolor: bool, +} + +const fn rgb(hex: u32) -> Color { + Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8) +} + +impl Theme { + pub const NAMES: [&'static str; 5] = ["gestalt", "latte", "nord", "dracula", "ansi"]; + + /// Catppuccin Mocha. + pub const GESTALT: Self = Self { + name: "gestalt", + text: rgb(0xcdd6f4), + subtle: rgb(0xa6adc8), + muted: rgb(0x6c7086), + surface: rgb(0x313244), + border: rgb(0x45475a), + accent: rgb(0xb4befe), + accent2: rgb(0xcba6f7), + success: rgb(0xa6e3a1), + warning: rgb(0xf9e2af), + error: rgb(0xf38ba8), + info: rgb(0x89b4fa), + truecolor: true, + }; + + /// Catppuccin Latte, for light terminals. + pub const LATTE: Self = Self { + name: "latte", + text: rgb(0x4c4f69), + subtle: rgb(0x6c6f85), + muted: rgb(0x9ca0b0), + surface: rgb(0xccd0da), + border: rgb(0xbcc0cc), + accent: rgb(0x7287fd), + accent2: rgb(0x8839ef), + success: rgb(0x40a02b), + warning: rgb(0xdf8e1d), + error: rgb(0xd20f39), + info: rgb(0x1e66f5), + truecolor: true, + }; + + pub const NORD: Self = Self { + name: "nord", + text: rgb(0xeceff4), + subtle: rgb(0xd8dee9), + muted: rgb(0x616e88), + surface: rgb(0x3b4252), + border: rgb(0x434c5e), + accent: rgb(0x88c0d0), + accent2: rgb(0x81a1c1), + success: rgb(0xa3be8c), + warning: rgb(0xebcb8b), + error: rgb(0xbf616a), + info: rgb(0x5e81ac), + truecolor: true, + }; + + pub const DRACULA: Self = Self { + name: "dracula", + text: rgb(0xf8f8f2), + subtle: rgb(0xd6d6d0), + muted: rgb(0x6272a4), + surface: rgb(0x44475a), + border: rgb(0x44475a), + accent: rgb(0xbd93f9), + accent2: rgb(0xff79c6), + success: rgb(0x50fa7b), + warning: rgb(0xf1fa8c), + error: rgb(0xff5555), + info: rgb(0x8be9fd), + truecolor: true, + }; + + /// The terminal's own 16-color palette. + pub const ANSI: Self = Self { + name: "ansi", + text: Color::Reset, + subtle: Color::Gray, + muted: Color::DarkGray, + surface: Color::DarkGray, + border: Color::DarkGray, + accent: Color::Cyan, + accent2: Color::Magenta, + success: Color::Green, + warning: Color::Yellow, + error: Color::Red, + info: Color::Blue, + truecolor: false, + }; + + /// Theme by name; truecolor themes fall back to `ansi` when the terminal lacks 24-bit color. + pub fn named(name: &str) -> Self { + let theme = match name { + "latte" => Self::LATTE, + "nord" => Self::NORD, + "dracula" => Self::DRACULA, + "ansi" => Self::ANSI, + _ => Self::GESTALT, + }; + if theme.truecolor && !supports_truecolor() { + Self::ANSI + } else { + theme + } + } + + pub fn next(self) -> Self { + let i = Self::NAMES + .iter() + .position(|n| *n == self.name) + .unwrap_or(0); + Self::named(Self::NAMES[(i + 1) % Self::NAMES.len()]) + } + + pub fn fg(&self, color: Color) -> Style { + Style::new().fg(color) + } + + pub fn text(&self) -> Style { + self.fg(self.text) + } + + pub fn muted(&self) -> Style { + self.fg(self.muted) + } + + pub fn subtle(&self) -> Style { + self.fg(self.subtle) + } + + pub fn bold(&self, color: Color) -> Style { + self.fg(color).add_modifier(Modifier::BOLD) + } + + pub fn selected_row(&self) -> Style { + Style::new().bg(self.surface).add_modifier(Modifier::BOLD) + } + + /// Color for a byte count: large amounts stand out. + pub fn size(&self, bytes: u64) -> Style { + const GB: u64 = 1_000_000_000; + match bytes { + b if b >= 10 * GB => self.bold(self.error), + b if b >= GB => self.bold(self.warning), + b if b >= 100_000_000 => self.text(), + _ => self.subtle(), + } + } + + /// Brand color for an ecosystem badge (GitHub Linguist colors where they exist). + pub fn ecosystem(&self, key: &str) -> Color { + if !self.truecolor { + return match key { + "rust" | "swift" | "java" | "maven" | "gradle" => Color::Red, + "node" | "deno" | "python" | "php" => Color::Yellow, + "go" | "dart" | "zig" => Color::Cyan, + "dotnet" | "elixir" | "haskell" => Color::Magenta, + _ => Color::Gray, + }; + } + rgb(match key { + "rust" => 0xdea584, + "node" => 0xf1e05a, + "deno" => 0x70ffaf, + "python" => 0x3572a5, + "go" => 0x00add8, + "maven" | "gradle" => 0xb07219, + "scala" => 0xc22d40, + "dotnet" => 0x178600, + "swift" | "xcode" | "cocoapods" | "carthage" => 0xf05138, + "dart" => 0x00b4ab, + "elixir" => 0x6e4a7e, + "erlang" => 0xb83998, + "gleam" => 0xffaff3, + "haskell" => 0x5e5086, + "ocaml" => 0xef7a08, + "zig" => 0xec915c, + "cmake" | "meson" | "xmake" => 0x6866fb, + "php" => 0x4f5d95, + "ruby" => 0x701516, + "terraform" => 0x7b42bc, + "unity" | "unreal" | "godot" => 0x478cbf, + "r" => 0x198ce7, + "julia" => 0xa270ba, + "nix" => 0x7e7eff, + _ => 0x9399b2, + }) + } +} + +fn supports_truecolor() -> bool { + std::env::var("COLORTERM").is_ok_and(|v| v == "truecolor" || v == "24bit") + || std::env::var("TERM_PROGRAM").is_ok_and(|v| { + matches!( + v.as_str(), + "iTerm.app" | "WezTerm" | "ghostty" | "vscode" | "WarpTerminal" + ) + }) +} + +/// Glyph set for marks, tree expanders and bars. +#[derive(Debug, Clone, Copy)] +pub struct Glyphs { + pub marked: &'static str, + pub unmarked: &'static str, + pub partial: &'static str, + pub locked: &'static str, + pub expanded: &'static str, + pub collapsed: &'static str, + pub leaf: &'static str, + pub bar_full: char, + pub bar_partial: &'static [char], + pub bar_empty: char, + pub spinner: &'static [&'static str], +} + +impl Glyphs { + pub const UNICODE: Self = Self { + marked: "●", + unmarked: "○", + partial: "◐", + locked: "⊘", + expanded: "▾", + collapsed: "▸", + leaf: " ", + bar_full: '█', + bar_partial: &['▏', '▎', '▍', '▌', '▋', '▊', '▉'], + bar_empty: ' ', + spinner: &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + }; + + pub const ASCII: Self = Self { + marked: "x", + unmarked: ".", + partial: "~", + locked: "!", + expanded: "v", + collapsed: ">", + leaf: " ", + bar_full: '#', + bar_partial: &[], + bar_empty: ' ', + spinner: &["|", "/", "-", "\\"], + }; + + pub fn named(name: &str) -> Self { + match name { + "ascii" => Self::ASCII, + _ => Self::UNICODE, + } + } + + /// A proportional bar `width` cells wide. + pub fn bar(&self, fraction: f64, width: usize) -> String { + let fraction = fraction.clamp(0.0, 1.0); + let eighths = (fraction * width as f64 * 8.0).round() as usize; + let full = eighths / 8; + let rest = eighths % 8; + let mut bar = String::with_capacity(width * 3); + for _ in 0..full.min(width) { + bar.push(self.bar_full); + } + let mut len = full.min(width); + if len < width && rest > 0 { + match self.bar_partial.get(rest - 1) { + Some(&c) => bar.push(c), + None if rest >= 4 => bar.push(self.bar_full), + None => bar.push(self.bar_empty), + } + len += 1; + } + for _ in len..width { + bar.push(self.bar_empty); + } + bar + } +} + +#[cfg(test)] +mod tests { + use super::*; + use unicode_width::UnicodeWidthStr; + + #[test] + fn bars_have_fixed_width() { + for glyphs in [Glyphs::UNICODE, Glyphs::ASCII] { + for f in [0.0, 0.01, 0.33, 0.5, 0.99, 1.0, 2.0] { + assert_eq!(glyphs.bar(f, 10).width(), 10, "{f}"); + } + } + assert_eq!(Glyphs::UNICODE.bar(1.0, 4), "████"); + assert_eq!(Glyphs::UNICODE.bar(0.0, 3), " "); + } + + #[test] + fn themes_cycle() { + let mut theme = Theme::ANSI; + let mut seen = Vec::new(); + for _ in 0..Theme::NAMES.len() { + seen.push(theme.name); + theme = theme.next(); + } + assert!(seen.contains(&"ansi")); + } +} diff --git a/crates/carwash/src/tui/view.rs b/crates/carwash/src/tui/view.rs new file mode 100644 index 0000000..5ab7328 --- /dev/null +++ b/crates/carwash/src/tui/view.rs @@ -0,0 +1,1024 @@ +//! Rendering. Pure functions of [`App`], except for recording table geometry for the mouse. + +use super::app::{App, Level, Mode, Review, ReviewPhase}; +use super::keymap::{BINDINGS, Section}; +use super::store::{EntryStatus, Row, RowKey}; +use carwash_core::clean::DeleteMode; +use carwash_core::select::Hold; +use carwash_core::{Detection, GitState, fmt}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{ + Block, BorderType, Borders, Cell, Clear, Gauge, Paragraph, Row as TableRow, Table, TableState, + Wrap, +}; + +const DETAILS_WIDTH: u16 = 46; +const MIN_WIDTH_FOR_DETAILS: u16 = 112; +const MIN_WIDTH_FOR_BARS: u16 = 96; + +pub fn render(frame: &mut Frame, app: &mut App) { + app.ensure_rows(); + let [header, toolbar, body, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + ]) + .areas(frame.area()); + + render_header(frame, app, header); + render_toolbar(frame, app, toolbar); + let details = app.show_details && body.width >= MIN_WIDTH_FOR_DETAILS; + if details { + let [table, side] = + Layout::horizontal([Constraint::Min(60), Constraint::Length(DETAILS_WIDTH)]) + .areas(body); + render_table(frame, app, table); + render_details(frame, app, side); + } else { + render_table(frame, app, body); + } + render_footer(frame, app, footer); + + match &app.mode { + Mode::Review(review) => render_review(frame, app, review), + Mode::Help => render_help(frame, app), + _ => {} + } +} + +fn home_relative(path: &std::path::Path) -> String { + if let Some(home) = std::env::var_os("HOME") + && let Ok(rest) = path.strip_prefix(&home) + { + return if rest.as_os_str().is_empty() { + "~".into() + } else { + format!("~/{}", rest.display()) + }; + } + path.display().to_string() +} + +fn spinner(app: &App) -> &'static str { + app.glyphs.spinner[app.spinner % app.glyphs.spinner.len()] +} + +/// Renders `left` and right-aligned `right` on one line; `right` wins when space runs out. +fn split_line(frame: &mut Frame, area: Rect, left: Line<'_>, right: Line<'_>) { + let right_width = (right.width() as u16).min(area.width); + let [left_area, right_area] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(right_width)]).areas(area); + frame.render_widget(Paragraph::new(left), left_area); + frame.render_widget( + Paragraph::new(right).alignment(Alignment::Right), + right_area, + ); +} + +fn render_header(frame: &mut Frame, app: &App, area: Rect) { + let t = &app.theme; + let left = Line::from(vec![ + Span::styled( + " carwash ", + t.bold(t.accent2).add_modifier(Modifier::REVERSED), + ), + Span::raw(" "), + Span::styled(home_relative(&app.store.root), t.bold(t.text)), + ]); + + let mut right: Vec = Vec::new(); + if let Some(progress) = app.scan_progress() { + right.push(Span::styled(format!("{} ", spinner(app)), t.fg(t.accent))); + right.push(Span::styled(progress, t.subtle())); + } else { + let totals = app.rows.totals; + right.push(Span::styled(fmt::bytes(totals.reclaimable), t.bold(t.text))); + right.push(Span::styled(" reclaimable", t.subtle())); + if let Some(elapsed) = app.scan.elapsed { + right.push(Span::styled( + format!(" · scanned in {:.1}s", elapsed.as_secs_f64()), + t.muted(), + )); + } + } + if let Some((free, total)) = app.disk { + right.push(Span::styled(" │ ", t.muted())); + right.push(Span::styled(fmt::bytes(free), t.bold(t.text))); + right.push(Span::styled( + format!(" free of {} ", fmt::bytes(total)), + t.subtle(), + )); + } + split_line(frame, area, left, Line::from(right)); +} + +fn render_toolbar(frame: &mut Frame, app: &App, area: Rect) { + let t = &app.theme; + let mut left = vec![ + Span::styled(" view ", t.muted()), + Span::styled(app.grouping.label(), t.fg(t.accent)), + Span::styled(" sort ", t.muted()), + Span::styled(app.sort.label(), t.fg(t.accent)), + ]; + if !app.query.is_empty() && !matches!(app.mode, Mode::Search) { + left.push(Span::styled(" filter ", t.muted())); + left.push(Span::styled(app.query.raw.clone(), t.fg(t.warning))); + left.push(Span::styled( + format!(" {} shown", app.rows.totals.artifacts), + t.muted(), + )); + } + let ready_bytes: u64 = app + .rows + .order + .iter() + .filter_map(|id| app.store.entry(*id)) + .filter(|e| { + e.status == EntryStatus::Present + && super::store::hold_for(e, &app.policy, app.now).is_none() + }) + .filter_map(|e| e.size()) + .map(|s| s.reclaimable) + .sum(); + let marked_bytes: u64 = app + .marked + .iter() + .filter_map(|id| app.store.entry(*id)) + .filter_map(|e| e.size()) + .map(|s| s.reclaimable) + .sum(); + let mut right = vec![ + Span::styled(fmt::bytes(ready_bytes), t.fg(t.success)), + Span::styled(" ready", t.muted()), + ]; + if !app.marked.is_empty() { + right.push(Span::styled(" │ ", t.muted())); + right.push(Span::styled( + format!( + "{} marked · {} ", + app.marked.len(), + fmt::bytes(marked_bytes) + ), + t.bold(t.accent2), + )); + } else { + right.push(Span::raw(" ")); + } + split_line(frame, area, Line::from(left), Line::from(right)); +} + +fn badge<'a>(app: &'a App, eco: carwash_core::EcoId) -> Span<'a> { + let ecosystem = app.registry.ecosystem(eco); + Span::styled( + ecosystem.badge.as_str(), + Style::new().fg(app.theme.ecosystem(&ecosystem.key)), + ) +} + +fn mark_cell(app: &App, row: &Row) -> Span<'static> { + let t = &app.theme; + let g = &app.glyphs; + if let RowKey::Artifact(id) = row.key { + let entry = app.store.entry(id); + if entry.is_some_and(|e| e.status == EntryStatus::Deleting) { + return Span::styled(spinner(app), t.fg(t.accent)); + } + return if app.marked.contains(&id) { + Span::styled(g.marked, t.bold(t.accent2)) + } else if row.totals.markable == 0 { + Span::styled(g.locked, t.fg(t.error)) + } else { + Span::styled(g.unmarked, t.muted()) + }; + } + let totals = row.totals; + if totals.markable == 0 { + Span::styled(g.locked, t.muted()) + } else if totals.marked == 0 { + Span::styled(g.unmarked, t.muted()) + } else if totals.marked >= totals.ready.max(1) { + Span::styled(g.marked, t.bold(t.accent2)) + } else { + Span::styled(g.partial, t.fg(t.accent2)) + } +} + +fn status_cell(app: &App, row: &Row) -> Span<'static> { + let t = &app.theme; + match row.key { + RowKey::Artifact(id) => { + let Some(entry) = app.store.entry(id) else { + return Span::raw(""); + }; + match &entry.status { + EntryStatus::Deleting => return Span::styled("deleting", t.muted()), + EntryStatus::Failed(_) => return Span::styled("failed", t.bold(t.error)), + _ => {} + } + match super::store::hold_for(entry, &app.policy, app.now) { + None => Span::styled("ready", t.fg(t.success)), + Some(Hold::Recent) => Span::styled("recent", t.fg(t.info)), + Some(Hold::Review) => Span::styled("review", t.fg(t.warning)), + Some(Hold::Protected) => Span::styled("protected", t.fg(t.error)), + } + } + _ => { + let totals = row.totals; + let held = totals.markable.saturating_sub(totals.ready); + if totals.marked > 0 { + Span::styled(format!("{} marked", totals.marked), t.fg(t.accent2)) + } else if held > 0 { + Span::styled(format!("{held} held"), t.muted()) + } else { + Span::raw("") + } + } + } +} + +fn size_cell(app: &App, row: &Row) -> Span<'static> { + let t = &app.theme; + let totals = row.totals; + if totals.artifacts == totals.unmeasured { + return Span::styled(if app.scan.running { spinner(app) } else { "?" }, t.muted()); + } + let text = fmt::bytes(totals.reclaimable); + if totals.estimated || (totals.unmeasured > 0 && app.scan.running) { + Span::styled(format!("~{text}"), t.subtle()) + } else { + Span::styled(text, t.size(totals.reclaimable)) + } +} + +fn render_table(frame: &mut Frame, app: &mut App, area: Rect) { + let t = app.theme; + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(t.fg(t.border)); + let inner = block.inner(area); + frame.render_widget(block, area); + + if app.rows.rows.is_empty() { + let message = if app.scan.running { + format!( + "{} {}", + spinner(app), + app.scan_progress().unwrap_or_default() + ) + } else if !app.query.is_empty() { + format!("No artifacts match “{}”", app.query.raw) + } else { + "Nothing to reclaim here.".to_string() + }; + let [middle] = Layout::vertical([Constraint::Length(1)]) + .flex(Flex::Center) + .areas(inner); + frame.render_widget( + Paragraph::new(message) + .alignment(Alignment::Center) + .style(t.subtle()), + middle, + ); + return; + } + + let bars = inner.width >= MIN_WIDTH_FOR_BARS; + let max = app.rows.max_top.max(1); + let glyphs = app.glyphs; + let rows: Vec = app + .rows + .rows + .iter() + .map(|row| { + let indent = " ".repeat(usize::from(row.depth)); + let expander = if row.expandable { + if row.expanded { + glyphs.expanded + } else { + glyphs.collapsed + } + } else { + glyphs.leaf + }; + let name_style = match row.key { + RowKey::Project(_) => t.bold(t.text), + RowKey::Artifact(_) => t.text(), + _ => t.fg(t.subtle), + }; + let mut name = vec![ + Span::raw(indent), + Span::styled(format!("{expander} "), t.muted()), + Span::styled(row.label.clone(), name_style), + ]; + let kind = match row.key { + RowKey::Artifact(id) => app + .store + .entry(id) + .map(|e| Span::styled(e.artifact.kind.label(), t.subtle())) + .unwrap_or_default(), + _ => { + if row.totals.artifacts > 1 { + name.push(Span::styled( + format!(" {}", row.totals.artifacts), + t.muted(), + )); + } + Span::raw("") + } + }; + let name = Line::from(name); + let mut ecos: Vec = Vec::new(); + for (i, &eco) in row.ecosystems.iter().take(2).enumerate() { + if i > 0 { + ecos.push(Span::raw(" ")); + } + ecos.push(badge(app, eco)); + } + let age = super::store::age(row.newest, app.now) + .map(fmt::age) + .unwrap_or_default(); + let mut cells = vec![ + Cell::from(mark_cell(app, row)), + Cell::from(name), + Cell::from(kind), + Cell::from(Line::from(ecos)), + Cell::from(status_cell(app, row)), + Cell::from(Line::from(size_cell(app, row)).alignment(Alignment::Right)), + ]; + if bars { + let fraction = row.totals.reclaimable as f64 / max as f64; + cells.push(Cell::from(Span::styled( + glyphs.bar(fraction, 10), + t.size(row.totals.reclaimable), + ))); + } + cells.push(Cell::from( + Line::from(Span::styled(age, t.muted())).alignment(Alignment::Right), + )); + TableRow::new(cells) + }) + .collect(); + + let mut widths = vec![ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(5), + Constraint::Length(5), + Constraint::Length(9), + Constraint::Length(9), + ]; + if bars { + widths.push(Constraint::Length(10)); + } + widths.push(Constraint::Length(4)); + + let mut header_cells = vec!["", "NAME", "KIND", "ECO", "STATUS", "FREED"]; + if bars { + header_cells.push(""); + } + header_cells.push("AGE"); + let header = TableRow::new(header_cells.into_iter().enumerate().map(|(i, h)| { + let line = Line::from(Span::styled(h, t.bold(t.muted))); + Cell::from(if i == 5 || h == "AGE" { + line.alignment(Alignment::Right) + } else { + line + }) + })); + + let table = Table::new(rows, widths) + .header(header) + .column_spacing(1) + .row_highlight_style(t.selected_row()); + + let mut state = TableState::default() + .with_offset(app.geometry.offset) + .with_selected(Some(app.selected)); + frame.render_stateful_widget(table, inner, &mut state); + app.geometry = super::app::TableGeometry { + area: inner, + first_row_y: inner.y + 1, + offset: state.offset(), + }; + app.page = usize::from(inner.height.saturating_sub(2)).max(1); +} + +fn kv<'a>(app: &App, key: &'a str, value: impl Into) -> Line<'a> { + let t = &app.theme; + Line::from(vec![ + Span::styled(format!("{key:<10}"), t.muted()), + Span::styled(value.into(), t.text()), + ]) +} + +fn render_details(frame: &mut Frame, app: &App, area: Rect) { + let t = &app.theme; + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(t.fg(t.border)) + .title(Span::styled(" Details ", t.muted())); + let mut lines: Vec = Vec::new(); + match app.selected_row().map(|r| (r.key.clone(), r.totals)) { + Some((RowKey::Artifact(id), _)) => { + if let Some(entry) = app.store.entry(id) { + let a = &entry.artifact; + lines.push(Line::from(Span::styled( + app.store.relative(&a.path).into_owned(), + t.bold(t.text), + ))); + lines.push(Line::default()); + if let Some(project) = a.project.and_then(|p| app.store.project(p)) { + lines.push(kv(app, "project", project.name.clone())); + } + if let Some(eco) = a.ecosystem { + lines.push(kv( + app, + "ecosystem", + app.registry.ecosystem(eco).name.clone(), + )); + } + lines.push(kv(app, "kind", a.kind.label())); + lines.push(kv(app, "detected", detection(app, a.detection))); + lines.push(kv(app, "git", git_state(a.git))); + let hold = super::store::hold_for(entry, &app.policy, app.now); + let (status, color) = match (a.safety(), hold) { + (carwash_core::Safety::Protected(_), _) => { + (a.safety().describe().to_string(), t.error) + } + (carwash_core::Safety::Review(_), _) => { + (a.safety().describe().to_string(), t.warning) + } + (_, Some(Hold::Recent)) => ("used recently; held back".to_string(), t.info), + _ => ("ready to clean".to_string(), t.success), + }; + lines.push(Line::from(vec![ + Span::styled(format!("{:<10}", "status"), t.muted()), + Span::styled(status, t.fg(color)), + ])); + lines.push(Line::default()); + match entry.size() { + Some(size) => { + let prefix = if entry.is_estimate() { "~" } else { "" }; + lines.push(kv( + app, + "frees", + format!("{prefix}{}", fmt::bytes(size.reclaimable)), + )); + lines.push(kv(app, "on disk", fmt::bytes(size.on_disk))); + if size.shared() > 0 { + lines.push(Line::from(vec![ + Span::styled(format!("{:<10}", "shared"), t.muted()), + Span::styled( + format!("{} hard-linked elsewhere", fmt::bytes(size.shared())), + t.fg(t.warning), + ), + ])); + } + lines.push(kv( + app, + "contents", + format!( + "{} files · {} dirs", + crate::commands::count(size.files), + crate::commands::count(size.dirs) + ), + )); + if size.errors > 0 { + lines.push(kv(app, "unreadable", size.errors.to_string())); + } + } + None => lines.push(kv(app, "frees", "measuring…")), + } + if let Some(age) = super::store::age(entry.last_modified(), app.now) { + lines.push(kv(app, "modified", format!("{} ago", fmt::age(age)))); + } + if let Detection::Rule { rule, .. } = a.detection + && let Some(regenerate) = &app.registry.rule(rule).regenerate + { + lines.push(Line::default()); + lines.push(Line::from(Span::styled("regenerate with", t.muted()))); + lines.push(Line::from(Span::styled(regenerate.clone(), t.subtle()))); + } + if let EntryStatus::Failed(error) = &entry.status { + lines.push(Line::default()); + lines.push(Line::from(Span::styled(error.clone(), t.fg(t.error)))); + } + } + } + Some((RowKey::Project(id), totals)) => { + if let Some(project) = app.store.project(id) { + let relative = app.store.relative(&project.path).into_owned(); + lines.push(Line::from(Span::styled( + project.name.clone(), + t.bold(t.text), + ))); + if relative != project.name { + lines.push(Line::from(Span::styled(relative, t.subtle()))); + } + lines.push(Line::default()); + let ecos: Vec = project + .ecosystems + .iter() + .map(|&e| app.registry.ecosystem(e).name.clone()) + .collect(); + lines.push(kv(app, "ecosystem", ecos.join(", "))); + if project.is_workspace { + lines.push(kv(app, "workspace", "yes (declares members)")); + } + if let Some(ws) = project.member_of.and_then(|w| app.store.project(w)) { + lines.push(kv(app, "member of", ws.name.clone())); + } + if let Some(repo) = &project.repo { + lines.push(kv(app, "repo", home_relative(repo))); + } + if let Some(age) = super::store::age(project.last_activity, app.now) { + lines.push(kv( + app, + "manifest", + format!("changed {} ago", fmt::age(age)), + )); + } + if project.outside_root { + lines.push(kv(app, "note", "encloses the scan root")); + } + lines.push(Line::default()); + push_totals(app, &mut lines, totals); + } + } + Some((RowKey::Dir(path), totals)) => { + lines.push(Line::from(Span::styled( + app.store.relative(&path).into_owned(), + t.bold(t.text), + ))); + lines.push(Line::default()); + push_totals(app, &mut lines, totals); + } + Some((RowKey::Orphans, totals)) => { + lines.push(Line::from(Span::styled( + "Outside any project", + t.bold(t.text), + ))); + lines.push(Line::from(Span::styled( + "Recognised by content (venvs, caches, stray node_modules).", + t.subtle(), + ))); + lines.push(Line::default()); + push_totals(app, &mut lines, totals); + } + None => {} + } + frame.render_widget( + Paragraph::new(Text::from(lines)) + .block(block) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn push_totals(app: &App, lines: &mut Vec>, totals: super::store::Totals) { + lines.push(kv(app, "artifacts", totals.artifacts.to_string())); + lines.push(kv(app, "frees", fmt::bytes(totals.reclaimable))); + if totals.on_disk != totals.reclaimable { + lines.push(kv(app, "on disk", fmt::bytes(totals.on_disk))); + } + lines.push(kv( + app, + "ready", + format!("{} of {}", totals.ready, totals.markable), + )); + if totals.marked > 0 { + lines.push(kv( + app, + "marked", + format!("{} · {}", totals.marked, fmt::bytes(totals.marked_bytes)), + )); + } +} + +fn detection(app: &App, detection: Detection) -> String { + match detection { + Detection::Rule { rule, confirmed } => { + let rule = app.registry.rule(rule); + if confirmed { + format!("rule `{}`, confirmed by contents", rule.path) + } else { + format!("rule `{}`", rule.path) + } + } + Detection::CacheDirTag => "CACHEDIR.TAG".into(), + Detection::PythonVenv => "pyvenv.cfg (virtualenv)".into(), + Detection::CondaEnv => "conda-meta (conda env)".into(), + Detection::CMakeBuild => "CMakeCache.txt".into(), + Detection::MesonBuild => "Meson build directory".into(), + Detection::CargoFingerprint => "Cargo fingerprints".into(), + Detection::CarwashLeftover => "interrupted carwash clean".into(), + } +} + +fn git_state(state: GitState) -> String { + match state { + GitState::Unknown => "checking…".into(), + GitState::NotInRepo => "not in a repository".into(), + GitState::Ignored => "ignored".into(), + GitState::Untracked => "untracked, not ignored".into(), + GitState::Tracked(n) => format!("{n} tracked files"), + } +} + +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + let t = &app.theme; + if matches!(app.mode, Mode::Search) { + let prompt = "/ "; + let width = area.width.saturating_sub(prompt.len() as u16 + 1) as usize; + let scroll = app.input.visual_scroll(width); + let line = Line::from(vec![ + Span::styled(prompt, t.bold(t.accent)), + Span::styled( + app.input.value().chars().skip(scroll).collect::(), + t.text(), + ), + ]); + frame.render_widget(Paragraph::new(line), area); + let cursor = (app.input.visual_cursor().saturating_sub(scroll)) as u16; + frame.set_cursor_position((area.x + prompt.len() as u16 + cursor, area.y)); + return; + } + if let Some(toast) = &app.toast { + let color = match toast.level { + Level::Info => t.accent, + Level::Warn => t.warning, + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {}", toast.text), + t.fg(color), + ))), + area, + ); + return; + } + let hints = [ + ("space", "mark"), + ("a", "mark ready"), + ("d", "clean"), + ("/", "filter"), + ("tab", "group"), + ("s", "sort"), + ("o", "reveal"), + ("?", "help"), + ("q", "quit"), + ]; + let mut spans = vec![Span::raw(" ")]; + for (i, (key, label)) in hints.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", t.muted())); + } + spans.push(Span::styled(*key, t.bold(t.accent))); + spans.push(Span::styled(format!(" {label}"), t.muted())); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let [vertical] = Layout::vertical([Constraint::Length(height.min(area.height))]) + .flex(Flex::Center) + .areas(area); + let [rect] = Layout::horizontal([Constraint::Length(width.min(area.width))]) + .flex(Flex::Center) + .areas(vertical); + rect +} + +fn render_review(frame: &mut Frame, app: &App, review: &Review) { + let t = &app.theme; + let area = frame.area(); + let width = (area.width * 7 / 10).clamp(60.min(area.width), 110); + let height = (area.height * 7 / 10).clamp(14.min(area.height), 40); + let popup = centered(area, width, height); + frame.render_widget(Clear, popup); + let title = match review.phase { + ReviewPhase::Confirm => " Review ", + ReviewPhase::Running { .. } => " Cleaning ", + ReviewPhase::Done { .. } => " Done ", + }; + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(t.fg(t.accent2)) + .title(Span::styled(title, t.bold(t.accent2))); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + match &review.phase { + ReviewPhase::Confirm => { + let verb = match review.mode { + DeleteMode::Permanent => "Delete", + DeleteMode::Trash => "Move to trash", + }; + let mut head = vec![Line::from(vec![ + Span::styled( + format!( + "{verb} {}, freeing ", + crate::commands::directories(review.items.len()) + ), + t.text(), + ), + Span::styled(fmt::bytes(review.bytes), t.bold(t.success)), + ])]; + for (hold, n) in &review.held { + let text = match hold { + Hold::Recent => format!("{n} used within the last {} days", recent_days(app)), + Hold::Review => { + format!("{n} have generic names git does not confirm as ignored") + } + Hold::Protected => format!("{n} protected (skipped)"), + }; + head.push(Line::from(Span::styled( + format!("! {text}"), + t.fg(t.warning), + ))); + } + if review.outside_root > 0 { + head.push(Line::from(Span::styled( + format!( + "! {} belong to a project above the scan root", + review.outside_root + ), + t.fg(t.warning), + ))); + } + let head_height = head.len() as u16 + 1; + let [head_area, list_area, foot_area] = Layout::vertical([ + Constraint::Length(head_height), + Constraint::Min(1), + Constraint::Length(3), + ]) + .areas(inner); + frame.render_widget(Paragraph::new(head), head_area); + + let visible = usize::from(list_area.height); + let start = review + .scroll + .min(review.items.len().saturating_sub(visible)); + let list: Vec = review + .items + .iter() + .skip(start) + .take(visible) + .filter_map(|id| app.store.entry(*id)) + .map(|entry| { + let bytes = entry.size().map_or(0, |s| s.reclaimable); + Line::from(vec![ + Span::styled(format!("{:>9} ", fmt::bytes(bytes)), t.size(bytes)), + Span::styled( + app.store.relative(&entry.artifact.path).into_owned(), + t.text(), + ), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(list), list_area); + + let mode_line = match review.mode { + DeleteMode::Permanent => Line::from(vec![ + Span::styled("mode ", t.muted()), + Span::styled("delete permanently", t.bold(t.error)), + Span::styled(" t: move to trash instead", t.muted()), + ]), + DeleteMode::Trash => Line::from(vec![ + Span::styled("mode ", t.muted()), + Span::styled("move to trash", t.bold(t.warning)), + Span::styled( + " (frees nothing until emptied) t: delete permanently", + t.muted(), + ), + ]), + }; + let keys = Line::from(vec![ + Span::styled("Enter", t.bold(t.success)), + Span::styled(" confirm ", t.muted()), + Span::styled("Esc", t.bold(t.accent)), + Span::styled(" cancel ", t.muted()), + Span::styled("↑↓", t.bold(t.accent)), + Span::styled(" scroll", t.muted()), + ]); + frame.render_widget( + Paragraph::new(vec![Line::default(), mode_line, keys]), + foot_area, + ); + } + ReviewPhase::Running { + done_bytes, + done, + failed, + current, + } => { + let [gauge_area, info] = + Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(inner); + let ratio = if review.bytes == 0 { + 0.0 + } else { + (*done_bytes as f64 / review.bytes as f64).clamp(0.0, 1.0) + }; + frame.render_widget( + Gauge::default() + .ratio(ratio) + .label(format!( + "{} / {}", + fmt::bytes(*done_bytes), + fmt::bytes(review.bytes) + )) + .gauge_style(t.fg(t.success)) + .block(Block::default().borders(Borders::NONE)), + gauge_area, + ); + let mut lines = vec![Line::from(Span::styled( + format!("{done} of {} done · {failed} failed", review.items.len()), + t.text(), + ))]; + if let Some(path) = current { + lines.push(Line::from(vec![ + Span::styled(format!("{} ", spinner(app)), t.fg(t.accent)), + Span::styled(app.store.relative(path).into_owned(), t.subtle()), + ])); + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled("Esc stop", t.muted()))); + frame.render_widget(Paragraph::new(lines), info); + } + ReviewPhase::Done { report, failures } => { + let mut lines = vec![Line::from(vec![ + Span::styled("Freed ", t.text()), + Span::styled(fmt::bytes(report.bytes), t.bold(t.success)), + Span::styled( + format!(" from {}.", crate::commands::directories(report.removed)), + t.text(), + ), + ])]; + if let (Some(before), Some(after)) = (report.free_before, report.free_after) { + lines.push(Line::from(Span::styled( + format!("Free space {} → {}", fmt::bytes(before), fmt::bytes(after)), + t.subtle(), + ))); + } + if review.mode == DeleteMode::Trash { + lines.push(Line::from(Span::styled( + "Moved to the trash: empty it to actually free the space.", + t.fg(t.warning), + ))); + } + if !failures.is_empty() { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + format!("{} failed:", failures.len()), + t.bold(t.error), + ))); + for (path, error) in failures.iter().take(12) { + lines.push(Line::from(vec![ + Span::styled(app.store.relative(path).into_owned(), t.text()), + Span::styled(format!(" {error}"), t.fg(t.error)), + ])); + } + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled("Press any key", t.muted()))); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + } +} + +fn recent_days(app: &App) -> u64 { + app.policy.recent.map_or(0, |d| d.as_secs() / 86_400) +} + +fn render_help(frame: &mut Frame, app: &App) { + let t = &app.theme; + let area = frame.area(); + let popup = centered(area, 84, 40); + frame.render_widget(Clear, popup); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(t.fg(t.accent)) + .title(Span::styled(" Help ", t.bold(t.accent))); + let mut lines: Vec = Vec::new(); + for section in Section::ALL { + lines.push(Line::from(Span::styled(section.title(), t.bold(t.accent2)))); + for binding in BINDINGS.iter().filter(|b| b.section == section) { + lines.push(Line::from(vec![ + Span::styled(format!(" {:<10}", binding.label), t.bold(t.text)), + Span::styled(binding.description, t.subtle()), + ])); + } + lines.push(Line::default()); + } + let g = &app.glyphs; + lines.push(Line::from(Span::styled("Legend", t.bold(t.accent2)))); + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", g.marked), t.fg(t.accent2)), + Span::styled("marked ", t.subtle()), + Span::styled(format!("{} ", g.partial), t.fg(t.accent2)), + Span::styled("partly ", t.subtle()), + Span::styled(format!("{} ", g.unmarked), t.muted()), + Span::styled("unmarked ", t.subtle()), + Span::styled(format!("{} ", g.locked), t.fg(t.error)), + Span::styled("protected", t.subtle()), + ])); + lines.push(Line::from(vec![ + Span::styled(" ready", t.fg(t.success)), + Span::styled(" safe to clean ", t.subtle()), + Span::styled("recent", t.fg(t.info)), + Span::styled(" used lately ", t.subtle()), + Span::styled("review", t.fg(t.warning)), + Span::styled(" generic name ", t.subtle()), + Span::styled("protected", t.fg(t.error)), + Span::styled(" git-tracked", t.subtle()), + ])); + lines.push(Line::from(Span::styled( + " ~ size from cache, refreshing FREED counts only bytes deletion frees", + t.subtle(), + ))); + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "Press any key to close", + t.muted(), + ))); + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::tests::app; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn draw(app: &mut App, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, app)).unwrap(); + let buffer = terminal.backend().buffer().clone(); + let mut text = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + text.push_str(buffer[(x, y)].symbol()); + } + text.push('\n'); + } + text + } + + fn press(app: &mut App, code: KeyCode) { + app.update(crate::tui::app::Msg::Key(KeyEvent::new( + code, + KeyModifiers::NONE, + ))); + } + + #[test] + fn renders_at_every_size_without_panicking() { + for mode in ['\0', '?', 'd', '/'] { + let mut app = app(); + press(&mut app, KeyCode::Char('a')); + if mode != '\0' { + press(&mut app, KeyCode::Char(mode)); + } + for (w, h) in [(1, 1), (10, 3), (40, 8), (80, 24), (120, 40), (200, 60)] { + draw(&mut app, w, h); + } + } + } + + #[test] + fn main_screen_shows_rows_totals_and_details() { + let mut app = app(); + press(&mut app, KeyCode::Char('E')); + let screen = draw(&mut app, 140, 20); + assert!(screen.contains("carwash")); + assert!(screen.contains("target")); + assert!(screen.contains("protected")); + assert!(screen.contains("recent")); + assert!(screen.contains("1.00 kB reclaimable"), "{screen}"); + assert!(screen.contains("Details")); + } + + #[test] + fn review_lists_marked_items() { + let mut app = app(); + press(&mut app, KeyCode::Char('a')); + press(&mut app, KeyCode::Char('d')); + let screen = draw(&mut app, 120, 30); + assert!( + screen.contains("Delete 1 directory, freeing 500 B"), + "{screen}" + ); + assert!(screen.contains("a/target")); + assert!(screen.contains("delete permanently")); + } +} From 28088aa86be26ff9495216204626ab31981ed145 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:32:22 -0400 Subject: [PATCH 08/22] feat: task discovery and `carwash tasks` / `carwash run` Tasks come from package.json scripts (run with the project's package manager), deno tasks, justfiles, Makefiles, Taskfiles, mise, poe/pdm, composer scripts and cargo aliases, plus standard commands declared per ecosystem in the rules (tasks = [...], with when/unless conditions). `carwash run ` runs it in every project that has it, sequentially with the terminal inherited or in parallel with prefixed output. --- .../carwash-core/src/ecosystem/builtin.toml | 120 +++ crates/carwash-core/src/ecosystem/mod.rs | 31 + crates/carwash-core/src/lib.rs | 1 + crates/carwash-core/src/tasks.rs | 812 ++++++++++++++++++ crates/carwash/Cargo.toml | 1 + crates/carwash/src/cli.rs | 61 ++ crates/carwash/src/commands/mod.rs | 1 + crates/carwash/src/commands/tasks.rs | 426 +++++++++ crates/carwash/src/main.rs | 2 + 9 files changed, 1455 insertions(+) create mode 100644 crates/carwash-core/src/tasks.rs create mode 100644 crates/carwash/src/commands/tasks.rs diff --git a/crates/carwash-core/src/ecosystem/builtin.toml b/crates/carwash-core/src/ecosystem/builtin.toml index a3a1af1..9e022bf 100644 --- a/crates/carwash-core/src/ecosystem/builtin.toml +++ b/crates/carwash-core/src/ecosystem/builtin.toml @@ -21,6 +21,10 @@ # confirm globs; if any child entry matches, the directory is confirmed as an artifact # require_confirm without a confirming child the directory is not treated as an artifact # regenerate hint shown to the user about how the artifact comes back +# +# Task fields (standard commands offered for every project of the ecosystem): +# name, run task name and program + arguments; `{pm}` is the JS package manager +# when / unless only offer the task when a path exists / does not exist schema = 1 @@ -34,6 +38,16 @@ adapter = "cargo" artifacts = [ { path = "target", kind = "build", confirm = ["CACHEDIR.TAG", ".rustc_info.json", "debug", "release"], regenerate = "cargo build" }, ] +tasks = [ + { name = "build", run = ["cargo", "build"] }, + { name = "test", run = ["cargo", "test"] }, + { name = "check", run = ["cargo", "check", "--all-targets"] }, + { name = "clippy", run = ["cargo", "clippy", "--all-targets"] }, + { name = "fmt", run = ["cargo", "fmt"] }, + { name = "doc", run = ["cargo", "doc"] }, + { name = "update", run = ["cargo", "update"], description = "update Cargo.lock within semver" }, + { name = "clean", run = ["cargo", "clean"] }, +] [[ecosystem]] id = "node" @@ -67,6 +81,10 @@ artifacts = [ { path = "out", kind = "build", ambiguous = true, regenerate = "your build script" }, { path = "coverage", kind = "other", ambiguous = true, regenerate = "your test script" }, ] +tasks = [ + { name = "install", run = ["{pm}", "install"] }, + { name = "outdated", run = ["{pm}", "outdated"] }, +] [[ecosystem]] id = "deno" @@ -77,6 +95,13 @@ lockfiles = ["deno.lock"] artifacts = [ { path = "node_modules", kind = "dependencies", regenerate = "deno install" }, ] +tasks = [ + { name = "test", run = ["deno", "test"] }, + { name = "check", run = ["deno", "check", "."] }, + { name = "lint", run = ["deno", "lint"] }, + { name = "fmt", run = ["deno", "fmt"] }, + { name = "outdated", run = ["deno", "outdated"] }, +] [[ecosystem]] id = "python" @@ -106,6 +131,13 @@ artifacts = [ { path = "build", kind = "build", ambiguous = true, confirm = ["lib", "lib.*", "bdist.*", "temp.*"], regenerate = "python -m build" }, { path = "dist", kind = "build", ambiguous = true, confirm = ["*.whl", "*.tar.gz"], regenerate = "python -m build" }, ] +tasks = [ + { name = "sync", run = ["uv", "sync"], when = "uv.lock" }, + { name = "test", run = ["uv", "run", "pytest"], when = "uv.lock" }, + { name = "install", run = ["poetry", "install"], when = "poetry.lock" }, + { name = "test", run = ["poetry", "run", "pytest"], when = "poetry.lock" }, + { name = "install", run = ["pdm", "install"], when = "pdm.lock" }, +] [[ecosystem]] id = "go" @@ -117,6 +149,13 @@ adapter = "go" artifacts = [ { path = "vendor", kind = "dependencies", ambiguous = true, confirm = ["modules.txt"], regenerate = "go mod vendor" }, ] +tasks = [ + { name = "build", run = ["go", "build", "./..."] }, + { name = "test", run = ["go", "test", "./..."] }, + { name = "vet", run = ["go", "vet", "./..."] }, + { name = "tidy", run = ["go", "mod", "tidy"] }, + { name = "update", run = ["go", "get", "-u", "./..."], description = "update module dependencies" }, +] [[ecosystem]] id = "maven" @@ -126,6 +165,14 @@ markers = ["pom.xml"] artifacts = [ { path = "target", kind = "build", ambiguous = true, confirm = ["classes", "maven-status", "maven-archiver", "surefire-reports", "generated-sources", "*.jar"], regenerate = "mvn package" }, ] +tasks = [ + { name = "package", run = ["./mvnw", "package"], when = "mvnw" }, + { name = "test", run = ["./mvnw", "test"], when = "mvnw" }, + { name = "clean", run = ["./mvnw", "clean"], when = "mvnw" }, + { name = "package", run = ["mvn", "package"], unless = "mvnw" }, + { name = "test", run = ["mvn", "test"], unless = "mvnw" }, + { name = "clean", run = ["mvn", "clean"], unless = "mvnw" }, +] [[ecosystem]] id = "gradle" @@ -142,6 +189,14 @@ artifacts = [ { path = ".externalNativeBuild", kind = "build", regenerate = "gradle build" }, { path = "out", kind = "build", ambiguous = true, confirm = ["production", "test"], regenerate = "IntelliJ build" }, ] +tasks = [ + { name = "build", run = ["./gradlew", "build"], when = "gradlew" }, + { name = "test", run = ["./gradlew", "test"], when = "gradlew" }, + { name = "clean", run = ["./gradlew", "clean"], when = "gradlew" }, + { name = "build", run = ["gradle", "build"], unless = "gradlew" }, + { name = "test", run = ["gradle", "test"], unless = "gradlew" }, + { name = "clean", run = ["gradle", "clean"], unless = "gradlew" }, +] [[ecosystem]] id = "scala" @@ -158,6 +213,10 @@ artifacts = [ { path = ".scala-build", kind = "build", regenerate = "scala-cli" }, { path = "out", kind = "build", ambiguous = true, confirm = ["mill-*"], regenerate = "mill" }, ] +tasks = [ + { name = "compile", run = ["sbt", "compile"], when = "build.sbt" }, + { name = "test", run = ["sbt", "test"], when = "build.sbt" }, +] [[ecosystem]] id = "bazel" @@ -179,6 +238,12 @@ artifacts = [ { path = "TestResults", kind = "other", regenerate = "dotnet test" }, { path = ".vs", kind = "cache", ambiguous = true, regenerate = "Visual Studio" }, ] +tasks = [ + { name = "build", run = ["dotnet", "build"] }, + { name = "test", run = ["dotnet", "test"] }, + { name = "restore", run = ["dotnet", "restore"] }, + { name = "clean", run = ["dotnet", "clean"] }, +] [[ecosystem]] id = "swift" @@ -190,6 +255,11 @@ artifacts = [ { path = ".build", kind = "build", regenerate = "swift build" }, { path = ".swiftpm", kind = "cache", ambiguous = true, regenerate = "swift package" }, ] +tasks = [ + { name = "build", run = ["swift", "build"] }, + { name = "test", run = ["swift", "test"] }, + { name = "update", run = ["swift", "package", "update"] }, +] [[ecosystem]] id = "xcode" @@ -238,6 +308,11 @@ artifacts = [ { path = "linux/flutter/ephemeral", kind = "build", regenerate = "flutter pub get" }, { path = "windows/flutter/ephemeral", kind = "build", regenerate = "flutter pub get" }, ] +tasks = [ + { name = "get", run = ["dart", "pub", "get"] }, + { name = "test", run = ["dart", "test"] }, + { name = "outdated", run = ["dart", "pub", "outdated"] }, +] [[ecosystem]] id = "elixir" @@ -253,6 +328,12 @@ artifacts = [ { path = ".lexical", kind = "cache", regenerate = "Lexical" }, { path = "cover", kind = "other", ambiguous = true, regenerate = "mix test --cover" }, ] +tasks = [ + { name = "deps", run = ["mix", "deps.get"] }, + { name = "compile", run = ["mix", "compile"] }, + { name = "test", run = ["mix", "test"] }, + { name = "format", run = ["mix", "format"] }, +] [[ecosystem]] id = "erlang" @@ -263,6 +344,10 @@ lockfiles = ["rebar.lock"] artifacts = [ { path = "_build", kind = "build", regenerate = "rebar3 compile" }, ] +tasks = [ + { name = "compile", run = ["rebar3", "compile"] }, + { name = "test", run = ["rebar3", "eunit"] }, +] [[ecosystem]] id = "gleam" @@ -273,6 +358,10 @@ lockfiles = ["manifest.toml"] artifacts = [ { path = "build", kind = "build", ambiguous = true, confirm = ["packages", "dev", "prod", "lsp"], regenerate = "gleam build" }, ] +tasks = [ + { name = "build", run = ["gleam", "build"] }, + { name = "test", run = ["gleam", "test"] }, +] [[ecosystem]] id = "haskell" @@ -285,6 +374,12 @@ artifacts = [ { path = ".stack-work", kind = "build", regenerate = "stack build" }, { path = "dist", kind = "build", ambiguous = true, confirm = ["setup-config", "build"], regenerate = "cabal build" }, ] +tasks = [ + { name = "build", run = ["stack", "build"], when = "stack.yaml" }, + { name = "test", run = ["stack", "test"], when = "stack.yaml" }, + { name = "build", run = ["cabal", "build"], unless = "stack.yaml" }, + { name = "test", run = ["cabal", "test"], unless = "stack.yaml" }, +] [[ecosystem]] id = "ocaml" @@ -295,6 +390,10 @@ artifacts = [ { path = "_build", kind = "build", regenerate = "dune build" }, { path = "_opam", kind = "environment", regenerate = "opam switch create ." }, ] +tasks = [ + { name = "build", run = ["dune", "build"] }, + { name = "test", run = ["dune", "test"] }, +] [[ecosystem]] id = "zig" @@ -306,6 +405,10 @@ artifacts = [ { path = "zig-cache", kind = "cache", regenerate = "zig build" }, { path = "zig-out", kind = "build", regenerate = "zig build" }, ] +tasks = [ + { name = "build", run = ["zig", "build"] }, + { name = "test", run = ["zig", "build", "test"] }, +] [[ecosystem]] id = "cmake" @@ -319,6 +422,10 @@ artifacts = [ { path = ".cache/clangd", kind = "cache", regenerate = "clangd" }, { path = "vcpkg_installed", kind = "dependencies", regenerate = "vcpkg install" }, ] +tasks = [ + { name = "configure", run = ["cmake", "-B", "build"] }, + { name = "build", run = ["cmake", "--build", "build"] }, +] [[ecosystem]] id = "meson" @@ -349,6 +456,10 @@ lockfiles = ["composer.lock"] artifacts = [ { path = "vendor", kind = "dependencies", ambiguous = true, confirm = ["autoload.php", "composer"], regenerate = "composer install" }, ] +tasks = [ + { name = "install", run = ["composer", "install"] }, + { name = "outdated", run = ["composer", "outdated", "--direct"] }, +] [[ecosystem]] id = "ruby" @@ -362,6 +473,10 @@ artifacts = [ { path = "tmp", kind = "cache", ambiguous = true, confirm = ["cache", "pids", "sockets"], regenerate = "rails tmp:create" }, { path = "coverage", kind = "other", ambiguous = true, regenerate = "your test suite" }, ] +tasks = [ + { name = "install", run = ["bundle", "install"] }, + { name = "outdated", run = ["bundle", "outdated"] }, +] [[ecosystem]] id = "terraform" @@ -373,6 +488,11 @@ artifacts = [ { path = ".terraform", kind = "dependencies", regenerate = "terraform init" }, { path = ".terragrunt-cache", kind = "cache", regenerate = "terragrunt init" }, ] +tasks = [ + { name = "init", run = ["terraform", "init"] }, + { name = "validate", run = ["terraform", "validate"] }, + { name = "plan", run = ["terraform", "plan"] }, +] [[ecosystem]] id = "unity" diff --git a/crates/carwash-core/src/ecosystem/mod.rs b/crates/carwash-core/src/ecosystem/mod.rs index 1bbf432..60211de 100644 --- a/crates/carwash-core/src/ecosystem/mod.rs +++ b/crates/carwash-core/src/ecosystem/mod.rs @@ -67,6 +67,25 @@ pub struct EcosystemSpec { pub adapter: Option, #[serde(default)] pub artifacts: Vec, + #[serde(default)] + pub tasks: Vec, +} + +/// A standard command every project of an ecosystem supports (`cargo test`, `go vet ./...`). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskSpec { + pub name: String, + /// Program and arguments. `{pm}` is replaced by the project's JavaScript package manager. + pub run: Vec, + #[serde(default)] + pub description: Option, + /// Only offered when this path exists in the project (e.g. `gradlew`). + #[serde(default)] + pub when: Option, + /// Not offered when this path exists. + #[serde(default)] + pub unless: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -149,6 +168,7 @@ pub struct Ecosystem { pub lockfiles: Vec, pub adapter: Option, pub rules: Vec, + pub tasks: Vec, /// Marker and lockfile names checked for activity timestamps. pub(crate) activity_files: Vec, } @@ -308,6 +328,16 @@ impl Registry { message: "duplicate id".into(), }); } + if let Some(task) = spec + .tasks + .iter() + .find(|t| t.run.is_empty() || t.name.is_empty()) + { + return Err(RulesError::Invalid { + ecosystem: spec.id, + message: format!("task `{}` needs a name and a command", task.name), + }); + } if spec.markers.is_empty() && spec.weak_markers.is_empty() { return Err(RulesError::Invalid { ecosystem: spec.id, @@ -441,6 +471,7 @@ impl Registry { lockfiles: spec.lockfiles, adapter, rules: eco_rules, + tasks: spec.tasks, activity_files, }); } diff --git a/crates/carwash-core/src/lib.rs b/crates/carwash-core/src/lib.rs index a1743fc..1b3f20e 100644 --- a/crates/carwash-core/src/lib.rs +++ b/crates/carwash-core/src/lib.rs @@ -26,6 +26,7 @@ pub mod paths; pub mod safety; pub mod scan; pub mod select; +pub mod tasks; pub use discover::{Counters, DiscoverOptions}; pub use ecosystem::{EcoId, Registry, RuleId}; diff --git a/crates/carwash-core/src/tasks.rs b/crates/carwash-core/src/tasks.rs new file mode 100644 index 0000000..c4bb92e --- /dev/null +++ b/crates/carwash-core/src/tasks.rs @@ -0,0 +1,812 @@ +//! Discovery of runnable tasks: project scripts and ecosystem standard commands. +//! +//! Every task resolves to a program and arguments (no shell), run in the project directory. +//! Sources, in priority order: package.json scripts, deno tasks, justfile, Makefile, +//! Taskfile, mise, poe/pdm (pyproject), composer scripts, cargo aliases, then the standard +//! tasks each ecosystem declares in its rules (`cargo test`, `go test ./...`...). + +use crate::ecosystem::{EcoId, Registry}; +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Task { + pub name: String, + /// Where the task comes from: a file name (`package.json`, `justfile`) or an ecosystem id + /// for standard tasks. + pub source: String, + pub program: String, + pub args: Vec, + pub description: Option, + pub cwd: PathBuf, + /// Declared by an ecosystem rule rather than by the project. + pub standard: bool, +} + +impl Task { + /// The command as a user would type it. + pub fn command_line(&self) -> String { + std::iter::once(self.program.as_str()) + .chain(self.args.iter().map(String::as_str)) + .map(quote) + .collect::>() + .join(" ") + } +} + +fn quote(arg: &str) -> String { + if !arg.is_empty() + && arg + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_./:=@+,%".contains(c)) + { + arg.to_owned() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; + +fn read(path: &Path) -> Option { + let meta = fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_FILE_BYTES { + return None; + } + fs::read_to_string(path).ok() +} + +/// Package manager for a JavaScript project, from `packageManager` or the nearest lockfile +/// (workspace members share the root's lockfile). +pub fn node_package_manager(dir: &Path) -> &'static str { + if let Some(manifest) = read(&dir.join("package.json")) + && let Ok(json) = serde_json::from_str::(&manifest) + && let Some(pm) = json.get("packageManager").and_then(|v| v.as_str()) + { + for known in ["pnpm", "yarn", "bun", "npm"] { + if pm.starts_with(known) { + return known; + } + } + } + for ancestor in dir.ancestors().take(6) { + for (lockfile, pm) in [ + ("pnpm-lock.yaml", "pnpm"), + ("yarn.lock", "yarn"), + ("bun.lock", "bun"), + ("bun.lockb", "bun"), + ("package-lock.json", "npm"), + ] { + if ancestor.join(lockfile).is_file() { + return pm; + } + } + } + "npm" +} + +struct Collector<'a> { + dir: &'a Path, + tasks: Vec, +} + +impl Collector<'_> { + fn add( + &mut self, + name: &str, + source: &str, + argv: Vec, + description: Option, + standard: bool, + ) { + if name.is_empty() || self.tasks.iter().any(|t| t.name == name) { + return; + } + let mut argv = argv.into_iter(); + let Some(program) = argv.next() else { + return; + }; + self.tasks.push(Task { + name: name.to_owned(), + source: source.to_owned(), + program, + args: argv.collect(), + description: description.filter(|d| !d.is_empty()), + cwd: self.dir.to_path_buf(), + standard, + }); + } +} + +fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|s| (*s).to_owned()).collect() +} + +/// Tasks available in `dir`, a project of `ecosystems`. +pub fn discover(dir: &Path, ecosystems: &[EcoId], registry: &Registry) -> Vec { + let mut out = Collector { + dir, + tasks: Vec::new(), + }; + let has = |name: &str| dir.join(name).exists(); + let is_node = ecosystems + .iter() + .any(|&e| registry.ecosystem(e).key == "node"); + let pm = if is_node { + node_package_manager(dir) + } else { + "npm" + }; + + if is_node { + package_json(&mut out, pm); + } + for name in ["deno.json", "deno.jsonc"] { + if has(name) { + deno(&mut out, name); + } + } + for name in ["justfile", "Justfile", ".justfile"] { + if let Some(source) = read(&dir.join(name)) { + for (task, description) in justfile_recipes(&source) { + out.add( + &task, + "justfile", + argv(&["just", &task]), + description, + false, + ); + } + break; + } + } + for name in ["GNUmakefile", "makefile", "Makefile"] { + if let Some(source) = read(&dir.join(name)) { + for (task, description) in makefile_targets(&source) { + out.add( + &task, + "Makefile", + argv(&["make", &task]), + description, + false, + ); + } + break; + } + } + for name in [ + "Taskfile.yml", + "Taskfile.yaml", + "taskfile.yml", + "taskfile.yaml", + ] { + if has(name) { + taskfile(&mut out, name); + break; + } + } + for name in ["mise.toml", ".mise.toml"] { + if has(name) { + mise(&mut out, name); + } + } + if has("pyproject.toml") { + pyproject(&mut out); + } + if has("composer.json") { + composer(&mut out); + } + for name in [".cargo/config.toml", ".cargo/config"] { + if has(name) { + cargo_aliases(&mut out, name); + } + } + + for &eco in ecosystems { + let ecosystem = registry.ecosystem(eco); + for spec in &ecosystem.tasks { + if spec.when.as_deref().is_some_and(|f| !has(f)) + || spec.unless.as_deref().is_some_and(has) + { + continue; + } + let run: Vec = spec.run.iter().map(|a| a.replace("{pm}", pm)).collect(); + out.add( + &spec.name, + &ecosystem.key, + run, + spec.description.clone(), + true, + ); + } + } + out.tasks +} + +fn package_json(out: &mut Collector<'_>, pm: &str) { + let Some(json) = read(&out.dir.join("package.json")) + .and_then(|s| serde_json::from_str::(&s).ok()) + else { + return; + }; + let Some(scripts) = json.get("scripts").and_then(|s| s.as_object()) else { + return; + }; + for (name, command) in scripts { + // `pretest`/`posttest` run automatically around `test`. + let hook = ["pre", "post"].iter().any(|p| { + name.strip_prefix(p) + .is_some_and(|base| scripts.contains_key(base)) + }); + if hook { + continue; + } + out.add( + name, + "package.json", + argv(&[pm, "run", name]), + command.as_str().map(str::to_owned), + false, + ); + } +} + +/// Removes `//` and `/* */` comments outside strings, for JSONC files such as `deno.jsonc`. +pub(crate) fn strip_json_comments(source: &str) -> String { + let mut out = String::with_capacity(source.len()); + let mut chars = source.chars().peekable(); + let mut in_string = false; + while let Some(c) = chars.next() { + if in_string { + out.push(c); + match c { + '\\' => { + if let Some(next) = chars.next() { + out.push(next); + } + } + '"' => in_string = false, + _ => {} + } + continue; + } + match (c, chars.peek()) { + ('"', _) => { + in_string = true; + out.push(c); + } + ('/', Some('/')) => { + for next in chars.by_ref() { + if next == '\n' { + out.push('\n'); + break; + } + } + } + ('/', Some('*')) => { + chars.next(); + let mut previous = ' '; + for next in chars.by_ref() { + if previous == '*' && next == '/' { + break; + } + previous = next; + } + } + _ => out.push(c), + } + } + out +} + +fn deno(out: &mut Collector<'_>, file: &str) { + let Some(json) = read(&out.dir.join(file)) + .and_then(|s| serde_json::from_str::(&strip_json_comments(&s)).ok()) + else { + return; + }; + let Some(tasks) = json.get("tasks").and_then(|t| t.as_object()) else { + return; + }; + for (name, value) in tasks { + let description = value + .as_str() + .or_else(|| value.get("description").and_then(|d| d.as_str())) + .or_else(|| value.get("command").and_then(|d| d.as_str())) + .map(str::to_owned); + out.add( + name, + file, + argv(&["deno", "task", name]), + description, + false, + ); + } +} + +/// Public recipes of a justfile with their doc comments. +pub fn justfile_recipes(source: &str) -> Vec<(String, Option)> { + let mut recipes = Vec::new(); + let mut comment: Option = None; + let mut private = false; + for line in source.lines() { + if line.starts_with(char::is_whitespace) || line.trim().is_empty() { + if line.trim().is_empty() { + comment = None; + private = false; + } + continue; + } + let line = line.trim_end(); + if let Some(text) = line.strip_prefix('#') { + if !text.starts_with('!') { + comment = Some(text.trim().to_owned()); + } + continue; + } + if line.starts_with('[') { + if line.contains("private") { + private = true; + } + if let Some(doc) = line + .split_once("doc(") + .and_then(|(_, rest)| rest.split(['\'', '"']).nth(1)) + { + comment = Some(doc.to_owned()); + } + continue; + } + let keyword = line.split_whitespace().next().unwrap_or_default(); + if matches!( + keyword, + "set" | "alias" | "export" | "import" | "mod" | "unexport" + ) { + comment = None; + continue; + } + let Some(colon) = line.find(':') else { + continue; + }; + if line[colon..].starts_with(":=") { + comment = None; + continue; + } + let head = line[..colon].trim_start_matches('@'); + let name = head.split_whitespace().next().unwrap_or_default(); + let valid = !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + if valid && !name.starts_with('_') && !private { + recipes.push((name.to_owned(), comment.take())); + } + comment = None; + private = false; + } + recipes +} + +/// Explicit targets of a Makefile, with `## description` comments. +pub fn makefile_targets(source: &str) -> Vec<(String, Option)> { + let mut targets: Vec<(String, Option)> = Vec::new(); + let mut comment: Option = None; + for line in source.lines() { + if line.starts_with('\t') { + continue; + } + if let Some(text) = line.strip_prefix('#') { + comment = Some(text.trim_start_matches('#').trim().to_owned()); + continue; + } + let Some(colon) = line.find(':') else { + comment = None; + continue; + }; + let rest = &line[colon + 1..]; + if rest.starts_with('=') || line[..colon].contains('=') { + comment = None; + continue; + } + let inline = rest.split_once("##").map(|(_, d)| d.trim().to_owned()); + let description = inline.or_else(|| comment.take()); + for name in line[..colon].split_whitespace() { + let valid = !name.starts_with('.') + && !name.contains(['%', '$', '(', ')']) + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_-./".contains(c)); + if valid && !targets.iter().any(|(t, _)| t == name) { + targets.push((name.to_owned(), description.clone())); + } + } + comment = None; + } + targets +} + +fn taskfile(out: &mut Collector<'_>, file: &str) { + let Some(doc) = + read(&out.dir.join(file)).and_then(|s| yaml_serde::from_str::(&s).ok()) + else { + return; + }; + let Some(tasks) = doc.get("tasks").and_then(|t| t.as_mapping()) else { + return; + }; + for (name, task) in tasks { + let Some(name) = name.as_str() else { continue }; + if task + .get("internal") + .and_then(|i| i.as_bool()) + .unwrap_or(false) + { + continue; + } + let description = task + .get("desc") + .or_else(|| task.get("summary")) + .and_then(|d| d.as_str()) + .map(str::to_owned); + out.add(name, file, argv(&["task", name]), description, false); + } +} + +fn mise(out: &mut Collector<'_>, file: &str) { + let Some(table) = read(&out.dir.join(file)).and_then(|s| s.parse::().ok()) else { + return; + }; + let Some(tasks) = table.get("tasks").and_then(|t| t.as_table()) else { + return; + }; + for (name, task) in tasks { + let description = match task { + toml::Value::String(run) => Some(run.clone()), + toml::Value::Table(t) => t + .get("description") + .or_else(|| t.get("run")) + .and_then(|d| d.as_str()) + .map(str::to_owned), + _ => None, + }; + let hidden = task + .get("hide") + .and_then(toml::Value::as_bool) + .unwrap_or(false); + if !hidden { + out.add(name, file, argv(&["mise", "run", name]), description, false); + } + } +} + +fn pyproject(out: &mut Collector<'_>) { + let Some(table) = + read(&out.dir.join("pyproject.toml")).and_then(|s| s.parse::().ok()) + else { + return; + }; + let tool = table.get("tool"); + let describe = |value: &toml::Value| match value { + toml::Value::String(cmd) => Some(cmd.clone()), + toml::Value::Table(t) => t + .get("help") + .or_else(|| t.get("cmd")) + .or_else(|| t.get("shell")) + .and_then(|d| d.as_str()) + .map(str::to_owned), + _ => None, + }; + if let Some(tasks) = tool + .and_then(|t| t.get("poe")) + .and_then(|p| p.get("tasks")) + .and_then(|t| t.as_table()) + { + for (name, value) in tasks.iter().filter(|(n, _)| !n.starts_with('_')) { + out.add( + name, + "pyproject.toml", + argv(&["poe", name]), + describe(value), + false, + ); + } + } + if let Some(scripts) = tool + .and_then(|t| t.get("pdm")) + .and_then(|p| p.get("scripts")) + .and_then(|s| s.as_table()) + { + for (name, value) in scripts.iter().filter(|(n, _)| !n.starts_with('_')) { + out.add( + name, + "pyproject.toml", + argv(&["pdm", "run", name]), + describe(value), + false, + ); + } + } +} + +fn composer(out: &mut Collector<'_>) { + let Some(json) = read(&out.dir.join("composer.json")) + .and_then(|s| serde_json::from_str::(&s).ok()) + else { + return; + }; + let Some(scripts) = json.get("scripts").and_then(|s| s.as_object()) else { + return; + }; + for (name, command) in scripts { + // Event hooks (pre-install-cmd, post-update-cmd...) are not meant to be run by hand. + if name.starts_with("pre-") || name.starts_with("post-") { + continue; + } + let description = match command { + serde_json::Value::String(s) => Some(s.clone()), + _ => None, + }; + out.add( + name, + "composer.json", + argv(&["composer", "run-script", name]), + description, + false, + ); + } +} + +fn cargo_aliases(out: &mut Collector<'_>, file: &str) { + let Some(table) = read(&out.dir.join(file)).and_then(|s| s.parse::().ok()) else { + return; + }; + let Some(aliases) = table.get("alias").and_then(|a| a.as_table()) else { + return; + }; + for (name, value) in aliases { + let description = match value { + toml::Value::String(s) => Some(format!("cargo {s}")), + toml::Value::Array(parts) => Some(format!( + "cargo {}", + parts + .iter() + .filter_map(|p| p.as_str()) + .collect::>() + .join(" ") + )), + _ => None, + }; + out.add( + name, + "cargo alias", + argv(&["cargo", name]), + description, + false, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &Path, name: &str, contents: &str) { + let path = dir.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + fn names(tasks: &[Task]) -> Vec<&str> { + tasks.iter().map(|t| t.name.as_str()).collect() + } + + #[test] + fn justfile_recipes_skip_private_settings_and_variables() { + let source = r#" +set shell := ["bash", "-c"] +version := "1.0" + +# Build everything +build *args: + cargo build {{args}} + +[private] +helper: + echo hi + +_hidden: + true + +[doc('Run the tests')] +@test: build + cargo test +alias t := test +"#; + assert_eq!( + justfile_recipes(source), + vec![ + ("build".to_owned(), Some("Build everything".to_owned())), + ("test".to_owned(), Some("Run the tests".to_owned())), + ] + ); + } + + #[test] + fn makefile_targets_with_descriptions() { + let source = "\ +CC := cc +.PHONY: all test +all: build ## Build it all +# Run tests +test: +\t./run-tests +%.o: %.c +\t$(CC) -c $< +docs/site: docs +lint fmt: +"; + assert_eq!( + makefile_targets(source), + vec![ + ("all".to_owned(), Some("Build it all".to_owned())), + ("test".to_owned(), Some("Run tests".to_owned())), + ("docs/site".to_owned(), None), + ("lint".to_owned(), None), + ("fmt".to_owned(), None), + ] + ); + } + + #[test] + fn jsonc_comments_are_stripped() { + let source = + "{ // tasks\n \"tasks\": { \"dev\": \"deno run a.ts // not a comment\" } /* end */ }"; + let json: serde_json::Value = serde_json::from_str(&strip_json_comments(source)).unwrap(); + assert_eq!(json["tasks"]["dev"], "deno run a.ts // not a comment"); + } + + #[test] + fn node_project_uses_its_package_manager() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "package.json", + r#"{"scripts":{"build":"tsc","pretest":"x","test":"vitest","prepare":"husky"}}"#, + ); + write(dir.path(), "pnpm-lock.yaml", ""); + let registry = Registry::builtin(); + let node = registry.find("node").unwrap(); + let tasks = discover(dir.path(), &[node], ®istry); + let build = tasks.iter().find(|t| t.name == "build").unwrap(); + assert_eq!(build.command_line(), "pnpm run build"); + assert_eq!(build.description.as_deref(), Some("tsc")); + assert!( + !names(&tasks).contains(&"pretest"), + "hook of an existing script" + ); + assert!( + names(&tasks).contains(&"prepare"), + "not a hook: no `pare` script" + ); + let install = tasks.iter().find(|t| t.name == "install").unwrap(); + assert!(install.standard); + assert_eq!(install.command_line(), "pnpm install"); + } + + #[test] + fn package_manager_field_wins_and_members_use_the_root_lockfile() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "yarn.lock", ""); + write(dir.path(), "packages/a/package.json", "{}"); + assert_eq!(node_package_manager(&dir.path().join("packages/a")), "yarn"); + write( + dir.path(), + "packages/b/package.json", + r#"{"packageManager":"bun@1.1.0"}"#, + ); + assert_eq!(node_package_manager(&dir.path().join("packages/b")), "bun"); + } + + #[test] + fn project_scripts_shadow_standard_tasks() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "Cargo.toml", "[package]\nname = \"x\"\n"); + write(dir.path(), "justfile", "test:\n cargo nextest run\n"); + write( + dir.path(), + ".cargo/config.toml", + "[alias]\nxtask = \"run -p xtask --\"\n", + ); + let registry = Registry::builtin(); + let rust = registry.find("rust").unwrap(); + let tasks = discover(dir.path(), &[rust], ®istry); + let test = tasks.iter().find(|t| t.name == "test").unwrap(); + assert_eq!(test.command_line(), "just test"); + assert!(!test.standard); + let xtask = tasks.iter().find(|t| t.name == "xtask").unwrap(); + assert_eq!(xtask.command_line(), "cargo xtask"); + assert!(tasks.iter().any(|t| t.name == "clippy" && t.standard)); + } + + #[test] + fn standard_tasks_respect_when_and_unless() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "build.gradle.kts", ""); + let registry = Registry::builtin(); + let gradle = registry.find("gradle").unwrap(); + let build = |tasks: Vec| { + tasks + .into_iter() + .find(|t| t.name == "build") + .unwrap() + .command_line() + }; + assert_eq!( + build(discover(dir.path(), &[gradle], ®istry)), + "gradle build" + ); + write(dir.path(), "gradlew", ""); + assert_eq!( + build(discover(dir.path(), &[gradle], ®istry)), + "./gradlew build" + ); + } + + #[test] + fn taskfile_mise_pyproject_and_composer() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "Taskfile.yml", + "version: '3'\ntasks:\n dev:\n desc: Start dev\n cmds: [go run .]\n secret:\n internal: true\n", + ); + write( + dir.path(), + "mise.toml", + "[tasks.lint]\ndescription = \"Lint all\"\nrun = \"eslint .\"\n", + ); + write( + dir.path(), + "pyproject.toml", + "[tool.poe.tasks]\nserve = \"uvicorn app:app\"\n[tool.pdm.scripts]\nmigrate = { cmd = \"alembic upgrade head\" }\n", + ); + write( + dir.path(), + "composer.json", + r#"{"scripts":{"test":"phpunit","post-install-cmd":"x"}}"#, + ); + let tasks = discover(dir.path(), &[], &Registry::builtin()); + let lines: Vec<(String, String)> = tasks + .iter() + .map(|t| (t.name.clone(), t.command_line())) + .collect(); + for expected in [ + ("dev", "task dev"), + ("lint", "mise run lint"), + ("serve", "poe serve"), + ("migrate", "pdm run migrate"), + ("test", "composer run-script test"), + ] { + assert!( + lines.contains(&(expected.0.to_owned(), expected.1.to_owned())), + "{expected:?} in {lines:?}" + ); + } + assert!(!names(&tasks).contains(&"secret")); + assert!(!names(&tasks).contains(&"post-install-cmd")); + } + + #[test] + fn command_lines_quote_when_needed() { + let task = Task { + name: "x".into(), + source: "s".into(), + program: "go".into(), + args: vec!["test".into(), "./...".into(), "it's".into()], + description: None, + cwd: PathBuf::new(), + standard: true, + }; + assert_eq!(task.command_line(), r"go test ./... 'it'\''s'"); + } +} diff --git a/crates/carwash/Cargo.toml b/crates/carwash/Cargo.toml index 91fdb49..f36d9fe 100644 --- a/crates/carwash/Cargo.toml +++ b/crates/carwash/Cargo.toml @@ -36,6 +36,7 @@ ratatui.workspace = true tui-input.workspace = true nucleo-matcher.workspace = true fs4.workspace = true +globset.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/carwash/src/cli.rs b/crates/carwash/src/cli.rs index 4a9454e..d1d89dd 100644 --- a/crates/carwash/src/cli.rs +++ b/crates/carwash/src/cli.rs @@ -54,6 +54,10 @@ pub enum Command { Scan(ScanArgs), /// Delete artifacts, after showing the plan and asking for confirmation. Clean(CleanArgs), + /// List project tasks: package.json scripts, just/make/Taskfile/mise targets, standard commands. + Tasks(TasksArgs), + /// Run a task in every project that has it. + Run(RunArgs), /// List the ecosystems carwash recognises. Ecosystems(JsonArgs), /// Show how much space carwash has reclaimed. @@ -201,6 +205,63 @@ pub struct CleanArgs { pub json: bool, } +#[derive(Debug, Args)] +pub struct TasksArgs { + #[command(flatten)] + pub walk: WalkArgs, + + /// Only projects of these ecosystems. + #[arg( + long = "ecosystem", + short = 'e', + value_name = "ID", + value_delimiter = ',' + )] + pub ecosystems: Vec, + + /// List every project's tasks instead of a summary. + #[arg(long)] + pub all: bool, + + /// Machine-readable output. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct RunArgs { + /// Task name, e.g. `test` or `build`. + pub task: String, + + #[command(flatten)] + pub walk: WalkArgs, + + /// Only projects of these ecosystems. + #[arg( + long = "ecosystem", + short = 'e', + value_name = "ID", + value_delimiter = ',' + )] + pub ecosystems: Vec, + + /// Only projects whose path (relative to PATH) matches this glob (repeatable). + #[arg(long, value_name = "GLOB")] + pub filter: Vec, + + /// Projects to run at once; output is prefixed when above 1. + #[arg(long, short = 'j', value_name = "N", default_value_t = 1)] + pub jobs: usize, + + /// Stop starting new projects after the first failure. + #[arg(long)] + pub fail_fast: bool, + + /// Print the commands without running them. + #[arg(long)] + pub dry_run: bool, +} + #[derive(Debug, Args)] pub struct JsonArgs { /// Machine-readable output. diff --git a/crates/carwash/src/commands/mod.rs b/crates/carwash/src/commands/mod.rs index daabea4..61ccbc1 100644 --- a/crates/carwash/src/commands/mod.rs +++ b/crates/carwash/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod clean; pub mod info; pub mod scan; +pub mod tasks; use anstyle::{AnsiColor, Style}; use carwash_core::select::Hold; diff --git a/crates/carwash/src/commands/tasks.rs b/crates/carwash/src/commands/tasks.rs new file mode 100644 index 0000000..7c4920f --- /dev/null +++ b/crates/carwash/src/commands/tasks.rs @@ -0,0 +1,426 @@ +//! `carwash tasks` and `carwash run`: project scripts across every ecosystem. + +use super::{count, display_path}; +use crate::cli::{RunArgs, TasksArgs, WalkArgs}; +use crate::context::Context; +use crate::table::{Align, Cell, Table}; +use anstyle::{AnsiColor, Style}; +use anyhow::{Context as _, Result, bail}; +use carwash_core::tasks::{self, Task}; +use carwash_core::{EcoId, Project}; +use globset::{Glob, GlobSetBuilder}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader, IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +struct ProjectTasks { + project: Project, + tasks: Vec, +} + +/// Discovers projects under the walk root (without sizes or git) and their tasks. +fn collect( + ctx: &Context, + walk: &WalkArgs, + ecosystems: &[EcoId], +) -> Result<(PathBuf, Vec)> { + let (root, options) = ctx.scan_options(walk, false, false)?; + let snapshot = ctx.run_scan(&root, &options, true); + let registry = ctx.registry(); + let mut out: Vec = snapshot + .projects + .into_iter() + .filter(|p| !p.outside_root) + .filter(|p| ecosystems.is_empty() || p.ecosystems.iter().any(|e| ecosystems.contains(e))) + .map(|project| { + let tasks = tasks::discover(&project.path, &project.ecosystems, registry); + ProjectTasks { project, tasks } + }) + .filter(|p| !p.tasks.is_empty()) + .collect(); + out.sort_by(|a, b| a.project.path.cmp(&b.project.path)); + Ok((root, out)) +} + +fn ecosystem_ids(ctx: &Context, keys: &[String]) -> Result> { + keys.iter() + .map(|key| { + ctx.registry() + .find(key) + .with_context(|| format!("unknown ecosystem `{key}` (see `carwash ecosystems`)")) + }) + .collect() +} + +#[derive(Serialize)] +struct ProjectOut<'a> { + path: &'a Path, + name: &'a str, + tasks: &'a [Task], +} + +pub fn list(ctx: &Context, args: &TasksArgs) -> Result { + let ecosystems = ecosystem_ids(ctx, &args.ecosystems)?; + let (root, projects) = collect(ctx, &args.walk, &ecosystems)?; + let mut out = anstream::stdout().lock(); + if args.json { + let list: Vec = projects + .iter() + .map(|p| ProjectOut { + path: &p.project.path, + name: &p.project.name, + tasks: &p.tasks, + }) + .collect(); + serde_json::to_writer_pretty(&mut out, &list)?; + writeln!(out)?; + return Ok(ExitCode::SUCCESS); + } + if projects.is_empty() { + writeln!(out, "No tasks found under {}.", root.display())?; + return Ok(ExitCode::SUCCESS); + } + let bold = Style::new().bold(); + let dim = Style::new().dimmed(); + if projects.len() == 1 || args.all { + for entry in &projects { + writeln!( + out, + "{}{}{} {}{}{}", + bold.render(), + entry.project.name, + bold.render_reset(), + dim.render(), + display_path(&entry.project.path, &root), + dim.render_reset() + )?; + let mut table = Table::new(&[ + (" TASK", Align::Left), + ("COMMAND", Align::Left), + ("SOURCE", Align::Left), + ("DESCRIPTION", Align::Left), + ]); + for task in &entry.tasks { + table.push(vec![ + Cell::styled(format!(" {}", task.name), AnsiColor::Cyan.on_default()), + Cell::new(task.command_line()), + Cell::styled(task.source.clone(), dim), + Cell::styled(task.description.clone().unwrap_or_default(), dim), + ]); + } + table.write(&mut out)?; + writeln!(out)?; + } + return Ok(ExitCode::SUCCESS); + } + + let mut by_name: BTreeMap<&str, (usize, usize)> = BTreeMap::new(); + for entry in &projects { + for task in &entry.tasks { + let counts = by_name.entry(task.name.as_str()).or_default(); + if task.standard { + counts.1 += 1; + } else { + counts.0 += 1; + } + } + } + let mut ranked: Vec<(&str, (usize, usize))> = by_name.into_iter().collect(); + ranked + .sort_by_key(|(name, (scripts, standard))| (std::cmp::Reverse(scripts + standard), *name)); + let mut table = Table::new(&[ + ("TASK", Align::Left), + ("PROJECTS", Align::Right), + ("FROM SCRIPTS", Align::Right), + ]); + for (name, (scripts, standard)) in ranked.iter().take(40) { + table.push(vec![ + Cell::styled(*name, AnsiColor::Cyan.on_default()), + Cell::new(count((scripts + standard) as u64)), + Cell::styled(count(*scripts as u64), dim), + ]); + } + table.write(&mut out)?; + writeln!( + out, + "\n{} tasks across {} projects. Run one with `carwash run `; list per project with --all.", + count(ranked.len() as u64), + count(projects.len() as u64) + )?; + Ok(ExitCode::SUCCESS) +} + +#[derive(Debug)] +struct Outcome { + label: String, + status: Result, + elapsed: Duration, +} + +pub fn run(ctx: &Context, args: &RunArgs) -> Result { + let ecosystems = ecosystem_ids(ctx, &args.ecosystems)?; + let (root, projects) = collect(ctx, &args.walk, &ecosystems)?; + let filter = if args.filter.is_empty() { + None + } else { + let mut builder = GlobSetBuilder::new(); + for pattern in &args.filter { + builder + .add(Glob::new(pattern).with_context(|| format!("invalid --filter `{pattern}`"))?); + } + Some(builder.build()?) + }; + let jobs: Vec<(String, Task)> = projects + .into_iter() + .filter(|p| { + let rel = display_path(&p.project.path, &root); + filter.as_ref().is_none_or(|f| f.is_match(&rel)) + }) + .filter_map(|p| { + let label = if p.project.path == root { + p.project.name.clone() + } else { + display_path(&p.project.path, &root) + }; + p.tasks + .into_iter() + .find(|t| t.name == args.task) + .map(|t| (label, t)) + }) + .collect(); + if jobs.is_empty() { + bail!( + "no project under {} has a task named `{}` (see `carwash tasks`)", + root.display(), + args.task + ); + } + + let mut out = anstream::stdout().lock(); + if args.dry_run { + for (label, task) in &jobs { + writeln!(out, "{label}: {}", task.command_line())?; + } + return Ok(ExitCode::SUCCESS); + } + drop(out); + + let parallel = args.jobs.max(1); + let color = std::io::stdout().is_terminal(); + let width = jobs.iter().map(|(l, _)| l.len()).max().unwrap_or(0).min(40); + let next = AtomicUsize::new(0); + let failed = AtomicBool::new(false); + let outcomes = Mutex::new(Vec::new()); + let print = Mutex::new(()); + + std::thread::scope(|scope| { + for worker in 0..parallel.min(jobs.len()) { + let (jobs, next, failed, outcomes, print) = (&jobs, &next, &failed, &outcomes, &print); + scope.spawn(move || { + loop { + if args.fail_fast && failed.load(Ordering::Relaxed) { + return; + } + let index = next.fetch_add(1, Ordering::Relaxed); + let Some((label, task)) = jobs.get(index) else { + return; + }; + let started = Instant::now(); + let status = if parallel == 1 { + run_inherited(label, task, print) + } else { + run_prefixed(label, task, width, worker, color, print) + }; + if !matches!(status, Ok(0)) { + failed.store(true, Ordering::Relaxed); + } + outcomes.lock().expect("outcomes lock").push(Outcome { + label: label.clone(), + status, + elapsed: started.elapsed(), + }); + } + }); + } + }); + + let mut outcomes = outcomes.into_inner().expect("outcomes lock"); + outcomes.sort_by(|a, b| a.label.cmp(&b.label)); + let mut out = anstream::stdout().lock(); + writeln!(out)?; + let mut table = Table::new(&[ + ("PROJECT", Align::Left), + ("RESULT", Align::Left), + ("TIME", Align::Right), + ]); + let mut failures = 0; + for outcome in &outcomes { + let result = match &outcome.status { + Ok(0) => Cell::styled("ok", AnsiColor::Green.on_default()), + Ok(code) => { + failures += 1; + Cell::styled(format!("exit {code}"), AnsiColor::Red.on_default().bold()) + } + Err(error) => { + failures += 1; + Cell::styled(error.clone(), AnsiColor::Red.on_default().bold()) + } + }; + table.push(vec![ + Cell::new(outcome.label.clone()), + result, + Cell::styled( + format!("{:.1}s", outcome.elapsed.as_secs_f64()), + Style::new().dimmed(), + ), + ]); + } + table.write(&mut out)?; + let skipped = jobs.len() - outcomes.len(); + let bold = Style::new().bold(); + write!( + out, + "\n{}`{}`{}: {} succeeded, {} failed", + bold.render(), + args.task, + bold.render_reset(), + outcomes.len() - failures, + failures + )?; + if skipped > 0 { + write!(out, ", {skipped} skipped (--fail-fast)")?; + } + writeln!(out)?; + Ok(if failures == 0 && skipped == 0 { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} + +fn command(task: &Task) -> Command { + let mut cmd = Command::new(&task.program); + cmd.args(&task.args) + .current_dir(&task.cwd) + .stdin(Stdio::null()); + cmd +} + +/// Sequential mode: the task owns the terminal, so colors and progress bars work natively. +fn run_inherited(label: &str, task: &Task, print: &Mutex<()>) -> Result { + { + let _guard = print.lock().expect("print lock"); + let accent = AnsiColor::Cyan.on_default().bold(); + anstream::println!( + "\n{}▶ {label}{} {}", + accent.render(), + accent.render_reset(), + task.command_line() + ); + } + command(task) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map(|s| s.code().unwrap_or(-1)) + .map_err(|e| spawn_error(task, &e)) +} + +fn spawn_error(task: &Task, error: &std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + format!("`{}` not found", task.program) + } else { + error.to_string() + } +} + +const LABEL_COLORS: [AnsiColor; 6] = [ + AnsiColor::Cyan, + AnsiColor::Magenta, + AnsiColor::Yellow, + AnsiColor::Green, + AnsiColor::Blue, + AnsiColor::BrightRed, +]; + +/// Parallel mode: lines are prefixed with the project so interleaved output stays readable. +fn run_prefixed( + label: &str, + task: &Task, + width: usize, + worker: usize, + color: bool, + print: &Mutex<()>, +) -> Result { + let mut cmd = command(task); + if color { + // Tools disable color on pipes; ask them to keep it since we pass it through. + cmd.env("FORCE_COLOR", "1") + .env("CLICOLOR_FORCE", "1") + .env("CARGO_TERM_COLOR", "always"); + } + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| spawn_error(task, &e))?; + let style = LABEL_COLORS[worker % LABEL_COLORS.len()].on_default(); + let prefix = format!( + "{}{:, + Box::new(stderr), + ] { + let prefix = &prefix; + scope.spawn(move || { + for line in BufReader::new(stream).lines().map_while(Result::ok) { + let _guard = print.lock().expect("print lock"); + anstream::println!("{prefix}{line}"); + } + }); + } + }); + child + .wait() + .map(|s| s.code().unwrap_or(-1)) + .map_err(|e| e.to_string()) +} + +fn truncate(text: &str, width: usize) -> String { + if text.chars().count() <= width { + text.to_owned() + } else { + let tail: String = text + .chars() + .rev() + .take(width.saturating_sub(1)) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("…{tail}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_labels_keep_their_end() { + assert_eq!(truncate("short", 10), "short"); + assert_eq!(truncate("apps/web/frontend", 8), "…rontend"); + } +} diff --git a/crates/carwash/src/main.rs b/crates/carwash/src/main.rs index fcd5416..2dc325b 100644 --- a/crates/carwash/src/main.rs +++ b/crates/carwash/src/main.rs @@ -76,6 +76,8 @@ fn run(cli: Cli) -> anyhow::Result { match &cli.command { Some(Command::Scan(args)) => commands::scan::run(&ctx, args), Some(Command::Clean(args)) => commands::clean::run(&ctx, args), + Some(Command::Tasks(args)) => commands::tasks::list(&ctx, args), + Some(Command::Run(args)) => commands::tasks::run(&ctx, args), Some(Command::Ecosystems(args)) => commands::info::ecosystems(&ctx, args), Some(Command::History(args)) => commands::info::history(&ctx, args), Some(Command::Completions { .. }) => unreachable!("handled above"), From 38ee097dd4e132e09672ea86a00b7b97fca3d645 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:41:10 -0400 Subject: [PATCH 09/22] feat(tui): Tasks tab with jobs in real terminals Projects on the left (mark several to fan out), their tasks below, and job output on the right, rendered from a pseudo-terminal through vt100 so colors and progress bars look like a normal shell. Jobs can be switched, stopped and cleared; quitting with running jobs asks twice. Keymap split into global, reclaim and tasks tables. --- crates/carwash/Cargo.toml | 3 + crates/carwash/src/tui/app.rs | 162 ++++++- crates/carwash/src/tui/keymap.rs | 588 +++++++++++++++--------- crates/carwash/src/tui/mod.rs | 66 +++ crates/carwash/src/tui/pty.rs | 94 ++++ crates/carwash/src/tui/query.rs | 5 + crates/carwash/src/tui/tasks.rs | 757 +++++++++++++++++++++++++++++++ crates/carwash/src/tui/view.rs | 90 +++- 8 files changed, 1507 insertions(+), 258 deletions(-) create mode 100644 crates/carwash/src/tui/pty.rs create mode 100644 crates/carwash/src/tui/tasks.rs diff --git a/crates/carwash/Cargo.toml b/crates/carwash/Cargo.toml index f36d9fe..45c3938 100644 --- a/crates/carwash/Cargo.toml +++ b/crates/carwash/Cargo.toml @@ -37,6 +37,9 @@ tui-input.workspace = true nucleo-matcher.workspace = true fs4.workspace = true globset.workspace = true +portable-pty.workspace = true +vt100.workspace = true +tui-term.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/carwash/src/tui/app.rs b/crates/carwash/src/tui/app.rs index 5892e8f..45e397c 100644 --- a/crates/carwash/src/tui/app.rs +++ b/crates/carwash/src/tui/app.rs @@ -1,14 +1,16 @@ //! Application state and the update function (Elm architecture: messages in, effects out). -use super::keymap::{self, Action}; +use super::keymap::{self, Action, Tab}; use super::query::{Fuzzy, Query}; use super::store::{self, EntryStatus, Grouping, RowKey, Rows, SortKey, Store, View}; +use super::tasks::{JobStatus, RunTarget, TasksState}; use super::theme::{Glyphs, Theme}; use carwash_core::cache::SizeCache; use carwash_core::clean::{CleanEvent, CleanItem, CleanReport, DeleteMode}; use carwash_core::history::Record; use carwash_core::select::{Hold, Policy}; -use carwash_core::{ArtifactId, Counters, Registry, ScanEvent, fmt}; +use carwash_core::tasks::Task; +use carwash_core::{ArtifactId, Counters, EcoId, Registry, ScanEvent, fmt}; use ratatui::crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, MouseEvent, MouseEventKind, }; @@ -32,6 +34,10 @@ pub enum Msg { Clean(CleanEvent), CleanFinished(CleanReport), Disk { free: u64, total: u64 }, + TasksDiscovered(PathBuf, Vec), + JobStarted { id: u64, label: String, task: Task }, + JobOutput(u64, Vec), + JobExited(u64, Result), } #[derive(Debug)] @@ -47,6 +53,17 @@ pub enum Effect { RecordHistory(Vec), Reveal(PathBuf), RefreshDisk, + DiscoverTasks { + path: PathBuf, + ecosystems: Vec, + }, + /// Runs task `name` in each target that has it, on terminals of `size` (rows, cols). + RunTask { + name: String, + targets: Vec, + size: (u16, u16), + }, + KillJob(u64), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -144,6 +161,10 @@ pub struct App { pub now: SystemTime, pub disk: Option<(u64, u64)>, pub spinner: usize, + pub tab: Tab, + pub tasks: TasksState, + /// Quit was pressed once while jobs were running. + quit_armed: bool, pub quit: bool, pub dirty: bool, } @@ -199,6 +220,9 @@ impl App { now: SystemTime::now(), disk: None, spinner: 0, + tab: Tab::Reclaim, + tasks: TasksState::default(), + quit_armed: false, quit: false, dirty: true, } @@ -209,9 +233,10 @@ impl App { self.scan.running || matches!(&self.mode, Mode::Review(r) if matches!(r.phase, ReviewPhase::Running { .. })) || self.toast.is_some() + || self.tasks.running() > 0 } - fn toast(&mut self, text: impl Into, level: Level) { + pub(crate) fn toast(&mut self, text: impl Into, level: Level) { self.toast = Some(Toast { text: text.into(), level, @@ -305,6 +330,44 @@ impl App { self.disk = Some((free, total)); Vec::new() } + Msg::TasksDiscovered(path, tasks) => { + self.tasks.tasks.insert(path, Some(tasks)); + Vec::new() + } + Msg::JobStarted { id, label, task } => { + self.tasks.add_job(id, label, task); + Vec::new() + } + Msg::JobOutput(id, bytes) => { + if let Some(job) = self.tasks.job_mut(id) { + job.parser.process(&bytes); + } + Vec::new() + } + Msg::JobExited(id, result) => { + self.tasks.finish_job(id, result); + if let Some(job) = self.tasks.jobs.iter().find(|j| j.id == id) { + let (text, level) = match &job.status { + JobStatus::Exited(0) => ( + format!("✓ {} · {} succeeded", job.label, job.task.name), + Level::Info, + ), + JobStatus::Exited(code) => ( + format!("✗ {} · {} failed (exit {code})", job.label, job.task.name), + Level::Warn, + ), + JobStatus::Failed(error) => ( + format!("✗ {} · {}: {error}", job.label, job.task.name), + Level::Warn, + ), + JobStatus::Running => (String::new(), Level::Info), + }; + if !text.is_empty() { + self.toast(text, level); + } + } + Vec::new() + } } } @@ -335,8 +398,8 @@ impl App { } Mode::Search => self.on_search_key(key), Mode::Review(_) => self.on_review_key(key), - Mode::Browse => match keymap::action_for(&key) { - Some(action) => self.on_action(action), + Mode::Browse => match keymap::action_for(self.tab, &key) { + Some(action) => self.on_global_action(action), None => { self.dirty = false; Vec::new() @@ -345,26 +408,92 @@ impl App { } } + /// Actions shared by every tab; the rest go to the current tab. + fn on_global_action(&mut self, action: Action) -> Vec { + if action != Action::Quit { + self.quit_armed = false; + } + match action { + Action::ShowReclaim => self.tab = Tab::Reclaim, + Action::ShowTasks => { + self.tab = Tab::Tasks; + return self.tasks_effects(); + } + Action::Theme => { + self.theme = self.theme.next(); + self.toast(format!("Theme: {}", self.theme.name), Level::Info); + } + Action::Help => self.mode = Mode::Help, + Action::Search => { + let current = match self.tab { + Tab::Reclaim => self.query.raw.clone(), + Tab::Tasks => self.tasks.filter.clone(), + }; + self.input = Input::new(current); + self.mode = Mode::Search; + } + Action::Quit => { + let running = self.tasks.running(); + if running > 0 && !self.quit_armed { + self.quit_armed = true; + self.toast( + format!("{running} jobs running: press q again to stop them and quit"), + Level::Warn, + ); + } else { + self.quit = true; + return vec![Effect::CancelScan]; + } + } + action => { + return match self.tab { + Tab::Reclaim => self.on_action(action), + Tab::Tasks => self.on_tasks_action(action), + }; + } + } + Vec::new() + } + fn on_search_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::Esc => { self.input.reset(); - self.set_query(String::new()); + self.apply_search(String::new()); self.mode = Mode::Browse; } KeyCode::Enter => self.mode = Mode::Browse, - KeyCode::Up => self.move_by(-1), - KeyCode::Down => self.move_by(1), + KeyCode::Up | KeyCode::Down => { + let action = if key.code == KeyCode::Up { + Action::Up + } else { + Action::Down + }; + return match self.tab { + Tab::Reclaim => self.on_action(action), + Tab::Tasks => self.on_tasks_action(action), + }; + } _ => { if self.input.handle_event(&Event::Key(key)).is_some() { let value = self.input.value().to_owned(); - self.set_query(value); + self.apply_search(value); } } } + if self.tab == Tab::Tasks { + return self.tasks_effects(); + } Vec::new() } + fn apply_search(&mut self, value: String) { + match self.tab { + Tab::Reclaim => self.set_query(value), + Tab::Tasks => self.tasks.set_filter(value), + } + } + fn set_query(&mut self, raw: String) { self.query = Query::parse(&raw, &self.registry); self.selected = 0; @@ -549,10 +678,6 @@ impl App { } } Action::Clean => self.open_review(), - Action::Search => { - self.input = Input::new(self.query.raw.clone()); - self.mode = Mode::Search; - } Action::Sort => { self.sort = self.sort.next(); self.view_changed(); @@ -562,10 +687,6 @@ impl App { self.view_changed(); } Action::Details => self.show_details = !self.show_details, - Action::Theme => { - self.theme = self.theme.next(); - self.toast(format!("Theme: {}", self.theme.name), Level::Info); - } Action::Rescan => { if self.scan.running { self.toast("A scan is already running", Level::Warn); @@ -581,11 +702,8 @@ impl App { return vec![Effect::Reveal(path)]; } } - Action::Help => self.mode = Mode::Help, - Action::Quit => { - self.quit = true; - return vec![Effect::CancelScan]; - } + // Global actions and other tabs' actions. + _ => self.dirty = false, } Vec::new() } diff --git a/crates/carwash/src/tui/keymap.rs b/crates/carwash/src/tui/keymap.rs index e1ff0dd..6831258 100644 --- a/crates/carwash/src/tui/keymap.rs +++ b/crates/carwash/src/tui/keymap.rs @@ -1,4 +1,4 @@ -//! Key bindings: one table drives both dispatch and the help screen. +//! Key bindings: tables drive both dispatch and the help screen. use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -25,33 +25,68 @@ pub enum Action { Details, Rescan, Open, + NextPane, + PreviousPane, + Run, + KillJob, + NextJob, + PreviousJob, + ClearJobs, + ShowReclaim, + ShowTasks, Theme, Help, Quit, } +/// The screen a binding applies to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Reclaim, + Tasks, +} + +impl Tab { + pub const ALL: [Tab; 2] = [Tab::Reclaim, Tab::Tasks]; + + pub fn title(self) -> &'static str { + match self { + Tab::Reclaim => "Reclaim", + Tab::Tasks => "Tasks", + } + } + + fn bindings(self) -> &'static [Binding] { + match self { + Tab::Reclaim => RECLAIM, + Tab::Tasks => TASKS, + } + } +} + /// Where a binding is listed in the help screen. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Section { Navigate, Select, View, + Jobs, General, } impl Section { - pub const ALL: [Section; 4] = [Self::Navigate, Self::Select, Self::View, Self::General]; - pub fn title(self) -> &'static str { match self { Self::Navigate => "Navigate", Self::Select => "Select & clean", Self::View => "View", + Self::Jobs => "Tasks & jobs", Self::General => "General", } } } +#[derive(Clone, Copy)] pub struct Binding { pub keys: &'static [(KeyCode, KeyModifiers)], pub label: &'static str, @@ -63,200 +98,320 @@ pub struct Binding { const NONE: KeyModifiers = KeyModifiers::NONE; const CTRL: KeyModifiers = KeyModifiers::CONTROL; -pub const BINDINGS: &[Binding] = &[ +const fn bind( + keys: &'static [(KeyCode, KeyModifiers)], + label: &'static str, + action: Action, + section: Section, + description: &'static str, +) -> Binding { Binding { - keys: &[(KeyCode::Up, NONE), (KeyCode::Char('k'), NONE)], - label: "↑ k", - action: Action::Up, - section: Section::Navigate, - description: "previous row", - }, - Binding { - keys: &[(KeyCode::Down, NONE), (KeyCode::Char('j'), NONE)], - label: "↓ j", - action: Action::Down, - section: Section::Navigate, - description: "next row", - }, - Binding { - keys: &[(KeyCode::PageUp, NONE), (KeyCode::Char('u'), CTRL)], - label: "PgUp ^u", - action: Action::PageUp, - section: Section::Navigate, - description: "page up", - }, - Binding { - keys: &[(KeyCode::PageDown, NONE), (KeyCode::Char('d'), CTRL)], - label: "PgDn ^d", - action: Action::PageDown, - section: Section::Navigate, - description: "page down", - }, - Binding { - keys: &[(KeyCode::Home, NONE), (KeyCode::Char('g'), NONE)], - label: "Home g", - action: Action::Top, - section: Section::Navigate, - description: "first row", - }, - Binding { - keys: &[(KeyCode::End, NONE), (KeyCode::Char('G'), NONE)], - label: "End G", - action: Action::Bottom, - section: Section::Navigate, - description: "last row", - }, - Binding { - keys: &[(KeyCode::Right, NONE), (KeyCode::Char('l'), NONE)], - label: "→ l", - action: Action::Expand, - section: Section::Navigate, - description: "expand", - }, - Binding { - keys: &[(KeyCode::Left, NONE), (KeyCode::Char('h'), NONE)], - label: "← h", - action: Action::Collapse, - section: Section::Navigate, - description: "collapse / go to parent", - }, - Binding { - keys: &[(KeyCode::Enter, NONE)], - label: "Enter", - action: Action::ToggleExpand, - section: Section::Navigate, - description: "toggle expand", - }, - Binding { - keys: &[(KeyCode::Char('E'), NONE)], - label: "E", - action: Action::ExpandAll, - section: Section::Navigate, - description: "expand all", - }, - Binding { - keys: &[(KeyCode::Char('C'), NONE)], - label: "C", - action: Action::CollapseAll, - section: Section::Navigate, - description: "collapse all", - }, - Binding { - keys: &[(KeyCode::Char(' '), NONE)], - label: "Space", - action: Action::Mark, - section: Section::Select, - description: "mark / unmark (directories mark their ready artifacts)", - }, - Binding { - keys: &[(KeyCode::Char('a'), NONE)], - label: "a", - action: Action::MarkAll, - section: Section::Select, - description: "mark every ready artifact shown", - }, - Binding { - keys: &[(KeyCode::Char('A'), NONE)], - label: "A", - action: Action::Unmark, - section: Section::Select, - description: "unmark everything", - }, - Binding { - keys: &[ - (KeyCode::Char('d'), NONE), - (KeyCode::Char('x'), NONE), + keys, + label, + action, + section, + description, + } +} + +use Action as A; +use KeyCode::{Char, Down, End, Enter, Home, Left, PageDown, PageUp, Right, Tab as TabKey, Up}; +use Section as S; + +const MOVE: [Binding; 6] = [ + bind( + &[(Up, NONE), (Char('k'), NONE)], + "↑ k", + A::Up, + S::Navigate, + "previous row", + ), + bind( + &[(Down, NONE), (Char('j'), NONE)], + "↓ j", + A::Down, + S::Navigate, + "next row", + ), + bind( + &[(PageUp, NONE), (Char('u'), CTRL)], + "PgUp ^u", + A::PageUp, + S::Navigate, + "page up", + ), + bind( + &[(PageDown, NONE), (Char('d'), CTRL)], + "PgDn ^d", + A::PageDown, + S::Navigate, + "page down", + ), + bind( + &[(Home, NONE), (Char('g'), NONE)], + "Home g", + A::Top, + S::Navigate, + "first row", + ), + bind( + &[(End, NONE), (Char('G'), NONE)], + "End G", + A::Bottom, + S::Navigate, + "last row", + ), +]; + +pub const GLOBAL: &[Binding] = &[ + bind( + &[(Char('1'), NONE)], + "1", + A::ShowReclaim, + S::General, + "reclaim space", + ), + bind(&[(Char('2'), NONE)], "2", A::ShowTasks, S::General, "tasks"), + bind( + &[(Char('t'), NONE)], + "t", + A::Theme, + S::General, + "next theme", + ), + bind( + &[(Char('?'), NONE), (KeyCode::F(1), NONE)], + "?", + A::Help, + S::General, + "help", + ), + bind( + &[(Char('q'), NONE), (Char('c'), CTRL)], + "q ^c", + A::Quit, + S::General, + "quit", + ), +]; + +pub const RECLAIM: &[Binding] = &[ + MOVE[0], + MOVE[1], + MOVE[2], + MOVE[3], + MOVE[4], + MOVE[5], + bind( + &[(Right, NONE), (Char('l'), NONE)], + "→ l", + A::Expand, + S::Navigate, + "expand", + ), + bind( + &[(Left, NONE), (Char('h'), NONE)], + "← h", + A::Collapse, + S::Navigate, + "collapse / go to parent", + ), + bind( + &[(Enter, NONE)], + "Enter", + A::ToggleExpand, + S::Navigate, + "toggle expand", + ), + bind( + &[(Char('E'), NONE)], + "E", + A::ExpandAll, + S::Navigate, + "expand all", + ), + bind( + &[(Char('C'), NONE)], + "C", + A::CollapseAll, + S::Navigate, + "collapse all", + ), + bind( + &[(Char(' '), NONE)], + "Space", + A::Mark, + S::Select, + "mark / unmark (directories mark their ready artifacts)", + ), + bind( + &[(Char('a'), NONE)], + "a", + A::MarkAll, + S::Select, + "mark every ready artifact shown", + ), + bind( + &[(Char('A'), NONE)], + "A", + A::Unmark, + S::Select, + "unmark everything", + ), + bind( + &[ + (Char('d'), NONE), + (Char('x'), NONE), (KeyCode::Delete, NONE), ], - label: "d x", - action: Action::Clean, - section: Section::Select, - description: "review and clean marked artifacts", - }, - Binding { - keys: &[(KeyCode::Char('/'), NONE)], - label: "/", - action: Action::Search, - section: Section::View, - description: "filter: text, eco:rust kind:deps size>1g age>30d is:ready", - }, - Binding { - keys: &[(KeyCode::Char('s'), NONE)], - label: "s", - action: Action::Sort, - section: Section::View, - description: "sort by size, age, name", - }, - Binding { - keys: &[(KeyCode::Tab, NONE)], - label: "Tab", - action: Action::Group, - section: Section::View, - description: "group as tree, projects, artifacts", - }, - Binding { - keys: &[(KeyCode::Char('i'), NONE)], - label: "i", - action: Action::Details, - section: Section::View, - description: "toggle details panel", - }, - Binding { - keys: &[(KeyCode::Char('t'), NONE)], - label: "t", - action: Action::Theme, - section: Section::View, - description: "next theme", - }, - Binding { - keys: &[(KeyCode::Char('r'), NONE)], - label: "r", - action: Action::Rescan, - section: Section::General, - description: "rescan", - }, - Binding { - keys: &[(KeyCode::Char('o'), NONE)], - label: "o", - action: Action::Open, - section: Section::General, - description: "reveal in file manager", - }, - Binding { - keys: &[(KeyCode::Char('?'), NONE), (KeyCode::F(1), NONE)], - label: "?", - action: Action::Help, - section: Section::General, - description: "help", - }, - Binding { - keys: &[(KeyCode::Char('q'), NONE), (KeyCode::Char('c'), CTRL)], - label: "q ^c", - action: Action::Quit, - section: Section::General, - description: "quit", - }, + "d x", + A::Clean, + S::Select, + "review and clean marked artifacts", + ), + bind( + &[(Char('/'), NONE)], + "/", + A::Search, + S::View, + "filter: text, eco:rust kind:deps size>1g age>30d is:ready", + ), + bind( + &[(Char('s'), NONE)], + "s", + A::Sort, + S::View, + "sort by size, age, name", + ), + bind( + &[(TabKey, NONE)], + "Tab", + A::Group, + S::View, + "group as tree, projects, artifacts", + ), + bind( + &[(Char('i'), NONE)], + "i", + A::Details, + S::View, + "toggle details panel", + ), + bind(&[(Char('r'), NONE)], "r", A::Rescan, S::View, "rescan"), + bind( + &[(Char('o'), NONE)], + "o", + A::Open, + S::View, + "reveal in file manager", + ), +]; + +pub const TASKS: &[Binding] = &[ + MOVE[0], + MOVE[1], + MOVE[2], + MOVE[3], + MOVE[4], + MOVE[5], + bind( + &[(TabKey, NONE), (Right, NONE), (Char('l'), NONE)], + "Tab → l", + A::NextPane, + S::Navigate, + "next pane", + ), + bind( + &[ + (KeyCode::BackTab, KeyModifiers::SHIFT), + (KeyCode::BackTab, NONE), + (Left, NONE), + (Char('h'), NONE), + ], + "⇧Tab ← h", + A::PreviousPane, + S::Navigate, + "previous pane", + ), + bind( + &[(Char('/'), NONE)], + "/", + A::Search, + S::Navigate, + "filter projects", + ), + bind( + &[(Char(' '), NONE)], + "Space", + A::Mark, + S::Jobs, + "mark project (run a task in all marked)", + ), + bind( + &[(Char('A'), NONE)], + "A", + A::Unmark, + S::Jobs, + "unmark all projects", + ), + bind( + &[(Enter, NONE)], + "Enter", + A::Run, + S::Jobs, + "run the selected task", + ), + bind( + &[(Char('x'), NONE)], + "x", + A::KillJob, + S::Jobs, + "stop the shown job", + ), + bind(&[(Char(']'), NONE)], "]", A::NextJob, S::Jobs, "next job"), + bind( + &[(Char('['), NONE)], + "[", + A::PreviousJob, + S::Jobs, + "previous job", + ), + bind( + &[(Char('c'), NONE)], + "c", + A::ClearJobs, + S::Jobs, + "clear finished jobs", + ), + bind( + &[(Char('o'), NONE)], + "o", + A::Open, + S::Jobs, + "reveal project in file manager", + ), ]; -pub fn action_for(key: &KeyEvent) -> Option { +/// Bindings shown in the help screen for `tab`, in display order. +pub fn help(tab: Tab) -> impl Iterator { + tab.bindings().iter().chain(GLOBAL) +} + +fn normalize(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) { // Terminals disagree on whether shifted characters carry SHIFT; ignore it for chars. - let modifiers = match key.code { - KeyCode::Char(_) => key.modifiers - KeyModifiers::SHIFT, - _ => key.modifiers, - }; - BINDINGS.iter().find_map(|binding| { - binding - .keys - .iter() - .any(|&(code, mods)| { - let mods = match code { - KeyCode::Char(_) => mods - KeyModifiers::SHIFT, - _ => mods, - }; - code == key.code && mods == modifiers - }) - .then_some(binding.action) - }) + match code { + Char(_) => (code, modifiers - KeyModifiers::SHIFT), + _ => (code, modifiers), + } +} + +pub fn action_for(tab: Tab, key: &KeyEvent) -> Option { + let pressed = normalize(key.code, key.modifiers); + tab.bindings() + .iter() + .chain(GLOBAL) + .find(|b| b.keys.iter().any(|&(c, m)| normalize(c, m) == pressed)) + .map(|b| b.action) } #[cfg(test)] @@ -269,43 +424,38 @@ mod tests { #[test] fn dispatch() { + let r = Tab::Reclaim; + assert_eq!(action_for(r, &key(Char('j'), NONE)), Some(A::Down)); assert_eq!( - action_for(&key(KeyCode::Char('j'), NONE)), - Some(Action::Down) - ); - assert_eq!( - action_for(&key(KeyCode::Char('G'), KeyModifiers::SHIFT)), - Some(Action::Bottom) - ); - assert_eq!( - action_for(&key(KeyCode::Char('G'), NONE)), - Some(Action::Bottom) - ); - assert_eq!( - action_for(&key(KeyCode::Char('c'), CTRL)), - Some(Action::Quit) + action_for(r, &key(Char('G'), KeyModifiers::SHIFT)), + Some(A::Bottom) ); + assert_eq!(action_for(r, &key(Char('c'), CTRL)), Some(A::Quit)); + assert_eq!(action_for(r, &key(Char('d'), CTRL)), Some(A::PageDown)); + assert_eq!(action_for(r, &key(Char('d'), NONE)), Some(A::Clean)); + assert_eq!(action_for(r, &key(TabKey, NONE)), Some(A::Group)); assert_eq!( - action_for(&key(KeyCode::Char('d'), CTRL)), - Some(Action::PageDown) + action_for(Tab::Tasks, &key(TabKey, NONE)), + Some(A::NextPane) ); assert_eq!( - action_for(&key(KeyCode::Char('d'), NONE)), - Some(Action::Clean) + action_for(Tab::Tasks, &key(Char('2'), NONE)), + Some(A::ShowTasks) ); - assert_eq!(action_for(&key(KeyCode::Char('z'), NONE)), None); + assert_eq!(action_for(r, &key(Char('z'), NONE)), None); } #[test] - fn no_key_is_bound_twice() { - let mut seen = std::collections::HashSet::new(); - for binding in BINDINGS { - for &(code, mods) in binding.keys { - let mods = match code { - KeyCode::Char(_) => mods - KeyModifiers::SHIFT, - _ => mods, - }; - assert!(seen.insert((code, mods)), "{code:?} {mods:?} bound twice"); + fn no_key_is_bound_twice_in_a_tab() { + for tab in Tab::ALL { + let mut seen = std::collections::HashSet::new(); + for binding in help(tab) { + for &(code, mods) in binding.keys { + assert!( + seen.insert(normalize(code, mods)), + "{code:?} {mods:?} bound twice in {tab:?}" + ); + } } } } diff --git a/crates/carwash/src/tui/mod.rs b/crates/carwash/src/tui/mod.rs index 65b274a..e71add1 100644 --- a/crates/carwash/src/tui/mod.rs +++ b/crates/carwash/src/tui/mod.rs @@ -5,8 +5,10 @@ mod app; mod keymap; +mod pty; mod query; mod store; +mod tasks; mod theme; mod view; @@ -18,6 +20,7 @@ use carwash_core::{Cancel, Counters, ScanOptions, history}; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event}; use ratatui::crossterm::execute; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -62,6 +65,9 @@ pub fn run(ctx: &Context, root: PathBuf, options: ScanOptions) -> Result<()> { tx, scan_cancel: None, clean_cancel: None, + jobs: HashMap::new(), + next_job: 0, + pty_size: (24, 80), }; runtime.execute(Effect::Scan); runtime.execute(Effect::RefreshDisk); @@ -91,6 +97,7 @@ fn event_loop( if app.dirty { terminal.draw(|frame| view::render(frame, app))?; app.dirty = false; + runtime.resize_jobs(app.tasks.pty_size); } let timeout = if app.animating() { FAST_TICK @@ -149,6 +156,9 @@ struct Runtime<'a> { tx: Sender, scan_cancel: Option, clean_cancel: Option, + jobs: HashMap, + next_job: u64, + pty_size: (u16, u16), } impl Runtime<'_> { @@ -159,6 +169,19 @@ impl Runtime<'_> { { cancel.cancel(); } + for (_, job) in self.jobs.drain() { + job.kill(); + } + } + + /// Keeps every job's terminal the size of the output pane. + fn resize_jobs(&mut self, size: (u16, u16)) { + if size != self.pty_size { + self.pty_size = size; + for job in self.jobs.values() { + job.resize(size); + } + } } fn execute(&mut self, effect: Effect) { @@ -219,6 +242,49 @@ impl Runtime<'_> { } } Effect::Reveal(path) => reveal(&path), + Effect::DiscoverTasks { path, ecosystems } => { + let (registry, tx) = (self.ctx.engine.registry().clone(), self.tx.clone()); + std::thread::spawn(move || { + let tasks = carwash_core::tasks::discover(&path, &ecosystems, ®istry); + let _ = tx.send(Msg::TasksDiscovered(path, tasks)); + }); + } + Effect::RunTask { + name, + targets, + size, + } => { + let registry = self.ctx.engine.registry().clone(); + for target in targets { + let Some(task) = + carwash_core::tasks::discover(&target.path, &target.ecosystems, ®istry) + .into_iter() + .find(|t| t.name == name) + else { + continue; + }; + let id = self.next_job; + self.next_job += 1; + let _ = self.tx.send(Msg::JobStarted { + id, + label: target.label, + task: task.clone(), + }); + match pty::spawn(id, &task, size, self.tx.clone()) { + Ok(job) => { + self.jobs.insert(id, job); + } + Err(error) => { + let _ = self.tx.send(Msg::JobExited(id, Err(error))); + } + } + } + } + Effect::KillJob(id) => { + if let Some(job) = self.jobs.remove(&id) { + job.kill(); + } + } Effect::RefreshDisk => { let (root, tx) = (self.root.clone(), self.tx.clone()); std::thread::spawn(move || { diff --git a/crates/carwash/src/tui/pty.rs b/crates/carwash/src/tui/pty.rs new file mode 100644 index 0000000..6858be4 --- /dev/null +++ b/crates/carwash/src/tui/pty.rs @@ -0,0 +1,94 @@ +//! Tasks run in pseudo-terminals, so tools keep their colors, progress bars and layout. + +use super::app::Msg; +use carwash_core::tasks::Task; +use crossbeam_channel::Sender; +use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use std::io::Read; + +pub struct PtyJob { + master: Box, + killer: Box, +} + +fn size((rows, cols): (u16, u16)) -> PtySize { + PtySize { + rows: rows.max(2), + cols: cols.max(10), + pixel_width: 0, + pixel_height: 0, + } +} + +/// Starts `task` on a new terminal; output and exit arrive as [`Msg::JobOutput`] and +/// [`Msg::JobExited`]. +pub fn spawn( + id: u64, + task: &Task, + dimensions: (u16, u16), + tx: Sender, +) -> Result { + let pair = native_pty_system() + .openpty(size(dimensions)) + .map_err(|e| format!("cannot open a terminal: {e}"))?; + let mut command = CommandBuilder::new(&task.program); + command.args(&task.args); + command.cwd(&task.cwd); + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + let mut child = pair.slave.spawn_command(command).map_err(|e| { + let message = e.to_string(); + if message.contains("No such file") || message.contains("not found") { + format!("`{}` not found", task.program) + } else { + message + } + })?; + // The child holds its own handle to the terminal; ours would keep reads from ending. + drop(pair.slave); + let killer = child.clone_killer(); + let mut reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("cannot read the terminal: {e}"))?; + + let output = tx.clone(); + std::thread::spawn(move || { + let mut buffer = [0u8; 16 * 1024]; + loop { + match reader.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(n) => { + if output + .send(Msg::JobOutput(id, buffer[..n].to_vec())) + .is_err() + { + break; + } + } + } + } + }); + std::thread::spawn(move || { + let result = child + .wait() + .map(|status| i32::try_from(status.exit_code()).unwrap_or(-1)) + .map_err(|e| e.to_string()); + let _ = tx.send(Msg::JobExited(id, result)); + }); + Ok(PtyJob { + master: pair.master, + killer, + }) +} + +impl PtyJob { + pub fn resize(&self, dimensions: (u16, u16)) { + let _ = self.master.resize(size(dimensions)); + } + + /// Stops the task. Dropping the terminal afterwards hangs up its whole process group. + pub fn kill(mut self) { + let _ = self.killer.kill(); + } +} diff --git a/crates/carwash/src/tui/query.rs b/crates/carwash/src/tui/query.rs index 9df9086..7332c2a 100644 --- a/crates/carwash/src/tui/query.rs +++ b/crates/carwash/src/tui/query.rs @@ -220,6 +220,11 @@ impl Fuzzy { .score(Utf32Str::new(haystack, &mut self.buf), &mut self.matcher) .is_some() } + + /// Plain fuzzy match, for lists without facets. + pub fn matches_text(&mut self, pattern: &Pattern, haystack: &str) -> bool { + self.matches(pattern, haystack) + } } #[cfg(test)] diff --git a/crates/carwash/src/tui/tasks.rs b/crates/carwash/src/tui/tasks.rs new file mode 100644 index 0000000..6febf59 --- /dev/null +++ b/crates/carwash/src/tui/tasks.rs @@ -0,0 +1,757 @@ +//! The Tasks tab: pick projects, run their scripts, watch output in real terminals. + +use super::app::{App, Effect, Level}; +use super::keymap::Action; +use super::query::Fuzzy; +use super::theme::Theme; +use carwash_core::tasks::Task; +use carwash_core::{EcoId, ProjectId}; +use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Modifier; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tui_term::widget::PseudoTerminal; + +/// Lines of scrollback kept per job. +const SCROLLBACK: usize = 10_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pane { + Projects, + Tasks, + Output, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JobStatus { + Running, + Exited(i32), + Failed(String), +} + +pub struct Job { + pub id: u64, + pub label: String, + pub task: Task, + pub parser: vt100::Parser, + pub status: JobStatus, + started: Instant, + pub elapsed: Option, +} + +impl std::fmt::Debug for Job { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Job") + .field("id", &self.id) + .field("label", &self.label) + .field("status", &self.status) + .finish_non_exhaustive() + } +} + +impl Job { + pub fn duration(&self) -> Duration { + self.elapsed.unwrap_or_else(|| self.started.elapsed()) + } +} + +/// A task run request, resolved by the runtime (which discovers the task in each project). +#[derive(Debug, Clone)] +pub struct RunTarget { + pub label: String, + pub path: PathBuf, + pub ecosystems: Vec, +} + +#[derive(Debug)] +pub struct TasksState { + pub focus: Pane, + pub filter: String, + /// Projects shown, in display order. + pub projects: Vec, + built_for: Option<(u64, String)>, + pub project_cursor: usize, + project_key: Option, + pub marked: HashSet, + /// Discovered tasks per project path; `None` while loading. + pub tasks: HashMap>>, + pub task_cursor: usize, + pub jobs: Vec, + pub active: usize, + /// Terminal size given to jobs, from the output pane. + pub pty_size: (u16, u16), + fuzzy: Fuzzy, +} + +impl Default for TasksState { + fn default() -> Self { + Self { + focus: Pane::Projects, + filter: String::new(), + projects: Vec::new(), + built_for: None, + project_cursor: 0, + project_key: None, + marked: HashSet::new(), + tasks: HashMap::new(), + task_cursor: 0, + jobs: Vec::new(), + active: 0, + pty_size: (24, 80), + fuzzy: Fuzzy::default(), + } + } +} + +impl TasksState { + pub fn running(&self) -> usize { + self.jobs + .iter() + .filter(|j| j.status == JobStatus::Running) + .count() + } + + pub fn job_mut(&mut self, id: u64) -> Option<&mut Job> { + self.jobs.iter_mut().find(|j| j.id == id) + } + + pub fn add_job(&mut self, id: u64, label: String, task: Task) { + let (rows, cols) = self.pty_size; + self.jobs.push(Job { + id, + label, + task, + parser: vt100::Parser::new(rows.max(2), cols.max(10), SCROLLBACK), + status: JobStatus::Running, + started: Instant::now(), + elapsed: None, + }); + self.active = self.jobs.len() - 1; + } + + pub fn finish_job(&mut self, id: u64, result: Result) { + if let Some(job) = self.job_mut(id) { + job.elapsed = Some(job.started.elapsed()); + job.status = match result { + Ok(code) => JobStatus::Exited(code), + Err(error) => { + let message = format!("\r\n\x1b[31m{error}\x1b[0m\r\n"); + job.parser.process(message.as_bytes()); + JobStatus::Failed(error) + } + }; + } + } + + pub fn set_filter(&mut self, filter: String) { + self.filter = filter; + self.project_cursor = 0; + self.project_key = None; + } +} + +impl App { + /// Rebuilds the project list when projects arrive or the filter changes. + pub fn ensure_task_projects(&mut self) { + let stamp = (self.store.revision, self.tasks.filter.clone()); + if self.tasks.built_for.as_ref() == Some(&stamp) { + return; + } + let pattern = (!self.tasks.filter.trim().is_empty()).then(|| { + Pattern::parse( + &self.tasks.filter, + CaseMatching::Smart, + Normalization::Smart, + ) + }); + let mut projects: Vec<(String, ProjectId)> = self + .store + .projects + .iter() + .map(|p| (self.store.relative(&p.path).into_owned(), p)) + .filter(|(rel, p)| { + pattern.as_ref().is_none_or(|pattern| { + self.tasks + .fuzzy + .matches_text(pattern, &format!("{rel} {}", p.name)) + }) + }) + .map(|(rel, p)| (rel, p.id)) + .collect(); + projects.sort(); + self.tasks.projects = projects.into_iter().map(|(_, id)| id).collect(); + self.tasks.built_for = Some(stamp); + if let Some(position) = self + .tasks + .project_key + .and_then(|key| self.tasks.projects.iter().position(|&p| p == key)) + { + self.tasks.project_cursor = position; + } + self.tasks.project_cursor = self + .tasks + .project_cursor + .min(self.tasks.projects.len().saturating_sub(1)); + } + + fn current_project(&self) -> Option { + self.tasks.projects.get(self.tasks.project_cursor).copied() + } + + /// Tasks of the current project; requests discovery when not yet known. + fn current_tasks(&mut self) -> (Option<&[Task]>, Option) { + let Some(project) = self.current_project().and_then(|id| self.store.project(id)) else { + return (None, None); + }; + let (path, ecosystems) = (project.path.clone(), project.ecosystems.clone()); + match self.tasks.tasks.entry(path) { + std::collections::hash_map::Entry::Occupied(entry) => { + (entry.into_mut().as_deref(), None) + } + std::collections::hash_map::Entry::Vacant(entry) => { + let effect = Effect::DiscoverTasks { + path: entry.key().clone(), + ecosystems, + }; + entry.insert(None); + (None, Some(effect)) + } + } + } + + /// Effects needed to show the current screen (task discovery for the selected project). + pub fn tasks_effects(&mut self) -> Vec { + self.ensure_task_projects(); + self.current_tasks().1.into_iter().collect() + } + + fn target(&self, id: ProjectId) -> Option { + let project = self.store.project(id)?; + let label = if project.path == self.store.root { + project.name.clone() + } else { + self.store.relative(&project.path).into_owned() + }; + Some(RunTarget { + label, + path: project.path.clone(), + ecosystems: project.ecosystems.clone(), + }) + } + + pub fn on_tasks_action(&mut self, action: Action) -> Vec { + self.ensure_task_projects(); + let tasks_len = self.current_tasks().0.map_or(0, <[Task]>::len); + let page = self.page.max(1); + let state = &mut self.tasks; + let mut move_cursor = |delta: isize, to: Option| match state.focus { + Pane::Projects => { + let last = state.projects.len().saturating_sub(1); + state.project_cursor = to + .unwrap_or_else(|| state.project_cursor.saturating_add_signed(delta)) + .min(last); + state.project_key = state.projects.get(state.project_cursor).copied(); + state.task_cursor = 0; + } + Pane::Tasks => { + let last = tasks_len.saturating_sub(1); + state.task_cursor = to + .unwrap_or_else(|| state.task_cursor.saturating_add_signed(delta)) + .min(last); + } + Pane::Output => { + if let Some(job) = state.jobs.get_mut(state.active) { + let screen = job.parser.screen_mut(); + let current = screen.scrollback(); + let target = match to { + Some(0) => usize::MAX, + Some(_) => 0, + None => current.saturating_add_signed(-delta), + }; + screen.set_scrollback(target); + } + } + }; + match action { + Action::Up => move_cursor(-1, None), + Action::Down => move_cursor(1, None), + Action::PageUp => move_cursor(-(page as isize), None), + Action::PageDown => move_cursor(page as isize, None), + Action::Top => move_cursor(0, Some(0)), + Action::Bottom => move_cursor(0, Some(usize::MAX)), + Action::NextPane => { + self.tasks.focus = match self.tasks.focus { + Pane::Projects => Pane::Tasks, + Pane::Tasks => Pane::Output, + Pane::Output => Pane::Projects, + }; + } + Action::PreviousPane => { + self.tasks.focus = match self.tasks.focus { + Pane::Projects => Pane::Output, + Pane::Tasks => Pane::Projects, + Pane::Output => Pane::Tasks, + }; + } + Action::Mark => { + if let Some(id) = self.current_project() { + if !self.tasks.marked.remove(&id) { + self.tasks.marked.insert(id); + } + if self.tasks.focus == Pane::Projects { + return self.on_tasks_action(Action::Down); + } + } + } + Action::Unmark => self.tasks.marked.clear(), + Action::Run => { + if self.tasks.focus == Pane::Projects { + self.tasks.focus = Pane::Tasks; + return self.tasks_effects(); + } + return self.run_selected_task(); + } + Action::KillJob => { + if let Some(job) = self.tasks.jobs.get(self.tasks.active) + && job.status == JobStatus::Running + { + return vec![Effect::KillJob(job.id)]; + } + } + Action::NextJob => { + if !self.tasks.jobs.is_empty() { + self.tasks.active = (self.tasks.active + 1) % self.tasks.jobs.len(); + } + } + Action::PreviousJob => { + if !self.tasks.jobs.is_empty() { + self.tasks.active = self + .tasks + .active + .checked_sub(1) + .unwrap_or(self.tasks.jobs.len() - 1); + } + } + Action::ClearJobs => { + self.tasks.jobs.retain(|j| j.status == JobStatus::Running); + self.tasks.active = self + .tasks + .active + .min(self.tasks.jobs.len().saturating_sub(1)); + } + Action::Open => { + if let Some(path) = self + .current_project() + .and_then(|id| self.store.project(id)) + .map(|p| p.path.clone()) + { + return vec![Effect::Reveal(path)]; + } + } + _ => {} + } + self.tasks_effects() + } + + fn run_selected_task(&mut self) -> Vec { + let cursor = self.tasks.task_cursor; + let (tasks, effect) = self.current_tasks(); + let Some(task) = tasks.and_then(|t| t.get(cursor)).cloned() else { + return effect.into_iter().collect(); + }; + let mut ids: Vec = if self.tasks.marked.is_empty() { + self.current_project().into_iter().collect() + } else { + self.tasks.marked.iter().copied().collect() + }; + ids.sort_by_key(|id| self.store.project(*id).map(|p| p.path.clone())); + let targets: Vec = ids.into_iter().filter_map(|id| self.target(id)).collect(); + if targets.len() > 1 { + self.toast( + format!( + "Running `{}` in {} marked projects", + task.name, + targets.len() + ), + Level::Info, + ); + } + self.tasks.focus = Pane::Output; + vec![Effect::RunTask { + name: task.name, + targets, + size: self.tasks.pty_size, + }] + } +} + +fn pane_block<'a>(theme: &Theme, title: String, focused: bool) -> Block<'a> { + let color = if focused { theme.accent } else { theme.border }; + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme.fg(color)) + .title(Span::styled( + format!(" {title} "), + if focused { + theme.bold(theme.accent) + } else { + theme.muted() + }, + )) +} + +fn spinner(app: &App) -> &'static str { + app.glyphs.spinner[app.spinner % app.glyphs.spinner.len()] +} + +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + app.ensure_task_projects(); + let t = app.theme; + let [left, output] = + Layout::horizontal([Constraint::Percentage(38), Constraint::Percentage(62)]).areas(area); + let [projects_area, tasks_area] = + Layout::vertical([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(left); + + // Projects. + let marked = &app.tasks.marked; + let items: Vec = app + .tasks + .projects + .iter() + .filter_map(|&id| app.store.project(id)) + .map(|project| { + let mark = if marked.contains(&project.id) { + Span::styled(format!("{} ", app.glyphs.marked), t.bold(t.accent2)) + } else { + Span::styled(format!("{} ", app.glyphs.unmarked), t.muted()) + }; + let mut spans = vec![mark]; + for (i, &eco) in project.ecosystems.iter().take(2).enumerate() { + let ecosystem = app.registry.ecosystem(eco); + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + ecosystem.badge.clone(), + t.fg(t.ecosystem(&ecosystem.key)), + )); + } + spans.push(Span::raw(" ")); + let label = if project.path == app.store.root { + project.name.clone() + } else { + app.store.relative(&project.path).into_owned() + }; + spans.push(Span::styled(label, t.text())); + ListItem::new(Line::from(spans)) + }) + .collect(); + let mut title = format!("Projects {}", app.tasks.projects.len()); + if !app.tasks.marked.is_empty() { + title.push_str(&format!(" · {} marked", app.tasks.marked.len())); + } + if !app.tasks.filter.is_empty() { + title.push_str(&format!(" · “{}”", app.tasks.filter)); + } + let mut state = ListState::default().with_selected(Some(app.tasks.project_cursor)); + frame.render_stateful_widget( + List::new(items) + .block(pane_block( + &t, + title, + app.tasks.focus == super::tasks::Pane::Projects, + )) + .highlight_style(t.selected_row()), + projects_area, + &mut state, + ); + + // Tasks of the current project. + let focus_tasks = app.tasks.focus == Pane::Tasks; + let current = app + .tasks + .projects + .get(app.tasks.project_cursor) + .and_then(|&id| app.store.project(id)) + .map(|p| p.path.clone()); + let tasks = current + .as_ref() + .and_then(|path| app.tasks.tasks.get(path)) + .cloned() + .flatten(); + match tasks { + Some(tasks) if !tasks.is_empty() => { + let width = tasks + .iter() + .map(|t| t.name.len()) + .max() + .unwrap_or(0) + .min(24); + let items: Vec = tasks + .iter() + .map(|task| { + let detail = task + .description + .clone() + .filter(|d| *d != task.command_line()) + .unwrap_or_else(|| task.command_line()); + ListItem::new(Line::from(vec![ + Span::styled(format!("{: frame.render_widget( + Paragraph::new(Span::styled("No tasks found for this project.", t.subtle())) + .block(pane_block(&t, "Tasks".into(), focus_tasks)), + tasks_area, + ), + None => frame.render_widget( + Paragraph::new(Span::styled( + if current.is_some() { + "Looking for tasks…" + } else { + "Select a project" + }, + t.subtle(), + )) + .block(pane_block(&t, "Tasks".into(), focus_tasks)), + tasks_area, + ), + } + + render_output(frame, app, output); +} + +fn render_output(frame: &mut Frame, app: &mut App, area: Rect) { + let t = app.theme; + let focused = app.tasks.focus == Pane::Output; + let running = app.tasks.running(); + let title = if app.tasks.jobs.is_empty() { + "Output".to_string() + } else { + format!("Output · {} jobs, {running} running", app.tasks.jobs.len()) + }; + let block = pane_block(&t, title, focused); + let inner = block.inner(area); + frame.render_widget(block, area); + if app.tasks.jobs.is_empty() { + let hint = vec![ + Line::from(Span::styled( + "Pick a project, then a task, and press Enter.", + t.subtle(), + )), + Line::from(Span::styled( + "Mark several projects with Space to run a task in all of them.", + t.muted(), + )), + ]; + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), inner); + return; + } + let [strip, info, terminal] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ]) + .areas(inner); + + // Job strip: one chip per job, the active one highlighted. + let mut chips: Vec = Vec::new(); + for (i, job) in app.tasks.jobs.iter().enumerate() { + let (glyph, color) = match job.status { + JobStatus::Running => (spinner(app), t.accent), + JobStatus::Exited(0) => ("✓", t.success), + JobStatus::Exited(_) | JobStatus::Failed(_) => ("✗", t.error), + }; + let mut style = t.fg(color); + if i == app.tasks.active { + style = style.add_modifier(Modifier::REVERSED | Modifier::BOLD); + } + chips.push(Span::styled( + format!(" {glyph} {}·{} ", job.label, job.task.name), + style, + )); + chips.push(Span::raw(" ")); + } + frame.render_widget(Paragraph::new(Line::from(chips)), strip); + + let (rows, cols) = (terminal.height.max(2), terminal.width.max(10)); + app.tasks.pty_size = (rows, cols); + let active = app.tasks.active.min(app.tasks.jobs.len() - 1); + let job = &mut app.tasks.jobs[active]; + if job.parser.screen().size() != (rows, cols) { + job.parser.screen_mut().set_size(rows, cols); + } + let status = match &job.status { + JobStatus::Running => Span::styled("running", t.fg(t.accent)), + JobStatus::Exited(0) => Span::styled("succeeded", t.fg(t.success)), + JobStatus::Exited(code) => Span::styled(format!("exit {code}"), t.bold(t.error)), + JobStatus::Failed(error) => Span::styled(error.clone(), t.bold(t.error)), + }; + let scrolled = job.parser.screen().scrollback(); + let mut line = vec![ + Span::styled(format!("$ {}", job.task.command_line()), t.subtle()), + Span::styled(" ", t.muted()), + status, + Span::styled(format!(" {:.1}s", job.duration().as_secs_f64()), t.muted()), + ]; + if scrolled > 0 { + line.push(Span::styled(format!(" ↑{scrolled}"), t.fg(t.warning))); + } + frame.render_widget(Paragraph::new(Line::from(line)), info); + let mut cursor = tui_term::widget::Cursor::default(); + if job.status != JobStatus::Running || job.parser.screen().hide_cursor() { + cursor.hide(); + } + frame.render_widget( + PseudoTerminal::new(job.parser.screen()).cursor(cursor), + terminal, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::Msg; + use crate::tui::app::tests::app; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn press(app: &mut App, code: KeyCode) -> Vec { + app.update(Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))) + } + + fn task(name: &str, cwd: &str) -> Task { + Task { + name: name.into(), + source: "justfile".into(), + program: "just".into(), + args: vec![name.into()], + description: None, + cwd: PathBuf::from(cwd), + standard: false, + } + } + + #[test] + fn opening_the_tab_discovers_tasks_and_enter_runs_them() { + let mut app = app(); + let effects = press(&mut app, KeyCode::Char('2')); + let Some(Effect::DiscoverTasks { path, .. }) = effects.first() else { + panic!("discovery expected, got {effects:?}"); + }; + assert_eq!(path, &PathBuf::from("/w/a")); + app.update(Msg::TasksDiscovered( + path.clone(), + vec![task("lint", "/w/a"), task("test", "/w/a")], + )); + + press(&mut app, KeyCode::Enter); // focus the task list + press(&mut app, KeyCode::Char('j')); + let effects = press(&mut app, KeyCode::Enter); + let Some(Effect::RunTask { name, targets, .. }) = effects.first() else { + panic!("run expected, got {effects:?}"); + }; + assert_eq!(name, "test"); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].path, PathBuf::from("/w/a")); + assert_eq!(app.tasks.focus, Pane::Output); + } + + #[test] + fn marked_projects_all_receive_the_task() { + let mut app = app(); + press(&mut app, KeyCode::Char('2')); + app.update(Msg::TasksDiscovered( + PathBuf::from("/w/a"), + vec![task("test", "/w/a")], + )); + press(&mut app, KeyCode::Char(' ')); + press(&mut app, KeyCode::Char(' ')); + assert_eq!(app.tasks.marked.len(), 2); + press(&mut app, KeyCode::Char('k')); + press(&mut app, KeyCode::Tab); + let effects = press(&mut app, KeyCode::Enter); + let Some(Effect::RunTask { targets, .. }) = effects.first() else { + panic!("run expected, got {effects:?}"); + }; + assert_eq!(targets.len(), 2); + } + + #[test] + fn job_lifecycle_updates_the_screen_and_status() { + let mut app = app(); + app.update(Msg::JobStarted { + id: 7, + label: "a".into(), + task: task("test", "/w/a"), + }); + assert_eq!(app.tasks.running(), 1); + assert!(app.animating()); + app.update(Msg::JobOutput( + 7, + b"hello \x1b[32mworld\x1b[0m\r\n".to_vec(), + )); + assert!( + app.tasks.jobs[0] + .parser + .screen() + .contents() + .contains("hello world") + ); + app.update(Msg::JobExited(7, Ok(1))); + assert_eq!(app.tasks.jobs[0].status, JobStatus::Exited(1)); + assert!(app.toast.as_ref().unwrap().text.contains("failed")); + + app.update(Msg::JobStarted { + id: 8, + label: "b".into(), + task: task("x", "/w/b"), + }); + app.update(Msg::JobExited(8, Err("`just` not found".into()))); + assert!( + app.tasks.jobs[1] + .parser + .screen() + .contents() + .contains("not found") + ); + press(&mut app, KeyCode::Char('2')); + press(&mut app, KeyCode::Char('c')); + assert!(app.tasks.jobs.is_empty(), "finished jobs cleared"); + } + + #[test] + fn quitting_with_running_jobs_needs_confirmation() { + let mut app = app(); + app.update(Msg::JobStarted { + id: 1, + label: "a".into(), + task: task("dev", "/w/a"), + }); + press(&mut app, KeyCode::Char('q')); + assert!(!app.quit); + press(&mut app, KeyCode::Char('q')); + assert!(app.quit); + } +} diff --git a/crates/carwash/src/tui/view.rs b/crates/carwash/src/tui/view.rs index 5ab7328..66fb3d8 100644 --- a/crates/carwash/src/tui/view.rs +++ b/crates/carwash/src/tui/view.rs @@ -1,7 +1,7 @@ //! Rendering. Pure functions of [`App`], except for recording table geometry for the mouse. use super::app::{App, Level, Mode, Review, ReviewPhase}; -use super::keymap::{BINDINGS, Section}; +use super::keymap::{self, Section, Tab}; use super::store::{EntryStatus, Row, RowKey}; use carwash_core::clean::DeleteMode; use carwash_core::select::Hold; @@ -32,7 +32,9 @@ pub fn render(frame: &mut Frame, app: &mut App) { render_header(frame, app, header); render_toolbar(frame, app, toolbar); let details = app.show_details && body.width >= MIN_WIDTH_FOR_DETAILS; - if details { + if app.tab == Tab::Tasks { + super::tasks::render(frame, app, body); + } else if details { let [table, side] = Layout::horizontal([Constraint::Min(60), Constraint::Length(DETAILS_WIDTH)]) .areas(body); @@ -118,12 +120,39 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { fn render_toolbar(frame: &mut Frame, app: &App, area: Rect) { let t = &app.theme; - let mut left = vec![ + let mut left = vec![Span::raw(" ")]; + for (i, tab) in Tab::ALL.iter().enumerate() { + let style = if *tab == app.tab { + t.bold(t.accent).add_modifier(Modifier::REVERSED) + } else { + t.muted() + }; + left.push(Span::styled(format!(" {} {} ", i + 1, tab.title()), style)); + left.push(Span::raw(" ")); + } + if app.tab == Tab::Tasks { + let running = app.tasks.running(); + let mut right = vec![Span::styled( + format!("{} jobs", app.tasks.jobs.len()), + t.muted(), + )]; + if running > 0 { + right.push(Span::styled( + format!(" · {running} running "), + t.fg(t.accent), + )); + } else { + right.push(Span::raw(" ")); + } + split_line(frame, area, Line::from(left), Line::from(right)); + return; + } + left.extend([ Span::styled(" view ", t.muted()), Span::styled(app.grouping.label(), t.fg(t.accent)), Span::styled(" sort ", t.muted()), Span::styled(app.sort.label(), t.fg(t.accent)), - ]; + ]); if !app.query.is_empty() && !matches!(app.mode, Mode::Search) { left.push(Span::styled(" filter ", t.muted())); left.push(Span::styled(app.query.raw.clone(), t.fg(t.warning))); @@ -663,17 +692,30 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ); return; } - let hints = [ - ("space", "mark"), - ("a", "mark ready"), - ("d", "clean"), - ("/", "filter"), - ("tab", "group"), - ("s", "sort"), - ("o", "reveal"), - ("?", "help"), - ("q", "quit"), - ]; + let hints: &[(&str, &str)] = match app.tab { + Tab::Reclaim => &[ + ("space", "mark"), + ("a", "mark ready"), + ("d", "clean"), + ("/", "filter"), + ("tab", "group"), + ("s", "sort"), + ("2", "tasks"), + ("?", "help"), + ("q", "quit"), + ], + Tab::Tasks => &[ + ("enter", "run"), + ("space", "mark project"), + ("tab", "pane"), + ("x", "stop"), + ("[ ]", "jobs"), + ("/", "filter"), + ("1", "reclaim"), + ("?", "help"), + ("q", "quit"), + ], + }; let mut spans = vec![Span::raw(" ")]; for (i, (key, label)) in hints.iter().enumerate() { if i > 0 { @@ -908,9 +950,15 @@ fn render_help(frame: &mut Frame, app: &App) { .border_style(t.fg(t.accent)) .title(Span::styled(" Help ", t.bold(t.accent))); let mut lines: Vec = Vec::new(); - for section in Section::ALL { + let mut sections: Vec
= Vec::new(); + for binding in keymap::help(app.tab) { + if !sections.contains(&binding.section) { + sections.push(binding.section); + } + } + for section in sections { lines.push(Line::from(Span::styled(section.title(), t.bold(t.accent2)))); - for binding in BINDINGS.iter().filter(|b| b.section == section) { + for binding in keymap::help(app.tab).filter(|b| b.section == section) { lines.push(Line::from(vec![ Span::styled(format!(" {:<10}", binding.label), t.bold(t.text)), Span::styled(binding.description, t.subtle()), @@ -918,6 +966,14 @@ fn render_help(frame: &mut Frame, app: &App) { } lines.push(Line::default()); } + if app.tab == Tab::Tasks { + lines.push(Line::from(Span::styled( + "Press any key to close", + t.muted(), + ))); + frame.render_widget(Paragraph::new(lines).block(block), popup); + return; + } let g = &app.glyphs; lines.push(Line::from(Span::styled("Legend", t.bold(t.accent2)))); lines.push(Line::from(vec![ From 5adac6146367fe32992a93152113c81a22e0fba5 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:58:04 -0400 Subject: [PATCH 10/22] feat: dependency checks across Rust, JavaScript, Python and Go carwash-core::deps reads declared dependencies from manifests and current versions from lockfiles/installs, then asks each registry directly (crates.io sparse index, npm abbreviated metadata, PyPI JSON, Go module proxy) for wanted/latest versions, with OSV vulnerability lookups. Lookups are deduplicated across projects, parallel, and cached. Updates are applied through each ecosystem's tool; major Cargo upgrades raise requirements with toml_edit, preserving formatting. New command: carwash outdated [--json] [--exit-code]. --- Cargo.lock | 1916 ++++++++++++++++++++++- Cargo.toml | 3 + crates/carwash-core/Cargo.toml | 5 + crates/carwash-core/src/deps/cargo.rs | 442 ++++++ crates/carwash-core/src/deps/go.rs | 186 +++ crates/carwash-core/src/deps/mod.rs | 643 ++++++++ crates/carwash-core/src/deps/npm.rs | 394 +++++ crates/carwash-core/src/deps/python.rs | 413 +++++ crates/carwash-core/src/lib.rs | 1 + crates/carwash/src/cli.rs | 37 + crates/carwash/src/commands/mod.rs | 1 + crates/carwash/src/commands/outdated.rs | 214 +++ crates/carwash/src/config.rs | 19 + crates/carwash/src/main.rs | 1 + 14 files changed, 4229 insertions(+), 46 deletions(-) create mode 100644 crates/carwash-core/src/deps/cargo.rs create mode 100644 crates/carwash-core/src/deps/go.rs create mode 100644 crates/carwash-core/src/deps/mod.rs create mode 100644 crates/carwash-core/src/deps/npm.rs create mode 100644 crates/carwash-core/src/deps/python.rs create mode 100644 crates/carwash/src/commands/outdated.rs diff --git a/Cargo.lock b/Cargo.lock index 570c565..43cdf3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -20,6 +26,12 @@ dependencies = [ "as-slice", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -85,6 +97,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "as-slice" version = "0.2.1" @@ -109,17 +136,68 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "bstr" @@ -138,6 +216,24 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "carwash" version = "0.4.0" @@ -150,8 +246,14 @@ dependencies = [ "clap", "clap_complete", "crossbeam-channel", + "filetime", + "fs4", + "globset", "indicatif", + "nucleo-matcher", + "portable-pty", "predicates", + "ratatui", "serde", "serde_json", "tempfile", @@ -159,7 +261,10 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "tui-input", + "tui-term", "unicode-width", + "vt100", ] [[package]] @@ -172,18 +277,32 @@ dependencies = [ "filetime", "fs4", "globset", + "nodejs-semver", + "pep440_rs", "rayon", "remove_dir_all", + "semver", "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "toml", + "toml_edit", "tracing", "trash", + "ureq", "yaml_serde", ] +[[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.62" @@ -200,6 +319,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -258,7 +383,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -273,6 +398,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.16.6" @@ -285,12 +424,74 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -325,6 +526,53 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "cvt" version = "0.1.2" @@ -334,6 +582,40 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -348,6 +630,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "deranged" version = "0.5.8" @@ -357,12 +645,70 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.15.0" @@ -401,12 +747,42 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -423,6 +799,29 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -432,12 +831,33 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs4" version = "1.1.0" @@ -458,7 +878,7 @@ dependencies = [ "cfg-if", "cvt", "libc", - "nix", + "nix 0.29.0", "windows-sys 0.52.0", ] @@ -487,21 +907,54 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", + "typenum", + "version_check", ] [[package]] -name = "globset" -version = "0.4.18" +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ @@ -524,7 +977,18 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -532,6 +996,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -539,6 +1008,28 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -563,12 +1054,122 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -594,12 +1195,43 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -618,6 +1250,23 @@ dependencies = [ "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.18", +] + +[[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" @@ -636,18 +1285,45 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libyaml-rs" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -663,6 +1339,25 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "matchers" version = "0.2.0" @@ -678,16 +1373,92 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[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" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", + "memoffset", +] + +[[package]] +name = "nodejs-semver" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1358f4e8050406325a3903addb0b1c1289aa9c353e4e0469cef6585b5876152f" +dependencies = [ + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", ] [[package]] @@ -714,12 +1485,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -729,6 +1521,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -750,7 +1551,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2", ] @@ -766,6 +1567,58 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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" @@ -779,12 +1632,118 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pep440_rs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31095ca1f396e3de32745f42b20deef7bc09077f918b085307e8eab6ddd8fb9c" +dependencies = [ + "once_cell", + "serde", + "unicode-width", + "unscanny", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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" @@ -797,6 +1756,36 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -840,7 +1829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -861,12 +1850,134 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-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.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.12.0" @@ -893,7 +2004,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -940,19 +2051,77 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -976,6 +2145,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -1004,7 +2177,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1029,6 +2202,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1038,12 +2233,71 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[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" @@ -1062,18 +2316,62 @@ 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.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "symlink" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1085,6 +2383,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1092,35 +2412,131 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", ] [[package]] -name = "terminal_size" -version = "0.4.4" +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "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 0.22.1", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "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 = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "rustix", - "windows-sys 0.61.2", + "thiserror-impl 1.0.69", ] -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1131,7 +2547,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1151,7 +2567,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -1174,6 +2592,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -1198,6 +2626,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -1232,7 +2673,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror", + "thiserror 2.0.18", "time", "tracing-subscriber", ] @@ -1245,7 +2686,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1305,12 +2746,63 @@ dependencies = [ "windows", ] +[[package]] +name = "tui-input" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb7c7ddaef6dcc58fae905d0f89a3df49ef77e93822df7447a5bb0babc9ece3" +dependencies = [ + "ratatui", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "tui-term" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338ded85dbe7f9ea2298321d126244f54e531e2b2006b97abdab8e47d6f3c88" +dependencies = [ + "ratatui-core", + "ratatui-widgets", + "vt100", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" @@ -1329,24 +2821,140 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "unscanny" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "urlencoding" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + +[[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" @@ -1356,6 +2964,12 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -1406,7 +3020,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1447,7 +3061,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.2", "hashbrown 0.15.5", "indexmap", "semver", @@ -1463,6 +3077,109 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -1516,7 +3233,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1527,7 +3244,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1669,6 +3386,18 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] [[package]] name = "wit-bindgen" @@ -1706,7 +3435,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1722,7 +3451,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1734,7 +3463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.2", "indexmap", "log", "serde", @@ -1764,6 +3493,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "yaml_serde" version = "0.10.7" @@ -1777,6 +3512,95 @@ dependencies = [ "serde", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 7807894..949cfc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ toml = "1.1" toml_edit = "0.25" yaml_serde = "0.10" semver = { version = "1.0", features = ["serde"] } +nodejs-semver = "6.0" +pep440_rs = "0.7" +ureq = { version = "3.4", features = ["json"] } cargo-lock = "11.1" jiff = { version = "0.2", features = ["serde"] } diff --git a/crates/carwash-core/Cargo.toml b/crates/carwash-core/Cargo.toml index 7ae6a23..ca94ee1 100644 --- a/crates/carwash-core/Cargo.toml +++ b/crates/carwash-core/Cargo.toml @@ -24,6 +24,11 @@ serde.workspace = true serde_json.workspace = true toml.workspace = true yaml_serde.workspace = true +toml_edit.workspace = true +semver.workspace = true +nodejs-semver.workspace = true +pep440_rs.workspace = true +ureq.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/crates/carwash-core/src/deps/cargo.rs b/crates/carwash-core/src/deps/cargo.rs new file mode 100644 index 0000000..9a5b62b --- /dev/null +++ b/crates/carwash-core/src/deps/cargo.rs @@ -0,0 +1,442 @@ +//! Rust: Cargo.toml requirements, Cargo.lock versions, the crates.io sparse index. + +use super::{ + Adapter, Bump, Declared, DepKind, Dependency, Releases, Source, find_upwards, read_capped, + semver_bump, task, +}; +use crate::tasks::Task; +use semver::{Version, VersionReq}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +pub struct Cargo; + +const TABLES: [(&str, DepKind); 3] = [ + ("dependencies", DepKind::Normal), + ("dev-dependencies", DepKind::Dev), + ("build-dependencies", DepKind::Build), +]; + +fn read_toml(path: &Path) -> Option { + read_capped(path)?.parse().ok() +} + +/// The workspace root manifest above `dir` (or `dir` itself), if any. +fn workspace_root(dir: &Path) -> Option<(PathBuf, toml::Table)> { + dir.ancestors().take(8).find_map(|d| { + let manifest = d.join("Cargo.toml"); + let table = read_toml(&manifest)?; + table.contains_key("workspace").then_some((manifest, table)) + }) +} + +/// Crates.io versions per package name from the nearest Cargo.lock. +fn locked_versions(dir: &Path) -> HashMap> { + let mut versions: HashMap> = HashMap::new(); + let Some(lock) = find_upwards(dir, "Cargo.lock", 8).and_then(|p| read_toml(&p)) else { + return versions; + }; + let packages = lock + .get("package") + .and_then(toml::Value::as_array) + .cloned() + .unwrap_or_default(); + for package in packages { + let from_crates_io = package + .get("source") + .and_then(toml::Value::as_str) + .is_some_and(|s| { + s.contains("crates.io-index") || s.starts_with("sparse+https://index.crates.io") + }); + if !from_crates_io { + continue; + } + if let (Some(name), Some(Ok(version))) = ( + package.get("name").and_then(toml::Value::as_str), + package + .get("version") + .and_then(toml::Value::as_str) + .map(Version::parse), + ) { + versions.entry(name.to_owned()).or_default().push(version); + } + } + versions +} + +/// (crate name, requirement) for a dependency entry on crates.io; `None` for path/git/other. +fn entry( + key: &str, + value: &toml::Value, + workspace_deps: Option<&toml::Table>, +) -> Option<(String, String)> { + match value { + toml::Value::String(req) => Some((key.to_owned(), req.clone())), + toml::Value::Table(table) => { + if table.get("workspace").and_then(toml::Value::as_bool) == Some(true) { + let inherited = workspace_deps?.get(key)?; + return entry(key, inherited, None); + } + if table.contains_key("path") + || table.contains_key("git") + || table.contains_key("registry") + { + return None; + } + let name = table + .get("package") + .and_then(toml::Value::as_str) + .unwrap_or(key); + let req = table.get("version").and_then(toml::Value::as_str)?; + Some((name.to_owned(), req.to_owned())) + } + _ => None, + } +} + +impl Adapter for Cargo { + fn declared(&self, dir: &Path) -> Result, String> { + let manifest = dir.join("Cargo.toml"); + let table = read_toml(&manifest).ok_or_else(|| "cannot read Cargo.toml".to_owned())?; + let root = workspace_root(dir); + let workspace_deps = root + .as_ref() + .and_then(|(_, t)| t.get("workspace")) + .and_then(|w| w.get("dependencies")) + .and_then(toml::Value::as_table); + let locked = locked_versions(dir); + + let mut sections: Vec<(&toml::Table, DepKind)> = Vec::new(); + for (key, kind) in TABLES { + if let Some(t) = table.get(key).and_then(toml::Value::as_table) { + sections.push((t, kind)); + } + } + if let Some(targets) = table.get("target").and_then(toml::Value::as_table) { + for target in targets.values() { + for (key, kind) in TABLES { + if let Some(t) = target.get(key).and_then(toml::Value::as_table) { + sections.push((t, kind)); + } + } + } + } + // The workspace root owns `[workspace.dependencies]`. + let is_root = root.as_ref().is_some_and(|(m, _)| m == &manifest); + let root_deps = if is_root { workspace_deps } else { None }; + + let mut out: Vec = Vec::new(); + let mut push = |key: &str, value: &toml::Value, kind: DepKind, own: bool| { + let Some((name, requirement)) = entry(key, value, workspace_deps) else { + return; + }; + if out.iter().any(|d| d.name == name) { + return; + } + let inherited = !own + || value + .get("workspace") + .and_then(toml::Value::as_bool) + .unwrap_or(false); + let edit = if inherited { + root.as_ref().map_or(manifest.clone(), |(m, _)| m.clone()) + } else { + manifest.clone() + }; + // Only a locked version satisfying the requirement is this dependency's: another + // one belongs to some other crate in the graph (the dependency may be unused). + let current = VersionReq::parse(&requirement).ok().and_then(|req| { + locked + .get(&name)? + .iter() + .filter(|v| req.matches(v)) + .max() + .map(Version::to_string) + }); + out.push(Declared { + name, + requirement, + current, + kind, + source: Source::Crates, + manifest: edit, + }); + }; + for (section, kind) in sections { + for (key, value) in section { + push(key, value, kind, true); + } + } + if let Some(deps) = root_deps { + for (key, value) in deps { + push(key, value, DepKind::Normal, false); + } + } + Ok(out) + } + + fn url(&self, name: &str) -> String { + let name = name.to_ascii_lowercase(); + let prefix = match name.len() { + 1 => "1".to_owned(), + 2 => "2".to_owned(), + 3 => format!("3/{}", &name[..1]), + _ => format!("{}/{}", &name[..2], &name[2..4]), + }; + format!("https://index.crates.io/{prefix}/{name}") + } + + fn parse(&self, body: &str) -> Result { + let mut versions = Vec::new(); + for line in body.lines().filter(|l| !l.trim().is_empty()) { + let record: serde_json::Value = + serde_json::from_str(line).map_err(|e| format!("bad index entry: {e}"))?; + if record["yanked"].as_bool() == Some(true) { + continue; + } + if let Some(version) = record["vers"].as_str() { + versions.push(version.to_owned()); + } + } + Ok(Releases { + versions, + latest: None, + }) + } + + fn resolve(&self, dep: &Declared, releases: &Releases) -> (Option, Option) { + let mut versions: Vec = releases + .versions + .iter() + .filter_map(|v| Version::parse(v).ok()) + .collect(); + versions.sort(); + let current_is_pre = dep + .current + .as_deref() + .and_then(|c| Version::parse(c).ok()) + .is_some_and(|c| !c.pre.is_empty()); + let wanted = VersionReq::parse(&dep.requirement).ok().and_then(|req| { + versions + .iter() + .rev() + .find(|v| req.matches(v)) + .map(Version::to_string) + }); + let latest = versions + .iter() + .rev() + .find(|v| v.pre.is_empty() || current_is_pre) + .map(Version::to_string); + (wanted, latest) + } + + fn bump(&self, from: &str, to: &str) -> Bump { + match (Version::parse(from), Version::parse(to)) { + (Ok(a), Ok(b)) => semver_bump((a.major, a.minor, a.patch), (b.major, b.minor, b.patch)), + _ => Bump::None, + } + } + + fn update(&self, dir: &Path, deps: &[&Dependency], latest: bool) -> Result, String> { + if latest { + for dep in deps { + let Some(version) = &dep.latest else { continue }; + raise_requirement(&dep.declared.manifest, &dep.declared.name, version)?; + } + } + let mut argv = vec!["cargo".to_owned(), "update".to_owned()]; + for dep in deps { + argv.push("-p".into()); + argv.push(dep.declared.name.clone()); + } + let names: Vec<&str> = deps.iter().map(|d| d.declared.name.as_str()).collect(); + let description = if latest { + format!("raise requirements and update {}", names.join(", ")) + } else { + format!("update {} within their requirements", names.join(", ")) + }; + Ok(vec![task(dir, "update", argv, description)]) + } +} + +/// Rewrites the requirement of `name` in `manifest` to `version`, keeping formatting. +fn raise_requirement(manifest: &Path, name: &str, version: &str) -> Result<(), String> { + let text = std::fs::read_to_string(manifest).map_err(|e| e.to_string())?; + let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e| format!("{e}"))?; + let mut changed = false; + let mut edit = |table: &mut toml_edit::Table| { + for (key, item) in table.iter_mut() { + let matches_name = key.get() == name + || item + .get("package") + .and_then(|p| p.as_str()) + .is_some_and(|p| p == name); + if !matches_name { + continue; + } + if item.is_str() { + *item = toml_edit::value(version); + changed = true; + } else if let Some(inline) = item.as_inline_table_mut() { + if inline.contains_key("version") { + inline.insert("version", version.into()); + changed = true; + } + } else if let Some(t) = item.as_table_mut() + && t.contains_key("version") + { + t.insert("version", toml_edit::value(version)); + changed = true; + } + } + }; + for key in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(t) = doc.get_mut(key).and_then(|i| i.as_table_mut()) { + edit(t); + } + } + if let Some(t) = doc + .get_mut("workspace") + .and_then(|w| w.get_mut("dependencies")) + .and_then(|i| i.as_table_mut()) + { + edit(t); + } + if let Some(targets) = doc.get_mut("target").and_then(|i| i.as_table_mut()) { + for (_, target) in targets.iter_mut() { + for key in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(t) = target.get_mut(key).and_then(|i| i.as_table_mut()) { + edit(t); + } + } + } + } + if !changed { + return Err(format!("`{name}` not found in {}", manifest.display())); + } + std::fs::write(manifest, doc.to_string()).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn write(dir: &Path, name: &str, contents: &str) { + let path = dir.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + const LOCK: &str = r#" +version = 4 +[[package]] +name = "serde" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "local" +version = "0.1.0" +"#; + + #[test] + fn declared_handles_renames_workspaces_targets_and_skips_local() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + "[workspace]\nmembers = [\"app\"]\n[workspace.dependencies]\nserde = \"1\"\n", + ); + write(dir.path(), "Cargo.lock", LOCK); + write( + dir.path(), + "app/Cargo.toml", + r#" +[package] +name = "app" +[dependencies] +serde = { workspace = true } +random = { package = "rand", version = "0.8" } +local = { path = "../local" } +[target.'cfg(unix)'.dev-dependencies] +libc = "0.2" +"#, + ); + let deps = Cargo.declared(&dir.path().join("app")).unwrap(); + let by_name = |n: &str| deps.iter().find(|d| d.name == n).unwrap(); + assert_eq!(deps.len(), 3, "{deps:?}"); + assert_eq!(by_name("serde").current.as_deref(), Some("1.0.200")); + assert_eq!(by_name("serde").manifest, dir.path().join("Cargo.toml")); + assert_eq!( + by_name("rand").current.as_deref(), + Some("0.8.5"), + "picks the version matching 0.8" + ); + assert_eq!(by_name("libc").kind, DepKind::Dev); + assert!(by_name("libc").current.is_none()); + } + + #[test] + fn sparse_index_paths() { + assert_eq!(Cargo.url("a"), "https://index.crates.io/1/a"); + assert_eq!(Cargo.url("ab"), "https://index.crates.io/2/ab"); + assert_eq!(Cargo.url("abc"), "https://index.crates.io/3/a/abc"); + assert_eq!(Cargo.url("Serde"), "https://index.crates.io/se/rd/serde"); + } + + #[test] + fn resolves_wanted_and_latest_skipping_yanked_and_prereleases() { + let body = [ + r#"{"name":"x","vers":"1.0.0","yanked":false}"#, + r#"{"name":"x","vers":"1.4.0","yanked":false}"#, + r#"{"name":"x","vers":"1.5.0","yanked":true}"#, + r#"{"name":"x","vers":"2.1.0","yanked":false}"#, + r#"{"name":"x","vers":"3.0.0-beta.1","yanked":false}"#, + ] + .join("\n"); + let releases = Cargo.parse(&body).unwrap(); + let dep = Declared { + name: "x".into(), + requirement: "1.0".into(), + current: Some("1.0.0".into()), + kind: DepKind::Normal, + source: Source::Crates, + manifest: PathBuf::new(), + }; + assert_eq!( + Cargo.resolve(&dep, &releases), + (Some("1.4.0".into()), Some("2.1.0".into())) + ); + assert_eq!(Cargo.bump("1.0.0", "2.1.0"), Bump::Major); + } + + #[test] + fn raising_requirements_preserves_formatting() { + let dir = tempfile::tempdir().unwrap(); + let manifest = dir.path().join("Cargo.toml"); + fs::write( + &manifest, + "[package]\nname = \"a\" # keep\n\n[dependencies]\nserde = { version = \"1\", features = [\"derive\"] }\nclap = \"3\"\n", + ) + .unwrap(); + raise_requirement(&manifest, "clap", "4.6.0").unwrap(); + raise_requirement(&manifest, "serde", "2.0.0").unwrap(); + let text = fs::read_to_string(&manifest).unwrap(); + assert!(text.contains("name = \"a\" # keep")); + assert!(text.contains("clap = \"4.6.0\"")); + assert!( + text.contains("version = \"2.0.0\", features = [\"derive\"]"), + "{text}" + ); + assert!(raise_requirement(&manifest, "missing", "1").is_err()); + } +} diff --git a/crates/carwash-core/src/deps/go.rs b/crates/carwash-core/src/deps/go.rs new file mode 100644 index 0000000..4981534 --- /dev/null +++ b/crates/carwash-core/src/deps/go.rs @@ -0,0 +1,186 @@ +//! Go: direct requirements in go.mod, latest versions from the module proxy. + +use super::{ + Adapter, Bump, Declared, DepKind, Dependency, Releases, Source, read_capped, semver_bump, task, +}; +use crate::tasks::Task; +use semver::Version; +use std::path::Path; + +pub struct Go; + +/// Direct, non-replaced requirements of a go.mod: (module path, version). +pub(crate) fn requirements(gomod: &str) -> Vec<(String, String)> { + let mut replaced = Vec::new(); + let mut required = Vec::new(); + let mut block: Option<&str> = None; + for raw in gomod.lines() { + let indirect = raw.contains("// indirect"); + let line = raw.split("//").next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + if let Some(open) = block { + if line == ")" { + block = None; + continue; + } + match open { + "require" if !indirect => { + let mut parts = line.split_whitespace(); + if let (Some(module), Some(version)) = (parts.next(), parts.next()) { + required.push((module.to_owned(), version.to_owned())); + } + } + "replace" => { + if let Some(module) = line.split_whitespace().next() { + replaced.push(module.to_owned()); + } + } + _ => {} + } + continue; + } + let mut parts = line.split_whitespace(); + match (parts.next(), parts.next(), parts.next()) { + (Some(keyword @ ("require" | "replace")), Some("("), _) => block = Some(keyword), + (Some("require"), Some(module), Some(version)) if !indirect => { + required.push((module.to_owned(), version.to_owned())); + } + (Some("replace"), Some(module), _) => replaced.push(module.to_owned()), + _ => {} + } + } + required.retain(|(module, _)| !replaced.contains(module)); + required +} + +/// Module path escaping for the proxy protocol: uppercase letters become `!` + lowercase. +pub(crate) fn escape(module: &str) -> String { + let mut out = String::with_capacity(module.len() + 4); + for c in module.chars() { + if c.is_ascii_uppercase() { + out.push('!'); + out.push(c.to_ascii_lowercase()); + } else { + out.push(c); + } + } + out +} + +fn parse(version: &str) -> Option { + Version::parse( + version + .trim_start_matches('v') + .split("+incompatible") + .next()?, + ) + .ok() +} + +impl Adapter for Go { + fn declared(&self, dir: &Path) -> Result, String> { + let manifest = dir.join("go.mod"); + let Some(gomod) = read_capped(&manifest) else { + // go.work-only directories have nothing of their own to update. + return Ok(Vec::new()); + }; + Ok(requirements(&gomod) + .into_iter() + .map(|(module, version)| Declared { + name: module, + requirement: version.clone(), + current: Some(version), + kind: DepKind::Normal, + source: Source::Go, + manifest: manifest.clone(), + }) + .collect()) + } + + fn url(&self, name: &str) -> String { + format!("https://proxy.golang.org/{}/@latest", escape(name)) + } + + fn parse(&self, body: &str) -> Result { + let json: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("bad proxy response: {e}"))?; + let latest = json["Version"].as_str().map(str::to_owned); + Ok(Releases { + versions: latest.clone().into_iter().collect(), + latest, + }) + } + + fn resolve(&self, dep: &Declared, releases: &Releases) -> (Option, Option) { + // Module paths carry the major version, so the proxy's latest is always compatible. + let latest = releases.latest.clone().filter(|latest| { + match (dep.current.as_deref().and_then(parse), parse(latest)) { + (Some(current), Some(latest)) => latest >= current, + _ => true, + } + }); + (latest.clone(), latest) + } + + fn bump(&self, from: &str, to: &str) -> Bump { + match (parse(from), parse(to)) { + (Some(a), Some(b)) => { + semver_bump((a.major, a.minor, a.patch), (b.major, b.minor, b.patch)) + } + _ => Bump::None, + } + } + + fn update(&self, dir: &Path, deps: &[&Dependency], _latest: bool) -> Result, String> { + let mut argv = vec!["go".to_owned(), "get".to_owned()]; + argv.extend(deps.iter().map(|d| format!("{}@latest", d.declared.name))); + let names: Vec<&str> = deps.iter().map(|d| d.declared.name.as_str()).collect(); + Ok(vec![task( + dir, + "update", + argv, + format!("update {}", names.join(", ")), + )]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_requirements_and_skips_indirect_and_replaced() { + let gomod = "module example.com/app\n\ngo 1.22\n\nrequire github.com/single/dep v1.0.0\n\nrequire (\n\tgithub.com/spf13/cobra v1.8.0\n\tgolang.org/x/sys v0.15.0 // indirect\n\tgithub.com/Masterminds/semver/v3 v3.2.1\n\tgithub.com/local/fork v0.1.0\n)\n\nreplace github.com/local/fork => ../fork\n"; + assert_eq!( + requirements(gomod), + vec![ + ("github.com/single/dep".to_string(), "v1.0.0".to_string()), + ("github.com/spf13/cobra".to_string(), "v1.8.0".to_string()), + ( + "github.com/Masterminds/semver/v3".to_string(), + "v3.2.1".to_string() + ), + ] + ); + } + + #[test] + fn escapes_uppercase_for_the_proxy() { + assert_eq!( + Go.url("github.com/Masterminds/semver/v3"), + "https://proxy.golang.org/github.com/!masterminds/semver/v3/@latest" + ); + } + + #[test] + fn bumps_and_pseudo_versions() { + assert_eq!(Go.bump("v1.8.0", "v1.9.1"), Bump::Minor); + assert_eq!(Go.bump("v0.15.0", "v0.16.0"), Bump::Major); + assert_eq!( + Go.bump("v2.0.0+incompatible", "v2.0.1+incompatible"), + Bump::Patch + ); + } +} diff --git a/crates/carwash-core/src/deps/mod.rs b/crates/carwash-core/src/deps/mod.rs new file mode 100644 index 0000000..b0686eb --- /dev/null +++ b/crates/carwash-core/src/deps/mod.rs @@ -0,0 +1,643 @@ +//! Outdated and vulnerable dependency detection across ecosystems. +//! +//! Declared dependencies come from manifests, current versions from lockfiles or installs, +//! and available versions straight from each registry (crates.io sparse index, npm, PyPI, Go +//! module proxy): no toolchain is needed to check. Applying updates goes through the +//! ecosystem's own tool (`cargo update`, `pnpm update`, `uv lock --upgrade-package`, `go get`). +//! +//! Lookups are deduplicated across projects and cached on disk, so checking a directory of +//! hundreds of projects costs one request per distinct package. + +mod cargo; +mod go; +mod npm; +mod python; + +use crate::ecosystem::{EcoId, Registry}; +use crate::tasks::Task; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// A package registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Source { + Crates, + Npm, + Pypi, + Go, +} + +impl Source { + pub fn key(self) -> &'static str { + match self { + Source::Crates => "crates", + Source::Npm => "npm", + Source::Pypi => "pypi", + Source::Go => "go", + } + } + + /// Ecosystem name in the OSV schema. + fn osv(self) -> &'static str { + match self { + Source::Crates => "crates.io", + Source::Npm => "npm", + Source::Pypi => "PyPI", + Source::Go => "Go", + } + } + + fn adapter(self) -> &'static dyn Adapter { + match self { + Source::Crates => &cargo::Cargo, + Source::Npm => &npm::Npm, + Source::Pypi => &python::Python, + Source::Go => &go::Go, + } + } + + /// The registry used by projects of the ecosystem with key `ecosystem`. + pub fn for_ecosystem(ecosystem: &str) -> Option { + Some(match ecosystem { + "rust" => Source::Crates, + "node" => Source::Npm, + "python" => Source::Pypi, + "go" => Source::Go, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DepKind { + Normal, + Dev, + Build, + Optional, +} + +impl DepKind { + pub fn label(self) -> &'static str { + match self { + DepKind::Normal => "", + DepKind::Dev => "dev", + DepKind::Build => "build", + DepKind::Optional => "optional", + } + } +} + +/// A dependency as written in a manifest. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Declared { + pub name: String, + /// Version requirement as written (`^1.2`, `>=2,<3`, `v1.4.0`). + pub requirement: String, + /// Version currently locked or installed. + pub current: Option, + pub kind: DepKind, + pub source: Source, + /// Manifest to edit when changing the requirement (a workspace root for inherited deps). + pub manifest: PathBuf, +} + +/// How far a newer version is from the current one. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Bump { + #[default] + None, + Patch, + Minor, + /// Breaking by semver rules (first non-zero component changes). + Major, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Dependency { + #[serde(flatten)] + pub declared: Declared, + /// Newest version the requirement allows. + pub wanted: Option, + /// Newest version published. + pub latest: Option, + /// From current to latest. + pub bump: Bump, + /// From current to wanted. + pub wanted_bump: Bump, + /// OSV advisory ids affecting the current version. + pub vulnerabilities: Vec, + pub error: Option, +} + +impl Dependency { + pub fn is_outdated(&self) -> bool { + self.bump != Bump::None + } +} + +impl ProjectDeps { + /// Dependencies to list for this project when `checked` projects are shown together: + /// inherited ones (a workspace `[workspace.dependencies]` entry) are listed under the + /// project owning the manifest, if it is among them. + pub fn listed<'a>(&'a self, checked: &'a [PathBuf]) -> impl Iterator { + self.dependencies.iter().filter(move |dep| { + let owner = dep.declared.manifest.parent(); + owner == Some(self.path.as_path()) + || !owner.is_some_and(|o| checked.iter().any(|c| c == o)) + }) + } +} + +/// Dependencies of one project. +#[derive(Debug, Clone, Serialize)] +pub struct ProjectDeps { + pub path: PathBuf, + pub dependencies: Vec, + pub errors: Vec, +} + +/// What to check. +#[derive(Debug, Clone)] +pub struct ProjectSpec { + pub path: PathBuf, + pub ecosystems: Vec, +} + +/// Published versions of a package. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(crate) struct Releases { + /// Installable versions (not yanked). + pub versions: Vec, + /// The registry's own "latest", when it has one. + pub latest: Option, +} + +/// Per-ecosystem behaviour. +trait Adapter: Sync { + fn declared(&self, dir: &Path) -> Result, String>; + fn url(&self, name: &str) -> String; + fn accept(&self) -> Option<&'static str> { + None + } + fn parse(&self, body: &str) -> Result; + /// (wanted, latest) for `dep` given published `releases`. + fn resolve(&self, dep: &Declared, releases: &Releases) -> (Option, Option); + fn bump(&self, from: &str, to: &str) -> Bump; + /// Commands (and manifest edits, for `latest`) that apply updates to `deps`. + fn update(&self, dir: &Path, deps: &[&Dependency], latest: bool) -> Result, String>; +} + +/// Semver distance, where a change in the first non-zero component is breaking. +pub(crate) fn semver_bump(from: (u64, u64, u64), to: (u64, u64, u64)) -> Bump { + if to <= from { + return Bump::None; + } + let breaking = match from { + (0, 0, _) => true, + (0, minor, _) => to.0 != 0 || to.1 != minor, + (major, ..) => to.0 != major, + }; + if breaking { + Bump::Major + } else if to.1 != from.1 { + Bump::Minor + } else { + Bump::Patch + } +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct CacheFile { + version: u32, + entries: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedReleases { + releases: Releases, + fetched_at: u64, +} + +/// A project's path, declared dependencies and the errors met reading them. +type Collected = (PathBuf, Vec, Vec); +/// Advisory ids by (registry, package, version). +type Advisories = HashMap<(Source, String, String), Vec>; + +const CACHE_VERSION: u32 = 1; +const WORKERS: usize = 16; +const USER_AGENT: &str = concat!( + "carwash/", + env!("CARGO_PKG_VERSION"), + " (https://github.com/epistates/carwash)" +); + +/// Progress of a check, for display. +#[derive(Debug, Default)] +pub struct CheckProgress { + pub total: AtomicUsize, + pub done: AtomicUsize, +} + +/// Checks dependencies against registries, with a persistent cache. +pub struct Checker { + agent: ureq::Agent, + cache: Mutex, + cache_path: Option, + ttl: Duration, + osv_url: String, +} + +impl std::fmt::Debug for Checker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Checker") + .field("cache_path", &self.cache_path) + .field("ttl", &self.ttl) + .finish_non_exhaustive() + } +} + +impl Checker { + /// `cache_path` persists registry responses; entries younger than `ttl` are reused. + pub fn new(cache_path: Option, ttl: Duration) -> Self { + let cache = cache_path + .as_deref() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|c| c.version == CACHE_VERSION) + .unwrap_or(CacheFile { + version: CACHE_VERSION, + entries: HashMap::new(), + }); + let agent = ureq::Agent::config_builder() + .user_agent(USER_AGENT) + .timeout_global(Some(Duration::from_secs(15))) + .http_status_as_error(false) + .build() + .new_agent(); + Self { + agent, + cache: Mutex::new(cache), + cache_path, + ttl, + osv_url: "https://api.osv.dev/v1/querybatch".into(), + } + } + + pub fn save(&self) -> std::io::Result<()> { + let Some(path) = &self.cache_path else { + return Ok(()); + }; + let mut cache = self.cache.lock().expect("deps cache lock"); + let cutoff = unix_now().saturating_sub(30 * 86_400); + cache.entries.retain(|_, e| e.fetched_at >= cutoff); + let json = serde_json::to_vec(&*cache).map_err(std::io::Error::other)?; + crate::paths::write_atomic(path, &json) + } + + fn get(&self, url: &str, accept: Option<&str>) -> Result { + let mut request = self.agent.get(url); + if let Some(accept) = accept { + request = request.header("Accept", accept); + } + let response = request.call().map_err(|e| e.to_string())?; + let status = response.status(); + if status == 404 || status == 410 { + return Err("not found in the registry".into()); + } + if !status.is_success() { + return Err(format!("registry returned {status}")); + } + response + .into_body() + .with_config() + .limit(64 * 1024 * 1024) + .read_to_string() + .map_err(|e| e.to_string()) + } + + fn releases(&self, source: Source, name: &str, refresh: bool) -> Result { + let key = format!("{}:{name}", source.key()); + if !refresh { + let cache = self.cache.lock().expect("deps cache lock"); + if let Some(entry) = cache.entries.get(&key) + && unix_now().saturating_sub(entry.fetched_at) < self.ttl.as_secs() + { + return Ok(entry.releases.clone()); + } + } + let adapter = source.adapter(); + let body = self.get(&adapter.url(name), adapter.accept())?; + let releases = adapter.parse(&body)?; + self.cache.lock().expect("deps cache lock").entries.insert( + key, + CachedReleases { + releases: releases.clone(), + fetched_at: unix_now(), + }, + ); + Ok(releases) + } + + /// Checks every project. Registry lookups are shared across projects. + pub fn check( + &self, + projects: &[ProjectSpec], + registry: &Registry, + vulnerabilities: bool, + refresh: bool, + progress: &CheckProgress, + ) -> Vec { + // Reading manifests and lockfiles is I/O-bound too: spread it over the workers. + let slots: Vec>> = + projects.iter().map(|_| Mutex::new(None)).collect(); + let next_project = AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..WORKERS.min(projects.len()) { + scope.spawn(|| { + loop { + let index = next_project.fetch_add(1, Ordering::Relaxed); + let Some(project) = projects.get(index) else { + break; + }; + let mut deps = Vec::new(); + let mut errors = Vec::new(); + for source in project + .ecosystems + .iter() + .filter_map(|&e| Source::for_ecosystem(®istry.ecosystem(e).key)) + { + match source.adapter().declared(&project.path) { + Ok(found) => deps.extend(found), + Err(error) => errors.push(error), + } + } + *slots[index].lock().expect("slot lock") = + Some((project.path.clone(), deps, errors)); + } + }); + } + }); + let declared: Vec = slots + .into_iter() + .filter_map(|slot| slot.into_inner().expect("slot lock")) + .collect(); + + let mut unique: Vec<(Source, String)> = declared + .iter() + .flat_map(|(_, deps, _)| deps.iter().map(|d| (d.source, d.name.clone()))) + .collect(); + unique.sort(); + unique.dedup(); + progress.total.store(unique.len(), Ordering::Relaxed); + + let fetched: Mutex>> = + Mutex::new(HashMap::new()); + let next = AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..WORKERS.min(unique.len()) { + scope.spawn(|| { + while let Some((source, name)) = + unique.get(next.fetch_add(1, Ordering::Relaxed)) + { + let result = self.releases(*source, name, refresh); + fetched + .lock() + .expect("fetched lock") + .insert((*source, name.clone()), result); + progress.done.fetch_add(1, Ordering::Relaxed); + } + }); + } + }); + let fetched = fetched.into_inner().expect("fetched lock"); + + let mut results: Vec = declared + .into_iter() + .map(|(path, deps, errors)| { + let dependencies = deps + .into_iter() + .map(|declared| resolve(declared, &fetched)) + .collect(); + ProjectDeps { + path, + dependencies, + errors, + } + }) + .collect(); + + if vulnerabilities { + match self.vulnerabilities(&results) { + Ok(found) => { + for project in &mut results { + for dep in &mut project.dependencies { + if let Some(current) = &dep.declared.current + && let Some(ids) = found.get(&( + dep.declared.source, + dep.declared.name.clone(), + current.clone(), + )) + { + dep.vulnerabilities = ids.clone(); + } + } + } + } + Err(error) => { + for project in &mut results { + project + .errors + .push(format!("vulnerability check failed: {error}")); + } + } + } + } + results + } + + /// Queries OSV for every distinct (package, current version). + fn vulnerabilities(&self, projects: &[ProjectDeps]) -> Result { + let mut queries: Vec<(Source, String, String)> = projects + .iter() + .flat_map(|p| &p.dependencies) + .filter_map(|d| { + d.declared + .current + .clone() + .map(|v| (d.declared.source, d.declared.name.clone(), v)) + }) + .collect(); + queries.sort(); + queries.dedup(); + let mut found = HashMap::new(); + for chunk in queries.chunks(1000) { + let body = serde_json::json!({ + "queries": chunk.iter().map(|(source, name, version)| serde_json::json!({ + "package": { "name": name, "ecosystem": source.osv() }, + "version": version.trim_start_matches('v'), + })).collect::>() + }); + let response = self + .agent + .post(&self.osv_url) + .send_json(&body) + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("OSV returned {}", response.status())); + } + let parsed: serde_json::Value = response + .into_body() + .read_json() + .map_err(|e| e.to_string())?; + let results = parsed["results"].as_array().cloned().unwrap_or_default(); + for (query, result) in chunk.iter().zip(results) { + let ids: Vec = result["vulns"] + .as_array() + .map(|v| { + v.iter() + .filter_map(|x| x["id"].as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + if !ids.is_empty() { + found.insert(query.clone(), ids); + } + } + } + Ok(found) + } +} + +fn resolve( + declared: Declared, + fetched: &HashMap<(Source, String), Result>, +) -> Dependency { + let adapter = declared.source.adapter(); + let mut dep = Dependency { + declared, + wanted: None, + latest: None, + bump: Bump::None, + wanted_bump: Bump::None, + vulnerabilities: Vec::new(), + error: None, + }; + match fetched.get(&(dep.declared.source, dep.declared.name.clone())) { + Some(Ok(releases)) => { + let (wanted, latest) = adapter.resolve(&dep.declared, releases); + if let Some(current) = &dep.declared.current { + if let Some(latest) = &latest { + dep.bump = adapter.bump(current, latest); + } + if let Some(wanted) = &wanted { + dep.wanted_bump = adapter.bump(current, wanted); + } + } + dep.wanted = wanted; + dep.latest = latest; + } + Some(Err(error)) => dep.error = Some(error.clone()), + None => dep.error = Some("not checked".into()), + } + dep +} + +/// Commands that update `deps` (all from the project at `dir`, one ecosystem each). +/// With `latest`, requirements are raised in the manifests first where the tool needs it. +pub fn update_tasks(dir: &Path, deps: &[&Dependency], latest: bool) -> Result, String> { + let mut by_source: Vec<(Source, Vec<&Dependency>)> = Vec::new(); + for dep in deps { + match by_source + .iter_mut() + .find(|(s, _)| *s == dep.declared.source) + { + Some((_, list)) => list.push(dep), + None => by_source.push((dep.declared.source, vec![dep])), + } + } + let mut tasks = Vec::new(); + for (source, list) in by_source { + tasks.extend(source.adapter().update(dir, &list, latest)?); + } + Ok(tasks) +} + +/// Finds `name` in `dir` or its ancestors (lockfiles live at workspace roots). +pub(crate) fn find_upwards(dir: &Path, name: &str, levels: usize) -> Option { + dir.ancestors() + .take(levels) + .map(|d| d.join(name)) + .find(|p| p.is_file()) +} + +pub(crate) fn read_capped(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + if meta.len() > 64 * 1024 * 1024 { + return None; + } + std::fs::read_to_string(path).ok() +} + +pub(crate) fn task(dir: &Path, name: &str, argv: Vec, description: String) -> Task { + let mut argv = argv.into_iter(); + Task { + name: name.to_owned(), + source: "update".into(), + program: argv.next().unwrap_or_default(), + args: argv.collect(), + description: Some(description), + cwd: dir.to_path_buf(), + standard: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semver_bumps_follow_breaking_rules() { + assert_eq!(semver_bump((1, 2, 3), (1, 2, 3)), Bump::None); + assert_eq!(semver_bump((1, 2, 3), (1, 2, 4)), Bump::Patch); + assert_eq!(semver_bump((1, 2, 3), (1, 3, 0)), Bump::Minor); + assert_eq!(semver_bump((1, 2, 3), (2, 0, 0)), Bump::Major); + assert_eq!(semver_bump((0, 3, 1), (0, 3, 2)), Bump::Patch); + assert_eq!(semver_bump((0, 3, 1), (0, 4, 0)), Bump::Major); + assert_eq!(semver_bump((0, 0, 3), (0, 0, 4)), Bump::Major); + assert_eq!(semver_bump((2, 0, 0), (1, 9, 9)), Bump::None); + } + + #[test] + fn resolve_records_registry_errors() { + let declared = Declared { + name: "missing".into(), + requirement: "1".into(), + current: Some("1.0.0".into()), + kind: DepKind::Normal, + source: Source::Crates, + manifest: PathBuf::from("Cargo.toml"), + }; + let mut fetched = HashMap::new(); + fetched.insert( + (Source::Crates, "missing".to_string()), + Err("not found in the registry".to_string()), + ); + let dep = resolve(declared, &fetched); + assert_eq!(dep.error.as_deref(), Some("not found in the registry")); + assert!(!dep.is_outdated()); + } +} diff --git a/crates/carwash-core/src/deps/npm.rs b/crates/carwash-core/src/deps/npm.rs new file mode 100644 index 0000000..3ecb666 --- /dev/null +++ b/crates/carwash-core/src/deps/npm.rs @@ -0,0 +1,394 @@ +//! JavaScript: package.json ranges, installed versions, the npm registry. + +use super::{ + Adapter, Bump, Declared, DepKind, Dependency, Releases, Source, find_upwards, read_capped, + semver_bump, task, +}; +use crate::tasks::{Task, node_package_manager}; +use nodejs_semver::{Range, Version}; +use std::path::Path; + +pub struct Npm; + +const SECTIONS: [(&str, DepKind); 3] = [ + ("dependencies", DepKind::Normal), + ("devDependencies", DepKind::Dev), + ("optionalDependencies", DepKind::Optional), +]; + +fn read_json(path: &Path) -> Option { + serde_json::from_str(&read_capped(path)?).ok() +} + +/// (package name, range) for a registry dependency; `None` for workspace, file, git, URL specs. +fn registry_spec(key: &str, spec: &str) -> Option<(String, String)> { + let spec = spec.trim(); + if let Some(alias) = spec.strip_prefix("npm:") { + // `npm:real-name@range`; scoped names start with `@`. + let split = alias[1..].find('@').map(|i| i + 1); + return Some(match split { + Some(i) => (alias[..i].to_owned(), alias[i + 1..].to_owned()), + None => (alias.to_owned(), "*".to_owned()), + }); + } + let local = [ + "workspace:", + "file:", + "link:", + "portal:", + "catalog:", + "patch:", + "exec:", + ]; + let remote = ["git", "http:", "https:", "github:", "gitlab:", "bitbucket:"]; + if local.iter().chain(&remote).any(|p| spec.starts_with(p)) + || spec.starts_with('.') + || spec.starts_with('/') + || spec.starts_with('~') && spec.contains('/') + || (spec.contains('/') && !spec.starts_with('@') && !spec.contains(' ')) + { + return None; + } + Some(( + key.to_owned(), + if spec.is_empty() { + "*".into() + } else { + spec.to_owned() + }, + )) +} + +/// `dir` relative to the directory holding `lock`, with `/` separators; `None` at the root. +fn relative_to_lock(dir: &Path, lock: &Path) -> Option { + let root = lock.parent()?; + dir.strip_prefix(root) + .ok() + .map(|r| r.to_string_lossy().replace('\\', "/")) + .filter(|r| !r.is_empty()) +} + +/// Lockfiles for one project, parsed once and queried per dependency. +struct Installed<'a> { + dir: &'a Path, + package_lock: Option<(serde_json::Value, Option)>, + pnpm_importer: Option, +} + +impl<'a> Installed<'a> { + fn load(dir: &'a Path) -> Self { + let package_lock = find_upwards(dir, "package-lock.json", 6) + .and_then(|lock| read_json(&lock).map(|json| (json, relative_to_lock(dir, &lock)))); + let pnpm_importer = find_upwards(dir, "pnpm-lock.yaml", 6).and_then(|lock| { + let doc = read_capped(&lock) + .and_then(|s| yaml_serde::from_str::(&s).ok())?; + let importer = relative_to_lock(dir, &lock).unwrap_or_else(|| ".".into()); + doc.get("importers")?.get(importer.as_str()).cloned() + }); + Self { + dir, + package_lock, + pnpm_importer, + } + } + + /// Installed version: node_modules (hoisted up to the workspace root), then lockfiles. + fn version(&self, name: &str) -> Option { + for ancestor in self.dir.ancestors().take(6) { + let manifest = ancestor + .join("node_modules") + .join(name) + .join("package.json"); + if let Some(version) = read_json(&manifest) + .as_ref() + .and_then(|j| j["version"].as_str()) + { + return Some(version.to_owned()); + } + } + if let Some((json, prefix)) = &self.package_lock { + let keys = [ + prefix.as_ref().map(|p| format!("{p}/node_modules/{name}")), + Some(format!("node_modules/{name}")), + ]; + for key in keys.into_iter().flatten() { + if let Some(version) = json["packages"][key.as_str()]["version"].as_str() { + return Some(version.to_owned()); + } + } + } + if let Some(importer) = &self.pnpm_importer { + for section in ["dependencies", "devDependencies", "optionalDependencies"] { + if let Some(version) = importer[section][name]["version"].as_str() { + // `1.2.3(react@18.2.0)` carries peer suffixes. + let version = version.split('(').next().unwrap_or(version); + if Version::parse(version).is_ok() { + return Some(version.to_owned()); + } + } + } + } + None + } +} + +impl Adapter for Npm { + fn declared(&self, dir: &Path) -> Result, String> { + let manifest = dir.join("package.json"); + let json = read_json(&manifest).ok_or_else(|| "cannot read package.json".to_owned())?; + let installed = Installed::load(dir); + let mut out: Vec = Vec::new(); + for (section, kind) in SECTIONS { + let Some(deps) = json[section].as_object() else { + continue; + }; + for (key, spec) in deps { + let Some((name, requirement)) = spec.as_str().and_then(|s| registry_spec(key, s)) + else { + continue; + }; + if out.iter().any(|d| d.name == name) { + continue; + } + out.push(Declared { + current: installed.version(key), + name, + requirement, + kind, + source: Source::Npm, + manifest: manifest.clone(), + }); + } + } + Ok(out) + } + + fn url(&self, name: &str) -> String { + format!("https://registry.npmjs.org/{}", name.replace('/', "%2f")) + } + + fn accept(&self) -> Option<&'static str> { + // Abbreviated metadata: a fraction of the full document. + Some("application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8") + } + + fn parse(&self, body: &str) -> Result { + let json: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("bad registry response: {e}"))?; + let versions = json["versions"] + .as_object() + .map(|v| v.keys().cloned().collect()) + .unwrap_or_default(); + Ok(Releases { + versions, + latest: json["dist-tags"]["latest"].as_str().map(str::to_owned), + }) + } + + fn resolve(&self, dep: &Declared, releases: &Releases) -> (Option, Option) { + let versions: Vec = releases + .versions + .iter() + .filter_map(|v| Version::parse(v).ok()) + .collect(); + let wanted = Range::parse(&dep.requirement) + .ok() + .and_then(|range| range.max_satisfying(&versions).map(Version::to_string)); + let latest = releases.latest.clone().or_else(|| { + versions + .iter() + .filter(|v| !v.is_prerelease()) + .max() + .map(Version::to_string) + }); + (wanted, latest) + } + + fn bump(&self, from: &str, to: &str) -> Bump { + match (Version::parse(from), Version::parse(to)) { + (Ok(a), Ok(b)) => semver_bump( + (a.major(), a.minor(), a.patch()), + (b.major(), b.minor(), b.patch()), + ), + _ => Bump::None, + } + } + + fn update(&self, dir: &Path, deps: &[&Dependency], latest: bool) -> Result, String> { + let pm = node_package_manager(dir); + let berry = find_upwards(dir, ".yarnrc.yml", 6).is_some(); + let names = |filter: &dyn Fn(&&&Dependency) -> bool, suffix: &str| -> Vec { + deps.iter() + .filter(filter) + .map(|d| format!("{}{suffix}", d.declared.name)) + .collect() + }; + let mut tasks = Vec::new(); + if latest { + for dev in [false, true] { + let list = names(&|d| (d.declared.kind == DepKind::Dev) == dev, "@latest"); + if list.is_empty() { + continue; + } + let mut argv = vec![ + pm.to_owned(), + if pm == "npm" { "install" } else { "add" }.to_owned(), + ]; + if dev { + argv.push(if pm == "bun" { "-d" } else { "-D" }.to_owned()); + } + argv.extend(list.clone()); + tasks.push(task( + dir, + "upgrade", + argv, + format!("upgrade {} to latest", list.join(", ")), + )); + } + } else { + let list = names(&|_| true, ""); + let verb = match (pm, berry) { + ("yarn", true) => "up", + ("yarn", false) => "upgrade", + _ => "update", + }; + let mut argv = vec![pm.to_owned(), verb.to_owned()]; + argv.extend(list.clone()); + tasks.push(task( + dir, + "update", + argv, + format!("update {} within their ranges", list.join(", ")), + )); + } + Ok(tasks) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn write(dir: &Path, name: &str, contents: &str) { + let path = dir.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + #[test] + fn registry_specs() { + assert_eq!( + registry_spec("a", "^1.2.0"), + Some(("a".into(), "^1.2.0".into())) + ); + assert_eq!(registry_spec("a", ""), Some(("a".into(), "*".into()))); + assert_eq!( + registry_spec("alias", "npm:@scope/real@^2"), + Some(("@scope/real".into(), "^2".into())) + ); + for local in [ + "workspace:*", + "file:../x", + "link:x", + "git+https://x", + "github:a/b", + "user/repo", + "catalog:", + ] { + assert_eq!(registry_spec("a", local), None, "{local}"); + } + assert!(registry_spec("a", ">=1 <2").is_some()); + } + + #[test] + fn installed_versions_from_node_modules_and_lockfiles() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write( + root, + "node_modules/react/package.json", + r#"{"version":"18.2.0"}"#, + ); + write( + root, + "package-lock.json", + r#"{"packages":{"node_modules/zod":{"version":"3.22.4"},"apps/web/node_modules/next":{"version":"14.1.0"}}}"#, + ); + write( + root, + "pnpm-lock.yaml", + "importers:\n apps/web:\n dependencies:\n swr:\n version: 2.2.0(react@18.2.0)\n", + ); + let web = root.join("apps/web"); + fs::create_dir_all(&web).unwrap(); + let installed = Installed::load(&web); + assert_eq!( + installed.version("react").as_deref(), + Some("18.2.0"), + "hoisted" + ); + assert_eq!(installed.version("next").as_deref(), Some("14.1.0")); + assert_eq!(installed.version("zod").as_deref(), Some("3.22.4")); + assert_eq!(installed.version("swr").as_deref(), Some("2.2.0")); + assert_eq!(installed.version("missing"), None); + } + + #[test] + fn resolves_ranges() { + let releases = Npm + .parse(r#"{"dist-tags":{"latest":"5.1.0"},"versions":{"4.1.0":{},"4.9.2":{},"5.0.0":{},"5.1.0":{},"6.0.0-rc.1":{}}}"#) + .unwrap(); + let dep = |req: &str| Declared { + name: "x".into(), + requirement: req.into(), + current: Some("4.1.0".into()), + kind: DepKind::Normal, + source: Source::Npm, + manifest: PathBuf::new(), + }; + assert_eq!( + Npm.resolve(&dep("^4.1.0"), &releases), + (Some("4.9.2".into()), Some("5.1.0".into())) + ); + assert_eq!( + Npm.resolve(&dep("~4.1.0"), &releases).0.as_deref(), + Some("4.1.0") + ); + assert_eq!( + Npm.resolve(&dep(">=4 <6 || 7"), &releases).0.as_deref(), + Some("5.1.0") + ); + assert_eq!(Npm.bump("4.1.0", "5.1.0"), Bump::Major); + } + + #[test] + fn update_commands_match_the_package_manager() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "pnpm-lock.yaml", ""); + let dep = |name: &str, kind| Dependency { + declared: Declared { + name: name.into(), + requirement: "^1".into(), + current: Some("1.0.0".into()), + kind, + source: Source::Npm, + manifest: PathBuf::new(), + }, + wanted: Some("1.1.0".into()), + latest: Some("2.0.0".into()), + bump: Bump::Major, + wanted_bump: Bump::Minor, + vulnerabilities: Vec::new(), + error: None, + }; + let a = dep("a", DepKind::Normal); + let b = dep("b", DepKind::Dev); + let compatible = Npm.update(dir.path(), &[&a, &b], false).unwrap(); + assert_eq!(compatible[0].command_line(), "pnpm update a b"); + let latest = Npm.update(dir.path(), &[&a, &b], true).unwrap(); + let lines: Vec = latest.iter().map(Task::command_line).collect(); + assert_eq!(lines, vec!["pnpm add a@latest", "pnpm add -D b@latest"]); + } +} diff --git a/crates/carwash-core/src/deps/python.rs b/crates/carwash-core/src/deps/python.rs new file mode 100644 index 0000000..c6d241e --- /dev/null +++ b/crates/carwash-core/src/deps/python.rs @@ -0,0 +1,413 @@ +//! Python: pyproject/requirements specifiers, uv/poetry/pdm locks, PyPI. + +use super::{ + Adapter, Bump, Declared, DepKind, Dependency, Releases, Source, find_upwards, read_capped, + semver_bump, task, +}; +use crate::tasks::Task; +use pep440_rs::{Version, VersionSpecifiers}; +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; + +pub struct Python; + +/// PEP 503 normalization: lowercase, runs of `-_.` become `-`. +pub(crate) fn normalize(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut separator = false; + for c in name.chars() { + if matches!(c, '-' | '_' | '.') { + separator = true; + } else { + if separator && !out.is_empty() { + out.push('-'); + } + separator = false; + out.push(c.to_ascii_lowercase()); + } + } + out +} + +/// Splits a PEP 508 requirement into (name, specifier); `None` for URL requirements. +pub(crate) fn split_requirement(line: &str) -> Option<(String, String)> { + let line = line.split(';').next()?.split('#').next()?.trim(); + if line.is_empty() || line.starts_with('-') || line.contains(" @ ") || line.contains("://") { + return None; + } + let end = line + .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + .unwrap_or(line.len()); + let name = &line[..end]; + if name.is_empty() { + return None; + } + let mut rest = line[end..].trim(); + if let Some(stripped) = rest.strip_prefix('[') { + rest = stripped.split_once(']').map_or("", |(_, r)| r).trim(); + } + let spec = rest.trim_start_matches('(').trim_end_matches(')').trim(); + Some((name.to_owned(), spec.to_owned())) +} + +/// Locked versions by normalized name, from uv.lock, poetry.lock or pdm.lock. +fn locked(dir: &Path) -> HashMap { + let mut versions = HashMap::new(); + for lock in ["uv.lock", "poetry.lock", "pdm.lock"] { + let Some(table) = find_upwards(dir, lock, 6) + .and_then(|p| read_capped(&p)) + .and_then(|s| s.parse::().ok()) + else { + continue; + }; + for package in table + .get("package") + .and_then(toml::Value::as_array) + .into_iter() + .flatten() + { + if let (Some(name), Some(version)) = ( + package.get("name").and_then(toml::Value::as_str), + package.get("version").and_then(toml::Value::as_str), + ) { + versions.insert(normalize(name), version.to_owned()); + } + } + if !versions.is_empty() { + break; + } + } + versions +} + +fn tool(dir: &Path) -> Option<&'static str> { + [ + ("uv.lock", "uv"), + ("poetry.lock", "poetry"), + ("pdm.lock", "pdm"), + ] + .into_iter() + .find(|(lock, _)| find_upwards(dir, lock, 6).is_some()) + .map(|(_, tool)| tool) +} + +impl Adapter for Python { + fn declared(&self, dir: &Path) -> Result, String> { + let locked = locked(dir); + let mut requirements: Vec<(String, DepKind, std::path::PathBuf)> = Vec::new(); + let pyproject = dir.join("pyproject.toml"); + if let Some(table) = read_capped(&pyproject).and_then(|s| s.parse::().ok()) { + let strings = |value: Option<&toml::Value>| -> Vec { + value + .and_then(toml::Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() + }; + let project = table.get("project"); + for line in strings(project.and_then(|p| p.get("dependencies"))) { + requirements.push((line, DepKind::Normal, pyproject.clone())); + } + if let Some(extras) = project + .and_then(|p| p.get("optional-dependencies")) + .and_then(toml::Value::as_table) + { + for lines in extras.values() { + for line in strings(Some(lines)) { + requirements.push((line, DepKind::Optional, pyproject.clone())); + } + } + } + if let Some(groups) = table + .get("dependency-groups") + .and_then(toml::Value::as_table) + { + for lines in groups.values() { + for line in strings(Some(lines)) { + requirements.push((line, DepKind::Dev, pyproject.clone())); + } + } + } + let uv_dev = table + .get("tool") + .and_then(|t| t.get("uv")) + .and_then(|u| u.get("dev-dependencies")); + for line in strings(uv_dev) { + requirements.push((line, DepKind::Dev, pyproject.clone())); + } + } + if requirements.is_empty() { + for file in [ + "requirements.txt", + "requirements-dev.txt", + "requirements/base.txt", + ] { + let path = dir.join(file); + if let Some(text) = read_capped(&path) { + let kind = if file.contains("dev") { + DepKind::Dev + } else { + DepKind::Normal + }; + for line in text.lines() { + requirements.push((line.to_owned(), kind, path.clone())); + } + } + } + } + let mut out: Vec = Vec::new(); + for (line, kind, manifest) in requirements { + let Some((name, spec)) = split_requirement(&line) else { + continue; + }; + let normalized = normalize(&name); + if out.iter().any(|d| d.name == normalized) { + continue; + } + // A pinned requirement is its own current version when nothing is locked. + let pinned = spec + .strip_prefix("==") + .filter(|v| !v.contains([',', '*'])) + .map(|v| v.trim().to_owned()); + out.push(Declared { + current: locked.get(&normalized).cloned().or(pinned), + name: normalized, + requirement: spec, + kind, + source: Source::Pypi, + manifest, + }); + } + Ok(out) + } + + fn url(&self, name: &str) -> String { + format!("https://pypi.org/pypi/{name}/json") + } + + fn parse(&self, body: &str) -> Result { + let json: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("bad PyPI response: {e}"))?; + let versions = json["releases"] + .as_object() + .map(|releases| { + releases + .iter() + .filter(|(_, files)| { + files + .as_array() + .is_some_and(|f| !f.is_empty() && f.iter().any(|x| x["yanked"] != true)) + }) + .map(|(v, _)| v.clone()) + .collect() + }) + .unwrap_or_default(); + Ok(Releases { + versions, + latest: json["info"]["version"].as_str().map(str::to_owned), + }) + } + + fn resolve(&self, dep: &Declared, releases: &Releases) -> (Option, Option) { + let specifiers = if dep.requirement.is_empty() { + VersionSpecifiers::from_str("").ok() + } else { + VersionSpecifiers::from_str(&dep.requirement).ok() + }; + let allow_pre = dep.requirement.contains(|c: char| c.is_ascii_alphabetic()); + let mut versions: Vec = releases + .versions + .iter() + .filter_map(|v| Version::from_str(v).ok()) + .filter(|v| allow_pre || !v.any_prerelease()) + .collect(); + versions.sort(); + let wanted = specifiers.and_then(|spec| { + versions + .iter() + .rev() + .find(|v| spec.contains(v)) + .map(ToString::to_string) + }); + let latest = releases + .latest + .clone() + .or_else(|| versions.last().map(ToString::to_string)); + (wanted, latest) + } + + fn bump(&self, from: &str, to: &str) -> Bump { + match (Version::from_str(from), Version::from_str(to)) { + (Ok(a), Ok(b)) if b > a => { + let part = |v: &Version, i: usize| v.release().get(i).copied().unwrap_or(0); + let bump = semver_bump( + (part(&a, 0), part(&a, 1), part(&a, 2)), + (part(&b, 0), part(&b, 1), part(&b, 2)), + ); + // Post-releases and dev builds change nothing in the first three parts. + if bump == Bump::None { + Bump::Patch + } else { + bump + } + } + _ => Bump::None, + } + } + + fn update(&self, dir: &Path, deps: &[&Dependency], latest: bool) -> Result, String> { + let tool = tool(dir).ok_or_else(|| { + "no uv, poetry or pdm lockfile: edit the requirements file to update".to_owned() + })?; + let names: Vec = deps.iter().map(|d| d.declared.name.clone()).collect(); + let argv: Vec = match (tool, latest) { + ("uv", false) => ["uv", "lock"] + .into_iter() + .map(str::to_owned) + .chain( + names + .iter() + .flat_map(|n| ["--upgrade-package".to_owned(), n.clone()]), + ) + .collect(), + ("uv", true) => std::iter::once("uv".to_owned()) + .chain(std::iter::once("add".to_owned())) + .chain(deps.iter().filter_map(|d| { + d.latest + .as_ref() + .map(|v| format!("{}>={v}", d.declared.name)) + })) + .collect(), + ("poetry", false) => std::iter::once("poetry".to_owned()) + .chain(std::iter::once("update".to_owned())) + .chain(names.iter().cloned()) + .collect(), + ("poetry", true) => std::iter::once("poetry".to_owned()) + .chain(std::iter::once("add".to_owned())) + .chain(names.iter().map(|n| format!("{n}@latest"))) + .collect(), + ("pdm", false) => std::iter::once("pdm".to_owned()) + .chain(std::iter::once("update".to_owned())) + .chain(names.iter().cloned()) + .collect(), + (_, _) => std::iter::once("pdm".to_owned()) + .chain(["update".to_owned(), "--unconstrained".to_owned()]) + .chain(names.iter().cloned()) + .collect(), + }; + let what = if latest { "upgrade" } else { "update" }; + Ok(vec![task( + dir, + what, + argv, + format!("{what} {}", names.join(", ")), + )]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + #[test] + fn normalizes_names() { + assert_eq!(normalize("Django_REST.framework"), "django-rest-framework"); + assert_eq!(normalize("ruamel.yaml"), "ruamel-yaml"); + } + + #[test] + fn splits_requirements() { + assert_eq!( + split_requirement("requests>=2.31"), + Some(("requests".into(), ">=2.31".into())) + ); + assert_eq!( + split_requirement("uvicorn[standard] (>=0.20,<1) ; python_version >= '3.9'"), + Some(("uvicorn".into(), ">=0.20,<1".into())) + ); + assert_eq!( + split_requirement("rich"), + Some(("rich".into(), String::new())) + ); + assert_eq!(split_requirement("pkg @ https://x/y.whl"), None); + assert_eq!(split_requirement("-r base.txt"), None); + assert_eq!(split_requirement("# comment"), None); + } + + #[test] + fn declared_from_pyproject_with_uv_lock() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"svc\"\ndependencies = [\"FastAPI>=0.100\", \"httpx\"]\n[dependency-groups]\ndev = [\"pytest>=8\"]\n", + ) + .unwrap(); + fs::write( + dir.path().join("uv.lock"), + "version = 1\n[[package]]\nname = \"fastapi\"\nversion = \"0.110.0\"\n[[package]]\nname = \"pytest\"\nversion = \"8.1.1\"\n", + ) + .unwrap(); + let deps = Python.declared(dir.path()).unwrap(); + let names: Vec<(&str, Option<&str>, DepKind)> = deps + .iter() + .map(|d| (d.name.as_str(), d.current.as_deref(), d.kind)) + .collect(); + assert_eq!( + names, + vec![ + ("fastapi", Some("0.110.0"), DepKind::Normal), + ("httpx", None, DepKind::Normal), + ("pytest", Some("8.1.1"), DepKind::Dev), + ] + ); + let task = Python.update(dir.path(), &[], false).unwrap(); + assert_eq!(task[0].program, "uv"); + } + + #[test] + fn requirements_pins_are_current_versions() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("requirements.txt"), + "flask==2.3.2\nclick>=8\n", + ) + .unwrap(); + let deps = Python.declared(dir.path()).unwrap(); + assert_eq!(deps[0].current.as_deref(), Some("2.3.2")); + assert_eq!(deps[1].current, None); + assert!( + Python.update(dir.path(), &[], false).is_err(), + "no lock tool to run" + ); + } + + #[test] + fn resolves_specifiers_and_skips_prereleases() { + let body = r#"{"info":{"version":"2.2.0"},"releases":{ + "1.9.0":[{"yanked":false}],"2.0.0":[{"yanked":false}],"2.1.0":[{"yanked":true}], + "2.2.0":[{"yanked":false}],"3.0.0a1":[{"yanked":false}],"0.1":[]}}"#; + let releases = Python.parse(body).unwrap(); + assert!(!releases.versions.contains(&"2.1.0".to_string()), "yanked"); + assert!(!releases.versions.contains(&"0.1".to_string()), "no files"); + let dep = Declared { + name: "x".into(), + requirement: ">=1.9,<2.1".into(), + current: Some("1.9.0".into()), + kind: DepKind::Normal, + source: Source::Pypi, + manifest: PathBuf::new(), + }; + assert_eq!( + Python.resolve(&dep, &releases), + (Some("2.0.0".into()), Some("2.2.0".into())) + ); + assert_eq!(Python.bump("1.9.0", "2.2.0"), Bump::Major); + assert_eq!(Python.bump("2.2.0", "2.2.0.post1"), Bump::Patch); + } +} diff --git a/crates/carwash-core/src/lib.rs b/crates/carwash-core/src/lib.rs index 1b3f20e..4299b40 100644 --- a/crates/carwash-core/src/lib.rs +++ b/crates/carwash-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod cache; pub mod clean; +pub mod deps; pub mod discover; pub mod ecosystem; pub mod fmt; diff --git a/crates/carwash/src/cli.rs b/crates/carwash/src/cli.rs index d1d89dd..083a854 100644 --- a/crates/carwash/src/cli.rs +++ b/crates/carwash/src/cli.rs @@ -58,6 +58,8 @@ pub enum Command { Tasks(TasksArgs), /// Run a task in every project that has it. Run(RunArgs), + /// Show outdated and vulnerable dependencies (Rust, JavaScript, Python, Go). + Outdated(OutdatedArgs), /// List the ecosystems carwash recognises. Ecosystems(JsonArgs), /// Show how much space carwash has reclaimed. @@ -262,6 +264,41 @@ pub struct RunArgs { pub dry_run: bool, } +#[derive(Debug, Args)] +pub struct OutdatedArgs { + #[command(flatten)] + pub walk: WalkArgs, + + /// Only projects of these ecosystems. + #[arg( + long = "ecosystem", + short = 'e', + value_name = "ID", + value_delimiter = ',' + )] + pub ecosystems: Vec, + + /// Also list dependencies that are up to date. + #[arg(long)] + pub all: bool, + + /// Skip the vulnerability lookup (OSV). + #[arg(long)] + pub no_vulns: bool, + + /// Ignore cached registry data. + #[arg(long)] + pub refresh: bool, + + /// Exit with status 1 when anything is outdated or vulnerable (for CI). + #[arg(long)] + pub exit_code: bool, + + /// Machine-readable output. + #[arg(long)] + pub json: bool, +} + #[derive(Debug, Args)] pub struct JsonArgs { /// Machine-readable output. diff --git a/crates/carwash/src/commands/mod.rs b/crates/carwash/src/commands/mod.rs index 61ccbc1..3883b33 100644 --- a/crates/carwash/src/commands/mod.rs +++ b/crates/carwash/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod clean; pub mod info; +pub mod outdated; pub mod scan; pub mod tasks; diff --git a/crates/carwash/src/commands/outdated.rs b/crates/carwash/src/commands/outdated.rs new file mode 100644 index 0000000..b669f42 --- /dev/null +++ b/crates/carwash/src/commands/outdated.rs @@ -0,0 +1,214 @@ +//! `carwash outdated`: outdated and vulnerable dependencies across projects. + +use super::{count, display_path}; +use crate::cli::OutdatedArgs; +use crate::context::Context; +use crate::table::{Align, Cell, Table}; +use anstyle::{AnsiColor, Style}; +use anyhow::{Context as _, Result}; +use carwash_core::deps::{Bump, CheckProgress, Checker, Dependency, ProjectSpec, Source}; +use carwash_core::{EcoId, Project}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::io::{IsTerminal, Write}; +use std::process::ExitCode; +use std::sync::atomic::Ordering; +use std::time::Duration; + +pub fn checker(ctx: &Context) -> Checker { + Checker::new( + ctx.dirs.as_ref().map(|d| d.cache.join("deps.json")), + Duration::from_secs(ctx.config.updates.cache_hours * 3600), + ) +} + +/// Projects whose ecosystems carwash can check. +pub fn checkable(ctx: &Context, project: &Project) -> bool { + project + .ecosystems + .iter() + .any(|&e| Source::for_ecosystem(&ctx.registry().ecosystem(e).key).is_some()) +} + +pub(crate) fn bump_cell(bump: Bump) -> Cell { + match bump { + Bump::Major => Cell::styled("major", AnsiColor::Red.on_default().bold()), + Bump::Minor => Cell::styled("minor", AnsiColor::Yellow.on_default()), + Bump::Patch => Cell::styled("patch", AnsiColor::Green.on_default()), + Bump::None => Cell::styled("", Style::new()), + } +} + +pub fn run(ctx: &Context, args: &OutdatedArgs) -> Result { + let ecosystems: Vec = args + .ecosystems + .iter() + .map(|key| { + ctx.registry() + .find(key) + .with_context(|| format!("unknown ecosystem `{key}` (see `carwash ecosystems`)")) + }) + .collect::>()?; + let (root, options) = ctx.scan_options(&args.walk, false, false)?; + let snapshot = ctx.run_scan(&root, &options, !args.json); + let projects: Vec<&Project> = snapshot + .projects + .iter() + .filter(|p| !p.outside_root && checkable(ctx, p)) + .filter(|p| ecosystems.is_empty() || p.ecosystems.iter().any(|e| ecosystems.contains(e))) + .collect(); + let specs: Vec = projects + .iter() + .map(|p| ProjectSpec { + path: p.path.clone(), + ecosystems: p.ecosystems.clone(), + }) + .collect(); + + let checker = checker(ctx); + let progress = CheckProgress::default(); + let bar = if !args.json && std::io::stderr().is_terminal() { + let bar = ProgressBar::new_spinner(); + bar.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}").expect("valid template"), + ); + bar.enable_steady_tick(Duration::from_millis(80)); + bar + } else { + ProgressBar::hidden() + }; + let results = std::thread::scope(|scope| { + let worker = scope.spawn(|| { + checker.check( + specs.as_slice(), + ctx.registry(), + !args.no_vulns, + args.refresh, + &progress, + ) + }); + while !worker.is_finished() { + bar.set_message(format!( + "Checking {} packages from {} projects ({} done)", + progress.total.load(Ordering::Relaxed), + specs.len(), + progress.done.load(Ordering::Relaxed) + )); + std::thread::sleep(Duration::from_millis(80)); + } + worker.join().expect("check thread") + }); + bar.finish_and_clear(); + if let Err(error) = checker.save() { + tracing::warn!(%error, "cannot save dependency cache"); + } + + let mut out = anstream::stdout().lock(); + if args.json { + serde_json::to_writer_pretty(&mut out, &results)?; + writeln!(out)?; + return Ok(ExitCode::SUCCESS); + } + + let dim = Style::new().dimmed(); + let bold = Style::new().bold(); + let (mut outdated, mut vulnerable, mut majors) = (0usize, 0usize, 0usize); + let mut shown_projects = 0usize; + let checked: Vec = results.iter().map(|r| r.path.clone()).collect(); + for (project, result) in projects.iter().zip(&results) { + let listed: Vec<&Dependency> = result.listed(&checked).collect(); + let rows: Vec<&Dependency> = listed + .iter() + .copied() + .filter(|d| args.all || d.is_outdated() || !d.vulnerabilities.is_empty()) + .collect(); + outdated += listed.iter().filter(|d| d.is_outdated()).count(); + majors += listed.iter().filter(|d| d.bump == Bump::Major).count(); + vulnerable += listed + .iter() + .filter(|d| !d.vulnerabilities.is_empty()) + .count(); + if rows.is_empty() && result.errors.is_empty() { + continue; + } + shown_projects += 1; + let label = if project.path == root { + project.name.clone() + } else { + display_path(&project.path, &root) + }; + writeln!(out, "{}{label}{}", bold.render(), bold.render_reset())?; + for error in &result.errors { + writeln!(out, " {}{error}{}", dim.render(), dim.render_reset())?; + } + if rows.is_empty() { + continue; + } + let mut table = Table::new(&[ + (" PACKAGE", Align::Left), + ("CURRENT", Align::Right), + ("WANTED", Align::Right), + ("LATEST", Align::Right), + ("BUMP", Align::Left), + ("KIND", Align::Left), + ("ADVISORIES", Align::Left), + ]); + for dep in rows { + let advisories = if dep.vulnerabilities.is_empty() { + Cell::styled(dep.error.clone().unwrap_or_default(), dim) + } else { + Cell::styled( + dep.vulnerabilities.join(" "), + AnsiColor::Red.on_default().bold(), + ) + }; + table.push(vec![ + Cell::new(format!(" {}", dep.declared.name)), + Cell::styled( + dep.declared.current.clone().unwrap_or_else(|| "-".into()), + dim, + ), + Cell::new(dep.wanted.clone().unwrap_or_default()), + Cell::styled(dep.latest.clone().unwrap_or_default(), bold), + bump_cell(dep.bump), + Cell::styled(dep.declared.kind.label(), dim), + advisories, + ]); + } + table.write(&mut out)?; + writeln!(out)?; + } + if shown_projects == 0 { + writeln!( + out, + "Everything is up to date across {} projects.", + count(projects.len() as u64) + )?; + } else { + let red = AnsiColor::Red.on_default().bold(); + write!( + out, + "{}{} outdated{} ({} major) in {} of {} projects", + bold.render(), + count(outdated as u64), + bold.render_reset(), + count(majors as u64), + count(shown_projects as u64), + count(projects.len() as u64) + )?; + if vulnerable > 0 { + write!( + out, + " · {}{} with known vulnerabilities{}", + red.render(), + count(vulnerable as u64), + red.render_reset() + )?; + } + writeln!(out)?; + } + Ok(if args.exit_code && (outdated > 0 || vulnerable > 0) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }) +} diff --git a/crates/carwash/src/config.rs b/crates/carwash/src/config.rs index cae274c..2c93e4f 100644 --- a/crates/carwash/src/config.rs +++ b/crates/carwash/src/config.rs @@ -10,9 +10,28 @@ use std::path::{Path, PathBuf}; pub struct Config { pub scan: ScanConfig, pub clean: CleanConfig, + pub updates: UpdatesConfig, pub ui: UiConfig, } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct UpdatesConfig { + /// Reuse registry lookups for this many hours. + pub cache_hours: u64, + /// Also look up known vulnerabilities (OSV). + pub vulnerabilities: bool, +} + +impl Default for UpdatesConfig { + fn default() -> Self { + Self { + cache_hours: 6, + vulnerabilities: true, + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct ScanConfig { diff --git a/crates/carwash/src/main.rs b/crates/carwash/src/main.rs index 2dc325b..631cf74 100644 --- a/crates/carwash/src/main.rs +++ b/crates/carwash/src/main.rs @@ -78,6 +78,7 @@ fn run(cli: Cli) -> anyhow::Result { Some(Command::Clean(args)) => commands::clean::run(&ctx, args), Some(Command::Tasks(args)) => commands::tasks::list(&ctx, args), Some(Command::Run(args)) => commands::tasks::run(&ctx, args), + Some(Command::Outdated(args)) => commands::outdated::run(&ctx, args), Some(Command::Ecosystems(args)) => commands::info::ecosystems(&ctx, args), Some(Command::History(args)) => commands::info::history(&ctx, args), Some(Command::Completions { .. }) => unreachable!("handled above"), From 734ee66c0380ba8786bb2c6364add201f72245a5 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:06:04 -0400 Subject: [PATCH 11/22] chore: update anyhow (RUSTSEC-2026-0190) and clap Applied from carwash's own Updates tab. --- Cargo.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43cdf3f..8c8ae21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -344,9 +344,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -354,9 +354,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -376,14 +376,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -734,7 +734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1482,7 +1482,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2084,7 +2084,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2415,7 +2415,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2438,7 +2438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] From 85b970926d6bb9539cef267b7078a15fa30cfba6 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:06:05 -0400 Subject: [PATCH 12/22] feat(tui): Updates tab Projects with status badges (outdated count by worst bump, vulnerable count), dependencies sorted vulnerable-first with current/wanted/latest, marking, `u` to update within requirements and `U` (twice) to raise them to latest. Updates run as jobs in the Tasks tab and the project is checked again when they finish. Shared pane/spinner helpers move to widgets.rs. --- crates/carwash/src/tui/app.rs | 103 +++- crates/carwash/src/tui/keymap.rs | 113 ++++- crates/carwash/src/tui/mod.rs | 78 ++- crates/carwash/src/tui/tasks.rs | 46 +- crates/carwash/src/tui/updates.rs | 803 ++++++++++++++++++++++++++++++ crates/carwash/src/tui/view.rs | 41 +- crates/carwash/src/tui/widgets.rs | 28 ++ 7 files changed, 1145 insertions(+), 67 deletions(-) create mode 100644 crates/carwash/src/tui/updates.rs create mode 100644 crates/carwash/src/tui/widgets.rs diff --git a/crates/carwash/src/tui/app.rs b/crates/carwash/src/tui/app.rs index 45e397c..72d1884 100644 --- a/crates/carwash/src/tui/app.rs +++ b/crates/carwash/src/tui/app.rs @@ -5,8 +5,10 @@ use super::query::{Fuzzy, Query}; use super::store::{self, EntryStatus, Grouping, RowKey, Rows, SortKey, Store, View}; use super::tasks::{JobStatus, RunTarget, TasksState}; use super::theme::{Glyphs, Theme}; +use super::updates::UpdatesState; use carwash_core::cache::SizeCache; use carwash_core::clean::{CleanEvent, CleanItem, CleanReport, DeleteMode}; +use carwash_core::deps::{CheckProgress, Dependency, ProjectDeps, ProjectSpec}; use carwash_core::history::Record; use carwash_core::select::{Hold, Policy}; use carwash_core::tasks::Task; @@ -33,11 +35,24 @@ pub enum Msg { Scan(ScanEvent), Clean(CleanEvent), CleanFinished(CleanReport), - Disk { free: u64, total: u64 }, + Disk { + free: u64, + total: u64, + }, TasksDiscovered(PathBuf, Vec), - JobStarted { id: u64, label: String, task: Task }, + JobStarted { + id: u64, + label: String, + task: Task, + /// Project whose dependencies to check again when the job ends. + recheck: Option, + }, JobOutput(u64, Vec), JobExited(u64, Result), + DepsProgress(Arc), + DepsChecked(Vec), + /// Applying updates to the project at the path could not start. + UpdateFailed(PathBuf, String), } #[derive(Debug)] @@ -64,6 +79,18 @@ pub enum Effect { size: (u16, u16), }, KillJob(u64), + CheckDeps { + specs: Vec, + refresh: bool, + }, + /// Updates `deps` of the project at `dir` (to latest when `latest`), as jobs. + ApplyUpdates { + dir: PathBuf, + label: String, + deps: Vec, + latest: bool, + size: (u16, u16), + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -163,6 +190,7 @@ pub struct App { pub spinner: usize, pub tab: Tab, pub tasks: TasksState, + pub updates: UpdatesState, /// Quit was pressed once while jobs were running. quit_armed: bool, pub quit: bool, @@ -222,6 +250,7 @@ impl App { spinner: 0, tab: Tab::Reclaim, tasks: TasksState::default(), + updates: UpdatesState::default(), quit_armed: false, quit: false, dirty: true, @@ -234,6 +263,7 @@ impl App { || matches!(&self.mode, Mode::Review(r) if matches!(r.phase, ReviewPhase::Running { .. })) || self.toast.is_some() || self.tasks.running() > 0 + || !self.updates.checking.is_empty() } pub(crate) fn toast(&mut self, text: impl Into, level: Level) { @@ -334,8 +364,26 @@ impl App { self.tasks.tasks.insert(path, Some(tasks)); Vec::new() } - Msg::JobStarted { id, label, task } => { - self.tasks.add_job(id, label, task); + Msg::JobStarted { + id, + label, + task, + recheck, + } => { + self.tasks.add_job(id, label, task, recheck); + Vec::new() + } + Msg::DepsProgress(progress) => { + self.updates.progress = Some(progress); + Vec::new() + } + Msg::DepsChecked(results) => { + self.updates.finish_check(results); + Vec::new() + } + Msg::UpdateFailed(path, error) => { + self.updates.updating.remove(&path); + self.toast(error, Level::Warn); Vec::new() } Msg::JobOutput(id, bytes) => { @@ -366,6 +414,20 @@ impl App { self.toast(text, level); } } + let recheck = self + .tasks + .jobs + .iter() + .find(|j| j.id == id) + .and_then(|j| j.recheck.clone()); + // Once every job for the project has finished, look at its dependencies again. + if let Some(path) = recheck + && !self.tasks.jobs.iter().any(|j| { + j.status == JobStatus::Running && j.recheck.as_ref() == Some(&path) + }) + { + return self.recheck(&path); + } Vec::new() } } @@ -424,10 +486,15 @@ impl App { self.toast(format!("Theme: {}", self.theme.name), Level::Info); } Action::Help => self.mode = Mode::Help, + Action::ShowUpdates => { + self.tab = Tab::Updates; + return self.updates_effects(); + } Action::Search => { let current = match self.tab { Tab::Reclaim => self.query.raw.clone(), Tab::Tasks => self.tasks.filter.clone(), + Tab::Updates => self.updates.filter.clone(), }; self.input = Input::new(current); self.mode = Mode::Search; @@ -445,16 +512,19 @@ impl App { return vec![Effect::CancelScan]; } } - action => { - return match self.tab { - Tab::Reclaim => self.on_action(action), - Tab::Tasks => self.on_tasks_action(action), - }; - } + action => return self.on_tab_action(action), } Vec::new() } + fn on_tab_action(&mut self, action: Action) -> Vec { + match self.tab { + Tab::Reclaim => self.on_action(action), + Tab::Tasks => self.on_tasks_action(action), + Tab::Updates => self.on_updates_action(action), + } + } + fn on_search_key(&mut self, key: KeyEvent) -> Vec { match key.code { KeyCode::Esc => { @@ -469,10 +539,7 @@ impl App { } else { Action::Down }; - return match self.tab { - Tab::Reclaim => self.on_action(action), - Tab::Tasks => self.on_tasks_action(action), - }; + return self.on_tab_action(action); } _ => { if self.input.handle_event(&Event::Key(key)).is_some() { @@ -481,16 +548,18 @@ impl App { } } } - if self.tab == Tab::Tasks { - return self.tasks_effects(); + match self.tab { + Tab::Reclaim => Vec::new(), + Tab::Tasks => self.tasks_effects(), + Tab::Updates => self.updates_effects(), } - Vec::new() } fn apply_search(&mut self, value: String) { match self.tab { Tab::Reclaim => self.set_query(value), Tab::Tasks => self.tasks.set_filter(value), + Tab::Updates => self.updates.set_filter(value), } } diff --git a/crates/carwash/src/tui/keymap.rs b/crates/carwash/src/tui/keymap.rs index 6831258..285be98 100644 --- a/crates/carwash/src/tui/keymap.rs +++ b/crates/carwash/src/tui/keymap.rs @@ -32,8 +32,13 @@ pub enum Action { NextJob, PreviousJob, ClearJobs, + Check, + CheckAll, + Update, + Upgrade, ShowReclaim, ShowTasks, + ShowUpdates, Theme, Help, Quit, @@ -44,15 +49,17 @@ pub enum Action { pub enum Tab { Reclaim, Tasks, + Updates, } impl Tab { - pub const ALL: [Tab; 2] = [Tab::Reclaim, Tab::Tasks]; + pub const ALL: [Tab; 3] = [Tab::Reclaim, Tab::Tasks, Tab::Updates]; pub fn title(self) -> &'static str { match self { Tab::Reclaim => "Reclaim", Tab::Tasks => "Tasks", + Tab::Updates => "Updates", } } @@ -60,6 +67,7 @@ impl Tab { match self { Tab::Reclaim => RECLAIM, Tab::Tasks => TASKS, + Tab::Updates => UPDATES, } } } @@ -71,6 +79,7 @@ pub enum Section { Select, View, Jobs, + Updates, General, } @@ -81,6 +90,7 @@ impl Section { Self::Select => "Select & clean", Self::View => "View", Self::Jobs => "Tasks & jobs", + Self::Updates => "Dependencies", Self::General => "General", } } @@ -172,6 +182,13 @@ pub const GLOBAL: &[Binding] = &[ "reclaim space", ), bind(&[(Char('2'), NONE)], "2", A::ShowTasks, S::General, "tasks"), + bind( + &[(Char('3'), NONE)], + "3", + A::ShowUpdates, + S::General, + "dependency updates", + ), bind( &[(Char('t'), NONE)], "t", @@ -392,6 +409,100 @@ pub const TASKS: &[Binding] = &[ ), ]; +pub const UPDATES: &[Binding] = &[ + MOVE[0], + MOVE[1], + MOVE[2], + MOVE[3], + MOVE[4], + MOVE[5], + bind( + &[ + (TabKey, NONE), + (KeyCode::BackTab, KeyModifiers::SHIFT), + (KeyCode::BackTab, NONE), + (Right, NONE), + (Left, NONE), + (Char('l'), NONE), + (Char('h'), NONE), + ], + "Tab ← →", + A::NextPane, + S::Navigate, + "switch pane", + ), + bind( + &[(Char('/'), NONE)], + "/", + A::Search, + S::Navigate, + "filter projects", + ), + bind( + &[(Enter, NONE)], + "Enter", + A::Run, + S::Updates, + "check the project (or marked projects)", + ), + bind( + &[(Char('c'), NONE)], + "c", + A::Check, + S::Updates, + "check again, bypassing the cache", + ), + bind( + &[(Char('C'), NONE)], + "C", + A::CheckAll, + S::Updates, + "check every project listed", + ), + bind( + &[(Char(' '), NONE)], + "Space", + A::Mark, + S::Updates, + "mark project / outdated dependency", + ), + bind( + &[(Char('a'), NONE)], + "a", + A::MarkAll, + S::Updates, + "mark all (projects or outdated dependencies)", + ), + bind( + &[(Char('A'), NONE)], + "A", + A::Unmark, + S::Updates, + "unmark everything", + ), + bind( + &[(Char('u'), NONE)], + "u", + A::Update, + S::Updates, + "update marked within their requirements", + ), + bind( + &[(Char('U'), NONE)], + "U", + A::Upgrade, + S::Updates, + "upgrade marked to latest (edits manifests; press twice)", + ), + bind( + &[(Char('o'), NONE)], + "o", + A::Open, + S::Updates, + "reveal project in file manager", + ), +]; + /// Bindings shown in the help screen for `tab`, in display order. pub fn help(tab: Tab) -> impl Iterator { tab.bindings().iter().chain(GLOBAL) diff --git a/crates/carwash/src/tui/mod.rs b/crates/carwash/src/tui/mod.rs index e71add1..02d69a7 100644 --- a/crates/carwash/src/tui/mod.rs +++ b/crates/carwash/src/tui/mod.rs @@ -10,12 +10,16 @@ mod query; mod store; mod tasks; mod theme; +mod updates; mod view; +mod widgets; use crate::context::Context; use anyhow::Result; use app::{App, Effect, Msg}; use carwash_core::clean::CleanOptions; +use carwash_core::deps::Checker; +use carwash_core::tasks::Task; use carwash_core::{Cancel, Counters, ScanOptions, history}; use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event}; @@ -68,6 +72,7 @@ pub fn run(ctx: &Context, root: PathBuf, options: ScanOptions) -> Result<()> { jobs: HashMap::new(), next_job: 0, pty_size: (24, 80), + checker: Arc::new(crate::commands::outdated::checker(ctx)), }; runtime.execute(Effect::Scan); runtime.execute(Effect::RefreshDisk); @@ -159,6 +164,7 @@ struct Runtime<'a> { jobs: HashMap, next_job: u64, pty_size: (u16, u16), + checker: Arc, } impl Runtime<'_> { @@ -174,6 +180,26 @@ impl Runtime<'_> { } } + /// Runs `task` on a new terminal; its output streams into the Tasks tab. + fn start_job(&mut self, label: String, task: Task, size: (u16, u16), recheck: Option) { + let id = self.next_job; + self.next_job += 1; + let _ = self.tx.send(Msg::JobStarted { + id, + label, + task: task.clone(), + recheck, + }); + match pty::spawn(id, &task, size, self.tx.clone()) { + Ok(job) => { + self.jobs.insert(id, job); + } + Err(error) => { + let _ = self.tx.send(Msg::JobExited(id, Err(error))); + } + } + } + /// Keeps every job's terminal the size of the output pane. fn resize_jobs(&mut self, size: (u16, u16)) { if size != self.pty_size { @@ -263,21 +289,47 @@ impl Runtime<'_> { else { continue; }; - let id = self.next_job; - self.next_job += 1; - let _ = self.tx.send(Msg::JobStarted { - id, - label: target.label, - task: task.clone(), - }); - match pty::spawn(id, &task, size, self.tx.clone()) { - Ok(job) => { - self.jobs.insert(id, job); - } - Err(error) => { - let _ = self.tx.send(Msg::JobExited(id, Err(error))); + self.start_job(target.label, task, size, None); + } + } + Effect::CheckDeps { specs, refresh } => { + let (checker, registry, tx) = ( + self.checker.clone(), + self.ctx.engine.registry().clone(), + self.tx.clone(), + ); + let vulnerabilities = self.ctx.config.updates.vulnerabilities; + std::thread::spawn(move || { + let progress = Arc::new(carwash_core::deps::CheckProgress::default()); + let _ = tx.send(Msg::DepsProgress(progress.clone())); + let results = + checker.check(&specs, ®istry, vulnerabilities, refresh, &progress); + if let Err(error) = checker.save() { + tracing::warn!(%error, "cannot save dependency cache"); + } + let _ = tx.send(Msg::DepsChecked(results)); + }); + } + Effect::ApplyUpdates { + dir, + label, + deps, + latest, + size, + } => { + let refs: Vec<&carwash_core::deps::Dependency> = deps.iter().collect(); + match carwash_core::deps::update_tasks(&dir, &refs, latest) { + Ok(tasks) => { + for task in tasks { + self.start_job(label.clone(), task, size, Some(dir.clone())); } } + Err(error) => { + let _ = self.tx.send(Msg::UpdateFailed( + dir, + format!("Cannot update {label}: {error}"), + )); + } } } Effect::KillJob(id) => { diff --git a/crates/carwash/src/tui/tasks.rs b/crates/carwash/src/tui/tasks.rs index 6febf59..f3062c6 100644 --- a/crates/carwash/src/tui/tasks.rs +++ b/crates/carwash/src/tui/tasks.rs @@ -3,7 +3,7 @@ use super::app::{App, Effect, Level}; use super::keymap::Action; use super::query::Fuzzy; -use super::theme::Theme; +use super::widgets::{pane, spinner}; use carwash_core::tasks::Task; use carwash_core::{EcoId, ProjectId}; use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; @@ -11,7 +11,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Modifier; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -42,6 +42,8 @@ pub struct Job { pub status: JobStatus, started: Instant, pub elapsed: Option, + /// Project whose dependencies are checked again when this job ends. + pub recheck: Option, } impl std::fmt::Debug for Job { @@ -120,7 +122,7 @@ impl TasksState { self.jobs.iter_mut().find(|j| j.id == id) } - pub fn add_job(&mut self, id: u64, label: String, task: Task) { + pub fn add_job(&mut self, id: u64, label: String, task: Task, recheck: Option) { let (rows, cols) = self.pty_size; self.jobs.push(Job { id, @@ -130,6 +132,7 @@ impl TasksState { status: JobStatus::Running, started: Instant::now(), elapsed: None, + recheck, }); self.active = self.jobs.len() - 1; } @@ -390,26 +393,6 @@ impl App { } } -fn pane_block<'a>(theme: &Theme, title: String, focused: bool) -> Block<'a> { - let color = if focused { theme.accent } else { theme.border }; - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(theme.fg(color)) - .title(Span::styled( - format!(" {title} "), - if focused { - theme.bold(theme.accent) - } else { - theme.muted() - }, - )) -} - -fn spinner(app: &App) -> &'static str { - app.glyphs.spinner[app.spinner % app.glyphs.spinner.len()] -} - pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { app.ensure_task_projects(); let t = app.theme; @@ -462,7 +445,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { let mut state = ListState::default().with_selected(Some(app.tasks.project_cursor)); frame.render_stateful_widget( List::new(items) - .block(pane_block( + .block(pane( &t, title, app.tasks.focus == super::tasks::Pane::Projects, @@ -511,11 +494,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { ListState::default().with_selected(focus_tasks.then_some(app.tasks.task_cursor)); frame.render_stateful_widget( List::new(items) - .block(pane_block( - &t, - format!("Tasks {}", tasks.len()), - focus_tasks, - )) + .block(pane(&t, format!("Tasks {}", tasks.len()), focus_tasks)) .highlight_style(t.selected_row()), tasks_area, &mut state, @@ -523,7 +502,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { } Some(_) => frame.render_widget( Paragraph::new(Span::styled("No tasks found for this project.", t.subtle())) - .block(pane_block(&t, "Tasks".into(), focus_tasks)), + .block(pane(&t, "Tasks".into(), focus_tasks)), tasks_area, ), None => frame.render_widget( @@ -535,7 +514,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { }, t.subtle(), )) - .block(pane_block(&t, "Tasks".into(), focus_tasks)), + .block(pane(&t, "Tasks".into(), focus_tasks)), tasks_area, ), } @@ -552,7 +531,7 @@ fn render_output(frame: &mut Frame, app: &mut App, area: Rect) { } else { format!("Output · {} jobs, {running} running", app.tasks.jobs.len()) }; - let block = pane_block(&t, title, focused); + let block = pane(&t, title, focused); let inner = block.inner(area); frame.render_widget(block, area); if app.tasks.jobs.is_empty() { @@ -705,6 +684,7 @@ mod tests { id: 7, label: "a".into(), task: task("test", "/w/a"), + recheck: None, }); assert_eq!(app.tasks.running(), 1); assert!(app.animating()); @@ -727,6 +707,7 @@ mod tests { id: 8, label: "b".into(), task: task("x", "/w/b"), + recheck: None, }); app.update(Msg::JobExited(8, Err("`just` not found".into()))); assert!( @@ -748,6 +729,7 @@ mod tests { id: 1, label: "a".into(), task: task("dev", "/w/a"), + recheck: None, }); press(&mut app, KeyCode::Char('q')); assert!(!app.quit); diff --git a/crates/carwash/src/tui/updates.rs b/crates/carwash/src/tui/updates.rs new file mode 100644 index 0000000..fa16541 --- /dev/null +++ b/crates/carwash/src/tui/updates.rs @@ -0,0 +1,803 @@ +//! The Updates tab: outdated and vulnerable dependencies per project, and applying updates. + +use super::app::{App, Effect, Level}; +use super::keymap::Action; +use super::query::Fuzzy; +use super::widgets::{pane, spinner}; +use carwash_core::deps::{Bump, CheckProgress, Dependency, ProjectDeps, ProjectSpec, Source}; +use carwash_core::{Project, ProjectId}; +use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState, Wrap}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pane { + Projects, + Dependencies, +} + +#[derive(Debug)] +pub struct UpdatesState { + pub focus: Pane, + pub filter: String, + pub projects: Vec, + built_for: Option<(u64, String)>, + pub project_cursor: usize, + project_key: Option, + pub marked_projects: HashSet, + pub results: HashMap, + pub checking: HashSet, + pub updating: HashSet, + pub dep_cursor: usize, + /// Marked dependencies as (project path, package name). + pub marked: HashSet<(PathBuf, String)>, + upgrade_armed: bool, + pub progress: Option>, + fuzzy: Fuzzy, +} + +impl Default for UpdatesState { + fn default() -> Self { + Self { + focus: Pane::Projects, + filter: String::new(), + projects: Vec::new(), + built_for: None, + project_cursor: 0, + project_key: None, + marked_projects: HashSet::new(), + results: HashMap::new(), + checking: HashSet::new(), + updating: HashSet::new(), + dep_cursor: 0, + marked: HashSet::new(), + upgrade_armed: false, + progress: None, + fuzzy: Fuzzy::default(), + } + } +} + +impl UpdatesState { + pub fn set_filter(&mut self, filter: String) { + self.filter = filter; + self.project_cursor = 0; + self.project_key = None; + } + + pub fn finish_check(&mut self, results: Vec) { + for result in results { + self.checking.remove(&result.path); + self.results.insert(result.path.clone(), result); + } + if self.checking.is_empty() { + self.progress = None; + } + } + + /// (outdated, vulnerable) across every checked project. + pub fn totals(&self) -> (usize, usize) { + self.results + .values() + .flat_map(|r| &r.dependencies) + .fold((0, 0), |(o, v), d| { + ( + o + usize::from(d.is_outdated()), + v + usize::from(!d.vulnerabilities.is_empty()), + ) + }) + } +} + +/// Dependencies in display order: vulnerable, then by bump size, then name. +pub fn sorted(result: &ProjectDeps) -> Vec<&Dependency> { + let mut deps: Vec<&Dependency> = result.dependencies.iter().collect(); + deps.sort_by(|a, b| { + a.vulnerabilities + .is_empty() + .cmp(&b.vulnerabilities.is_empty()) + .then(b.bump.cmp(&a.bump)) + .then(a.declared.name.cmp(&b.declared.name)) + }); + deps +} + +fn checkable(app: &App, project: &Project) -> bool { + project + .ecosystems + .iter() + .any(|&e| Source::for_ecosystem(&app.registry.ecosystem(e).key).is_some()) +} + +impl App { + pub fn ensure_update_projects(&mut self) { + let stamp = (self.store.revision, self.updates.filter.clone()); + if self.updates.built_for.as_ref() == Some(&stamp) { + return; + } + let pattern = (!self.updates.filter.trim().is_empty()).then(|| { + Pattern::parse( + &self.updates.filter, + CaseMatching::Smart, + Normalization::Smart, + ) + }); + let mut projects: Vec<(String, ProjectId)> = Vec::new(); + for project in &self.store.projects { + if !checkable(self, project) { + continue; + } + let rel = self.store.relative(&project.path).into_owned(); + let keep = pattern.as_ref().is_none_or(|pattern| { + self.updates + .fuzzy + .matches_text(pattern, &format!("{rel} {}", project.name)) + }); + if keep { + projects.push((rel, project.id)); + } + } + projects.sort(); + self.updates.projects = projects.into_iter().map(|(_, id)| id).collect(); + self.updates.built_for = Some(stamp); + if let Some(position) = self + .updates + .project_key + .and_then(|key| self.updates.projects.iter().position(|&p| p == key)) + { + self.updates.project_cursor = position; + } + self.updates.project_cursor = self + .updates + .project_cursor + .min(self.updates.projects.len().saturating_sub(1)); + } + + fn update_project(&self) -> Option<&Project> { + self.updates + .projects + .get(self.updates.project_cursor) + .and_then(|&id| self.store.project(id)) + } + + fn spec(&self, id: ProjectId) -> Option { + self.store.project(id).map(|p| ProjectSpec { + path: p.path.clone(), + ecosystems: p.ecosystems.clone(), + }) + } + + /// Checks `ids`, skipping projects already being checked. + fn check(&mut self, ids: Vec, refresh: bool) -> Vec { + let specs: Vec = ids + .into_iter() + .filter_map(|id| self.spec(id)) + .filter(|s| !self.updates.checking.contains(&s.path)) + .collect(); + if specs.is_empty() { + return Vec::new(); + } + self.updates + .checking + .extend(specs.iter().map(|s| s.path.clone())); + vec![Effect::CheckDeps { specs, refresh }] + } + + /// Re-checks the project at `path` (after an update job finished). + pub fn recheck(&mut self, path: &PathBuf) -> Vec { + self.updates.updating.remove(path); + match self + .store + .projects + .iter() + .find(|p| &p.path == path) + .map(|p| p.id) + { + Some(id) => self.check(vec![id], false), + None => Vec::new(), + } + } + + /// Dependencies of the current project, in display order. + fn current_deps(&self) -> Vec { + self.update_project() + .and_then(|p| self.updates.results.get(&p.path)) + .map(|r| sorted(r).into_iter().cloned().collect()) + .unwrap_or_default() + } + + /// Opening the tab checks the selected project once. + pub fn updates_effects(&mut self) -> Vec { + self.ensure_update_projects(); + match self.update_project() { + Some(project) + if !self.updates.results.contains_key(&project.path) + && !self.updates.checking.contains(&project.path) => + { + let id = project.id; + self.check(vec![id], false) + } + _ => Vec::new(), + } + } + + pub fn on_updates_action(&mut self, action: Action) -> Vec { + self.ensure_update_projects(); + if !matches!(action, Action::Upgrade) { + self.updates.upgrade_armed = false; + } + let deps = self.current_deps(); + let page = self.page.max(1) as isize; + let focus = self.updates.focus; + let mut move_to = |delta: isize, absolute: Option| { + let state = &mut self.updates; + match focus { + Pane::Projects => { + let last = state.projects.len().saturating_sub(1); + state.project_cursor = absolute + .unwrap_or_else(|| state.project_cursor.saturating_add_signed(delta)) + .min(last); + state.project_key = state.projects.get(state.project_cursor).copied(); + state.dep_cursor = 0; + } + Pane::Dependencies => { + let last = deps.len().saturating_sub(1); + state.dep_cursor = absolute + .unwrap_or_else(|| state.dep_cursor.saturating_add_signed(delta)) + .min(last); + } + } + }; + match action { + Action::Up => move_to(-1, None), + Action::Down => move_to(1, None), + Action::PageUp => move_to(-page, None), + Action::PageDown => move_to(page, None), + Action::Top => move_to(0, Some(0)), + Action::Bottom => move_to(0, Some(usize::MAX)), + Action::NextPane | Action::PreviousPane => { + self.updates.focus = match self.updates.focus { + Pane::Projects => Pane::Dependencies, + Pane::Dependencies => Pane::Projects, + }; + } + Action::Mark => match self.updates.focus { + Pane::Projects => { + if let Some(id) = self.update_project().map(|p| p.id) { + if !self.updates.marked_projects.remove(&id) { + self.updates.marked_projects.insert(id); + } + return self.on_updates_action(Action::Down); + } + } + Pane::Dependencies => { + if let (Some(project), Some(dep)) = + (self.update_project(), deps.get(self.updates.dep_cursor)) + { + let key = (project.path.clone(), dep.declared.name.clone()); + if !dep.is_outdated() { + self.toast(format!("{} is up to date", dep.declared.name), Level::Info); + } else if !self.updates.marked.remove(&key) { + self.updates.marked.insert(key); + } + return self.on_updates_action(Action::Down); + } + } + }, + Action::MarkAll => match self.updates.focus { + Pane::Projects => { + self.updates + .marked_projects + .extend(self.updates.projects.iter().copied()); + } + Pane::Dependencies => { + if let Some(path) = self.update_project().map(|p| p.path.clone()) { + for dep in deps.iter().filter(|d| d.is_outdated()) { + self.updates + .marked + .insert((path.clone(), dep.declared.name.clone())); + } + } + } + }, + Action::Unmark => { + self.updates.marked.clear(); + self.updates.marked_projects.clear(); + } + Action::Check | Action::Run if self.updates.focus == Pane::Projects => { + let ids: Vec = if self.updates.marked_projects.is_empty() { + self.update_project().map(|p| p.id).into_iter().collect() + } else { + self.updates.marked_projects.iter().copied().collect() + }; + return self.check(ids, true); + } + Action::Run => self.updates.focus = Pane::Projects, + Action::Check => { + let ids = self.update_project().map(|p| p.id).into_iter().collect(); + return self.check(ids, true); + } + Action::CheckAll => { + let ids = self.updates.projects.clone(); + let n = ids.len(); + self.toast(format!("Checking {n} projects"), Level::Info); + return self.check(ids, false); + } + Action::Update => return self.apply_updates(&deps, false), + Action::Upgrade => { + if !self.updates.upgrade_armed { + self.updates.upgrade_armed = true; + let n = self.selected_updates(&deps).len(); + self.toast( + format!("Press U again to raise {n} requirements to the latest versions (edits manifests)"), + Level::Warn, + ); + return Vec::new(); + } + self.updates.upgrade_armed = false; + return self.apply_updates(&deps, true); + } + Action::Open => { + if let Some(path) = self.update_project().map(|p| p.path.clone()) { + return vec![Effect::Reveal(path)]; + } + } + _ => {} + } + self.updates_effects() + } + + /// Marked outdated deps of the current project, or the one under the cursor. + fn selected_updates(&self, deps: &[Dependency]) -> Vec { + let Some(project) = self.update_project() else { + return Vec::new(); + }; + let marked: Vec = deps + .iter() + .filter(|d| { + d.is_outdated() + && self + .updates + .marked + .contains(&(project.path.clone(), d.declared.name.clone())) + }) + .cloned() + .collect(); + if !marked.is_empty() { + return marked; + } + deps.get(self.updates.dep_cursor) + .filter(|d| d.is_outdated() && self.updates.focus == Pane::Dependencies) + .cloned() + .into_iter() + .collect() + } + + fn apply_updates(&mut self, deps: &[Dependency], latest: bool) -> Vec { + let selected = self.selected_updates(deps); + let Some(project) = self.update_project() else { + return Vec::new(); + }; + if selected.is_empty() { + self.toast( + "Mark outdated dependencies with Space (a marks all), or select one", + Level::Warn, + ); + return Vec::new(); + } + let (path, label) = ( + project.path.clone(), + if project.path == self.store.root { + project.name.clone() + } else { + self.store.relative(&project.path).into_owned() + }, + ); + for dep in &selected { + self.updates + .marked + .remove(&(path.clone(), dep.declared.name.clone())); + } + self.updates.updating.insert(path.clone()); + self.toast( + format!( + "{} {} in {label}: output in the Tasks tab (2)", + if latest { "Upgrading" } else { "Updating" }, + crate::commands::count(selected.len() as u64) + ), + Level::Info, + ); + vec![Effect::ApplyUpdates { + dir: path, + label, + deps: selected, + latest, + size: self.tasks.pty_size, + }] + } +} + +/// A version for display: build metadata (`+spec-1.1.0`) does not affect precedence and +/// only crowds the column. +fn version(v: Option<&str>) -> String { + match v { + Some(v) => v.split('+').next().unwrap_or(v).to_owned(), + None => "-".to_owned(), + } +} + +fn bump_span(app: &App, bump: Bump) -> Span<'static> { + let t = &app.theme; + match bump { + Bump::Major => Span::styled("major", t.bold(t.error)), + Bump::Minor => Span::styled("minor", t.fg(t.warning)), + Bump::Patch => Span::styled("patch", t.fg(t.success)), + Bump::None => Span::raw(""), + } +} + +fn project_status(app: &App, project: &Project) -> Span<'static> { + let t = &app.theme; + if app.updates.checking.contains(&project.path) || app.updates.updating.contains(&project.path) + { + return Span::styled(format!("{:>4}", spinner(app)), t.fg(t.accent)); + } + let Some(result) = app.updates.results.get(&project.path) else { + return Span::styled(" ·", t.muted()); + }; + let vulnerable = result + .dependencies + .iter() + .filter(|d| !d.vulnerabilities.is_empty()) + .count(); + let outdated: Vec<&Dependency> = result + .dependencies + .iter() + .filter(|d| d.is_outdated()) + .collect(); + let worst = outdated.iter().map(|d| d.bump).max().unwrap_or_default(); + if vulnerable > 0 { + Span::styled(format!("{:>3}!", vulnerable), t.bold(t.error)) + } else if !outdated.is_empty() { + let color = match worst { + Bump::Major => t.error, + Bump::Minor => t.warning, + _ => t.success, + }; + Span::styled(format!("{:>3}↑", outdated.len()), t.fg(color)) + } else if !result.errors.is_empty() { + Span::styled(" ?", t.fg(t.warning)) + } else { + Span::styled(" ✓", t.fg(t.success)) + } +} + +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + app.ensure_update_projects(); + let t = app.theme; + let [left, right] = + Layout::horizontal([Constraint::Percentage(34), Constraint::Percentage(66)]).areas(area); + + let items: Vec = app + .updates + .projects + .iter() + .filter_map(|&id| app.store.project(id)) + .map(|project| { + let mark = if app.updates.marked_projects.contains(&project.id) { + Span::styled(format!("{} ", app.glyphs.marked), t.bold(t.accent2)) + } else { + Span::styled(format!("{} ", app.glyphs.unmarked), t.muted()) + }; + let label = if project.path == app.store.root { + project.name.clone() + } else { + app.store.relative(&project.path).into_owned() + }; + ListItem::new(Line::from(vec![ + mark, + project_status(app, project), + Span::raw(" "), + Span::styled(label, t.text()), + ])) + }) + .collect(); + let mut title = format!("Projects {}", app.updates.projects.len()); + if let Some(progress) = &app.updates.progress { + title.push_str(&format!( + " · checking {}/{}", + progress.done.load(Ordering::Relaxed), + progress.total.load(Ordering::Relaxed) + )); + } + if !app.updates.filter.is_empty() { + title.push_str(&format!(" · “{}”", app.updates.filter)); + } + let mut state = ListState::default().with_selected(Some(app.updates.project_cursor)); + frame.render_stateful_widget( + List::new(items) + .block(pane(&t, title, app.updates.focus == Pane::Projects)) + .highlight_style(t.selected_row()), + left, + &mut state, + ); + + let focused = app.updates.focus == Pane::Dependencies; + let Some(project) = app.update_project() else { + frame.render_widget( + Paragraph::new(Span::styled( + "No Rust, JavaScript, Python or Go projects here.", + t.subtle(), + )) + .block(pane(&t, "Dependencies".into(), focused)), + right, + ); + return; + }; + let path = project.path.clone(); + let Some(result) = app.updates.results.get(&path) else { + let message = if app.updates.checking.contains(&path) { + format!("{} Checking registries…", spinner(app)) + } else { + "Press c to check this project, C to check every project listed.".into() + }; + frame.render_widget( + Paragraph::new(Span::styled(message, t.subtle())).block(pane( + &t, + "Dependencies".into(), + focused, + )), + right, + ); + return; + }; + let deps = sorted(result); + let outdated = deps.iter().filter(|d| d.is_outdated()).count(); + let title = format!("Dependencies {} · {outdated} outdated", deps.len()); + let block = pane(&t, title, focused); + let inner = block.inner(right); + frame.render_widget(block, right); + let [table_area, notes] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(if result.errors.is_empty() { 0 } else { 2 }), + ]) + .areas(inner); + + let rows: Vec = deps + .iter() + .map(|dep| { + let marked = app + .updates + .marked + .contains(&(path.clone(), dep.declared.name.clone())); + let mark = if marked { + Span::styled(app.glyphs.marked, t.bold(t.accent2)) + } else if dep.is_outdated() { + Span::styled(app.glyphs.unmarked, t.muted()) + } else { + Span::raw(" ") + }; + let dim = !dep.is_outdated() && dep.vulnerabilities.is_empty(); + let name_style = if dim { t.muted() } else { t.text() }; + let latest_style = match dep.bump { + Bump::Major => t.bold(t.error), + Bump::Minor => t.fg(t.warning), + Bump::Patch => t.fg(t.success), + Bump::None => t.muted(), + }; + let advisories = if dep.vulnerabilities.is_empty() { + Span::styled(dep.error.clone().unwrap_or_default(), t.muted()) + } else { + Span::styled(dep.vulnerabilities.join(" "), t.bold(t.error)) + }; + Row::new(vec![ + Cell::from(mark), + Cell::from(Span::styled(dep.declared.name.clone(), name_style)), + Cell::from( + Line::from(Span::styled( + version(dep.declared.current.as_deref()), + t.subtle(), + )) + .alignment(Alignment::Right), + ), + Cell::from( + Line::from(Span::styled(version(dep.wanted.as_deref()), t.text())) + .alignment(Alignment::Right), + ), + Cell::from( + Line::from(Span::styled(version(dep.latest.as_deref()), latest_style)) + .alignment(Alignment::Right), + ), + Cell::from(bump_span(app, dep.bump)), + Cell::from(Span::styled(dep.declared.kind.label(), t.muted())), + Cell::from(advisories), + ]) + }) + .collect(); + let header = Row::new( + [ + "", + "PACKAGE", + "CURRENT", + "WANTED", + "LATEST", + "BUMP", + "KIND", + "ADVISORIES", + ] + .into_iter() + .map(|h| Cell::from(Span::styled(h, t.bold(t.muted)))), + ); + let widths = [ + Constraint::Length(1), + Constraint::Fill(2), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(5), + Constraint::Length(8), + Constraint::Fill(1), + ]; + let mut state = TableState::default().with_selected(focused.then_some(app.updates.dep_cursor)); + frame.render_stateful_widget( + Table::new(rows, widths) + .header(header) + .column_spacing(1) + .row_highlight_style(t.selected_row()), + table_area, + &mut state, + ); + if !result.errors.is_empty() { + frame.render_widget( + Paragraph::new(Span::styled( + result.errors.join("; "), + Style::new().fg(t.warning), + )) + .wrap(Wrap { trim: true }), + notes, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::Msg; + use crate::tui::app::tests::app; + use carwash_core::deps::{Declared, DepKind}; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn press(app: &mut App, code: KeyCode) -> Vec { + app.update(Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))) + } + + fn dep(name: &str, current: &str, latest: &str, bump: Bump, vulns: &[&str]) -> Dependency { + Dependency { + declared: Declared { + name: name.into(), + requirement: "1".into(), + current: Some(current.into()), + kind: DepKind::Normal, + source: Source::Crates, + manifest: PathBuf::from("/w/a/Cargo.toml"), + }, + wanted: Some(latest.into()), + latest: Some(latest.into()), + bump, + wanted_bump: bump, + vulnerabilities: vulns.iter().map(|s| (*s).to_owned()).collect(), + error: None, + } + } + + fn checked(app: &mut App) { + app.update(Msg::DepsChecked(vec![ProjectDeps { + path: PathBuf::from("/w/a"), + dependencies: vec![ + dep("current", "1.0.0", "1.0.0", Bump::None, &[]), + dep("minor", "1.0.0", "1.2.0", Bump::Minor, &[]), + dep("vulnerable", "1.0.0", "1.0.1", Bump::Patch, &["RUSTSEC-1"]), + dep("major", "1.0.0", "2.0.0", Bump::Major, &[]), + ], + errors: Vec::new(), + }])); + } + + #[test] + fn opening_checks_the_selected_project_once() { + let mut app = app(); + // The fixture's projects have ecosystem 0 (rust), which carwash can check. + let effects = press(&mut app, KeyCode::Char('3')); + assert!( + matches!(effects.first(), Some(Effect::CheckDeps { specs, .. }) if specs.len() == 1) + ); + assert!(app.animating(), "spinner while checking"); + let again = press(&mut app, KeyCode::Char('3')); + assert!(again.is_empty(), "already checking"); + } + + #[test] + fn dependencies_sort_vulnerable_then_by_bump() { + let mut app = app(); + checked(&mut app); + let names: Vec = sorted(&app.updates.results[&PathBuf::from("/w/a")]) + .into_iter() + .map(|d| d.declared.name.clone()) + .collect(); + assert_eq!(names, ["vulnerable", "major", "minor", "current"]); + assert_eq!(app.updates.totals(), (3, 1)); + } + + #[test] + fn marking_and_updating_emit_jobs_for_the_marked_deps() { + let mut app = app(); + press(&mut app, KeyCode::Char('3')); + checked(&mut app); + press(&mut app, KeyCode::Tab); + press(&mut app, KeyCode::Char('a')); + assert_eq!(app.updates.marked.len(), 3, "only outdated deps are marked"); + let effects = press(&mut app, KeyCode::Char('u')); + let Some(Effect::ApplyUpdates { + deps, latest, dir, .. + }) = effects.first() + else { + panic!("updates expected, got {effects:?}"); + }; + assert!(!latest); + assert_eq!(dir, &PathBuf::from("/w/a")); + assert_eq!(deps.len(), 3); + assert!(app.updates.updating.contains(&PathBuf::from("/w/a"))); + } + + #[test] + fn upgrading_to_latest_needs_a_second_press() { + let mut app = app(); + press(&mut app, KeyCode::Char('3')); + checked(&mut app); + press(&mut app, KeyCode::Tab); + press(&mut app, KeyCode::Char('j')); + assert!(press(&mut app, KeyCode::Char('U')).is_empty()); + assert!(app.toast.as_ref().unwrap().text.contains("again")); + let effects = press(&mut app, KeyCode::Char('U')); + assert!(matches!( + effects.first(), + Some(Effect::ApplyUpdates { latest: true, .. }) + )); + } + + #[test] + fn finished_update_jobs_trigger_a_recheck() { + let mut app = app(); + app.updates.updating.insert(PathBuf::from("/w/a")); + app.update(Msg::JobStarted { + id: 3, + label: "a".into(), + task: carwash_core::tasks::Task { + name: "update".into(), + source: "update".into(), + program: "cargo".into(), + args: vec!["update".into()], + description: None, + cwd: PathBuf::from("/w/a"), + standard: true, + }, + recheck: Some(PathBuf::from("/w/a")), + }); + let effects = app.update(Msg::JobExited(3, Ok(0))); + assert!(matches!(effects.first(), Some(Effect::CheckDeps { .. }))); + assert!(!app.updates.updating.contains(&PathBuf::from("/w/a"))); + } + + #[test] + fn build_metadata_is_hidden() { + assert_eq!(version(Some("1.1.2+spec-1.1.0")), "1.1.2"); + assert_eq!(version(None), "-"); + } +} diff --git a/crates/carwash/src/tui/view.rs b/crates/carwash/src/tui/view.rs index 66fb3d8..71963c3 100644 --- a/crates/carwash/src/tui/view.rs +++ b/crates/carwash/src/tui/view.rs @@ -34,6 +34,8 @@ pub fn render(frame: &mut Frame, app: &mut App) { let details = app.show_details && body.width >= MIN_WIDTH_FOR_DETAILS; if app.tab == Tab::Tasks { super::tasks::render(frame, app, body); + } else if app.tab == Tab::Updates { + super::updates::render(frame, app, body); } else if details { let [table, side] = Layout::horizontal([Constraint::Min(60), Constraint::Length(DETAILS_WIDTH)]) @@ -65,9 +67,7 @@ fn home_relative(path: &std::path::Path) -> String { path.display().to_string() } -fn spinner(app: &App) -> &'static str { - app.glyphs.spinner[app.spinner % app.glyphs.spinner.len()] -} +use super::widgets::spinner; /// Renders `left` and right-aligned `right` on one line; `right` wins when space runs out. fn split_line(frame: &mut Frame, area: Rect, left: Line<'_>, right: Line<'_>) { @@ -147,6 +147,28 @@ fn render_toolbar(frame: &mut Frame, app: &App, area: Rect) { split_line(frame, area, Line::from(left), Line::from(right)); return; } + if app.tab == Tab::Updates { + let (outdated, vulnerable) = app.updates.totals(); + let mut right = vec![Span::styled( + format!("{} checked", app.updates.results.len()), + t.muted(), + )]; + if outdated > 0 { + right.push(Span::styled( + format!(" · {outdated} outdated"), + t.fg(t.warning), + )); + } + if vulnerable > 0 { + right.push(Span::styled( + format!(" · {vulnerable} vulnerable"), + t.bold(t.error), + )); + } + right.push(Span::raw(" ")); + split_line(frame, area, Line::from(left), Line::from(right)); + return; + } left.extend([ Span::styled(" view ", t.muted()), Span::styled(app.grouping.label(), t.fg(t.accent)), @@ -715,6 +737,17 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ("?", "help"), ("q", "quit"), ], + Tab::Updates => &[ + ("enter", "check"), + ("C", "check all"), + ("space", "mark"), + ("u", "update"), + ("U", "upgrade"), + ("tab", "pane"), + ("/", "filter"), + ("?", "help"), + ("q", "quit"), + ], }; let mut spans = vec![Span::raw(" ")]; for (i, (key, label)) in hints.iter().enumerate() { @@ -966,7 +999,7 @@ fn render_help(frame: &mut Frame, app: &App) { } lines.push(Line::default()); } - if app.tab == Tab::Tasks { + if app.tab != Tab::Reclaim { lines.push(Line::from(Span::styled( "Press any key to close", t.muted(), diff --git a/crates/carwash/src/tui/widgets.rs b/crates/carwash/src/tui/widgets.rs new file mode 100644 index 0000000..0974b41 --- /dev/null +++ b/crates/carwash/src/tui/widgets.rs @@ -0,0 +1,28 @@ +//! Small rendering helpers shared by every tab. + +use super::app::App; +use super::theme::Theme; +use ratatui::text::Span; +use ratatui::widgets::{Block, BorderType, Borders}; + +/// A rounded pane; focused panes get an accent border and title. +pub fn pane<'a>(theme: &Theme, title: String, focused: bool) -> Block<'a> { + let color = if focused { theme.accent } else { theme.border }; + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme.fg(color)) + .title(Span::styled( + format!(" {title} "), + if focused { + theme.bold(theme.accent) + } else { + theme.muted() + }, + )) +} + +/// The current spinner frame. +pub fn spinner(app: &App) -> &'static str { + app.glyphs.spinner[app.spinner % app.glyphs.spinner.len()] +} From a2083ecee70e5cdde40a6aa013f98553927201f3 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:19:57 -0400 Subject: [PATCH 13/22] feat: global caches outside projects Built-in specs for 32 per-user caches (package stores, toolchains, IDE and SDK caches) with per-platform paths, user overrides in caches.toml, and per-child expansion that keeps the default rustup toolchain. `carwash caches` lists and measures them; `carwash caches clean` runs the tool's own prune command when installed, or deletes when safe. The TUI gains a Caches tab (4) with the same actions; prune commands run as jobs in the Tasks tab and re-measure when they finish. --- crates/carwash-core/src/caches.rs | 341 ++++++++++++++++++++++++ crates/carwash-core/src/caches.toml | 266 ++++++++++++++++++ crates/carwash-core/src/discover.rs | 2 +- crates/carwash-core/src/lib.rs | 1 + crates/carwash-core/src/paths.rs | 5 + crates/carwash-core/src/scan.rs | 5 + crates/carwash/src/cli.rs | 33 +++ crates/carwash/src/commands/caches.rs | 281 +++++++++++++++++++ crates/carwash/src/commands/mod.rs | 1 + crates/carwash/src/main.rs | 1 + crates/carwash/src/tui/app.rs | 71 +++-- crates/carwash/src/tui/caches.rs | 370 ++++++++++++++++++++++++++ crates/carwash/src/tui/keymap.rs | 61 ++++- crates/carwash/src/tui/mod.rs | 90 ++++++- crates/carwash/src/tui/tasks.rs | 25 +- crates/carwash/src/tui/updates.rs | 2 +- crates/carwash/src/tui/view.rs | 19 ++ 17 files changed, 1544 insertions(+), 30 deletions(-) create mode 100644 crates/carwash-core/src/caches.rs create mode 100644 crates/carwash-core/src/caches.toml create mode 100644 crates/carwash/src/commands/caches.rs create mode 100644 crates/carwash/src/tui/caches.rs diff --git a/crates/carwash-core/src/caches.rs b/crates/carwash-core/src/caches.rs new file mode 100644 index 0000000..161292b --- /dev/null +++ b/crates/carwash-core/src/caches.rs @@ -0,0 +1,341 @@ +//! Per-user caches outside any project: package stores, toolchains, IDE and SDK caches. +//! +//! Entries are data (`caches.toml`), resolved against the environment and platform. Each says +//! whether deleting the directory is safe and which command the tool offers to prune it. + +use crate::model::Size; +use crate::tasks::Task; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const BUILTIN: &str = include_str!("caches.toml"); +const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CacheSpec { + pub id: String, + pub name: String, + #[serde(default)] + pub ecosystem: Option, + pub paths: Vec, + #[serde(default)] + pub delete: bool, + #[serde(default)] + pub prune: Option>, + #[serde(default)] + pub children: bool, + #[serde(default)] + pub child_prune: Option>, + #[serde(default)] + pub keep: Option, + #[serde(default)] + pub note: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CachesFile { + schema: u32, + #[serde(default)] + cache: Vec, +} + +fn parse(source: &str) -> Result, String> { + let file: CachesFile = toml::from_str(source).map_err(|e| e.to_string())?; + if file.schema != SCHEMA_VERSION { + return Err(format!("unsupported caches schema {}", file.schema)); + } + Ok(file.cache) +} + +pub fn builtin_specs() -> Vec { + parse(BUILTIN).expect("built-in caches parse") +} + +/// Built-ins with user entries applied (same `id` replaces, new ids are added). +pub fn specs_with_overrides(user: &str) -> Result, String> { + let mut specs = builtin_specs(); + for spec in parse(user)? { + match specs.iter_mut().find(|s| s.id == spec.id) { + Some(existing) => *existing = spec, + None => specs.push(spec), + } + } + Ok(specs) +} + +/// A cache present on this machine. +#[derive(Debug, Clone, Serialize)] +pub struct GlobalCache { + /// `spec-id`, or `spec-id/child` for per-child entries. + pub id: String, + pub name: String, + pub path: PathBuf, + pub ecosystem: Option, + /// Removing the directory is safe. + pub deletable: bool, + /// The tool's own cleanup command. + pub prune: Option>, + pub note: Option, + pub size: Option, +} + +impl GlobalCache { + /// The prune command as a task, when its program is installed. + pub fn prune_task(&self) -> Option { + let argv = self.prune.as_ref()?; + let program = argv.first()?; + find_program(program)?; + let cwd = crate::paths::home().unwrap_or_else(|| PathBuf::from("/")); + Some(Task { + name: format!("prune {}", self.id), + source: "cache".into(), + program: program.clone(), + args: argv[1..].to_vec(), + description: Some(self.name.clone()), + cwd, + standard: true, + }) + } +} + +/// Looks `program` up on `PATH`. +pub fn find_program(program: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .flat_map(|dir| { + let plain = dir.join(program); + let exe = dir.join(format!("{program}.exe")); + [plain, exe] + }) + .find(|candidate| candidate.is_file()) +} + +fn platform_ok(prefix: &str) -> bool { + match prefix { + "macos" => cfg!(target_os = "macos"), + "linux" => cfg!(target_os = "linux"), + "windows" => cfg!(windows), + _ => false, + } +} + +/// Resolves one path candidate; `None` when its platform or variable does not apply. +fn resolve(candidate: &str, home: &Path, env: &dyn Fn(&str) -> Option) -> Option { + let candidate = match candidate.split_once(':') { + Some((prefix, rest)) if matches!(prefix, "macos" | "linux" | "windows") => { + if !platform_ok(prefix) { + return None; + } + rest + } + _ => candidate, + }; + if let Some(rest) = candidate.strip_prefix("~/") { + return Some(home.join(rest)); + } + if let Some(rest) = candidate.strip_prefix('$') { + let (var, tail) = rest.split_once('/').unwrap_or((rest, "")); + let value = env(var).filter(|v| !v.is_empty())?; + let base = PathBuf::from(value); + return Some(if tail.is_empty() { + base + } else { + base.join(tail) + }); + } + Some(PathBuf::from(candidate)) +} + +/// The default rustup toolchain, from `settings.toml` next to `toolchains/`. +fn rustup_default(toolchains: &Path) -> Option { + let settings = std::fs::read_to_string(toolchains.parent()?.join("settings.toml")).ok()?; + let table: toml::Table = settings.parse().ok()?; + table + .get("default_toolchain") + .and_then(toml::Value::as_str) + .map(str::to_owned) +} + +/// Caches present under `home`, children expanded, in spec order. Sizes are not measured. +pub fn discover(specs: &[CacheSpec], home: &Path) -> Vec { + discover_with(specs, home, &|var| std::env::var(var).ok()) +} + +fn discover_with( + specs: &[CacheSpec], + home: &Path, + env: &dyn Fn(&str) -> Option, +) -> Vec { + let mut out = Vec::new(); + for spec in specs { + let Some(path) = spec + .paths + .iter() + .filter_map(|c| resolve(c, home, env)) + .find(|p| p.is_dir()) + else { + continue; + }; + if !spec.children { + out.push(GlobalCache { + id: spec.id.clone(), + name: spec.name.clone(), + path, + ecosystem: spec.ecosystem.clone(), + deletable: spec.delete, + prune: spec.prune.clone(), + note: spec.note.clone(), + size: None, + }); + continue; + } + let keep = match spec.keep.as_deref() { + Some("rustup-default") => rustup_default(&path), + _ => None, + }; + let Ok(entries) = std::fs::read_dir(&path) else { + continue; + }; + let mut children: Vec<(String, PathBuf)> = entries + .filter_map(Result::ok) + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| e.file_name().into_string().ok().map(|n| (n, e.path()))) + .filter(|(name, _)| !name.starts_with('.')) + .filter(|(name, _)| { + keep.as_ref() + .is_none_or(|k| name != k && !name.starts_with(&format!("{k}-"))) + }) + .collect(); + children.sort(); + for (child, child_path) in children { + out.push(GlobalCache { + id: format!("{}/{child}", spec.id), + name: format!("{}: {child}", spec.name), + path: child_path, + ecosystem: spec.ecosystem.clone(), + deletable: spec.delete, + prune: spec + .child_prune + .as_ref() + .map(|argv| argv.iter().map(|a| a.replace("{name}", &child)).collect()), + note: spec.note.clone(), + size: None, + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn env(vars: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |key| { + vars.iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| (*v).to_owned()) + } + } + + #[test] + fn builtin_caches_parse_and_are_unique() { + let specs = builtin_specs(); + assert!(specs.len() >= 30); + let mut ids: Vec<&str> = specs.iter().map(|s| s.id.as_str()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), specs.len()); + for spec in &specs { + assert!( + spec.delete || spec.prune.is_some() || spec.child_prune.is_some(), + "{} has no action", + spec.id + ); + } + } + + #[test] + fn resolves_home_env_and_platform_candidates() { + let home = Path::new("/home/me"); + let vars = env(&[("CARGO_HOME", "/opt/cargo")]); + assert_eq!( + resolve("~/.npm", home, &vars), + Some(PathBuf::from("/home/me/.npm")) + ); + assert_eq!( + resolve("$CARGO_HOME/registry", home, &vars), + Some(PathBuf::from("/opt/cargo/registry")) + ); + assert_eq!(resolve("$UNSET/x", home, &vars), None); + let other = if cfg!(target_os = "macos") { + "linux:~/x" + } else { + "macos:~/x" + }; + assert_eq!(resolve(other, home, &vars), None); + } + + #[test] + fn discovers_existing_caches_and_expands_children() { + let home = tempfile::tempdir().unwrap(); + let root = home.path(); + fs::create_dir_all(root.join(".npm/_cacache")).unwrap(); + for toolchain in [ + "stable-aarch64-apple-darwin", + "nightly-aarch64-apple-darwin", + "1.80-aarch64-apple-darwin", + ] { + fs::create_dir_all(root.join(".rustup/toolchains").join(toolchain)).unwrap(); + } + fs::write( + root.join(".rustup/settings.toml"), + "default_toolchain = \"stable\"\n", + ) + .unwrap(); + + let caches = discover_with(&builtin_specs(), root, &env(&[])); + let ids: Vec<&str> = caches.iter().map(|c| c.id.as_str()).collect(); + assert!(ids.contains(&"npm")); + assert!(ids.contains(&"rustup-toolchains/nightly-aarch64-apple-darwin")); + assert!(ids.contains(&"rustup-toolchains/1.80-aarch64-apple-darwin")); + assert!( + !ids.iter().any(|id| id.contains("stable")), + "default toolchain kept: {ids:?}" + ); + let nightly = caches + .iter() + .find(|c| c.id.ends_with("nightly-aarch64-apple-darwin")) + .unwrap(); + assert_eq!( + nightly.prune.as_deref().unwrap(), + [ + "rustup", + "toolchain", + "uninstall", + "nightly-aarch64-apple-darwin" + ] + ); + assert!( + !ids.contains(&"cargo-registry"), + "absent caches are skipped" + ); + } + + #[test] + fn user_overrides_replace_and_add() { + let specs = specs_with_overrides( + "schema = 1\n[[cache]]\nid = \"npm\"\nname = \"npm (custom)\"\npaths = [\"~/.npm\"]\ndelete = true\n[[cache]]\nid = \"acme\"\nname = \"Acme\"\npaths = [\"~/.acme\"]\ndelete = true\n", + ) + .unwrap(); + assert_eq!( + specs.iter().find(|s| s.id == "npm").unwrap().name, + "npm (custom)" + ); + assert!(specs.iter().any(|s| s.id == "acme")); + assert!(specs_with_overrides("schema = 9").is_err()); + } +} diff --git a/crates/carwash-core/src/caches.toml b/crates/carwash-core/src/caches.toml new file mode 100644 index 0000000..89c8948 --- /dev/null +++ b/crates/carwash-core/src/caches.toml @@ -0,0 +1,266 @@ +# Built-in global (per-user) caches. +# +# Users can add or override entries in ~/.config/carwash/caches.toml using the same schema; +# an entry with an existing `id` replaces the built-in. +# +# Fields: +# id, name identity +# ecosystem ecosystem id the cache belongs to (for badges), optional +# paths candidates, first existing wins. `~` is the home directory, `$VAR/...` +# is skipped when VAR is unset, `macos:`/`linux:`/`windows:` limit a candidate +# to one platform +# delete removing the directory is safe: tools recreate it (default false) +# prune the tool's own cleanup command, preferred when the tool is installed +# children list each subdirectory as its own entry (toolchains, OS versions) +# child_prune command for one child; `{name}` is the child's directory name +# keep children never offered: `rustup-default` keeps the default toolchain +# note shown next to the entry + +schema = 1 + +[[cache]] +id = "cargo-registry" +name = "Cargo registry (downloaded crates)" +ecosystem = "rust" +paths = ["$CARGO_HOME/registry", "~/.cargo/registry"] +delete = true +note = "Crates are downloaded again on the next build. Cargo 1.88+ also trims this automatically." + +[[cache]] +id = "cargo-git" +name = "Cargo git checkouts" +ecosystem = "rust" +paths = ["$CARGO_HOME/git", "~/.cargo/git"] +delete = true + +[[cache]] +id = "rustup-toolchains" +name = "Rust toolchains" +ecosystem = "rust" +paths = ["$RUSTUP_HOME/toolchains", "~/.rustup/toolchains"] +children = true +child_prune = ["rustup", "toolchain", "uninstall", "{name}"] +keep = "rustup-default" +note = "Your default toolchain is not listed." + +[[cache]] +id = "npm" +name = "npm cache" +ecosystem = "node" +paths = ["~/.npm/_cacache"] +delete = true +prune = ["npm", "cache", "clean", "--force"] + +[[cache]] +id = "pnpm-store" +name = "pnpm store" +ecosystem = "node" +paths = ["$PNPM_HOME/store", "macos:~/Library/pnpm/store", "linux:~/.local/share/pnpm/store", "windows:~/AppData/Local/pnpm/store"] +prune = ["pnpm", "store", "prune"] +note = "Pruning removes packages no project uses; they are fetched again after a branch switch." + +[[cache]] +id = "yarn" +name = "Yarn cache" +ecosystem = "node" +paths = ["macos:~/Library/Caches/Yarn", "linux:~/.cache/yarn", "~/.yarn/berry/cache"] +delete = true +prune = ["yarn", "cache", "clean"] + +[[cache]] +id = "bun" +name = "Bun cache" +ecosystem = "node" +paths = ["$BUN_INSTALL_CACHE_DIR", "~/.bun/install/cache"] +delete = true +prune = ["bun", "pm", "cache", "rm"] + +[[cache]] +id = "deno" +name = "Deno cache" +ecosystem = "deno" +paths = ["$DENO_DIR", "macos:~/Library/Caches/deno", "linux:~/.cache/deno"] +delete = true +prune = ["deno", "clean"] + +[[cache]] +id = "node-gyp" +name = "node-gyp headers" +ecosystem = "node" +paths = ["macos:~/Library/Caches/node-gyp", "linux:~/.cache/node-gyp"] +delete = true + +[[cache]] +id = "electron" +name = "Electron downloads" +ecosystem = "node" +paths = ["macos:~/Library/Caches/electron", "linux:~/.cache/electron"] +delete = true + +[[cache]] +id = "playwright" +name = "Playwright browsers" +ecosystem = "node" +paths = ["$PLAYWRIGHT_BROWSERS_PATH", "macos:~/Library/Caches/ms-playwright", "linux:~/.cache/ms-playwright"] +delete = true +note = "Reinstall with `npx playwright install`." + +[[cache]] +id = "pip" +name = "pip cache" +ecosystem = "python" +paths = ["$PIP_CACHE_DIR", "macos:~/Library/Caches/pip", "linux:~/.cache/pip"] +delete = true + +[[cache]] +id = "uv" +name = "uv cache" +ecosystem = "python" +paths = ["$UV_CACHE_DIR", "~/.cache/uv", "macos:~/Library/Caches/uv"] +delete = true +prune = ["uv", "cache", "prune"] +note = "Pruning keeps what current environments use; deleting everything is also safe." + +[[cache]] +id = "poetry" +name = "Poetry cache" +ecosystem = "python" +paths = ["$POETRY_CACHE_DIR", "macos:~/Library/Caches/pypoetry", "linux:~/.cache/pypoetry"] +delete = true + +[[cache]] +id = "huggingface" +name = "Hugging Face models and datasets" +ecosystem = "python" +paths = ["$HF_HOME", "~/.cache/huggingface"] +delete = true +note = "Downloaded models: large, and slow to fetch again." + +[[cache]] +id = "pre-commit" +name = "pre-commit environments" +paths = ["$PRE_COMMIT_HOME", "~/.cache/pre-commit"] +delete = true +prune = ["pre-commit", "gc"] + +[[cache]] +id = "go-build" +name = "Go build cache" +ecosystem = "go" +paths = ["$GOCACHE", "macos:~/Library/Caches/go-build", "linux:~/.cache/go-build"] +delete = true +prune = ["go", "clean", "-cache"] + +[[cache]] +id = "go-mod" +name = "Go module cache" +ecosystem = "go" +paths = ["$GOMODCACHE", "$GOPATH/pkg/mod", "~/go/pkg/mod"] +prune = ["go", "clean", "-modcache"] +note = "Files are read-only on disk, so only `go clean -modcache` removes them." + +[[cache]] +id = "gradle" +name = "Gradle caches" +ecosystem = "gradle" +paths = ["$GRADLE_USER_HOME/caches", "~/.gradle/caches"] +delete = true +note = "Stop Gradle daemons first (`gradle --stop`)." + +[[cache]] +id = "gradle-wrapper" +name = "Gradle wrapper distributions" +ecosystem = "gradle" +paths = ["$GRADLE_USER_HOME/wrapper/dists", "~/.gradle/wrapper/dists"] +delete = true + +[[cache]] +id = "maven" +name = "Maven local repository" +ecosystem = "maven" +paths = ["~/.m2/repository"] +delete = true + +[[cache]] +id = "nuget" +name = "NuGet packages" +ecosystem = "dotnet" +paths = ["$NUGET_PACKAGES", "~/.nuget/packages"] +delete = true +prune = ["dotnet", "nuget", "locals", "all", "--clear"] + +[[cache]] +id = "xcode-derived-data" +name = "Xcode DerivedData" +ecosystem = "xcode" +paths = ["macos:~/Library/Developer/Xcode/DerivedData"] +delete = true +note = "Build products and indexes; Xcode rebuilds them." + +[[cache]] +id = "xcode-device-support" +name = "iOS device support" +ecosystem = "xcode" +paths = ["macos:~/Library/Developer/Xcode/iOS DeviceSupport"] +children = true +delete = true +note = "Copied again when a device running that OS version is connected." + +[[cache]] +id = "xcode-watch-device-support" +name = "watchOS device support" +ecosystem = "xcode" +paths = ["macos:~/Library/Developer/Xcode/watchOS DeviceSupport"] +children = true +delete = true + +[[cache]] +id = "simulators" +name = "iOS simulators" +ecosystem = "xcode" +paths = ["macos:~/Library/Developer/CoreSimulator"] +prune = ["xcrun", "simctl", "delete", "unavailable"] +note = "Removes simulators for runtimes no longer installed. Old runtimes: `xcrun simctl runtime list`." + +[[cache]] +id = "cocoapods" +name = "CocoaPods cache" +ecosystem = "cocoapods" +paths = ["macos:~/Library/Caches/CocoaPods"] +delete = true +prune = ["pod", "cache", "clean", "--all"] + +[[cache]] +id = "swiftpm" +name = "SwiftPM cache" +ecosystem = "swift" +paths = ["macos:~/Library/Caches/org.swift.swiftpm", "linux:~/.cache/org.swift.swiftpm"] +delete = true + +[[cache]] +id = "pub" +name = "Dart pub cache" +ecosystem = "dart" +paths = ["$PUB_CACHE", "~/.pub-cache"] +prune = ["dart", "pub", "cache", "clean"] + +[[cache]] +id = "homebrew" +name = "Homebrew downloads" +paths = ["$HOMEBREW_CACHE", "macos:~/Library/Caches/Homebrew", "linux:~/.cache/Homebrew"] +delete = true +prune = ["brew", "cleanup", "--prune=all"] + +[[cache]] +id = "jetbrains" +name = "JetBrains IDE caches" +paths = ["macos:~/Library/Caches/JetBrains", "linux:~/.cache/JetBrains"] +delete = true +note = "IDEs rebuild their indexes on next start." + +[[cache]] +id = "sccache" +name = "sccache" +ecosystem = "rust" +paths = ["$SCCACHE_DIR", "macos:~/Library/Caches/Mozilla.sccache", "linux:~/.cache/sccache"] +delete = true diff --git a/crates/carwash-core/src/discover.rs b/crates/carwash-core/src/discover.rs index 3b3044e..5f9ed3c 100644 --- a/crates/carwash-core/src/discover.rs +++ b/crates/carwash-core/src/discover.rs @@ -645,7 +645,7 @@ impl<'a> Walker<'a> { /// Walks from the root's parent upwards (to the enclosing repository root, stopping /// below the home directory) so a scan started inside a project knows about it. fn enclosing_context(&self, root: &Path) -> Ctx { - let home = etcetera::home_dir().ok(); + let home = crate::paths::home(); let mut chain = Vec::new(); let mut current = root.parent(); while let Some(dir) = current { diff --git a/crates/carwash-core/src/lib.rs b/crates/carwash-core/src/lib.rs index 4299b40..c61e188 100644 --- a/crates/carwash-core/src/lib.rs +++ b/crates/carwash-core/src/lib.rs @@ -14,6 +14,7 @@ //! TOML file in the same format. pub mod cache; +pub mod caches; pub mod clean; pub mod deps; pub mod discover; diff --git a/crates/carwash-core/src/paths.rs b/crates/carwash-core/src/paths.rs index 1e07533..e93e025 100644 --- a/crates/carwash-core/src/paths.rs +++ b/crates/carwash-core/src/paths.rs @@ -60,6 +60,11 @@ impl Dirs { } } +/// The user's home directory. +pub fn home() -> Option { + etcetera::home_dir().ok() +} + /// Writes `contents` to `path` atomically (temporary file in the same directory, then rename). pub fn write_atomic(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { let dir = path diff --git a/crates/carwash-core/src/scan.rs b/crates/carwash-core/src/scan.rs index 39d7340..302c86b 100644 --- a/crates/carwash-core/src/scan.rs +++ b/crates/carwash-core/src/scan.rs @@ -166,6 +166,11 @@ impl Engine { .install(|| measure_all(targets, counters, cancel, sink)); } + /// Measures one directory tree on the engine's pool. + pub fn measure_path(&self, path: &Path, cancel: &Cancel) -> Size { + self.pool.install(|| measure::measure(path, cancel)) + } + /// Deletes artifacts; see [`clean::clean`]. pub fn clean( &self, diff --git a/crates/carwash/src/cli.rs b/crates/carwash/src/cli.rs index 083a854..c0d889c 100644 --- a/crates/carwash/src/cli.rs +++ b/crates/carwash/src/cli.rs @@ -60,6 +60,8 @@ pub enum Command { Run(RunArgs), /// Show outdated and vulnerable dependencies (Rust, JavaScript, Python, Go). Outdated(OutdatedArgs), + /// List and clean per-user caches (package stores, toolchains, SDK and IDE caches). + Caches(CachesArgs), /// List the ecosystems carwash recognises. Ecosystems(JsonArgs), /// Show how much space carwash has reclaimed. @@ -299,6 +301,37 @@ pub struct OutdatedArgs { pub json: bool, } +#[derive(Debug, Args)] +pub struct CachesArgs { + #[command(subcommand)] + pub action: Option, + + /// Machine-readable output. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Subcommand)] +pub enum CachesAction { + /// Clean caches by id (as listed by `carwash caches`); a parent id selects its children. + Clean { + #[arg(required = true, value_name = "ID")] + ids: Vec, + + /// Do not ask for confirmation. + #[arg(long, short = 'y')] + yes: bool, + + /// Show what would be done, then stop. + #[arg(long)] + dry_run: bool, + + /// Delete the directory even when the tool's own prune command is available. + #[arg(long)] + delete: bool, + }, +} + #[derive(Debug, Args)] pub struct JsonArgs { /// Machine-readable output. diff --git a/crates/carwash/src/commands/caches.rs b/crates/carwash/src/commands/caches.rs new file mode 100644 index 0000000..7e33237 --- /dev/null +++ b/crates/carwash/src/commands/caches.rs @@ -0,0 +1,281 @@ +//! `carwash caches`: per-user caches outside projects. + +use super::size_style; +use crate::cli::{CachesAction, CachesArgs}; +use crate::context::Context; +use crate::table::{Align, Cell, Table}; +use anstyle::{AnsiColor, Style}; +use anyhow::{Context as _, Result, bail}; +use carwash_core::caches::{self, GlobalCache}; +use carwash_core::clean::{CleanItem, CleanOptions, DeleteMode}; +use carwash_core::history::{self, Record}; +use carwash_core::{ArtifactId, ArtifactKind, Cancel, fmt}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::io::{BufRead, IsTerminal, Write}; +use std::process::{Command, ExitCode, Stdio}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +/// Caches present on this machine, with user overrides from `caches.toml`. +pub fn present(ctx: &Context) -> Result> { + let specs = match ctx.dirs.as_ref().map(|d| d.config.join("caches.toml")) { + Some(path) if path.exists() => { + let text = std::fs::read_to_string(&path) + .with_context(|| format!("cannot read {}", path.display()))?; + caches::specs_with_overrides(&text) + .map_err(anyhow::Error::msg) + .with_context(|| format!("invalid caches in {}", path.display()))? + } + _ => caches::builtin_specs(), + }; + let home = carwash_core::paths::home().context("cannot find the home directory")?; + Ok(caches::discover(&specs, &home)) +} + +/// Measures every cache, a few at a time, reporting progress on stderr. +pub fn measure(ctx: &Context, caches: &mut [GlobalCache], show_progress: bool) { + let bar = if show_progress && std::io::stderr().is_terminal() { + let bar = ProgressBar::new_spinner(); + bar.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}").expect("valid template"), + ); + bar.enable_steady_tick(Duration::from_millis(80)); + bar + } else { + ProgressBar::hidden() + }; + let done = AtomicUsize::new(0); + let total = caches.len(); + let slots: Vec> = caches.iter_mut().map(Mutex::new).collect(); + let next = AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..4.min(total) { + scope.spawn(|| { + while let Some(slot) = slots.get(next.fetch_add(1, Ordering::Relaxed)) { + let mut cache = slot.lock().expect("cache slot"); + cache.size = Some(ctx.engine.measure_path(&cache.path, &Cancel::new())); + done.fetch_add(1, Ordering::Relaxed); + } + }); + } + while done.load(Ordering::Relaxed) < total { + bar.set_message(format!( + "Measuring caches ({}/{total})", + done.load(Ordering::Relaxed) + )); + std::thread::sleep(Duration::from_millis(80)); + } + }); + bar.finish_and_clear(); +} + +fn action(cache: &GlobalCache) -> (String, Style) { + let dim = Style::new().dimmed(); + match (&cache.prune, cache.prune_task(), cache.deletable) { + (Some(_), Some(task), _) => (task.command_line(), Style::new()), + (Some(argv), None, true) => (format!("delete ({} not installed)", argv[0]), dim), + (Some(argv), None, false) => ( + format!("needs `{}`", argv[0]), + AnsiColor::Yellow.on_default(), + ), + (None, _, true) => ("delete".into(), Style::new()), + (None, _, false) => ("none".into(), dim), + } +} + +pub fn run(ctx: &Context, args: &CachesArgs) -> Result { + let mut caches = present(ctx)?; + match &args.action { + None => list(ctx, &mut caches, args.json), + Some(CachesAction::Clean { + ids, + yes, + dry_run, + delete, + }) => clean(ctx, &mut caches, ids, *yes, *dry_run, *delete), + } +} + +fn list(ctx: &Context, caches: &mut [GlobalCache], json: bool) -> Result { + measure(ctx, caches, !json); + caches.sort_by_key(|c| std::cmp::Reverse(c.size.map_or(0, |s| s.on_disk))); + let mut out = anstream::stdout().lock(); + if json { + serde_json::to_writer_pretty(&mut out, &caches)?; + writeln!(out)?; + return Ok(ExitCode::SUCCESS); + } + if caches.is_empty() { + writeln!(out, "No known caches found.")?; + return Ok(ExitCode::SUCCESS); + } + let mut table = Table::new(&[ + ("SIZE", Align::Right), + ("ID", Align::Left), + ("CACHE", Align::Left), + ("CLEAN WITH", Align::Left), + ]); + let mut total = 0; + for cache in caches.iter() { + let bytes = cache.size.map_or(0, |s| s.on_disk); + total += bytes; + let (action, style) = action(cache); + table.push(vec![ + Cell::styled(fmt::bytes(bytes), size_style(bytes)), + Cell::styled(cache.id.clone(), Style::new().dimmed()), + Cell::new(cache.name.clone()), + Cell::styled(action, style), + ]); + } + table.write(&mut out)?; + let bold = Style::new().bold(); + writeln!( + out, + "\n{}{}{} in {} caches. Clean some with `carwash caches clean ...`.", + bold.render(), + fmt::bytes(total), + bold.render_reset(), + caches.len() + )?; + Ok(ExitCode::SUCCESS) +} + +fn clean( + ctx: &Context, + caches: &mut [GlobalCache], + ids: &[String], + yes: bool, + dry_run: bool, + force_delete: bool, +) -> Result { + let mut selected: Vec = Vec::new(); + for id in ids { + let matching: Vec<&GlobalCache> = caches + .iter() + .filter(|c| &c.id == id || c.id.starts_with(&format!("{id}/"))) + .collect(); + if matching.is_empty() { + bail!("no cache `{id}` on this machine (see `carwash caches`)"); + } + selected.extend(matching.into_iter().cloned()); + } + measure(ctx, &mut selected, true); + + let mut out = anstream::stdout().lock(); + let mut plan: Vec<(GlobalCache, Option)> = Vec::new(); + for cache in selected { + let task = if force_delete { + None + } else { + cache.prune_task() + }; + if task.is_none() && !cache.deletable { + let program = cache.prune.as_ref().map_or("its tool", |p| p[0].as_str()); + bail!( + "{} can only be cleaned with `{program}`, which is not installed", + cache.name + ); + } + let how = task + .as_ref() + .map_or_else(|| "delete".to_owned(), |t| t.command_line()); + writeln!( + out, + "{:>9} {} {}{how}{}", + fmt::bytes(cache.size.map_or(0, |s| s.on_disk)), + cache.name, + Style::new().dimmed().render(), + Style::new().dimmed().render_reset() + )?; + plan.push((cache, task)); + } + if dry_run { + writeln!(out, "Dry run: nothing was changed.")?; + return Ok(ExitCode::SUCCESS); + } + if !yes { + if !std::io::stdin().is_terminal() { + bail!( + "refusing to clean without confirmation in a non-interactive session; pass --yes" + ); + } + write!(out, "Clean {} caches? [y/N] ", plan.len())?; + out.flush()?; + let mut answer = String::new(); + std::io::stdin().lock().read_line(&mut answer)?; + if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { + writeln!(out, "Nothing changed.")?; + return Ok(ExitCode::SUCCESS); + } + } + drop(out); + + let mut failures = 0; + let mut records = Vec::new(); + for (cache, task) in plan { + let before = cache.size.map_or(0, |s| s.on_disk); + let ok = match task { + Some(task) => { + anstream::println!("▶ {}", task.command_line()); + let status = Command::new(&task.program) + .args(&task.args) + .current_dir(&task.cwd) + .stdin(Stdio::inherit()) + .status(); + matches!(status, Ok(s) if s.success()) + } + None => { + let Some(parent) = cache.path.parent() else { + continue; + }; + let report = ctx.engine.clean( + &[CleanItem { + id: ArtifactId(0), + path: cache.path.clone(), + expected_bytes: before, + }], + &CleanOptions { + mode: DeleteMode::Permanent, + allowed_roots: vec![parent.to_path_buf()], + dry_run: false, + }, + &Cancel::new(), + &|_| {}, + ); + report.removed == 1 + } + }; + if ok { + let after = if cache.path.exists() { + ctx.engine.measure_path(&cache.path, &Cancel::new()).on_disk + } else { + 0 + }; + let freed = before.saturating_sub(after); + anstream::println!(" freed {} from {}", fmt::bytes(freed), cache.name); + records.push(Record::now( + cache.path.clone(), + freed, + ArtifactKind::Cache, + cache.ecosystem.clone(), + DeleteMode::Permanent, + )); + } else { + failures += 1; + anstream::eprintln!(" failed to clean {}", cache.name); + } + } + if let Some(dirs) = &ctx.dirs + && let Err(error) = history::append(&dirs.history_file(), &records) + { + tracing::warn!(%error, "cannot write history"); + } + let total: u64 = records.iter().map(|r| r.bytes).sum(); + anstream::println!("Freed {} in total.", fmt::bytes(total)); + Ok(if failures == 0 { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} diff --git a/crates/carwash/src/commands/mod.rs b/crates/carwash/src/commands/mod.rs index 3883b33..abe3c30 100644 --- a/crates/carwash/src/commands/mod.rs +++ b/crates/carwash/src/commands/mod.rs @@ -1,5 +1,6 @@ //! Non-interactive subcommands. +pub mod caches; pub mod clean; pub mod info; pub mod outdated; diff --git a/crates/carwash/src/main.rs b/crates/carwash/src/main.rs index 631cf74..d45b962 100644 --- a/crates/carwash/src/main.rs +++ b/crates/carwash/src/main.rs @@ -79,6 +79,7 @@ fn run(cli: Cli) -> anyhow::Result { Some(Command::Tasks(args)) => commands::tasks::list(&ctx, args), Some(Command::Run(args)) => commands::tasks::run(&ctx, args), Some(Command::Outdated(args)) => commands::outdated::run(&ctx, args), + Some(Command::Caches(args)) => commands::caches::run(&ctx, args), Some(Command::Ecosystems(args)) => commands::info::ecosystems(&ctx, args), Some(Command::History(args)) => commands::info::history(&ctx, args), Some(Command::Completions { .. }) => unreachable!("handled above"), diff --git a/crates/carwash/src/tui/app.rs b/crates/carwash/src/tui/app.rs index 72d1884..152c1b2 100644 --- a/crates/carwash/src/tui/app.rs +++ b/crates/carwash/src/tui/app.rs @@ -1,18 +1,20 @@ //! Application state and the update function (Elm architecture: messages in, effects out). +use super::caches::CachesState; use super::keymap::{self, Action, Tab}; use super::query::{Fuzzy, Query}; use super::store::{self, EntryStatus, Grouping, RowKey, Rows, SortKey, Store, View}; -use super::tasks::{JobStatus, RunTarget, TasksState}; +use super::tasks::{After, JobStatus, RunTarget, TasksState}; use super::theme::{Glyphs, Theme}; use super::updates::UpdatesState; use carwash_core::cache::SizeCache; +use carwash_core::caches::GlobalCache; use carwash_core::clean::{CleanEvent, CleanItem, CleanReport, DeleteMode}; use carwash_core::deps::{CheckProgress, Dependency, ProjectDeps, ProjectSpec}; use carwash_core::history::Record; use carwash_core::select::{Hold, Policy}; use carwash_core::tasks::Task; -use carwash_core::{ArtifactId, Counters, EcoId, Registry, ScanEvent, fmt}; +use carwash_core::{ArtifactId, Counters, EcoId, Registry, ScanEvent, Size, fmt}; use ratatui::crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, MouseEvent, MouseEventKind, }; @@ -44,8 +46,8 @@ pub enum Msg { id: u64, label: String, task: Task, - /// Project whose dependencies to check again when the job ends. - recheck: Option, + /// What to refresh when the job ends. + after: After, }, JobOutput(u64, Vec), JobExited(u64, Result), @@ -53,6 +55,8 @@ pub enum Msg { DepsChecked(Vec), /// Applying updates to the project at the path could not start. UpdateFailed(PathBuf, String), + CachesDiscovered(Vec), + CacheMeasured(String, Size), } #[derive(Debug)] @@ -91,6 +95,16 @@ pub enum Effect { latest: bool, size: (u16, u16), }, + DiscoverCaches, + /// Prunes (as jobs) or deletes each cache, then measures it again. + CleanCaches { + caches: Vec, + size: (u16, u16), + }, + MeasureCache { + id: String, + path: PathBuf, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -191,6 +205,7 @@ pub struct App { pub tab: Tab, pub tasks: TasksState, pub updates: UpdatesState, + pub caches: CachesState, /// Quit was pressed once while jobs were running. quit_armed: bool, pub quit: bool, @@ -251,6 +266,7 @@ impl App { tab: Tab::Reclaim, tasks: TasksState::default(), updates: UpdatesState::default(), + caches: CachesState::default(), quit_armed: false, quit: false, dirty: true, @@ -264,6 +280,7 @@ impl App { || self.toast.is_some() || self.tasks.running() > 0 || !self.updates.checking.is_empty() + || !self.caches.busy.is_empty() } pub(crate) fn toast(&mut self, text: impl Into, level: Level) { @@ -368,9 +385,17 @@ impl App { id, label, task, - recheck, + after, } => { - self.tasks.add_job(id, label, task, recheck); + self.tasks.add_job(id, label, task, after); + Vec::new() + } + Msg::CachesDiscovered(caches) => { + self.caches.set(caches); + Vec::new() + } + Msg::CacheMeasured(id, size) => { + self.caches.measured(&id, size); Vec::new() } Msg::DepsProgress(progress) => { @@ -414,21 +439,26 @@ impl App { self.toast(text, level); } } - let recheck = self + let after = self .tasks .jobs .iter() .find(|j| j.id == id) - .and_then(|j| j.recheck.clone()); - // Once every job for the project has finished, look at its dependencies again. - if let Some(path) = recheck - && !self.tasks.jobs.iter().any(|j| { - j.status == JobStatus::Running && j.recheck.as_ref() == Some(&path) - }) - { - return self.recheck(&path); + .map(|j| j.after.clone()) + .unwrap_or_default(); + // Refresh once every job with the same follow-up has finished. + let pending = self + .tasks + .jobs + .iter() + .any(|j| j.status == JobStatus::Running && j.after == after); + match after { + After::Recheck(path) if !pending => self.recheck(&path), + After::Remeasure { id, path } if !pending => { + vec![Effect::MeasureCache { id, path }] + } + _ => Vec::new(), } - Vec::new() } } } @@ -490,11 +520,17 @@ impl App { self.tab = Tab::Updates; return self.updates_effects(); } + Action::ShowCaches => { + self.tab = Tab::Caches; + return self.caches_effects(); + } + Action::Search if self.tab == Tab::Caches => self.dirty = false, Action::Search => { let current = match self.tab { Tab::Reclaim => self.query.raw.clone(), Tab::Tasks => self.tasks.filter.clone(), Tab::Updates => self.updates.filter.clone(), + Tab::Caches => String::new(), }; self.input = Input::new(current); self.mode = Mode::Search; @@ -522,6 +558,7 @@ impl App { Tab::Reclaim => self.on_action(action), Tab::Tasks => self.on_tasks_action(action), Tab::Updates => self.on_updates_action(action), + Tab::Caches => self.on_caches_action(action), } } @@ -552,6 +589,7 @@ impl App { Tab::Reclaim => Vec::new(), Tab::Tasks => self.tasks_effects(), Tab::Updates => self.updates_effects(), + Tab::Caches => Vec::new(), } } @@ -560,6 +598,7 @@ impl App { Tab::Reclaim => self.set_query(value), Tab::Tasks => self.tasks.set_filter(value), Tab::Updates => self.updates.set_filter(value), + Tab::Caches => {} } } diff --git a/crates/carwash/src/tui/caches.rs b/crates/carwash/src/tui/caches.rs new file mode 100644 index 0000000..5131ae7 --- /dev/null +++ b/crates/carwash/src/tui/caches.rs @@ -0,0 +1,370 @@ +//! The Caches tab: per-user caches outside projects. + +use super::app::{App, Effect, Level}; +use super::keymap::Action; +use super::widgets::{pane, spinner}; +use carwash_core::caches::GlobalCache; +use carwash_core::{Size, fmt}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Cell, Paragraph, Row, Table, TableState, Wrap}; +use std::collections::HashSet; + +#[derive(Debug, Default)] +pub struct CachesState { + /// `None` until discovery finished. + pub caches: Option>, + pub cursor: usize, + pub marked: HashSet, + /// Being cleaned or measured. + pub busy: HashSet, + clean_armed: bool, + requested: bool, +} + +impl CachesState { + pub fn set(&mut self, mut caches: Vec) { + if let Some(previous) = &self.caches { + for cache in &mut caches { + cache.size = previous + .iter() + .find(|p| p.id == cache.id) + .and_then(|p| p.size); + } + } + self.busy.extend(caches.iter().map(|c| c.id.clone())); + self.caches = Some(caches); + self.sort(); + } + + pub fn measured(&mut self, id: &str, size: Size) { + self.busy.remove(id); + if let Some(cache) = self + .caches + .as_mut() + .and_then(|c| c.iter_mut().find(|c| c.id == id)) + { + cache.size = Some(size); + } + self.sort(); + } + + /// Largest first; the cursor stays on the same cache. + fn sort(&mut self) { + let Some(caches) = &mut self.caches else { + return; + }; + let current = caches.get(self.cursor).map(|c| c.id.clone()); + caches.sort_by_key(|c| std::cmp::Reverse(c.size.map_or(0, |s| s.on_disk))); + if let Some(position) = current.and_then(|id| caches.iter().position(|c| c.id == id)) { + self.cursor = position; + } + } + + pub fn total(&self) -> u64 { + self.caches + .iter() + .flatten() + .filter_map(|c| c.size) + .map(|s| s.on_disk) + .sum() + } +} + +impl App { + /// Opening the tab discovers and measures caches once. + pub fn caches_effects(&mut self) -> Vec { + if self.caches.requested { + return Vec::new(); + } + self.caches.requested = true; + vec![Effect::DiscoverCaches] + } + + fn selected_caches(&self) -> Vec { + let Some(caches) = &self.caches.caches else { + return Vec::new(); + }; + let marked: Vec = caches + .iter() + .filter(|c| self.caches.marked.contains(&c.id)) + .cloned() + .collect(); + if marked.is_empty() { + caches + .get(self.caches.cursor) + .cloned() + .into_iter() + .collect() + } else { + marked + } + } + + pub fn on_caches_action(&mut self, action: Action) -> Vec { + if action != Action::Clean { + self.caches.clean_armed = false; + } + let len = self.caches.caches.as_ref().map_or(0, Vec::len); + let last = len.saturating_sub(1); + let page = self.page.max(1); + let state = &mut self.caches; + match action { + Action::Up => state.cursor = state.cursor.saturating_sub(1), + Action::Down => state.cursor = (state.cursor + 1).min(last), + Action::PageUp => state.cursor = state.cursor.saturating_sub(page), + Action::PageDown => state.cursor = (state.cursor + page).min(last), + Action::Top => state.cursor = 0, + Action::Bottom => state.cursor = last, + Action::Mark => { + if let Some(id) = state + .caches + .as_ref() + .and_then(|c| c.get(state.cursor)) + .map(|c| c.id.clone()) + { + if !state.marked.remove(&id) { + state.marked.insert(id); + } + state.cursor = (state.cursor + 1).min(last); + } + } + Action::Unmark => state.marked.clear(), + Action::Rescan => { + state.requested = false; + return self.caches_effects(); + } + Action::Open => { + if let Some(path) = self.selected_caches().first().map(|c| c.path.clone()) { + return vec![Effect::Reveal(path)]; + } + } + Action::Clean => { + let selected = self.selected_caches(); + if selected.is_empty() { + return Vec::new(); + } + let bytes: u64 = selected + .iter() + .filter_map(|c| c.size) + .map(|s| s.on_disk) + .sum(); + if !self.caches.clean_armed { + self.caches.clean_armed = true; + self.toast( + format!( + "Press d again to clean {} ({})", + if selected.len() == 1 { + selected[0].name.clone() + } else { + format!("{} caches", selected.len()) + }, + fmt::bytes(bytes) + ), + Level::Warn, + ); + return Vec::new(); + } + self.caches.clean_armed = false; + for cache in &selected { + self.caches.marked.remove(&cache.id); + self.caches.busy.insert(cache.id.clone()); + } + self.toast( + format!( + "Cleaning {}; prune commands run in the Tasks tab (2)", + fmt::bytes(bytes) + ), + Level::Info, + ); + return vec![Effect::CleanCaches { + caches: selected, + size: self.tasks.pty_size, + }]; + } + _ => {} + } + Vec::new() + } +} + +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + let t = app.theme; + let Some(caches) = &app.caches.caches else { + frame.render_widget( + Paragraph::new(Span::styled( + format!("{} Looking for caches…", spinner(app)), + t.subtle(), + )) + .block(pane(&t, "Caches".into(), true)), + area, + ); + return; + }; + let [table_area, detail] = + Layout::vertical([Constraint::Min(3), Constraint::Length(4)]).areas(area); + let rows: Vec = caches + .iter() + .map(|cache| { + let mark = if app.caches.busy.contains(&cache.id) { + Span::styled(spinner(app), t.fg(t.accent)) + } else if app.caches.marked.contains(&cache.id) { + Span::styled(app.glyphs.marked, t.bold(t.accent2)) + } else { + Span::styled(app.glyphs.unmarked, t.muted()) + }; + let size = match cache.size { + Some(size) => Span::styled(fmt::bytes(size.on_disk), t.size(size.on_disk)), + None => Span::styled("…", t.muted()), + }; + let action = match (cache.prune_task(), cache.deletable) { + (Some(task), _) => Span::styled(task.command_line(), t.text()), + (None, true) => Span::styled("delete", t.subtle()), + (None, false) => Span::styled( + format!( + "needs `{}`", + cache.prune.as_ref().map_or("?", |p| p[0].as_str()) + ), + t.fg(t.warning), + ), + }; + let badge = cache + .ecosystem + .as_deref() + .and_then(|key| app.registry.find(key)) + .map(|eco| { + let e = app.registry.ecosystem(eco); + Span::styled(e.badge.clone(), t.fg(t.ecosystem(&e.key))) + }) + .unwrap_or_default(); + Row::new(vec![ + Cell::from(mark), + Cell::from(Line::from(size).alignment(Alignment::Right)), + Cell::from(badge), + Cell::from(Span::styled(cache.name.clone(), t.text())), + Cell::from(action), + ]) + }) + .collect(); + let title = format!( + "Caches {} · {}{}", + caches.len(), + fmt::bytes(app.caches.total()), + if app.caches.marked.is_empty() { + String::new() + } else { + format!(" · {} marked", app.caches.marked.len()) + } + ); + let header = Row::new( + ["", "SIZE", "", "CACHE", "CLEAN WITH"] + .into_iter() + .map(|h| Cell::from(Span::styled(h, t.bold(t.muted)))), + ); + let mut state = TableState::default().with_selected(Some(app.caches.cursor)); + frame.render_stateful_widget( + Table::new( + rows, + [ + Constraint::Length(1), + Constraint::Length(9), + Constraint::Length(2), + Constraint::Fill(2), + Constraint::Fill(1), + ], + ) + .header(header) + .column_spacing(1) + .row_highlight_style(t.selected_row()) + .block(pane(&t, title, true)), + table_area, + &mut state, + ); + if let Some(cache) = caches.get(app.caches.cursor) { + let mut lines = vec![Line::from(Span::styled( + cache.path.display().to_string(), + t.subtle(), + ))]; + if let Some(note) = &cache.note { + lines.push(Line::from(Span::styled(note.clone(), t.muted()))); + } + frame.render_widget( + Paragraph::new(lines).wrap(Wrap { trim: true }).block(pane( + &t, + cache.name.clone(), + false, + )), + detail, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::Msg; + use crate::tui::app::tests::app; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::path::PathBuf; + + fn press(app: &mut App, code: KeyCode) -> Vec { + app.update(Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))) + } + + fn cache(id: &str) -> GlobalCache { + GlobalCache { + id: id.into(), + name: id.into(), + path: PathBuf::from(format!("/home/{id}")), + ecosystem: None, + deletable: true, + prune: None, + note: None, + size: None, + } + } + + #[test] + fn caches_are_discovered_once_measured_and_sorted() { + let mut app = app(); + let effects = press(&mut app, KeyCode::Char('4')); + assert!(matches!(effects.first(), Some(Effect::DiscoverCaches))); + assert!(press(&mut app, KeyCode::Char('4')).is_empty()); + app.update(Msg::CachesDiscovered(vec![cache("small"), cache("big")])); + let size = |bytes| Size { + on_disk: bytes, + reclaimable: bytes, + ..Size::default() + }; + app.update(Msg::CacheMeasured("small".into(), size(10))); + app.update(Msg::CacheMeasured("big".into(), size(1_000))); + let ids: Vec<&str> = app + .caches + .caches + .as_ref() + .unwrap() + .iter() + .map(|c| c.id.as_str()) + .collect(); + assert_eq!(ids, ["big", "small"]); + assert_eq!(app.caches.total(), 1_010); + assert!(app.caches.busy.is_empty()); + } + + #[test] + fn cleaning_needs_a_second_press() { + let mut app = app(); + press(&mut app, KeyCode::Char('4')); + app.update(Msg::CachesDiscovered(vec![cache("a"), cache("b")])); + app.caches.busy.clear(); + press(&mut app, KeyCode::Char(' ')); + assert!(press(&mut app, KeyCode::Char('d')).is_empty()); + let effects = press(&mut app, KeyCode::Char('d')); + let Some(Effect::CleanCaches { caches, .. }) = effects.first() else { + panic!("clean expected, got {effects:?}"); + }; + assert_eq!(caches.len(), 1); + assert!(app.caches.busy.contains(&caches[0].id)); + } +} diff --git a/crates/carwash/src/tui/keymap.rs b/crates/carwash/src/tui/keymap.rs index 285be98..881364d 100644 --- a/crates/carwash/src/tui/keymap.rs +++ b/crates/carwash/src/tui/keymap.rs @@ -39,6 +39,7 @@ pub enum Action { ShowReclaim, ShowTasks, ShowUpdates, + ShowCaches, Theme, Help, Quit, @@ -50,16 +51,18 @@ pub enum Tab { Reclaim, Tasks, Updates, + Caches, } impl Tab { - pub const ALL: [Tab; 3] = [Tab::Reclaim, Tab::Tasks, Tab::Updates]; + pub const ALL: [Tab; 4] = [Tab::Reclaim, Tab::Tasks, Tab::Updates, Tab::Caches]; pub fn title(self) -> &'static str { match self { Tab::Reclaim => "Reclaim", Tab::Tasks => "Tasks", Tab::Updates => "Updates", + Tab::Caches => "Caches", } } @@ -68,6 +71,7 @@ impl Tab { Tab::Reclaim => RECLAIM, Tab::Tasks => TASKS, Tab::Updates => UPDATES, + Tab::Caches => CACHES, } } } @@ -189,6 +193,13 @@ pub const GLOBAL: &[Binding] = &[ S::General, "dependency updates", ), + bind( + &[(Char('4'), NONE)], + "4", + A::ShowCaches, + S::General, + "global caches", + ), bind( &[(Char('t'), NONE)], "t", @@ -503,6 +514,54 @@ pub const UPDATES: &[Binding] = &[ ), ]; +pub const CACHES: &[Binding] = &[ + MOVE[0], + MOVE[1], + MOVE[2], + MOVE[3], + MOVE[4], + MOVE[5], + bind( + &[(Char(' '), NONE)], + "Space", + A::Mark, + S::Select, + "mark / unmark a cache", + ), + bind( + &[(Char('A'), NONE)], + "A", + A::Unmark, + S::Select, + "unmark everything", + ), + bind( + &[ + (Char('d'), NONE), + (Char('x'), NONE), + (KeyCode::Delete, NONE), + ], + "d x", + A::Clean, + S::Select, + "clean marked (or selected) caches; press twice", + ), + bind( + &[(Char('r'), NONE)], + "r", + A::Rescan, + S::View, + "measure again", + ), + bind( + &[(Char('o'), NONE)], + "o", + A::Open, + S::View, + "reveal in file manager", + ), +]; + /// Bindings shown in the help screen for `tab`, in display order. pub fn help(tab: Tab) -> impl Iterator { tab.bindings().iter().chain(GLOBAL) diff --git a/crates/carwash/src/tui/mod.rs b/crates/carwash/src/tui/mod.rs index 02d69a7..c7e234c 100644 --- a/crates/carwash/src/tui/mod.rs +++ b/crates/carwash/src/tui/mod.rs @@ -4,6 +4,7 @@ //! place with side effects: terminal I/O, engine threads, the filesystem. mod app; +mod caches; mod keymap; mod pty; mod query; @@ -181,14 +182,14 @@ impl Runtime<'_> { } /// Runs `task` on a new terminal; its output streams into the Tasks tab. - fn start_job(&mut self, label: String, task: Task, size: (u16, u16), recheck: Option) { + fn start_job(&mut self, label: String, task: Task, size: (u16, u16), after: tasks::After) { let id = self.next_job; self.next_job += 1; let _ = self.tx.send(Msg::JobStarted { id, label, task: task.clone(), - recheck, + after, }); match pty::spawn(id, &task, size, self.tx.clone()) { Ok(job) => { @@ -289,7 +290,7 @@ impl Runtime<'_> { else { continue; }; - self.start_job(target.label, task, size, None); + self.start_job(target.label, task, size, tasks::After::Nothing); } } Effect::CheckDeps { specs, refresh } => { @@ -321,7 +322,12 @@ impl Runtime<'_> { match carwash_core::deps::update_tasks(&dir, &refs, latest) { Ok(tasks) => { for task in tasks { - self.start_job(label.clone(), task, size, Some(dir.clone())); + self.start_job( + label.clone(), + task, + size, + tasks::After::Recheck(dir.clone()), + ); } } Err(error) => { @@ -332,6 +338,82 @@ impl Runtime<'_> { } } } + Effect::DiscoverCaches => match crate::commands::caches::present(self.ctx) { + Ok(caches) => { + let _ = self.tx.send(Msg::CachesDiscovered(caches.clone())); + let (engine, tx) = (self.ctx.engine.clone(), self.tx.clone()); + std::thread::spawn(move || { + for cache in caches { + let size = engine.measure_path(&cache.path, &Cancel::new()); + let _ = tx.send(Msg::CacheMeasured(cache.id, size)); + } + }); + } + Err(error) => { + let _ = self.tx.send(Msg::CachesDiscovered(Vec::new())); + tracing::warn!(%error, "cannot list caches"); + } + }, + Effect::MeasureCache { id, path } => { + let (engine, tx) = (self.ctx.engine.clone(), self.tx.clone()); + std::thread::spawn(move || { + let size = engine.measure_path(&path, &Cancel::new()); + let _ = tx.send(Msg::CacheMeasured(id, size)); + }); + } + Effect::CleanCaches { caches, size } => { + for cache in caches { + if let Some(task) = cache.prune_task() { + let after = tasks::After::Remeasure { + id: cache.id.clone(), + path: cache.path.clone(), + }; + self.start_job(cache.name.clone(), task, size, after); + continue; + } + let (engine, tx) = (self.ctx.engine.clone(), self.tx.clone()); + let history = self.ctx.dirs.as_ref().map(|d| d.history_file()); + std::thread::spawn(move || { + let before = cache.size.map_or(0, |s| s.on_disk); + if cache.deletable + && let Some(parent) = cache.path.parent() + { + let report = engine.clean( + &[carwash_core::clean::CleanItem { + id: carwash_core::ArtifactId(0), + path: cache.path.clone(), + expected_bytes: before, + }], + &CleanOptions { + mode: carwash_core::clean::DeleteMode::Permanent, + allowed_roots: vec![parent.to_path_buf()], + dry_run: false, + }, + &Cancel::new(), + &|_| {}, + ); + if report.removed == 1 + && let Some(file) = history + { + let record = carwash_core::history::Record::now( + cache.path.clone(), + before, + carwash_core::ArtifactKind::Cache, + cache.ecosystem.clone(), + carwash_core::clean::DeleteMode::Permanent, + ); + let _ = history::append(&file, &[record]); + } + } + let size = if cache.path.exists() { + engine.measure_path(&cache.path, &Cancel::new()) + } else { + carwash_core::Size::default() + }; + let _ = tx.send(Msg::CacheMeasured(cache.id, size)); + }); + } + } Effect::KillJob(id) => { if let Some(job) = self.jobs.remove(&id) { job.kill(); diff --git a/crates/carwash/src/tui/tasks.rs b/crates/carwash/src/tui/tasks.rs index f3062c6..bf89e80 100644 --- a/crates/carwash/src/tui/tasks.rs +++ b/crates/carwash/src/tui/tasks.rs @@ -42,8 +42,19 @@ pub struct Job { pub status: JobStatus, started: Instant, pub elapsed: Option, - /// Project whose dependencies are checked again when this job ends. - pub recheck: Option, + /// What to refresh when this job ends. + pub after: After, +} + +/// Follow-up work once a job ends. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum After { + #[default] + Nothing, + /// Check the dependencies of the project at this path again. + Recheck(PathBuf), + /// Measure a cache again. + Remeasure { id: String, path: PathBuf }, } impl std::fmt::Debug for Job { @@ -122,7 +133,7 @@ impl TasksState { self.jobs.iter_mut().find(|j| j.id == id) } - pub fn add_job(&mut self, id: u64, label: String, task: Task, recheck: Option) { + pub fn add_job(&mut self, id: u64, label: String, task: Task, after: After) { let (rows, cols) = self.pty_size; self.jobs.push(Job { id, @@ -132,7 +143,7 @@ impl TasksState { status: JobStatus::Running, started: Instant::now(), elapsed: None, - recheck, + after, }); self.active = self.jobs.len() - 1; } @@ -684,7 +695,7 @@ mod tests { id: 7, label: "a".into(), task: task("test", "/w/a"), - recheck: None, + after: After::Nothing, }); assert_eq!(app.tasks.running(), 1); assert!(app.animating()); @@ -707,7 +718,7 @@ mod tests { id: 8, label: "b".into(), task: task("x", "/w/b"), - recheck: None, + after: After::Nothing, }); app.update(Msg::JobExited(8, Err("`just` not found".into()))); assert!( @@ -729,7 +740,7 @@ mod tests { id: 1, label: "a".into(), task: task("dev", "/w/a"), - recheck: None, + after: After::Nothing, }); press(&mut app, KeyCode::Char('q')); assert!(!app.quit); diff --git a/crates/carwash/src/tui/updates.rs b/crates/carwash/src/tui/updates.rs index fa16541..23f5a01 100644 --- a/crates/carwash/src/tui/updates.rs +++ b/crates/carwash/src/tui/updates.rs @@ -788,7 +788,7 @@ mod tests { cwd: PathBuf::from("/w/a"), standard: true, }, - recheck: Some(PathBuf::from("/w/a")), + after: crate::tui::tasks::After::Recheck(PathBuf::from("/w/a")), }); let effects = app.update(Msg::JobExited(3, Ok(0))); assert!(matches!(effects.first(), Some(Effect::CheckDeps { .. }))); diff --git a/crates/carwash/src/tui/view.rs b/crates/carwash/src/tui/view.rs index 71963c3..11650b8 100644 --- a/crates/carwash/src/tui/view.rs +++ b/crates/carwash/src/tui/view.rs @@ -36,6 +36,8 @@ pub fn render(frame: &mut Frame, app: &mut App) { super::tasks::render(frame, app, body); } else if app.tab == Tab::Updates { super::updates::render(frame, app, body); + } else if app.tab == Tab::Caches { + super::caches::render(frame, app, body); } else if details { let [table, side] = Layout::horizontal([Constraint::Min(60), Constraint::Length(DETAILS_WIDTH)]) @@ -147,6 +149,14 @@ fn render_toolbar(frame: &mut Frame, app: &App, area: Rect) { split_line(frame, area, Line::from(left), Line::from(right)); return; } + if app.tab == Tab::Caches { + let right = Line::from(vec![ + Span::styled(fmt::bytes(app.caches.total()), t.bold(t.text)), + Span::styled(" in caches ", t.muted()), + ]); + split_line(frame, area, Line::from(left), right); + return; + } if app.tab == Tab::Updates { let (outdated, vulnerable) = app.updates.totals(); let mut right = vec![Span::styled( @@ -748,6 +758,15 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ("?", "help"), ("q", "quit"), ], + Tab::Caches => &[ + ("space", "mark"), + ("d", "clean"), + ("r", "measure"), + ("o", "reveal"), + ("1", "reclaim"), + ("?", "help"), + ("q", "quit"), + ], }; let mut spans = vec![Span::raw(" ")]; for (i, (key, label)) in hints.iter().enumerate() { From d9b58047b410a3df2ffe0ce7adb0c04da2c5c6ef Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:27:13 -0400 Subject: [PATCH 14/22] chore: drop unused dependencies, update crossbeam-epoch (RUSTSEC-2026-0204) The rewrite no longer uses tokio, futures, reqwest, process-wrap, shlex, humantime, jiff, cargo-lock, insta or a direct crossterm dependency; carwash-core never used crossbeam-channel or tracing. --- Cargo.lock | 6 ++---- Cargo.toml | 13 +------------ crates/carwash-core/Cargo.toml | 2 -- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c8ae21..d7c80f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,7 +271,6 @@ dependencies = [ name = "carwash-core" version = "0.4.0" dependencies = [ - "crossbeam-channel", "dashmap", "etcetera", "filetime", @@ -288,7 +287,6 @@ dependencies = [ "thiserror 2.0.18", "toml", "toml_edit", - "tracing", "trash", "ureq", "yaml_serde", @@ -513,9 +511,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] diff --git a/Cargo.toml b/Cargo.toml index 949cfc7..be30faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,8 +36,6 @@ semver = { version = "1.0", features = ["serde"] } nodejs-semver = "6.0" pep440_rs = "0.7" ureq = { version = "3.4", features = ["json"] } -cargo-lock = "11.1" -jiff = { version = "0.2", features = ["serde"] } # Errors, logging thiserror = "2.0" @@ -50,29 +48,20 @@ tracing-appender = "0.2" clap = { version = "4.6", features = ["derive", "env", "wrap_help"] } clap_complete = "4.6" ratatui = "0.30" -crossterm = { version = "0.29", features = ["event-stream"] } tui-input = "0.15" nucleo-matcher = "0.3" unicode-width = "0.2" -humantime = "2.2" indicatif = "0.18" anstream = "1.0" anstyle = "1.0" -# Async, processes, network -tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs"] } -tokio-util = "0.7" -futures = "0.3" -reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "http2"] } +# Terminals portable-pty = "0.9" vt100 = "0.16" tui-term = "0.3" -process-wrap = { version = "10.0", features = ["std"] } -shlex = "2.0" # Testing tempfile = "3.27" -insta = "1.48" assert_cmd = "2.2" predicates = "3.1" diff --git a/crates/carwash-core/Cargo.toml b/crates/carwash-core/Cargo.toml index ca94ee1..9696e28 100644 --- a/crates/carwash-core/Cargo.toml +++ b/crates/carwash-core/Cargo.toml @@ -13,7 +13,6 @@ categories = ["development-tools", "filesystem"] [dependencies] rayon.workspace = true -crossbeam-channel.workspace = true dashmap.workspace = true globset.workspace = true remove_dir_all.workspace = true @@ -30,7 +29,6 @@ nodejs-semver.workspace = true pep440_rs.workspace = true ureq.workspace = true thiserror.workspace = true -tracing.workspace = true [dev-dependencies] tempfile.workspace = true From 9267ac9f1591e5429de357969bd65c89e4dd9e71 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:27:13 -0400 Subject: [PATCH 15/22] fix(tui): mouse wheel scrolls the current tab Scrolling on Tasks, Updates or Caches moved the hidden Reclaim cursor. Other tabs now receive Up/Down actions; clicks stay Reclaim-only. --- crates/carwash/src/tui/app.rs | 12 ++++++++++++ crates/carwash/src/tui/caches.rs | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/carwash/src/tui/app.rs b/crates/carwash/src/tui/app.rs index 152c1b2..e85b23a 100644 --- a/crates/carwash/src/tui/app.rs +++ b/crates/carwash/src/tui/app.rs @@ -677,6 +677,18 @@ impl App { self.dirty = false; return Vec::new(); } + if self.tab != Tab::Reclaim { + // Other tabs record no row geometry; the wheel moves their own cursor. + let action = match mouse.kind { + MouseEventKind::ScrollDown => Action::Down, + MouseEventKind::ScrollUp => Action::Up, + _ => { + self.dirty = false; + return Vec::new(); + } + }; + return (0..3).flat_map(|_| self.on_global_action(action)).collect(); + } match mouse.kind { MouseEventKind::ScrollDown => self.move_by(3), MouseEventKind::ScrollUp => self.move_by(-3), diff --git a/crates/carwash/src/tui/caches.rs b/crates/carwash/src/tui/caches.rs index 5131ae7..643179f 100644 --- a/crates/carwash/src/tui/caches.rs +++ b/crates/carwash/src/tui/caches.rs @@ -352,6 +352,24 @@ mod tests { assert!(app.caches.busy.is_empty()); } + #[test] + fn the_wheel_scrolls_the_caches_list() { + use ratatui::crossterm::event::{MouseEvent, MouseEventKind}; + let mut app = app(); + press(&mut app, KeyCode::Char('4')); + let caches = (0..5).map(|i| cache(&format!("c{i}"))).collect(); + app.update(Msg::CachesDiscovered(caches)); + let reclaim_before = app.selected; + app.update(Msg::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + })); + assert_eq!(app.caches.cursor, 3); + assert_eq!(app.selected, reclaim_before); + } + #[test] fn cleaning_needs_a_second_press() { let mut app = app(); From 8b12fc0fd07b4301cb84a3794aed95d500be221e Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:27:14 -0400 Subject: [PATCH 16/22] fix(config): ignore configuration files written by 0.3 0.3 stored its config at the same path on Linux, so upgrading made every command fail on unknown fields. Such files now produce a warning and defaults. Also: the home directory always comes from paths::home(), the caches override file from Dirs::caches_file(), and config comments list the real theme and icon choices. --- crates/carwash-core/src/paths.rs | 4 +++ crates/carwash/src/cli.rs | 1 + crates/carwash/src/commands/caches.rs | 2 +- crates/carwash/src/config.rs | 38 ++++++++++++++++++++++++--- crates/carwash/src/tui/view.rs | 2 +- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/carwash-core/src/paths.rs b/crates/carwash-core/src/paths.rs index e93e025..da46781 100644 --- a/crates/carwash-core/src/paths.rs +++ b/crates/carwash-core/src/paths.rs @@ -47,6 +47,10 @@ impl Dirs { self.config.join("ecosystems.toml") } + pub fn caches_file(&self) -> PathBuf { + self.config.join("caches.toml") + } + pub fn size_cache_file(&self) -> PathBuf { self.cache.join("sizes.json") } diff --git a/crates/carwash/src/cli.rs b/crates/carwash/src/cli.rs index c0d889c..42a056b 100644 --- a/crates/carwash/src/cli.rs +++ b/crates/carwash/src/cli.rs @@ -315,6 +315,7 @@ pub struct CachesArgs { pub enum CachesAction { /// Clean caches by id (as listed by `carwash caches`); a parent id selects its children. Clean { + /// Cache ids, e.g. `npm` or `rustup-toolchains/nightly-aarch64-apple-darwin`. #[arg(required = true, value_name = "ID")] ids: Vec, diff --git a/crates/carwash/src/commands/caches.rs b/crates/carwash/src/commands/caches.rs index 7e33237..a331617 100644 --- a/crates/carwash/src/commands/caches.rs +++ b/crates/carwash/src/commands/caches.rs @@ -19,7 +19,7 @@ use std::time::Duration; /// Caches present on this machine, with user overrides from `caches.toml`. pub fn present(ctx: &Context) -> Result> { - let specs = match ctx.dirs.as_ref().map(|d| d.config.join("caches.toml")) { + let specs = match ctx.dirs.as_ref().map(|d| d.caches_file()) { Some(path) if path.exists() => { let text = std::fs::read_to_string(&path) .with_context(|| format!("cannot read {}", path.display()))?; diff --git a/crates/carwash/src/config.rs b/crates/carwash/src/config.rs index 2c93e4f..1ac955c 100644 --- a/crates/carwash/src/config.rs +++ b/crates/carwash/src/config.rs @@ -80,8 +80,9 @@ impl Default for CleanConfig { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct UiConfig { + /// `gestalt`, `latte`, `nord`, `dracula` or `ansi` (16 colors). pub theme: String, - /// `unicode`, `nerd` (Nerd Font glyphs) or `ascii`. + /// `unicode` or `ascii`. pub icons: String, } @@ -106,9 +107,17 @@ const HOME_EXCLUDES: &[&str] = &[ ]; impl Config { - /// Loads `path`; a missing file yields defaults, a malformed one is an error. + /// Loads `path`; a missing file yields defaults, a malformed one is an error. A file + /// written by carwash 0.3 (same path on Linux) is ignored with a warning. pub fn load(path: &Path) -> Result { match std::fs::read_to_string(path) { + Ok(text) if is_legacy(&text) => { + anstream::eprintln!( + "warning: ignoring {}: it was written by carwash 0.3. Delete it, or see the README for the new format.", + path.display() + ); + Ok(Self::default()) + } Ok(text) => toml::from_str(&text) .with_context(|| format!("invalid configuration in {}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), @@ -118,7 +127,7 @@ impl Config { /// Absolute exclusion paths: configured ones plus, if enabled, the home defaults. pub fn excludes(&self) -> Vec { - let home = std::env::var_os("HOME").map(PathBuf::from); + let home = carwash_core::paths::home(); let mut out: Vec = self .scan .exclude @@ -134,6 +143,17 @@ impl Config { } } +/// A 0.3 configuration: only 0.3 tables (`[layout]`, `[keybindings]`...), none of ours. +fn is_legacy(text: &str) -> bool { + const LEGACY: &[&str] = &["theme", "layout", "keybindings", "progress", "app"]; + const CURRENT: &[&str] = &["scan", "clean", "updates", "ui"]; + let Ok(table) = text.parse::() else { + return false; + }; + table.keys().any(|k| LEGACY.contains(&k.as_str())) + && !table.keys().any(|k| CURRENT.contains(&k.as_str())) +} + pub fn expand_home(raw: &str, home: Option<&Path>) -> PathBuf { match (raw.strip_prefix("~/").or((raw == "~").then_some("")), home) { (Some(rest), Some(home)) => home.join(rest), @@ -158,6 +178,18 @@ mod tests { assert!(toml::from_str::("[scan]\nexclud = []\n").is_err()); } + #[test] + fn version_0_3_files_are_recognised() { + let legacy = "[theme]\ncurrent = \"dark\"\n[layout]\nleft_pane_percent = 30\n[app]\ncache_ttl_minutes = 5\n"; + assert!(is_legacy(legacy)); + assert!(!is_legacy("[ui]\ntheme = \"nord\"\n")); + assert!(!is_legacy("[layout]\nx = 1\n[scan]\nexclude = []\n")); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, legacy).unwrap(); + assert_eq!(Config::load(&path).unwrap().clean.recent_days, 7); + } + #[test] fn home_expansion() { let home = Path::new("/home/me"); diff --git a/crates/carwash/src/tui/view.rs b/crates/carwash/src/tui/view.rs index 11650b8..1c40425 100644 --- a/crates/carwash/src/tui/view.rs +++ b/crates/carwash/src/tui/view.rs @@ -57,7 +57,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { } fn home_relative(path: &std::path::Path) -> String { - if let Some(home) = std::env::var_os("HOME") + if let Some(home) = carwash_core::paths::home() && let Ok(rest) = path.strip_prefix(&home) { return if rest.as_os_str().is_empty() { From 2a5a37e54da87a0ef12dd3032c8ba2245a4e548e Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:27:14 -0400 Subject: [PATCH 17/22] ci: lint, test on three platforms, MSRV and cargo-deny justfile recipes now target the workspace (the old ones ran `cargo test --lib` at the virtual root, which fails). --- .github/workflows/ci.yml | 85 ++++++++++++++++++---------------------- .gitignore | 1 + deny.toml | 36 +++++++++++++++++ justfile | 75 ++++++++++++----------------------- 4 files changed, 100 insertions(+), 97 deletions(-) create mode 100644 deny.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 640de34..d064b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,65 +2,56 @@ name: CI on: push: - branches: [ main, develop ] + branches: [main] pull_request: - branches: [ main, develop ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true env: CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 + RUSTFLAGS: -D warnings jobs: - test: - name: Test + lint: + name: Format and Clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v4 + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache target directory - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Run tests - run: cargo test --verbose - - - name: Run clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - - name: Check formatting - run: cargo fmt -- --check + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets --locked + - run: cargo doc --workspace --no-deps --locked - build: - name: Build + test: + name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build release - run: cargo build --release --verbose - - - name: Run basic smoke test - run: cargo run --release -- --help \ No newline at end of file + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace --locked + + msrv: + name: MSRV (1.88) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@1.88 + - uses: Swatinem/rust-cache@v2 + - run: cargo check --workspace --all-targets --locked + + deny: + name: Advisories and licenses + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.gitignore b/.gitignore index dde55f0..6760cf5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ Thumbs.db # Development notes *.md !README.md +.claude/ diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..de72314 --- /dev/null +++ b/deny.toml @@ -0,0 +1,36 @@ +# cargo-deny: https://embarkstudios.github.io/cargo-deny/ + +[graph] +all-features = true + +[advisories] +version = 2 +yanked = "deny" + +[licenses] +version = 2 +confidence-threshold = 0.9 +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] + +[bans] +multiple-versions = "allow" +wildcards = "deny" +allow-wildcard-paths = true + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/justfile b/justfile index 02f4dda..c17914d 100644 --- a/justfile +++ b/justfile @@ -1,76 +1,51 @@ -# CarWash Development Tasks -# Usage: just +# carwash development tasks. Usage: just -# Default recipe - show available commands default: @just --list -# Install carwash locally from source +# Install carwash from this checkout install: - cargo install --path . + cargo install --locked --path crates/carwash -# Build debug version build: cargo build -# Build optimized release version build-release: cargo build --release -# Run the application (scan current directory) -run: - cargo run - -# Run with a specific directory -run-path path: +# Open the TUI on a directory +run path=".": cargo run -- {{path}} -# Run all tests test: - cargo test --lib - -# Run tests with output -test-verbose: - cargo test --lib -- --nocapture + cargo test --workspace -# Run a specific test +# Run tests matching a name, with output test-name name: - cargo test {{name}} -- --nocapture + cargo test --workspace {{name}} -- --nocapture -# Format code fmt: - cargo fmt - -# Check code formatting without modifying -fmt-check: - cargo fmt -- --check + cargo fmt --all -# Run clippy lints clippy: - cargo clippy + cargo clippy --workspace --all-targets -- -D warnings -# Run clippy with warnings as errors -clippy-strict: - cargo clippy -- -D warnings +# Advisories, licenses and sources +deny: + cargo deny check -# Run all checks (fmt, clippy, test) -check: fmt clippy test +# Compile with the minimum supported Rust version +msrv: + cargo +1.88 check --workspace --all-targets -# Package for publishing (creates tarball) -package: - cargo package +# Everything CI runs +check: + cargo fmt --all -- --check + cargo clippy --workspace --all-targets -- -D warnings + cargo test --workspace + cargo deny check -# Publish to crates.io +# Publish the engine first, then the binary publish: - cargo publish - -# Clean build artifacts -clean: - cargo clean - -# Update dependencies -update-deps: - cargo update - -# Update and rebuild -update: update-deps build + cargo publish -p carwash-core + cargo publish -p carwash From 5a1d8a0f314fb8fb74cbb64b094576cab5d619af Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:27:14 -0400 Subject: [PATCH 18/22] docs: README and changelog for 0.4 --- CHANGELOG.md | 32 ++++++ README.md | 275 +++++++++++++++++++++++---------------------------- 2 files changed, 157 insertions(+), 150 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf43679..4b0cbd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to CarWash will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - Unreleased + +A rewrite. carwash is no longer a Rust project manager: it reclaims disk space across projects +in any of 40 ecosystems, and checks dependencies and runs tasks along the way. Nothing from 0.3 +carries over, including the configuration file. + +### Added +- Engine crate `carwash-core`, independent of the UI: parallel discovery driven by declarative + ecosystem rules, content detection (`CACHEDIR.TAG`, virtualenvs, conda, CMake and Meson build + trees, orphaned Cargo targets), workspace and enclosing-project awareness. +- Sizes from allocated blocks with hard links counted once, and a separate "reclaimable" figure + for data that deleting the artifact would actually free. +- Safety policy from git: tracked content is protected; generic names such as `build` or + `vendor` need git or content confirmation; recently modified artifacts are held back. +- Validated deletion: re-checked just before removal, renamed to a staging name, then removed in + parallel. Optional trash mode. Interrupted cleans are found and finished by the next scan. +- CLI: `scan`, `clean`, `tasks`, `run`, `outdated`, `caches`, `ecosystems`, `history`, + `completions`; `--json` on every listing. +- TUI with four tabs: Reclaim (tree, fuzzy and facet filter, review before cleaning), Tasks (jobs + in pseudo-terminals), Updates (outdated and vulnerable dependencies for Rust, JavaScript, Python + and Go, updated through each ecosystem's own tool) and Caches (per-user caches outside projects). +- Task discovery from `package.json`, `deno.json`, justfiles, Makefiles, Taskfiles, mise, + Poe/PDM, Composer and Cargo aliases, plus standard commands per ecosystem. +- 32 global caches (package stores, toolchains, Xcode and simulator data, IDE caches) with the + tool's own prune command where one exists. +- User-extensible rules in `ecosystems.toml` and `caches.toml`. +- Themes: gestalt (default), latte, nord, dracula, and a 16-color fallback. + +### Changed +- Configuration moved to `~/.config/carwash/config.toml` with a new schema. +- Minimum supported Rust version is 1.88. + ## [0.3.5] - 2026-03-03 ### Added diff --git a/README.md b/README.md index 21ec775..46f4d56 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,151 @@ -# CarWash - Rust Project Manager +# carwash -A TUI (Terminal User Interface) for managing multiple Rust projects with ease. CarWash provides an intuitive interface for running cargo commands across multiple projects simultaneously, managing dependencies, and monitoring build outputs. +Reclaim disk space across every project on your machine. carwash finds build outputs, dependency +installs, virtualenvs and caches in 40 ecosystems, tells you which ones are safe to delete, and +cleans them. It also checks dependencies for updates and vulnerabilities and runs project tasks, +all from one TUI or a scriptable CLI. [![Crates.io](https://img.shields.io/crates/v/carwash.svg)](https://crates.io/crates/carwash) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![CI](https://github.com/epistates/carwash/actions/workflows/ci.yml/badge.svg)](https://github.com/epistates/carwash/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -Carwash - -## Features - -### Core Features -- **Multi-Project Management**: Automatically discover and manage all Rust projects in a directory tree -- **Parallel Command Execution**: Run cargo commands across multiple projects simultaneously -- **Interactive Command Palette**: Fuzzy search through cargo commands with vim-style navigation -- **Real-time Output Monitoring**: Watch command output with colored syntax highlighting -- **Dependency Management**: Check for outdated dependencies and update them interactively -- **Workspace Support**: Intelligently handles Cargo workspaces and member projects - -### Enterprise-Grade UX -- **Beautiful UI**: Modern, colorful interface with intuitive layouts -- **Status Indicators**: Real-time status updates with progress indicators -- **Smart Color Coding**: Errors (red), warnings (yellow), success (green) -- **Scrollable Output**: Navigate through large command outputs with keyboard shortcuts -- **Multiple Tabs**: Switch between running commands with arrow keys -- **Project Selection**: Select single or multiple projects with visual checkboxes - -### Keyboard-Driven Workflow -- **Vim-style Navigation**: `j/k` for up/down, `h/l` for left/right -- **Quick Commands**: Press `:` to open command palette instantly -- **Space to Select**: Toggle project selection with spacebar -- **Esc to Cancel**: Consistent escape behavior throughout -- **Tab to Toggle**: Switch command scope between selected and all projects - -## Installation - -### Prerequisites -- **Rust 1.93.0+** (Rust Edition 2024) - -### From crates.io (Recommended) - -```bash -cargo install carwash -``` - -See the package on [crates.io](https://crates.io/crates/carwash). - -### From source - -```bash -# Clone the repository -git clone https://github.com/epistates/carwash.git -cd carwash - -# Build and install -cargo install --path . - -# Or run directly -cargo run -- /path/to/your/rust/projects -``` - -## Quick Start +Point it at a single package, a monorepo, or a directory holding hundreds of repositories in +mixed languages: ```bash -# Run in current directory -carwash - -# Run in specific directory -carwash ~/my-rust-projects - -# Show help -carwash --help +carwash ~/work # interactive +carwash scan ~/work # what could be freed, largest first +carwash clean ~/work --older-than 30d ``` -## Usage Guide - -### Navigation +## What it finds -| Key | Action | -|-----|--------| -| `↑`/`↓` or `j`/`k` | Navigate projects | -| `←`/`→` | Switch output tabs | -| `Space` | Toggle project selection | -| `PgUp`/`PgDown` | Scroll output | +| | Examples | +|---|---| +| Build outputs | `target/`, `build/`, `dist/`, `.next/`, `DerivedData/`, `_build/`, `zig-out/`, `bin/` + `obj/` | +| Dependency installs | `node_modules/`, `vendor/`, `Pods/`, `deps/`, `.gradle/`, `renv/library/` | +| Environments | `.venv/`, `.tox/`, `.direnv/`, `.pixi/`, conda environments | +| Tool caches | `__pycache__/`, `.pytest_cache/`, `.turbo/`, `.parcel-cache/`, `.terraform/`, `.godot/` | +| Global caches | Cargo registry, old Rust toolchains, npm/pnpm/yarn/bun, pip/uv/poetry, Go modules, Gradle, Maven, Homebrew, Xcode DerivedData, iOS device support, simulators, and more | -### Commands +Projects are recognised by their manifests (`Cargo.toml`, `package.json`, `pyproject.toml`, +`go.mod`, `pom.xml`, `*.csproj`, `Package.swift`, `pubspec.yaml`, `mix.exs`, `CMakeLists.txt`, +`*.uproject`, `project.godot`...). Directories that are unmistakable on their own are found +without a manifest too: a `CACHEDIR.TAG`, a `pyvenv.cfg`, a CMake or Meson build tree, a Cargo +`target/` whose `Cargo.toml` is gone. Run `carwash ecosystems` for the full list. -| Key | Action | -|-----|--------| -| `:` | Open command palette | -| `u` | Check for dependency updates | -| `?` | Show help screen | -| `q` | Quit application | -| `Ctrl+C` | Force quit | +## Safety -### Command Palette +Deleting the wrong directory is the one mistake a cleaner cannot make, so every artifact gets a +status before anything is selected: -1. Press `:` to open the command palette -2. Type to filter commands (fuzzy search) -3. Use `↑`/`↓` or `j`/`k` to select a command -4. Press `Tab` to toggle between **Selected Projects** and **All Projects** -5. Press `Enter` to execute the command -6. Press `Esc` to cancel +- **protected**: git tracks files inside it (a committed `vendor/` or `dist/`). Never deleted. +- **review**: a generic name like `build` or `bin` that nothing confirms as generated, or one git + reports as untracked rather than ignored. Needs `--include-review` or a look in the TUI. +- **recent**: modified in the last 7 days. Held back by default so you don't delete what you're + using. +- **ready**: everything else. -## Architecture +Each target is re-checked just before deletion (still a directory, not a symlink, inside the +scanned root, no `.git` inside). Permanent deletion renames the directory to a staging name +first, which is instant and atomic, then removes it in parallel. An interrupted clean leaves a +`.carwash-trash-*` directory that the next scan offers to finish. `--trash` moves to the system +trash instead. -CarWash is built with: +Sizes are allocated blocks, not apparent sizes, with hard links counted once. "Reclaimable" only +counts data whose every link is inside the artifact, so a pnpm `node_modules` hard-linked into +the global store shows how much deleting it would actually free. -- **[Ratatui](https://github.com/ratatui-org/ratatui)**: Modern TUI framework for Rust -- **[Tokio](https://tokio.rs/)**: Async runtime for parallel command execution -- **[crates.io API](https://crates.io/)**: For checking dependency versions -- **Component-Based UI**: Modular, maintainable UI components -- **Redux-style State Management**: Predictable state updates with reducer pattern +## Install -### Project Structure - -``` -src/ -├── main.rs # Entry point and main event loop -├── app.rs # Application state and reducer -├── handlers.rs # State transition logic -├── project.rs # Project discovery and parsing -├── runner.rs # Command execution and updates -├── tree.rs # Hierarchical navigation system -├── cache.rs # Dependency check caching -├── settings.rs # User preferences -├── config/ # Unified configuration system -│ ├── keybinding_config.rs -│ └── theme_config.rs -├── components/ # UI Components -│ ├── dependencies.rs -│ ├── output.rs -│ ├── projects.rs -│ └── ... -└── ui/ # Visual orchestration - ├── layout.rs - ├── modal.rs - └── theme.rs +```bash +cargo install --locked carwash ``` -## Customization - -CarWash uses a carefully chosen color scheme optimized for terminal visibility: +Or from a checkout: `cargo install --locked --path crates/carwash`. Requires Rust 1.88 or newer. + +## The TUI + +Run `carwash [PATH]`. Four tabs, switched with `1` to `4`: + +1. **Reclaim**: every project and artifact as a tree (or flat by project or artifact, `Tab`), + sorted by size. Mark with `Space`, press `d` to review and clean. `/` filters with fuzzy text + plus facets: `eco:rust,node kind:deps size>1g age>30d is:ready`. +2. **Tasks**: scripts and targets from `package.json`, `deno.json`, `justfile`, `Makefile`, + `Taskfile`, `mise`, Poe/PDM, Composer and Cargo aliases, plus standard commands per ecosystem. + `Enter` runs the task in a real terminal inside the TUI; mark several projects to run it in + all of them. +3. **Updates**: outdated and vulnerable dependencies for Rust, JavaScript, Python and Go. `u` + updates within your requirements, `U` upgrades to latest (and edits manifests). Projects are + re-checked after the update finishes. +4. **Caches**: global caches outside any project. `d` (twice) runs the tool's own prune command + when it is installed, or deletes the directory when that is safe. + +`?` lists every key for the current tab. `t` cycles themes. The wheel scrolls every tab; in +Reclaim, clicking selects a row and clicking it again expands it. + +## The CLI + +| Command | | +|---|---| +| `carwash scan [PATH]` | List reclaimable artifacts. `--json`, `--sort`, `--limit`, filters below | +| `carwash clean [PATH]` | Show the plan, confirm, delete. `--dry-run`, `--yes`, `--trash`, `--include-review`, `--include-recent` | +| `carwash tasks [PATH]` | Tasks per project. `--all` for every project's list | +| `carwash run TASK [PATH]` | Run a task in every project that has it. `-j N`, `--filter GLOB`, `--fail-fast`, `--dry-run` | +| `carwash outdated [PATH]` | Outdated and vulnerable dependencies. `--exit-code` for CI, `--no-vulns`, `--refresh` | +| `carwash caches` | Global caches and their sizes | +| `carwash caches clean ID...` | Clean caches; a parent id such as `rustup-toolchains` selects all its children | +| `carwash ecosystems` | Recognised ecosystems | +| `carwash history` | Space reclaimed so far | +| `carwash completions SHELL` | Shell completions | + +Filters shared by `scan` and `clean`: `--min-size 500MB`, `--older-than 3mo`, +`--kind build,deps,cache,env,other,leftover`, `-e rust,node`, `--exclude PATH`, `--max-depth N`, +`--hidden`, `--cross-fs`. Every listing command takes `--json`. + +## Configuration + +`~/.config/carwash/config.toml` (or `$CARWASH_CONFIG`). Every key is optional: + +```toml +[scan] +exclude = ["~/work/archive"] # never entered +default_excludes = true # also skip ~/Library, ~/Pictures, ~/go/pkg/mod... +include_hidden = false # hidden directories are entered inside projects either way +same_filesystem = true +threads = 0 # 0: derived from the CPU count + +[clean] +mode = "permanent" # or "trash" +recent_days = 7 + +[updates] +cache_hours = 6 # reuse registry lookups +vulnerabilities = true # query OSV + +[ui] +theme = "gestalt" # gestalt, latte, nord, dracula, ansi +icons = "unicode" # or ascii +``` -- **Cyan**: Borders, titles, and interactive elements -- **Green**: Success states and selected items -- **Yellow**: Warnings and pending states -- **Red**: Errors and critical information -- **Magenta**: Special modes like update wizard -- **Dark Gray**: Secondary information and help text +Ecosystems and global caches are data, not code. Add your own, or replace a built-in by reusing +its `id`, in `~/.config/carwash/ecosystems.toml` and `~/.config/carwash/caches.toml`. The +built-in files document every field: +[`builtin.toml`](crates/carwash-core/src/ecosystem/builtin.toml), +[`caches.toml`](crates/carwash-core/src/caches.toml). -## Contributing +carwash keeps a size cache and registry cache in `~/.cache/carwash`, history in +`~/.local/share/carwash`, and TUI logs in `~/.local/state/carwash/logs`. `CARWASH_HOME` puts all +of it under one directory. -Contributions are welcome! Areas for improvement: +## How it works -- [ ] Custom command templates -- [ ] Project favorites/bookmarks -- [ ] Command history persistence -- [ ] Export/save command outputs -- [ ] Plugin system -- [ ] Remote project support +The workspace has two crates. `carwash-core` is the engine and has no UI code: parallel +discovery that reads names only and never descends into artifacts, batched git inspection +(`git ls-files`, `git check-ignore --stdin`), parallel measurement, the safety policy, and the +cleaner. `carwash` is the CLI and a ratatui TUI built as an Elm-style update loop, with tasks +running in pseudo-terminals. -## 📝 License +## License [MIT](./LICENSE) - -## Acknowledgments - -Built with: -- [Ratatui](https://github.com/ratatui-org/ratatui) - Amazing TUI framework -- [Tokio](https://tokio.rs/) - Powerful async runtime -- [crates.io API](https://github.com/crates-io/crates.io) - Crate metadata - ---- - -**Made with ❤️ for the Rust community** From 9bac97f674ff4b0a50a51b162b392b5386f9123f Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:54:30 -0400 Subject: [PATCH 19/22] feat(tui): remember cache sizes; footer with global keys and tooltips Caches: discovery runs at startup (it only checks known paths), and sizes come from the size cache, so the tab and its total appear instantly. Only sizes older than a day are measured when the tab is shown; r measures everything. `carwash caches` stores its measurements there too. The cursor stays on the largest cache until the user moves. Footer: hints are derived from the keymap instead of a parallel list. This tab's actions are on the left, the keys that work in any tab on the right under an "any tab" label. Hovering a hint or tab shows what it does (for d, exactly what will be deleted or run); clicking runs it. Help shows the tab's purpose, separates "Any tab" keys, sizes itself to its content and switches to balanced columns on short terminals. --- README.md | 9 +- crates/carwash/src/commands/caches.rs | 24 +- crates/carwash/src/tui/app.rs | 99 +++++- crates/carwash/src/tui/caches.rs | 282 ++++++++++++++-- crates/carwash/src/tui/keymap.rs | 145 +++++++-- crates/carwash/src/tui/mod.rs | 34 +- crates/carwash/src/tui/view.rs | 447 +++++++++++++++++++------- 7 files changed, 830 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index 46f4d56..1371602 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,13 @@ Run `carwash [PATH]`. Four tabs, switched with `1` to `4`: updates within your requirements, `U` upgrades to latest (and edits manifests). Projects are re-checked after the update finishes. 4. **Caches**: global caches outside any project. `d` (twice) runs the tool's own prune command - when it is installed, or deletes the directory when that is safe. + when it is installed, or deletes the directory when that is safe. Sizes are remembered for a + day, so the tab opens instantly; `r` measures again. -`?` lists every key for the current tab. `t` cycles themes. The wheel scrolls every tab; in -Reclaim, clicking selects a row and clicking it again expands it. +The footer shows the current tab's keys on the left and the keys that work in any tab on the +right. Hover over a key for what it does (for `d`, exactly what it will delete or run), click it +to run it, or click a tab to switch. `?` lists every key. `t` cycles themes. The wheel scrolls +every tab; in Reclaim, clicking selects a row and clicking it again expands it. ## The CLI diff --git a/crates/carwash/src/commands/caches.rs b/crates/carwash/src/commands/caches.rs index a331617..dd6860e 100644 --- a/crates/carwash/src/commands/caches.rs +++ b/crates/carwash/src/commands/caches.rs @@ -68,6 +68,18 @@ pub fn measure(ctx: &Context, caches: &mut [GlobalCache], show_progress: bool) { } }); bar.finish_and_clear(); + remember(ctx, caches); +} + +/// Stores measured sizes in the size cache, where the TUI's Caches tab finds them. +fn remember(ctx: &Context, caches: &[GlobalCache]) { + ctx.update_size_cache(|sizes| { + for cache in caches { + if let Some(size) = cache.size { + sizes.insert(cache.path.clone(), size); + } + } + }); } fn action(cache: &GlobalCache) -> (String, Style) { @@ -213,7 +225,8 @@ fn clean( let mut failures = 0; let mut records = Vec::new(); - for (cache, task) in plan { + let mut cleaned = Vec::new(); + for (mut cache, task) in plan { let before = cache.size.map_or(0, |s| s.on_disk); let ok = match task { Some(task) => { @@ -248,11 +261,11 @@ fn clean( }; if ok { let after = if cache.path.exists() { - ctx.engine.measure_path(&cache.path, &Cancel::new()).on_disk + ctx.engine.measure_path(&cache.path, &Cancel::new()) } else { - 0 + carwash_core::Size::default() }; - let freed = before.saturating_sub(after); + let freed = before.saturating_sub(after.on_disk); anstream::println!(" freed {} from {}", fmt::bytes(freed), cache.name); records.push(Record::now( cache.path.clone(), @@ -261,11 +274,14 @@ fn clean( cache.ecosystem.clone(), DeleteMode::Permanent, )); + cache.size = Some(after); + cleaned.push(cache); } else { failures += 1; anstream::eprintln!(" failed to clean {}", cache.name); } } + remember(ctx, &cleaned); if let Some(dirs) = &ctx.dirs && let Err(error) = history::append(&dirs.history_file(), &records) { diff --git a/crates/carwash/src/tui/app.rs b/crates/carwash/src/tui/app.rs index e85b23a..47da704 100644 --- a/crates/carwash/src/tui/app.rs +++ b/crates/carwash/src/tui/app.rs @@ -101,10 +101,8 @@ pub enum Effect { caches: Vec, size: (u16, u16), }, - MeasureCache { - id: String, - path: PathBuf, - }, + /// Measures each `(id, path)` and reports it with [`Msg::CacheMeasured`]. + MeasureCaches(Vec<(String, PathBuf)>), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -173,6 +171,31 @@ pub struct TableGeometry { pub offset: usize, } +/// A clickable, hoverable region recorded while rendering: a tab title or a footer hint. +#[derive(Debug, Clone, Copy)] +pub struct Hotspot { + pub area: Rect, + pub target: Target, +} + +#[derive(Debug, Clone, Copy)] +pub enum Target { + Tab(Tab), + Binding(&'static keymap::Binding), +} + +impl Target { + fn action(self) -> Action { + match self { + Target::Tab(Tab::Reclaim) => Action::ShowReclaim, + Target::Tab(Tab::Tasks) => Action::ShowTasks, + Target::Tab(Tab::Updates) => Action::ShowUpdates, + Target::Tab(Tab::Caches) => Action::ShowCaches, + Target::Binding(binding) => binding.action, + } + } +} + pub struct App { pub store: Store, pub registry: Arc, @@ -194,6 +217,10 @@ pub struct App { selected_key: Option, pub page: usize, pub geometry: TableGeometry, + /// Recorded by the last render. + pub hotspots: Vec, + /// The hotspot under the mouse, shown with a tooltip. + pub hover: Option, pub scan: ScanStatus, pub toast: Option, pub theme: Theme, @@ -255,6 +282,8 @@ impl App { selected_key: None, page: 10, geometry: TableGeometry::default(), + hotspots: Vec::new(), + hover: None, scan: ScanStatus::default(), toast: None, theme, @@ -335,6 +364,39 @@ impl App { self.selected_key = self.rows.rows.get(self.selected).map(|r| r.key.clone()); } + /// Effects to run when the UI starts. + pub fn startup(&mut self) -> Vec { + let mut effects = vec![Effect::Scan, Effect::RefreshDisk]; + effects.extend(self.discover_caches()); + effects + } + + /// What `action` would do right now, for its tooltip. + pub fn action_context(&self, action: Action) -> Option { + match (self.tab, action) { + (Tab::Reclaim, Action::Clean) if self.marked.is_empty() => { + Some("Nothing marked yet: Space marks a row, a marks everything ready.".into()) + } + (Tab::Reclaim, Action::Clean) => Some(format!( + "{} marked · frees {}", + self.marked.len(), + fmt::bytes(self.marked_bytes()) + )), + (Tab::Caches, Action::Clean) => self.caches_clean_preview(), + _ => None, + } + } + + /// Bytes freed by cleaning the marked artifacts. + pub fn marked_bytes(&self) -> u64 { + self.marked + .iter() + .filter_map(|id| self.store.entry(*id)) + .filter_map(|e| e.size()) + .map(|s| s.reclaimable) + .sum() + } + pub fn selected_row(&self) -> Option<&store::Row> { self.rows.rows.get(self.selected) } @@ -390,12 +452,9 @@ impl App { self.tasks.add_job(id, label, task, after); Vec::new() } - Msg::CachesDiscovered(caches) => { - self.caches.set(caches); - Vec::new() - } + Msg::CachesDiscovered(caches) => self.on_caches_discovered(caches), Msg::CacheMeasured(id, size) => { - self.caches.measured(&id, size); + self.on_cache_measured(&id, size); Vec::new() } Msg::DepsProgress(progress) => { @@ -455,7 +514,7 @@ impl App { match after { After::Recheck(path) if !pending => self.recheck(&path), After::Remeasure { id, path } if !pending => { - vec![Effect::MeasureCache { id, path }] + vec![Effect::MeasureCaches(vec![(id, path)])] } _ => Vec::new(), } @@ -483,6 +542,7 @@ impl App { } fn on_key(&mut self, key: KeyEvent) -> Vec { + self.hover = None; match &mut self.mode { Mode::Help => { self.mode = Mode::Browse; @@ -677,6 +737,25 @@ impl App { self.dirty = false; return Vec::new(); } + let spot = self + .hotspots + .iter() + .find(|h| { + h.area.contains(ratatui::layout::Position { + x: mouse.column, + y: mouse.row, + }) + }) + .copied(); + if mouse.kind == MouseEventKind::Moved { + self.dirty = spot.map(|s| s.area) != self.hover.map(|s| s.area); + self.hover = spot; + return Vec::new(); + } + if let (MouseEventKind::Down(_), Some(spot)) = (mouse.kind, spot) { + self.hover = None; + return self.on_global_action(spot.target.action()); + } if self.tab != Tab::Reclaim { // Other tabs record no row geometry; the wheel moves their own cursor. let action = match mouse.kind { diff --git a/crates/carwash/src/tui/caches.rs b/crates/carwash/src/tui/caches.rs index 643179f..32481f1 100644 --- a/crates/carwash/src/tui/caches.rs +++ b/crates/carwash/src/tui/caches.rs @@ -3,13 +3,19 @@ use super::app::{App, Effect, Level}; use super::keymap::Action; use super::widgets::{pane, spinner}; +use carwash_core::cache::SizeCache; use carwash_core::caches::GlobalCache; use carwash_core::{Size, fmt}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Cell, Paragraph, Row, Table, TableState, Wrap}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Remembered sizes younger than this are shown without measuring again. +const SIZES_FRESH_FOR: Duration = Duration::from_secs(24 * 60 * 60); #[derive(Debug, Default)] pub struct CachesState { @@ -19,44 +25,76 @@ pub struct CachesState { pub marked: HashSet, /// Being cleaned or measured. pub busy: HashSet, + /// When each cache's size was measured, in this session or an earlier one. + measured_at: HashMap, clean_armed: bool, requested: bool, + /// The tab has been shown; stale sizes are measured from then on. + opened: bool, + /// The next discovery measures every cache, fresh or not. + remeasure_all: bool, + /// The user has moved the cursor. + moved: bool, } impl CachesState { - pub fn set(&mut self, mut caches: Vec) { - if let Some(previous) = &self.caches { - for cache in &mut caches { - cache.size = previous - .iter() - .find(|p| p.id == cache.id) - .and_then(|p| p.size); + /// Installs discovered caches with their remembered sizes. + fn set(&mut self, mut caches: Vec, sizes: &SizeCache) { + for cache in &mut caches { + if let Some(cached) = sizes.get(&cache.path) { + cache.size = Some(cached.size); + self.measured_at.insert( + cache.id.clone(), + UNIX_EPOCH + Duration::from_secs(cached.measured_at), + ); } } - self.busy.extend(caches.iter().map(|c| c.id.clone())); self.caches = Some(caches); self.sort(); } - pub fn measured(&mut self, id: &str, size: Size) { + /// Records a measurement; returns the cache's path. + fn measured(&mut self, id: &str, size: Size, now: SystemTime) -> Option { self.busy.remove(id); - if let Some(cache) = self + let cache = self .caches .as_mut() - .and_then(|c| c.iter_mut().find(|c| c.id == id)) - { - cache.size = Some(size); - } + .and_then(|c| c.iter_mut().find(|c| c.id == id))?; + cache.size = Some(size); + let path = cache.path.clone(); + self.measured_at.insert(id.to_owned(), now); self.sort(); + Some(path) + } + + pub fn measured_age(&self, id: &str, now: SystemTime) -> Option { + self.measured_at + .get(id) + .map(|at| now.duration_since(*at).unwrap_or_default()) + } + + /// Age of the oldest size shown, when it is not from this session's measuring. + pub fn oldest_age(&self, now: SystemTime) -> Option { + self.caches + .iter() + .flatten() + .filter(|c| !self.busy.contains(&c.id)) + .filter_map(|c| self.measured_age(&c.id, now)) + .max() + .filter(|age| *age >= Duration::from_secs(60)) } - /// Largest first; the cursor stays on the same cache. + /// Largest first. Once the user has moved, the cursor stays on the same cache; until + /// then it stays on the first row. fn sort(&mut self) { let Some(caches) = &mut self.caches else { return; }; let current = caches.get(self.cursor).map(|c| c.id.clone()); caches.sort_by_key(|c| std::cmp::Reverse(c.size.map_or(0, |s| s.on_disk))); + if !self.moved { + return; + } if let Some(position) = current.and_then(|id| caches.iter().position(|c| c.id == id)) { self.cursor = position; } @@ -73,13 +111,56 @@ impl CachesState { } impl App { - /// Opening the tab discovers and measures caches once. + /// Discovery is cheap (a few dozen known paths), so it runs at startup; measuring waits + /// until the tab is shown. + pub fn discover_caches(&mut self) -> Vec { + self.caches.requested = true; + vec![Effect::DiscoverCaches] + } + + /// Showing the tab measures caches with no remembered size, or a stale one. pub fn caches_effects(&mut self) -> Vec { - if self.caches.requested { + self.caches.opened = true; + if !self.caches.requested { + return self.discover_caches(); + } + self.measure_caches() + } + + pub fn on_caches_discovered(&mut self, caches: Vec) -> Vec { + self.caches.set(caches, &self.cache); + self.measure_caches() + } + + pub fn on_cache_measured(&mut self, id: &str, size: Size) { + if let Some(path) = self.caches.measured(id, size, self.now) { + self.cache.insert(path, size); + } + } + + fn measure_caches(&mut self) -> Vec { + let state = &mut self.caches; + if !state.opened { return Vec::new(); } - self.caches.requested = true; - vec![Effect::DiscoverCaches] + let all = std::mem::take(&mut state.remeasure_all); + let targets: Vec<(String, PathBuf)> = state + .caches + .iter() + .flatten() + .filter(|c| !state.busy.contains(&c.id)) + .filter(|c| { + all || state + .measured_age(&c.id, self.now) + .is_none_or(|age| age > SIZES_FRESH_FOR) + }) + .map(|c| (c.id.clone(), c.path.clone())) + .collect(); + if targets.is_empty() { + return Vec::new(); + } + state.busy.extend(targets.iter().map(|(id, _)| id.clone())); + vec![Effect::MeasureCaches(targets)] } fn selected_caches(&self) -> Vec { @@ -102,6 +183,24 @@ impl App { } } + /// What cleaning would do to the marked (or selected) caches. + pub fn caches_clean_preview(&self) -> Option { + let selected = self.selected_caches(); + match selected.as_slice() { + [] => None, + [cache] => Some(match (cache.prune_task(), cache.deletable) { + (Some(task), _) => format!("{}: runs `{}`", cache.name, task.command_line()), + (None, true) => format!("{}: deletes {}", cache.name, cache.path.display()), + (None, false) => format!("{}: its tool is not installed", cache.name), + }), + many => Some(format!( + "{} marked caches · {}", + many.len(), + fmt::bytes(many.iter().filter_map(|c| c.size).map(|s| s.on_disk).sum()) + )), + } + } + pub fn on_caches_action(&mut self, action: Action) -> Vec { if action != Action::Clean { self.caches.clean_armed = false; @@ -110,6 +209,18 @@ impl App { let last = len.saturating_sub(1); let page = self.page.max(1); let state = &mut self.caches; + if matches!( + action, + Action::Up + | Action::Down + | Action::PageUp + | Action::PageDown + | Action::Top + | Action::Bottom + | Action::Mark + ) { + state.moved = true; + } match action { Action::Up => state.cursor = state.cursor.saturating_sub(1), Action::Down => state.cursor = (state.cursor + 1).min(last), @@ -132,8 +243,8 @@ impl App { } Action::Unmark => state.marked.clear(), Action::Rescan => { - state.requested = false; - return self.caches_effects(); + state.remeasure_all = true; + return self.discover_caches(); } Action::Open => { if let Some(path) = self.selected_caches().first().map(|c| c.path.clone()) { @@ -247,16 +358,20 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { ]) }) .collect(); - let title = format!( - "Caches {} · {}{}", + let mut title = format!( + "Caches {} · {}", caches.len(), - fmt::bytes(app.caches.total()), - if app.caches.marked.is_empty() { - String::new() - } else { - format!(" · {} marked", app.caches.marked.len()) - } + fmt::bytes(app.caches.total()) ); + if !app.caches.marked.is_empty() { + title.push_str(&format!(" · {} marked", app.caches.marked.len())); + } + if let Some(age) = app.caches.oldest_age(app.now) { + title.push_str(&format!( + " · sizes up to {} old (r to measure)", + fmt::age(age) + )); + } let header = Row::new( ["", "SIZE", "", "CACHE", "CLEAN WITH"] .into_iter() @@ -282,10 +397,19 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { &mut state, ); if let Some(cache) = caches.get(app.caches.cursor) { - let mut lines = vec![Line::from(Span::styled( - cache.path.display().to_string(), - t.subtle(), - ))]; + let measured = if app.caches.busy.contains(&cache.id) { + "measuring…".to_owned() + } else { + match app.caches.measured_age(&cache.id, app.now) { + Some(age) if age < Duration::from_secs(60) => "measured just now".to_owned(), + Some(age) => format!("measured {} ago", fmt::age(age)), + None => String::new(), + } + }; + let mut lines = vec![Line::from(vec![ + Span::styled(cache.path.display().to_string(), t.subtle()), + Span::styled(format!(" {measured}"), t.muted()), + ])]; if let Some(note) = &cache.note { lines.push(Line::from(Span::styled(note.clone(), t.muted()))); } @@ -306,7 +430,7 @@ mod tests { use crate::tui::app::Msg; use crate::tui::app::tests::app; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use std::path::PathBuf; + use std::path::Path; fn press(app: &mut App, code: KeyCode) -> Vec { app.update(Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))) @@ -350,6 +474,94 @@ mod tests { assert_eq!(ids, ["big", "small"]); assert_eq!(app.caches.total(), 1_010); assert!(app.caches.busy.is_empty()); + assert_eq!(app.caches.cursor, 0, "the largest cache is selected first"); + press(&mut app, KeyCode::Char('j')); + app.update(Msg::CacheMeasured("small".into(), size(5_000))); + assert_eq!( + app.caches.cursor, 0, + "after moving, the cursor follows its cache" + ); + } + + fn measured_ids(effects: &[Effect]) -> Vec<&str> { + effects + .iter() + .flat_map(|e| match e { + Effect::MeasureCaches(targets) => { + targets.iter().map(|(id, _)| id.as_str()).collect() + } + _ => Vec::new(), + }) + .collect() + } + + #[test] + fn startup_discovers_but_measures_only_once_the_tab_is_shown() { + let mut app = app(); + assert!( + app.startup() + .iter() + .any(|e| matches!(e, Effect::DiscoverCaches)) + ); + let effects = app.update(Msg::CachesDiscovered(vec![cache("a")])); + assert!(measured_ids(&effects).is_empty()); + let effects = press(&mut app, KeyCode::Char('4')); + assert_eq!(measured_ids(&effects), ["a"]); + // Showing the tab again does not measure twice. + press(&mut app, KeyCode::Char('1')); + assert!(measured_ids(&press(&mut app, KeyCode::Char('4'))).is_empty()); + } + + #[test] + fn remembered_sizes_are_shown_and_only_stale_ones_measured() { + let mut app = app(); + let size = Size { + on_disk: 500, + ..Size::default() + }; + app.cache.insert(PathBuf::from("/home/fresh"), size); + press(&mut app, KeyCode::Char('4')); + let effects = app.update(Msg::CachesDiscovered(vec![cache("fresh"), cache("new")])); + assert_eq!(measured_ids(&effects), ["new"]); + assert_eq!(app.caches.total(), 500); + + // A day later the remembered size is stale; `r` measures everything regardless. + let mut later = crate::tui::app::tests::app(); + later.cache.insert(PathBuf::from("/home/fresh"), size); + later.now += SIZES_FRESH_FOR + Duration::from_secs(60); + press(&mut later, KeyCode::Char('4')); + let effects = later.update(Msg::CachesDiscovered(vec![cache("fresh")])); + assert_eq!(measured_ids(&effects), ["fresh"]); + + app.update(Msg::CacheMeasured("new".into(), size)); + assert!( + press(&mut app, KeyCode::Char('r')) + .iter() + .any(|e| matches!(e, Effect::DiscoverCaches)) + ); + let effects = app.update(Msg::CachesDiscovered(vec![cache("fresh"), cache("new")])); + assert_eq!(measured_ids(&effects), ["fresh", "new"]); + } + + #[test] + fn measurements_are_remembered() { + let mut app = app(); + press(&mut app, KeyCode::Char('4')); + app.update(Msg::CachesDiscovered(vec![cache("a")])); + let size = Size { + on_disk: 7, + ..Size::default() + }; + app.update(Msg::CacheMeasured("a".into(), size)); + assert_eq!(app.cache.get(Path::new("/home/a")).unwrap().size, size); + } + + #[test] + fn clean_preview_names_the_command() { + let mut app = app(); + press(&mut app, KeyCode::Char('4')); + app.update(Msg::CachesDiscovered(vec![cache("a")])); + assert_eq!(app.caches_clean_preview().unwrap(), "a: deletes /home/a"); } #[test] diff --git a/crates/carwash/src/tui/keymap.rs b/crates/carwash/src/tui/keymap.rs index 881364d..f35704b 100644 --- a/crates/carwash/src/tui/keymap.rs +++ b/crates/carwash/src/tui/keymap.rs @@ -66,6 +66,24 @@ impl Tab { } } + /// One sentence on what the tab is for (help screen, tab tooltip). + pub fn about(self) -> &'static str { + match self { + Tab::Reclaim => { + "Build outputs, dependency installs and caches inside the projects under this directory." + } + Tab::Tasks => { + "Scripts and targets from package.json, justfiles, Makefiles and more, run in real terminals." + } + Tab::Updates => { + "Outdated and vulnerable dependencies for Rust, JavaScript, Python and Go." + } + Tab::Caches => { + "Per-user caches outside your projects. Sizes are remembered for a day; r measures again." + } + } + } + fn bindings(self) -> &'static [Binding] { match self { Tab::Reclaim => RECLAIM, @@ -95,18 +113,36 @@ impl Section { Self::View => "View", Self::Jobs => "Tasks & jobs", Self::Updates => "Dependencies", - Self::General => "General", + Self::General => "Any tab", } } } -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy)] pub struct Binding { pub keys: &'static [(KeyCode, KeyModifiers)], pub label: &'static str, pub action: Action, pub section: Section, pub description: &'static str, + /// Short label when the binding is offered in the footer. + pub hint: Option<&'static str>, +} + +impl Binding { + const fn hint(mut self, hint: &'static str) -> Self { + self.hint = Some(hint); + self + } + + /// The first key of the label, for the footer. + pub fn key(&self) -> &'static str { + self.label.split(' ').next().unwrap_or(self.label) + } + + pub fn is_global(&self) -> bool { + self.section == Section::General + } } const NONE: KeyModifiers = KeyModifiers::NONE; @@ -125,6 +161,7 @@ const fn bind( action, section, description, + hint: None, } } @@ -206,21 +243,24 @@ pub const GLOBAL: &[Binding] = &[ A::Theme, S::General, "next theme", - ), + ) + .hint("theme"), bind( &[(Char('?'), NONE), (KeyCode::F(1), NONE)], "?", A::Help, S::General, "help", - ), + ) + .hint("help"), bind( &[(Char('q'), NONE), (Char('c'), CTRL)], "q ^c", A::Quit, S::General, "quit", - ), + ) + .hint("quit"), ]; pub const RECLAIM: &[Binding] = &[ @@ -271,14 +311,16 @@ pub const RECLAIM: &[Binding] = &[ A::Mark, S::Select, "mark / unmark (directories mark their ready artifacts)", - ), + ) + .hint("mark"), bind( &[(Char('a'), NONE)], "a", A::MarkAll, S::Select, "mark every ready artifact shown", - ), + ) + .hint("mark ready"), bind( &[(Char('A'), NONE)], "A", @@ -296,35 +338,40 @@ pub const RECLAIM: &[Binding] = &[ A::Clean, S::Select, "review and clean marked artifacts", - ), + ) + .hint("clean"), bind( &[(Char('/'), NONE)], "/", A::Search, S::View, "filter: text, eco:rust kind:deps size>1g age>30d is:ready", - ), + ) + .hint("filter"), bind( &[(Char('s'), NONE)], "s", A::Sort, S::View, "sort by size, age, name", - ), + ) + .hint("sort"), bind( &[(TabKey, NONE)], "Tab", A::Group, S::View, "group as tree, projects, artifacts", - ), + ) + .hint("group"), bind( &[(Char('i'), NONE)], "i", A::Details, S::View, "toggle details panel", - ), + ) + .hint("details"), bind(&[(Char('r'), NONE)], "r", A::Rescan, S::View, "rescan"), bind( &[(Char('o'), NONE)], @@ -348,7 +395,8 @@ pub const TASKS: &[Binding] = &[ A::NextPane, S::Navigate, "next pane", - ), + ) + .hint("pane"), bind( &[ (KeyCode::BackTab, KeyModifiers::SHIFT), @@ -367,14 +415,16 @@ pub const TASKS: &[Binding] = &[ A::Search, S::Navigate, "filter projects", - ), + ) + .hint("filter"), bind( &[(Char(' '), NONE)], "Space", A::Mark, S::Jobs, "mark project (run a task in all marked)", - ), + ) + .hint("mark"), bind( &[(Char('A'), NONE)], "A", @@ -388,15 +438,17 @@ pub const TASKS: &[Binding] = &[ A::Run, S::Jobs, "run the selected task", - ), + ) + .hint("run"), bind( &[(Char('x'), NONE)], "x", A::KillJob, S::Jobs, "stop the shown job", - ), - bind(&[(Char(']'), NONE)], "]", A::NextJob, S::Jobs, "next job"), + ) + .hint("stop"), + bind(&[(Char(']'), NONE)], "]", A::NextJob, S::Jobs, "next job").hint("next job"), bind( &[(Char('['), NONE)], "[", @@ -441,21 +493,24 @@ pub const UPDATES: &[Binding] = &[ A::NextPane, S::Navigate, "switch pane", - ), + ) + .hint("pane"), bind( &[(Char('/'), NONE)], "/", A::Search, S::Navigate, "filter projects", - ), + ) + .hint("filter"), bind( &[(Enter, NONE)], "Enter", A::Run, S::Updates, "check the project (or marked projects)", - ), + ) + .hint("check"), bind( &[(Char('c'), NONE)], "c", @@ -469,14 +524,16 @@ pub const UPDATES: &[Binding] = &[ A::CheckAll, S::Updates, "check every project listed", - ), + ) + .hint("check all"), bind( &[(Char(' '), NONE)], "Space", A::Mark, S::Updates, "mark project / outdated dependency", - ), + ) + .hint("mark"), bind( &[(Char('a'), NONE)], "a", @@ -497,14 +554,16 @@ pub const UPDATES: &[Binding] = &[ A::Update, S::Updates, "update marked within their requirements", - ), + ) + .hint("update"), bind( &[(Char('U'), NONE)], "U", A::Upgrade, S::Updates, "upgrade marked to latest (edits manifests; press twice)", - ), + ) + .hint("upgrade"), bind( &[(Char('o'), NONE)], "o", @@ -527,7 +586,8 @@ pub const CACHES: &[Binding] = &[ A::Mark, S::Select, "mark / unmark a cache", - ), + ) + .hint("mark"), bind( &[(Char('A'), NONE)], "A", @@ -545,21 +605,24 @@ pub const CACHES: &[Binding] = &[ A::Clean, S::Select, "clean marked (or selected) caches; press twice", - ), + ) + .hint("clean"), bind( &[(Char('r'), NONE)], "r", A::Rescan, S::View, - "measure again", - ), + "measure every cache again", + ) + .hint("measure"), bind( &[(Char('o'), NONE)], "o", A::Open, S::View, "reveal in file manager", - ), + ) + .hint("reveal"), ]; /// Bindings shown in the help screen for `tab`, in display order. @@ -567,6 +630,14 @@ pub fn help(tab: Tab) -> impl Iterator { tab.bindings().iter().chain(GLOBAL) } +/// The footer: `tab`'s hinted bindings, actions before navigation, then the global ones. +pub fn footer(tab: Tab) -> (Vec<&'static Binding>, Vec<&'static Binding>) { + let mut local: Vec<&Binding> = tab.bindings().iter().filter(|b| b.hint.is_some()).collect(); + local.sort_by_key(|b| b.section == Section::Navigate); + let global = GLOBAL.iter().filter(|b| b.hint.is_some()).collect(); + (local, global) +} + fn normalize(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) { // Terminals disagree on whether shifted characters carry SHIFT; ignore it for chars. match code { @@ -615,6 +686,20 @@ mod tests { assert_eq!(action_for(r, &key(Char('z'), NONE)), None); } + #[test] + fn every_tab_has_a_footer_and_global_keys_are_marked_global() { + for tab in Tab::ALL { + let (local, global) = footer(tab); + assert!(!local.is_empty(), "{tab:?} has no footer hints"); + assert!(local.iter().all(|b| !b.is_global())); + assert!(global.iter().all(|b| b.is_global())); + assert!(global.iter().any(|b| b.action == A::Help)); + assert!(global.iter().any(|b| b.action == A::Quit)); + } + let (tasks, _) = footer(Tab::Tasks); + assert_eq!(tasks[0].action, A::Mark, "actions come before navigation"); + } + #[test] fn no_key_is_bound_twice_in_a_tab() { for tab in Tab::ALL { diff --git a/crates/carwash/src/tui/mod.rs b/crates/carwash/src/tui/mod.rs index c7e234c..377dec0 100644 --- a/crates/carwash/src/tui/mod.rs +++ b/crates/carwash/src/tui/mod.rs @@ -75,8 +75,9 @@ pub fn run(ctx: &Context, root: PathBuf, options: ScanOptions) -> Result<()> { pty_size: (24, 80), checker: Arc::new(crate::commands::outdated::checker(ctx)), }; - runtime.execute(Effect::Scan); - runtime.execute(Effect::RefreshDisk); + for effect in app.startup() { + runtime.execute(effect); + } let result = event_loop(&mut terminal, &mut app, &mut runtime, &rx); @@ -338,27 +339,20 @@ impl Runtime<'_> { } } } - Effect::DiscoverCaches => match crate::commands::caches::present(self.ctx) { - Ok(caches) => { - let _ = self.tx.send(Msg::CachesDiscovered(caches.clone())); - let (engine, tx) = (self.ctx.engine.clone(), self.tx.clone()); - std::thread::spawn(move || { - for cache in caches { - let size = engine.measure_path(&cache.path, &Cancel::new()); - let _ = tx.send(Msg::CacheMeasured(cache.id, size)); - } - }); - } - Err(error) => { - let _ = self.tx.send(Msg::CachesDiscovered(Vec::new())); + Effect::DiscoverCaches => { + let caches = crate::commands::caches::present(self.ctx).unwrap_or_else(|error| { tracing::warn!(%error, "cannot list caches"); - } - }, - Effect::MeasureCache { id, path } => { + Vec::new() + }); + let _ = self.tx.send(Msg::CachesDiscovered(caches)); + } + Effect::MeasureCaches(targets) => { let (engine, tx) = (self.ctx.engine.clone(), self.tx.clone()); std::thread::spawn(move || { - let size = engine.measure_path(&path, &Cancel::new()); - let _ = tx.send(Msg::CacheMeasured(id, size)); + for (id, path) in targets { + let size = engine.measure_path(&path, &Cancel::new()); + let _ = tx.send(Msg::CacheMeasured(id, size)); + } }); } Effect::CleanCaches { caches, size } => { diff --git a/crates/carwash/src/tui/view.rs b/crates/carwash/src/tui/view.rs index 1c40425..d2fbd46 100644 --- a/crates/carwash/src/tui/view.rs +++ b/crates/carwash/src/tui/view.rs @@ -1,7 +1,7 @@ //! Rendering. Pure functions of [`App`], except for recording table geometry for the mouse. -use super::app::{App, Level, Mode, Review, ReviewPhase}; -use super::keymap::{self, Section, Tab}; +use super::app::{App, Hotspot, Level, Mode, Review, ReviewPhase, Target}; +use super::keymap::{self, Binding, Section, Tab}; use super::store::{EntryStatus, Row, RowKey}; use carwash_core::clean::DeleteMode; use carwash_core::select::Hold; @@ -14,6 +14,7 @@ use ratatui::widgets::{ Block, BorderType, Borders, Cell, Clear, Gauge, Paragraph, Row as TableRow, Table, TableState, Wrap, }; +use unicode_width::UnicodeWidthStr; const DETAILS_WIDTH: u16 = 46; const MIN_WIDTH_FOR_DETAILS: u16 = 112; @@ -31,6 +32,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { render_header(frame, app, header); render_toolbar(frame, app, toolbar); + let mut hotspots = tab_hotspots(app, toolbar); let details = app.show_details && body.width >= MIN_WIDTH_FOR_DETAILS; if app.tab == Tab::Tasks { super::tasks::render(frame, app, body); @@ -47,12 +49,14 @@ pub fn render(frame: &mut Frame, app: &mut App) { } else { render_table(frame, app, body); } - render_footer(frame, app, footer); + hotspots.extend(render_footer(frame, app, footer)); + app.hotspots = hotspots; match &app.mode { Mode::Review(review) => render_review(frame, app, review), Mode::Help => render_help(frame, app), - _ => {} + Mode::Browse => render_tooltip(frame, app), + Mode::Search => {} } } @@ -120,18 +124,40 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { split_line(frame, area, left, Line::from(right)); } -fn render_toolbar(frame: &mut Frame, app: &App, area: Rect) { +/// The tab titles, each followed by a space, after a leading space. +fn tab_spans(app: &App) -> Vec> { let t = &app.theme; - let mut left = vec![Span::raw(" ")]; + let mut spans = vec![Span::raw(" ")]; for (i, tab) in Tab::ALL.iter().enumerate() { let style = if *tab == app.tab { t.bold(t.accent).add_modifier(Modifier::REVERSED) } else { t.muted() }; - left.push(Span::styled(format!(" {} {} ", i + 1, tab.title()), style)); - left.push(Span::raw(" ")); + spans.push(Span::styled(format!(" {} {} ", i + 1, tab.title()), style)); + spans.push(Span::raw(" ")); } + spans +} + +fn tab_hotspots(app: &App, area: Rect) -> Vec { + let spans = tab_spans(app); + let mut x = area.x + spans[0].width() as u16; + let mut out = Vec::new(); + for (tab, pair) in Tab::ALL.iter().zip(spans[1..].chunks(2)) { + let width = pair[0].width() as u16; + out.push(Hotspot { + area: Rect::new(x, area.y, width, 1).intersection(area), + target: Target::Tab(*tab), + }); + x += width + pair.get(1).map_or(0, |s| s.width() as u16); + } + out +} + +fn render_toolbar(frame: &mut Frame, app: &App, area: Rect) { + let t = &app.theme; + let mut left = tab_spans(app); if app.tab == Tab::Tasks { let running = app.tasks.running(); let mut right = vec![Span::styled( @@ -205,13 +231,7 @@ fn render_toolbar(frame: &mut Frame, app: &App, area: Rect) { .filter_map(|e| e.size()) .map(|s| s.reclaimable) .sum(); - let marked_bytes: u64 = app - .marked - .iter() - .filter_map(|id| app.store.entry(*id)) - .filter_map(|e| e.size()) - .map(|s| s.reclaimable) - .sum(); + let marked_bytes = app.marked_bytes(); let mut right = vec![ Span::styled(fmt::bytes(ready_bytes), t.fg(t.success)), Span::styled(" ready", t.muted()), @@ -692,7 +712,7 @@ fn git_state(state: GitState) -> String { } } -fn render_footer(frame: &mut Frame, app: &App, area: Rect) { +fn render_footer(frame: &mut Frame, app: &App, area: Rect) -> Vec { let t = &app.theme; if matches!(app.mode, Mode::Search) { let prompt = "/ "; @@ -708,7 +728,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(line), area); let cursor = (app.input.visual_cursor().saturating_sub(scroll)) as u16; frame.set_cursor_position((area.x + prompt.len() as u16 + cursor, area.y)); - return; + return Vec::new(); } if let Some(toast) = &app.toast { let color = match toast.level { @@ -722,61 +742,133 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ))), area, ); - return; + return Vec::new(); } - let hints: &[(&str, &str)] = match app.tab { - Tab::Reclaim => &[ - ("space", "mark"), - ("a", "mark ready"), - ("d", "clean"), - ("/", "filter"), - ("tab", "group"), - ("s", "sort"), - ("2", "tasks"), - ("?", "help"), - ("q", "quit"), - ], - Tab::Tasks => &[ - ("enter", "run"), - ("space", "mark project"), - ("tab", "pane"), - ("x", "stop"), - ("[ ]", "jobs"), - ("/", "filter"), - ("1", "reclaim"), - ("?", "help"), - ("q", "quit"), - ], - Tab::Updates => &[ - ("enter", "check"), - ("C", "check all"), - ("space", "mark"), - ("u", "update"), - ("U", "upgrade"), - ("tab", "pane"), - ("/", "filter"), - ("?", "help"), - ("q", "quit"), - ], - Tab::Caches => &[ - ("space", "mark"), - ("d", "clean"), - ("r", "measure"), - ("o", "reveal"), - ("1", "reclaim"), - ("?", "help"), - ("q", "quit"), - ], + // This tab's actions on the left, as many as fit; the keys that work everywhere on the + // right, labelled as such. + const GAP: u16 = 2; + const GLOBAL_LABEL: &str = "│ any tab "; + let (local, global) = keymap::footer(app.tab); + let width = |b: &Binding| (b.key().width() + 1 + b.hint.unwrap_or_default().width()) as u16; + let item = |b: &'static Binding, key_style: Style| { + [ + Span::styled(b.key(), key_style), + Span::styled(format!(" {}", b.hint.unwrap_or_default()), t.muted()), + ] }; + let global_width = GLOBAL_LABEL.width() as u16 + + global.iter().map(|b| width(b)).sum::() + + GAP * (global.len() as u16).saturating_sub(1) + + 1; + let show_global = global_width + 20 <= area.width; + let limit = if show_global { + area.right().saturating_sub(global_width + GAP) + } else { + area.right() + }; + + let mut hotspots = Vec::new(); let mut spans = vec![Span::raw(" ")]; - for (i, (key, label)) in hints.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" ", t.muted())); + let mut x = area.x + 1; + for b in local { + let start = if hotspots.is_empty() { x } else { x + GAP }; + if start + width(b) > limit { + break; + } + if !hotspots.is_empty() { + spans.push(Span::raw(" ")); } - spans.push(Span::styled(*key, t.bold(t.accent))); - spans.push(Span::styled(format!(" {label}"), t.muted())); + spans.extend(item(b, t.bold(t.accent))); + hotspots.push(Hotspot { + area: Rect::new(start, area.y, width(b), 1), + target: Target::Binding(b), + }); + x = start + width(b); } frame.render_widget(Paragraph::new(Line::from(spans)), area); + + if show_global { + let right_area = Rect::new(area.right() - global_width, area.y, global_width, 1); + let mut spans = vec![Span::styled(GLOBAL_LABEL, t.muted())]; + let mut x = right_area.x + GLOBAL_LABEL.width() as u16; + for (i, b) in global.into_iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + x += GAP; + } + spans.extend(item(b, t.bold(t.subtle))); + hotspots.push(Hotspot { + area: Rect::new(x, area.y, width(b), 1), + target: Target::Binding(b), + }); + x += width(b); + } + frame.render_widget(Paragraph::new(Line::from(spans)), right_area); + } + hotspots +} + +/// Explains the hovered tab or footer key, above the footer or below the tab bar. +fn render_tooltip(frame: &mut Frame, app: &App) { + let t = &app.theme; + let Some(hover) = app.hover else { + return; + }; + // The hotspot may have moved or gone since the mouse last moved. + let Some(spot) = app.hotspots.iter().find(|h| h.area == hover.area) else { + return; + }; + let (title, mut lines) = match spot.target { + Target::Tab(tab) => { + let key = Tab::ALL.iter().position(|t| *t == tab).unwrap_or(0) + 1; + ( + format!("{} · press {key}", tab.title()), + vec![Line::from(Span::styled(tab.about(), t.text()))], + ) + } + Target::Binding(binding) => { + let mut lines = vec![Line::from(Span::styled(binding.description, t.text()))]; + if let Some(context) = app.action_context(binding.action) { + lines.push(Line::from(Span::styled(context, t.fg(t.accent2)))); + } + if binding.is_global() { + lines.push(Line::from(Span::styled("Works in any tab.", t.muted()))); + } + (binding.label.to_owned(), lines) + } + }; + lines.push(Line::from(Span::styled( + "click to run · ? for all keys", + t.muted(), + ))); + let area = frame.area(); + let inner = lines + .iter() + .map(Line::width) + .max() + .unwrap_or(0) + .max(title.width() + 2) as u16; + let width = (inner + 4).min(area.width); + let height = (lines.len() as u16 + 2).min(area.height); + let y = if spot.area.y > area.height / 2 { + spot.area.y.saturating_sub(height) + } else { + spot.area.bottom() + }; + let x = spot.area.x.min(area.right().saturating_sub(width)); + let popup = Rect::new(x, y, width, height).intersection(area); + frame.render_widget(Clear, popup); + frame.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(t.fg(t.accent)) + .padding(ratatui::widgets::Padding::horizontal(1)) + .title(Span::styled(format!(" {title} "), t.bold(t.accent))), + ), + popup, + ); } fn centered(area: Rect, width: u16, height: u16) -> Rect { @@ -994,22 +1086,34 @@ fn recent_days(app: &App) -> u64 { fn render_help(frame: &mut Frame, app: &App) { let t = &app.theme; let area = frame.area(); - let popup = centered(area, 84, 40); - frame.render_widget(Clear, popup); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(t.fg(t.accent)) - .title(Span::styled(" Help ", t.bold(t.accent))); - let mut lines: Vec = Vec::new(); + .title(Span::styled( + format!(" Help · {} ", app.tab.title()), + t.bold(t.accent), + )); + let header = vec![ + Line::from(Span::styled(app.tab.about(), t.text())), + Line::default(), + ]; + let closing = Line::from(Span::styled( + "Any key closes · hover a key in the footer for details, click it to run", + t.muted(), + )); + + // This tab's keys, then the keys that work everywhere (and the legend). let mut sections: Vec
= Vec::new(); for binding in keymap::help(app.tab) { if !sections.contains(&binding.section) { sections.push(binding.section); } } + let mut local: Vec> = Vec::new(); + let mut global: Vec = Vec::new(); for section in sections { - lines.push(Line::from(Span::styled(section.title(), t.bold(t.accent2)))); + let mut lines = vec![Line::from(Span::styled(section.title(), t.bold(t.accent2)))]; for binding in keymap::help(app.tab).filter(|b| b.section == section) { lines.push(Line::from(vec![ Span::styled(format!(" {:<10}", binding.label), t.bold(t.text)), @@ -1017,47 +1121,100 @@ fn render_help(frame: &mut Frame, app: &App) { ])); } lines.push(Line::default()); + if section == Section::General { + global.extend(lines); + } else { + local.push(lines); + } } - if app.tab != Tab::Reclaim { - lines.push(Line::from(Span::styled( - "Press any key to close", - t.muted(), - ))); + if app.tab == Tab::Reclaim { + let g = &app.glyphs; + global.extend([ + Line::from(Span::styled("Legend", t.bold(t.accent2))), + Line::from(vec![ + Span::styled(format!(" {} ", g.marked), t.fg(t.accent2)), + Span::styled("marked ", t.subtle()), + Span::styled(format!("{} ", g.partial), t.fg(t.accent2)), + Span::styled("partly ", t.subtle()), + Span::styled(format!("{} ", g.unmarked), t.muted()), + Span::styled("unmarked", t.subtle()), + ]), + Line::from(vec![ + Span::styled(format!(" {} ", g.locked), t.fg(t.error)), + Span::styled("protected: git tracks files inside", t.subtle()), + ]), + Line::from(vec![ + Span::styled(" ready", t.fg(t.success)), + Span::styled(" safe to clean ", t.subtle()), + Span::styled("recent", t.fg(t.info)), + Span::styled(" used lately", t.subtle()), + ]), + Line::from(vec![ + Span::styled(" review", t.fg(t.warning)), + Span::styled(" generic name, not confirmed by git", t.subtle()), + ]), + Line::from(Span::styled(" ~ size from cache, refreshing", t.subtle())), + Line::from(Span::styled( + " FREED counts only bytes deletion frees", + t.subtle(), + )), + Line::default(), + ]); + } + + let width_of = |lines: &[Line]| lines.iter().map(Line::width).max().unwrap_or(0) as u16; + let chrome = 4; // borders and padding + let local_height: usize = local.iter().map(Vec::len).sum(); + let single_height = (header.len() + local_height + global.len() + 1) as u16 + 2; + let block = block.padding(ratatui::widgets::Padding::horizontal(1)); + if single_height <= area.height { + let mut lines = header; + lines.extend(local.into_iter().flatten()); + lines.extend(global); + lines.push(closing); + let popup = centered(area, width_of(&lines) + chrome, single_height); + frame.render_widget(Clear, popup); frame.render_widget(Paragraph::new(lines).block(block), popup); return; } - let g = &app.glyphs; - lines.push(Line::from(Span::styled("Legend", t.bold(t.accent2)))); - lines.push(Line::from(vec![ - Span::styled(format!(" {} ", g.marked), t.fg(t.accent2)), - Span::styled("marked ", t.subtle()), - Span::styled(format!("{} ", g.partial), t.fg(t.accent2)), - Span::styled("partly ", t.subtle()), - Span::styled(format!("{} ", g.unmarked), t.muted()), - Span::styled("unmarked ", t.subtle()), - Span::styled(format!("{} ", g.locked), t.fg(t.error)), - Span::styled("protected", t.subtle()), - ])); - lines.push(Line::from(vec![ - Span::styled(" ready", t.fg(t.success)), - Span::styled(" safe to clean ", t.subtle()), - Span::styled("recent", t.fg(t.info)), - Span::styled(" used lately ", t.subtle()), - Span::styled("review", t.fg(t.warning)), - Span::styled(" generic name ", t.subtle()), - Span::styled("protected", t.fg(t.error)), - Span::styled(" git-tracked", t.subtle()), - ])); - lines.push(Line::from(Span::styled( - " ~ size from cache, refreshing FREED counts only bytes deletion frees", - t.subtle(), - ))); - lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "Press any key to close", - t.muted(), - ))); - frame.render_widget(Paragraph::new(lines).block(block), popup); + // Too tall for the terminal: this tab's keys beside the global ones. Trailing tab + // sections move to the right column while that makes the popup shorter. + let mut right_sections: Vec> = Vec::new(); + let (mut left_height, mut right_height) = (local_height, global.len()); + while local.len() > 1 { + let moved = local.last().map_or(0, Vec::len); + if left_height.max(right_height) <= (left_height - moved).max(right_height + moved) { + break; + } + right_sections.insert(0, local.pop().unwrap_or_default()); + (left_height, right_height) = (left_height - moved, right_height + moved); + } + let local: Vec = local.into_iter().flatten().collect(); + let global: Vec = right_sections.into_iter().flatten().chain(global).collect(); + const GUTTER: u16 = 3; + let (left_width, right_width) = (width_of(&local), width_of(&global)); + let body = local.len().max(global.len()) as u16; + let width = (left_width + GUTTER + right_width).max(width_of(&header)) + chrome; + let popup = centered(area, width, header.len() as u16 + body + 1 + 2); + frame.render_widget(Clear, popup); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let [top, columns, bottom] = Layout::vertical([ + Constraint::Length(header.len() as u16), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(inner); + let [left, _, right] = Layout::horizontal([ + Constraint::Length(left_width), + Constraint::Length(GUTTER), + Constraint::Fill(1), + ]) + .areas(columns); + frame.render_widget(Paragraph::new(header).wrap(Wrap { trim: true }), top); + frame.render_widget(Paragraph::new(local), left); + frame.render_widget(Paragraph::new(global), right); + frame.render_widget(Paragraph::new(closing), bottom); } #[cfg(test)] @@ -1103,6 +1260,80 @@ mod tests { } } + fn mouse(app: &mut App, kind: ratatui::crossterm::event::MouseEventKind, area: Rect) { + app.update(crate::tui::app::Msg::Mouse( + ratatui::crossterm::event::MouseEvent { + kind, + column: area.x, + row: area.y, + modifiers: KeyModifiers::NONE, + }, + )); + } + + #[test] + fn footer_separates_tab_keys_from_global_ones() { + let mut app = app(); + let screen = draw(&mut app, 140, 20); + let footer = screen.lines().last().unwrap(); + let global = footer.find("any tab").expect(footer); + assert!(footer.find("d clean").unwrap() < global, "{footer}"); + assert!(footer.find("? help").unwrap() > global, "{footer}"); + assert!(footer.find("q quit").unwrap() > global, "{footer}"); + // Narrow terminals keep the global keys and drop tab keys from the end. + let screen = draw(&mut app, 60, 20); + let footer = screen.lines().last().unwrap(); + assert!(footer.contains("q quit"), "{footer}"); + } + + #[test] + fn hovering_explains_and_clicking_runs() { + use crate::tui::keymap::Action; + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + let mut app = app(); + draw(&mut app, 140, 20); + let clean = app + .hotspots + .iter() + .find(|h| matches!(h.target, Target::Binding(b) if b.action == Action::Clean)) + .copied() + .unwrap(); + mouse(&mut app, MouseEventKind::Moved, clean.area); + let screen = draw(&mut app, 140, 20); + assert!( + screen.contains("review and clean marked artifacts"), + "{screen}" + ); + assert!(screen.contains("Nothing marked yet"), "{screen}"); + // Moving within the same hotspot does not redraw. + mouse(&mut app, MouseEventKind::Moved, clean.area); + assert!(!app.dirty); + + let caches = app + .hotspots + .iter() + .find(|h| matches!(h.target, Target::Tab(Tab::Caches))) + .copied() + .unwrap(); + mouse( + &mut app, + MouseEventKind::Down(MouseButton::Left), + caches.area, + ); + assert_eq!(app.tab, Tab::Caches); + assert!(app.hover.is_none()); + } + + #[test] + fn help_fits_short_terminals_in_two_columns() { + let mut app = app(); + press(&mut app, KeyCode::Char('?')); + let screen = draw(&mut app, 160, 30); + assert!(screen.contains("Help · Reclaim"), "{screen}"); + assert!(screen.contains("Any tab"), "{screen}"); + assert!(screen.contains("Any key closes"), "{screen}"); + } + #[test] fn main_screen_shows_rows_totals_and_details() { let mut app = app(); From 9b78131397982986368aabf971d5bdb745e4030a Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:23:48 -0400 Subject: [PATCH 20/22] feat(tui): scan another folder with p; fingerprinted, saved cache sizes Folder: p opens a path prompt in the footer with directory completion (Tab, case-insensitive, ~ and relative paths). Enter validates the path, rescans there and resets what belonged to the old scan; jobs keep running. Scans are numbered so events of a superseded scan are dropped instead of landing in the new store. Cache sizes: remembered sizes are saved as soon as measuring finishes (and after each scan), not only on a clean exit, so closing the terminal no longer loses them. Each size carries a fingerprint of the cache's top two levels (newest mtime, entry count), taken just before measuring: a changed fingerprint means measure now, an unchanged one trusts the size for a week, no fingerprint falls back to a day. The size cache is now the only record of when a size was measured. Fix: a message that changed nothing cleared the redraw flag set by an earlier message of the same batch, delaying the redraw until the next idle tick (up to a second). --- README.md | 8 +- crates/carwash-core/src/cache.rs | 114 ++++++++++- crates/carwash-core/src/caches.rs | 6 + crates/carwash/src/commands/caches.rs | 12 +- crates/carwash/src/tui/app.rs | 94 +++++++-- crates/carwash/src/tui/caches.rs | 171 +++++++++++----- crates/carwash/src/tui/folder.rs | 274 ++++++++++++++++++++++++++ crates/carwash/src/tui/keymap.rs | 9 + crates/carwash/src/tui/mod.rs | 81 ++++++-- crates/carwash/src/tui/tasks.rs | 10 + crates/carwash/src/tui/updates.rs | 11 ++ crates/carwash/src/tui/view.rs | 106 ++++++++-- 12 files changed, 791 insertions(+), 105 deletions(-) create mode 100644 crates/carwash/src/tui/folder.rs diff --git a/README.md b/README.md index 1371602..4734981 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,12 @@ Run `carwash [PATH]`. Four tabs, switched with `1` to `4`: updates within your requirements, `U` upgrades to latest (and edits manifests). Projects are re-checked after the update finishes. 4. **Caches**: global caches outside any project. `d` (twice) runs the tool's own prune command - when it is installed, or deletes the directory when that is safe. Sizes are remembered for a - day, so the tab opens instantly; `r` measures again. + when it is installed, or deletes the directory when that is safe. Sizes are remembered, so + the tab opens instantly. A cache is measured again when the top of its tree has changed + (entries added or removed there), after a week otherwise, or when you press `r`. + +`p` scans another folder without restarting: type a path (Tab completes, `~` and relative +paths work) and press Enter. Running tasks keep running. The footer shows the current tab's keys on the left and the keys that work in any tab on the right. Hover over a key for what it does (for `d`, exactly what it will delete or run), click it diff --git a/crates/carwash-core/src/cache.rs b/crates/carwash-core/src/cache.rs index 161a143..624f34d 100644 --- a/crates/carwash-core/src/cache.rs +++ b/crates/carwash-core/src/cache.rs @@ -18,16 +18,77 @@ pub struct CachedSize { pub size: Size, /// Unix seconds. pub measured_at: u64, + /// The tree's [`Fingerprint`] taken just before measuring, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, } impl CachedSize { - pub fn age(&self) -> Duration { - let now = unix_now(); + /// Time since the measurement, as of `now`. + pub fn age_at(&self, now: SystemTime) -> Duration { + let now = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); Duration::from_secs(now.saturating_sub(self.measured_at)) } } -#[derive(Debug, Default, Serialize, Deserialize)] +/// A cheap summary of a tree's top levels: the newest modification time and the number of +/// entries in the directories at most [`Fingerprint::DEPTH`] levels down. +/// +/// Adding or removing entries up there changes it, which is how caches usually change +/// (new packages, toolchains, simulators). Edits deeper down leave it untouched, so an +/// unchanged fingerprint is evidence, not proof, that the tree is the same. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Fingerprint { + /// Nanoseconds since the Unix epoch. + pub newest: u64, + pub entries: u64, +} + +impl Fingerprint { + pub const DEPTH: usize = 2; + /// Trees with more entries than this near the top get no fingerprint. + const MAX_ENTRIES: u64 = 50_000; + + /// `None` when `path` cannot be read or is too wide near the top. + pub fn of(path: &Path) -> Option { + let mut fingerprint = Self { + newest: 0, + entries: 0, + }; + fingerprint.visit(path, 0, true)?; + Some(fingerprint) + } + + fn visit(&mut self, dir: &Path, depth: usize, root: bool) -> Option<()> { + let modified = std::fs::symlink_metadata(dir) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_nanos() as u64); + self.newest = self.newest.max(modified); + if depth == Self::DEPTH { + return Some(()); + } + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + // An unreadable subdirectory is part of the summary as it is; the root must read. + Err(_) if !root => return Some(()), + Err(_) => return None, + }; + for entry in entries.flatten() { + self.entries += 1; + if self.entries > Self::MAX_ENTRIES { + return None; + } + if entry.file_type().is_ok_and(|t| t.is_dir()) { + self.visit(&entry.path(), depth + 1, false)?; + } + } + Some(()) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SizeCache { version: u32, entries: HashMap, @@ -66,11 +127,17 @@ impl SizeCache { } pub fn insert(&mut self, path: PathBuf, size: Size) { + self.insert_with(path, size, None); + } + + /// Records a size along with the tree's fingerprint from just before measuring. + pub fn insert_with(&mut self, path: PathBuf, size: Size, fingerprint: Option) { self.entries.insert( path, CachedSize { size, measured_at: unix_now(), + fingerprint, }, ); } @@ -112,4 +179,45 @@ mod tests { std::fs::write(&file, b"{not json").unwrap(); assert!(SizeCache::load(&file).is_empty()); } + + #[test] + fn entries_without_a_fingerprint_still_load() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("sizes.json"); + let mut cache = SizeCache::load(&file); + cache.insert(PathBuf::from("/p"), Size::default()); + cache.save(&file).unwrap(); + let text = std::fs::read_to_string(&file).unwrap(); + assert!( + !text.contains("fingerprint"), + "same shape as before: {text}" + ); + let cache = SizeCache::load(&file); + let entry = cache.get(Path::new("/p")).expect("old entry loads"); + assert_eq!(entry.fingerprint, None); + } + + #[test] + fn fingerprints_notice_changes_near_the_top_only() { + use filetime::{FileTime, set_file_mtime}; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("a/b/c/d")).unwrap(); + let old = FileTime::from_unix_time(1_000_000, 0); + for sub in ["", "a", "a/b", "a/b/c", "a/b/c/d"] { + set_file_mtime(root.join(sub), old).unwrap(); + } + let before = Fingerprint::of(root).unwrap(); + assert_eq!(Fingerprint::of(root), Some(before), "stable"); + + // Four levels down: invisible. + std::fs::write(root.join("a/b/c/d/deep"), b"x").unwrap(); + assert_eq!(Fingerprint::of(root), Some(before)); + + // Two levels down (a new package dir, say): noticed. + std::fs::create_dir(root.join("a/b/new")).unwrap(); + assert_ne!(Fingerprint::of(root), Some(before)); + + assert_eq!(Fingerprint::of(&root.join("missing")), None); + } } diff --git a/crates/carwash-core/src/caches.rs b/crates/carwash-core/src/caches.rs index 161292b..d473f25 100644 --- a/crates/carwash-core/src/caches.rs +++ b/crates/carwash-core/src/caches.rs @@ -3,6 +3,7 @@ //! Entries are data (`caches.toml`), resolved against the environment and platform. Each says //! whether deleting the directory is safe and which command the tool offers to prune it. +use crate::cache::Fingerprint; use crate::model::Size; use crate::tasks::Task; use serde::{Deserialize, Serialize}; @@ -79,6 +80,9 @@ pub struct GlobalCache { pub prune: Option>, pub note: Option, pub size: Option, + /// Taken at discovery, to tell whether a remembered size is still current. + #[serde(skip)] + pub fingerprint: Option, } impl GlobalCache { @@ -182,6 +186,7 @@ fn discover_with( out.push(GlobalCache { id: spec.id.clone(), name: spec.name.clone(), + fingerprint: Fingerprint::of(&path), path, ecosystem: spec.ecosystem.clone(), deletable: spec.delete, @@ -213,6 +218,7 @@ fn discover_with( out.push(GlobalCache { id: format!("{}/{child}", spec.id), name: format!("{}: {child}", spec.name), + fingerprint: Fingerprint::of(&child_path), path: child_path, ecosystem: spec.ecosystem.clone(), deletable: spec.delete, diff --git a/crates/carwash/src/commands/caches.rs b/crates/carwash/src/commands/caches.rs index dd6860e..11cce70 100644 --- a/crates/carwash/src/commands/caches.rs +++ b/crates/carwash/src/commands/caches.rs @@ -6,9 +6,11 @@ use crate::context::Context; use crate::table::{Align, Cell, Table}; use anstyle::{AnsiColor, Style}; use anyhow::{Context as _, Result, bail}; +use carwash_core::cache::Fingerprint; use carwash_core::caches::{self, GlobalCache}; use carwash_core::clean::{CleanItem, CleanOptions, DeleteMode}; use carwash_core::history::{self, Record}; +use carwash_core::paths::Dirs; use carwash_core::{ArtifactId, ArtifactKind, Cancel, fmt}; use indicatif::{ProgressBar, ProgressStyle}; use std::io::{BufRead, IsTerminal, Write}; @@ -18,8 +20,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; /// Caches present on this machine, with user overrides from `caches.toml`. -pub fn present(ctx: &Context) -> Result> { - let specs = match ctx.dirs.as_ref().map(|d| d.caches_file()) { +pub fn present(dirs: Option<&Dirs>) -> Result> { + let specs = match dirs.map(Dirs::caches_file) { Some(path) if path.exists() => { let text = std::fs::read_to_string(&path) .with_context(|| format!("cannot read {}", path.display()))?; @@ -54,6 +56,7 @@ pub fn measure(ctx: &Context, caches: &mut [GlobalCache], show_progress: bool) { scope.spawn(|| { while let Some(slot) = slots.get(next.fetch_add(1, Ordering::Relaxed)) { let mut cache = slot.lock().expect("cache slot"); + cache.fingerprint = Fingerprint::of(&cache.path); cache.size = Some(ctx.engine.measure_path(&cache.path, &Cancel::new())); done.fetch_add(1, Ordering::Relaxed); } @@ -76,7 +79,7 @@ fn remember(ctx: &Context, caches: &[GlobalCache]) { ctx.update_size_cache(|sizes| { for cache in caches { if let Some(size) = cache.size { - sizes.insert(cache.path.clone(), size); + sizes.insert_with(cache.path.clone(), size, cache.fingerprint); } } }); @@ -97,7 +100,7 @@ fn action(cache: &GlobalCache) -> (String, Style) { } pub fn run(ctx: &Context, args: &CachesArgs) -> Result { - let mut caches = present(ctx)?; + let mut caches = present(ctx.dirs.as_ref())?; match &args.action { None => list(ctx, &mut caches, args.json), Some(CachesAction::Clean { @@ -260,6 +263,7 @@ fn clean( } }; if ok { + cache.fingerprint = Fingerprint::of(&cache.path); let after = if cache.path.exists() { ctx.engine.measure_path(&cache.path, &Cancel::new()) } else { diff --git a/crates/carwash/src/tui/app.rs b/crates/carwash/src/tui/app.rs index 47da704..1ce896e 100644 --- a/crates/carwash/src/tui/app.rs +++ b/crates/carwash/src/tui/app.rs @@ -1,13 +1,14 @@ //! Application state and the update function (Elm architecture: messages in, effects out). use super::caches::CachesState; +use super::folder::FolderPrompt; use super::keymap::{self, Action, Tab}; use super::query::{Fuzzy, Query}; use super::store::{self, EntryStatus, Grouping, RowKey, Rows, SortKey, Store, View}; use super::tasks::{After, JobStatus, RunTarget, TasksState}; use super::theme::{Glyphs, Theme}; use super::updates::UpdatesState; -use carwash_core::cache::SizeCache; +use carwash_core::cache::{Fingerprint, SizeCache}; use carwash_core::caches::GlobalCache; use carwash_core::clean::{CleanEvent, CleanItem, CleanReport, DeleteMode}; use carwash_core::deps::{CheckProgress, Dependency, ProjectDeps, ProjectSpec}; @@ -33,8 +34,16 @@ pub enum Msg { Mouse(MouseEvent), Resize, Tick, - ScanStarted(Arc), - Scan(ScanEvent), + /// Scan number `.0` started; its events carry the same number. + ScanStarted(u64, Arc), + Scan(u64, ScanEvent), + /// Directories completing `input` in the folder prompt. + PathCompletions { + input: String, + candidates: Vec, + }, + /// The folder prompt's path, resolved and checked, or why it cannot be scanned. + RootChanged(Result), Clean(CleanEvent), CleanFinished(CleanReport), Disk { @@ -56,7 +65,12 @@ pub enum Msg { /// Applying updates to the project at the path could not start. UpdateFailed(PathBuf, String), CachesDiscovered(Vec), - CacheMeasured(String, Size), + CacheMeasured { + id: String, + size: Size, + /// Taken just before measuring. + fingerprint: Option, + }, } #[derive(Debug)] @@ -103,6 +117,12 @@ pub enum Effect { }, /// Measures each `(id, path)` and reports it with [`Msg::CacheMeasured`]. MeasureCaches(Vec<(String, PathBuf)>), + /// Writes the size cache now, rather than only when the UI exits. + SaveSizes(SizeCache), + /// Lists directories completing the folder prompt's input. + CompletePath(String), + /// Scans the folder typed in the prompt instead (relative to the current root). + ChangeRoot(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -151,6 +171,8 @@ pub struct Review { pub enum Mode { Browse, Search, + /// Typing another folder to scan. + Folder, Review(Box), Help, } @@ -161,6 +183,8 @@ pub struct ScanStatus { pub discovered: bool, pub counters: Option>, pub elapsed: Option, + /// Events of other scans are stale. + pub generation: u64, } /// Geometry of the last rendered table, for mouse hit-testing. @@ -233,6 +257,7 @@ pub struct App { pub tasks: TasksState, pub updates: UpdatesState, pub caches: CachesState, + pub folder: FolderPrompt, /// Quit was pressed once while jobs were running. quit_armed: bool, pub quit: bool, @@ -296,6 +321,7 @@ impl App { tasks: TasksState::default(), updates: UpdatesState::default(), caches: CachesState::default(), + folder: FolderPrompt::default(), quit_armed: false, quit: false, dirty: true, @@ -401,7 +427,16 @@ impl App { self.rows.rows.get(self.selected) } + /// Handles one message. Handlers clear `dirty` when their message changed nothing; that + /// must not cancel a redraw an earlier message of the same batch asked for. pub fn update(&mut self, msg: Msg) -> Vec { + let pending = self.dirty; + let effects = self.handle(msg); + self.dirty |= pending; + effects + } + + fn handle(&mut self, msg: Msg) -> Vec { self.dirty = true; match msg { Msg::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key), @@ -423,16 +458,29 @@ impl App { } Vec::new() } - Msg::ScanStarted(counters) => { + Msg::ScanStarted(generation, counters) => { self.scan = ScanStatus { running: true, discovered: false, counters: Some(counters), elapsed: None, + generation, }; Vec::new() } - Msg::Scan(event) => self.on_scan(event), + Msg::Scan(generation, event) if generation == self.scan.generation => { + self.on_scan(event) + } + // A superseded scan (the folder changed) still draining its events. + Msg::Scan(..) => { + self.dirty = false; + Vec::new() + } + Msg::PathCompletions { input, candidates } => { + self.on_path_completions(input, candidates); + Vec::new() + } + Msg::RootChanged(result) => self.on_root_changed(result), Msg::Clean(event) => self.on_clean(event), Msg::CleanFinished(report) => self.on_clean_finished(report), Msg::Disk { free, total } => { @@ -453,10 +501,11 @@ impl App { Vec::new() } Msg::CachesDiscovered(caches) => self.on_caches_discovered(caches), - Msg::CacheMeasured(id, size) => { - self.on_cache_measured(&id, size); - Vec::new() - } + Msg::CacheMeasured { + id, + size, + fingerprint, + } => self.on_cache_measured(&id, size, fingerprint), Msg::DepsProgress(progress) => { self.updates.progress = Some(progress); Vec::new() @@ -534,6 +583,8 @@ impl App { ScanEvent::Finished { elapsed } => { self.scan.running = false; self.scan.elapsed = Some(*elapsed); + self.store.apply(event, &self.cache); + return vec![Effect::SaveSizes(self.cache.clone())]; } _ => {} } @@ -549,6 +600,7 @@ impl App { Vec::new() } Mode::Search => self.on_search_key(key), + Mode::Folder => self.on_folder_key(key), Mode::Review(_) => self.on_review_key(key), Mode::Browse => match keymap::action_for(self.tab, &key) { Some(action) => self.on_global_action(action), @@ -576,6 +628,7 @@ impl App { self.toast(format!("Theme: {}", self.theme.name), Level::Info); } Action::Help => self.mode = Mode::Help, + Action::ChangeRoot => return self.open_folder_prompt(), Action::ShowUpdates => { self.tab = Tab::Updates; return self.updates_effects(); @@ -662,7 +715,7 @@ impl App { } } - fn set_query(&mut self, raw: String) { + pub(super) fn set_query(&mut self, raw: String) { self.query = Query::parse(&raw, &self.registry); self.selected = 0; self.selected_key = None; @@ -1247,7 +1300,7 @@ pub(crate) mod tests { GitState::Tracked(4), )), ] { - app.update(Msg::Scan(event)); + app.update(Msg::Scan(0, event)); } app.ensure_rows(); app @@ -1370,14 +1423,17 @@ pub(crate) mod tests { press(&mut app, KeyCode::Char('j')); assert_eq!(app.selected_row().unwrap().label, "b"); // A size change that reorders rows keeps the cursor on "b". - app.update(Msg::Scan(ScanEvent::Measured { - id: ArtifactId(2), - size: Size { - reclaimable: 10_000, - on_disk: 10_000, - ..Size::default() + app.update(Msg::Scan( + 0, + ScanEvent::Measured { + id: ArtifactId(2), + size: Size { + reclaimable: 10_000, + on_disk: 10_000, + ..Size::default() + }, }, - })); + )); app.ensure_rows(); assert_eq!(app.selected_row().unwrap().label, "b"); assert_eq!(app.selected, 0); diff --git a/crates/carwash/src/tui/caches.rs b/crates/carwash/src/tui/caches.rs index 32481f1..ab0e6a1 100644 --- a/crates/carwash/src/tui/caches.rs +++ b/crates/carwash/src/tui/caches.rs @@ -3,19 +3,21 @@ use super::app::{App, Effect, Level}; use super::keymap::Action; use super::widgets::{pane, spinner}; -use carwash_core::cache::SizeCache; +use carwash_core::cache::{CachedSize, Fingerprint, SizeCache}; use carwash_core::caches::GlobalCache; use carwash_core::{Size, fmt}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Cell, Paragraph, Row, Table, TableState, Wrap}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::path::PathBuf; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime}; -/// Remembered sizes younger than this are shown without measuring again. +/// Remembered sizes younger than this are shown without measuring again... const SIZES_FRESH_FOR: Duration = Duration::from_secs(24 * 60 * 60); +/// ...or younger than this when the cache's fingerprint shows no change. +const UNCHANGED_FRESH_FOR: Duration = Duration::from_secs(7 * 24 * 60 * 60); #[derive(Debug, Default)] pub struct CachesState { @@ -25,8 +27,6 @@ pub struct CachesState { pub marked: HashSet, /// Being cleaned or measured. pub busy: HashSet, - /// When each cache's size was measured, in this session or an earlier one. - measured_at: HashMap, clean_armed: bool, requested: bool, /// The tab has been shown; stale sizes are measured from then on. @@ -41,49 +41,31 @@ impl CachesState { /// Installs discovered caches with their remembered sizes. fn set(&mut self, mut caches: Vec, sizes: &SizeCache) { for cache in &mut caches { - if let Some(cached) = sizes.get(&cache.path) { - cache.size = Some(cached.size); - self.measured_at.insert( - cache.id.clone(), - UNIX_EPOCH + Duration::from_secs(cached.measured_at), - ); - } + cache.size = sizes.get(&cache.path).map(|c| c.size); } self.caches = Some(caches); self.sort(); } /// Records a measurement; returns the cache's path. - fn measured(&mut self, id: &str, size: Size, now: SystemTime) -> Option { + fn measured( + &mut self, + id: &str, + size: Size, + fingerprint: Option, + ) -> Option { self.busy.remove(id); let cache = self .caches .as_mut() .and_then(|c| c.iter_mut().find(|c| c.id == id))?; cache.size = Some(size); + cache.fingerprint = fingerprint; let path = cache.path.clone(); - self.measured_at.insert(id.to_owned(), now); self.sort(); Some(path) } - pub fn measured_age(&self, id: &str, now: SystemTime) -> Option { - self.measured_at - .get(id) - .map(|at| now.duration_since(*at).unwrap_or_default()) - } - - /// Age of the oldest size shown, when it is not from this session's measuring. - pub fn oldest_age(&self, now: SystemTime) -> Option { - self.caches - .iter() - .flatten() - .filter(|c| !self.busy.contains(&c.id)) - .filter_map(|c| self.measured_age(&c.id, now)) - .max() - .filter(|age| *age >= Duration::from_secs(60)) - } - /// Largest first. Once the user has moved, the cursor stays on the same cache; until /// then it stays on the first row. fn sort(&mut self) { @@ -110,7 +92,37 @@ impl CachesState { } } +/// Whether `cache` must be measured again, given what was remembered about it. +fn is_stale(cache: &GlobalCache, remembered: Option<&CachedSize>, now: SystemTime) -> bool { + let Some(remembered) = remembered else { + return true; + }; + let age = remembered.age_at(now); + match (remembered.fingerprint, cache.fingerprint) { + (Some(then), Some(current)) if then == current => age > UNCHANGED_FRESH_FOR, + (Some(_), Some(_)) => true, + _ => age > SIZES_FRESH_FOR, + } +} + impl App { + /// How long ago `cache`'s size was measured, in this session or an earlier one. + pub fn cache_measured_age(&self, cache: &GlobalCache) -> Option { + self.cache.get(&cache.path).map(|c| c.age_at(self.now)) + } + + /// Age of the oldest size shown, when it is not from the last minute. + pub fn caches_oldest_age(&self) -> Option { + self.caches + .caches + .iter() + .flatten() + .filter(|c| !self.caches.busy.contains(&c.id)) + .filter_map(|c| self.cache_measured_age(c)) + .max() + .filter(|age| *age >= Duration::from_secs(60)) + } + /// Discovery is cheap (a few dozen known paths), so it runs at startup; measuring waits /// until the tab is shown. pub fn discover_caches(&mut self) -> Vec { @@ -132,9 +144,21 @@ impl App { self.measure_caches() } - pub fn on_cache_measured(&mut self, id: &str, size: Size) { - if let Some(path) = self.caches.measured(id, size, self.now) { - self.cache.insert(path, size); + /// Remembers the size; once nothing is left to measure, saves the size cache so the + /// next start has it even if this session does not end cleanly. + pub fn on_cache_measured( + &mut self, + id: &str, + size: Size, + fingerprint: Option, + ) -> Vec { + if let Some(path) = self.caches.measured(id, size, fingerprint) { + self.cache.insert_with(path, size, fingerprint); + } + if self.caches.busy.is_empty() { + vec![Effect::SaveSizes(self.cache.clone())] + } else { + Vec::new() } } @@ -149,11 +173,7 @@ impl App { .iter() .flatten() .filter(|c| !state.busy.contains(&c.id)) - .filter(|c| { - all || state - .measured_age(&c.id, self.now) - .is_none_or(|age| age > SIZES_FRESH_FOR) - }) + .filter(|c| all || is_stale(c, self.cache.get(&c.path), self.now)) .map(|c| (c.id.clone(), c.path.clone())) .collect(); if targets.is_empty() { @@ -366,7 +386,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { if !app.caches.marked.is_empty() { title.push_str(&format!(" · {} marked", app.caches.marked.len())); } - if let Some(age) = app.caches.oldest_age(app.now) { + if let Some(age) = app.caches_oldest_age() { title.push_str(&format!( " · sizes up to {} old (r to measure)", fmt::age(age) @@ -400,7 +420,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { let measured = if app.caches.busy.contains(&cache.id) { "measuring…".to_owned() } else { - match app.caches.measured_age(&cache.id, app.now) { + match app.cache_measured_age(cache) { Some(age) if age < Duration::from_secs(60) => "measured just now".to_owned(), Some(age) => format!("measured {} ago", fmt::age(age)), None => String::new(), @@ -446,9 +466,66 @@ mod tests { prune: None, note: None, size: None, + fingerprint: None, + } + } + + fn measured(id: &str, size: Size) -> Msg { + Msg::CacheMeasured { + id: id.into(), + size, + fingerprint: None, } } + fn fingerprint(newest: u64) -> Option { + Some(Fingerprint { newest, entries: 3 }) + } + + #[test] + fn fingerprints_decide_how_long_a_remembered_size_holds() { + let now = SystemTime::now(); + let mut sizes = SizeCache::default(); + sizes.insert_with(PathBuf::from("/home/a"), Size::default(), fingerprint(1)); + let remembered = sizes.get(Path::new("/home/a")); + let two_days = now + Duration::from_secs(2 * 24 * 60 * 60); + let two_weeks = now + Duration::from_secs(14 * 24 * 60 * 60); + let with = |fp| GlobalCache { + fingerprint: fp, + ..cache("a") + }; + // Unchanged: trusted for a week. + assert!(!is_stale(&with(fingerprint(1)), remembered, two_days)); + assert!(is_stale(&with(fingerprint(1)), remembered, two_weeks)); + // Changed near the top: measured now. + assert!(is_stale(&with(fingerprint(2)), remembered, now)); + // Unknown: a day. + assert!(!is_stale(&with(None), remembered, now)); + assert!(is_stale(&with(None), remembered, two_days)); + assert!(is_stale(&with(fingerprint(1)), None, now)); + } + + #[test] + fn finishing_the_measurements_saves_the_sizes() { + let mut app = app(); + press(&mut app, KeyCode::Char('4')); + app.update(Msg::CachesDiscovered(vec![cache("a"), cache("b")])); + let first = app.update(measured("a", Size::default())); + assert!(!first.iter().any(|e| matches!(e, Effect::SaveSizes(_)))); + let last = app.update(Msg::CacheMeasured { + id: "b".into(), + size: Size::default(), + fingerprint: fingerprint(9), + }); + let Some(Effect::SaveSizes(sizes)) = last.first() else { + panic!("expected a save, got {last:?}"); + }; + assert_eq!( + sizes.get(Path::new("/home/b")).unwrap().fingerprint, + fingerprint(9) + ); + } + #[test] fn caches_are_discovered_once_measured_and_sorted() { let mut app = app(); @@ -461,8 +538,8 @@ mod tests { reclaimable: bytes, ..Size::default() }; - app.update(Msg::CacheMeasured("small".into(), size(10))); - app.update(Msg::CacheMeasured("big".into(), size(1_000))); + app.update(measured("small", size(10))); + app.update(measured("big", size(1_000))); let ids: Vec<&str> = app .caches .caches @@ -476,7 +553,7 @@ mod tests { assert!(app.caches.busy.is_empty()); assert_eq!(app.caches.cursor, 0, "the largest cache is selected first"); press(&mut app, KeyCode::Char('j')); - app.update(Msg::CacheMeasured("small".into(), size(5_000))); + app.update(measured("small", size(5_000))); assert_eq!( app.caches.cursor, 0, "after moving, the cursor follows its cache" @@ -533,7 +610,7 @@ mod tests { let effects = later.update(Msg::CachesDiscovered(vec![cache("fresh")])); assert_eq!(measured_ids(&effects), ["fresh"]); - app.update(Msg::CacheMeasured("new".into(), size)); + app.update(measured("new", size)); assert!( press(&mut app, KeyCode::Char('r')) .iter() @@ -552,7 +629,7 @@ mod tests { on_disk: 7, ..Size::default() }; - app.update(Msg::CacheMeasured("a".into(), size)); + app.update(measured("a", size)); assert_eq!(app.cache.get(Path::new("/home/a")).unwrap().size, size); } diff --git a/crates/carwash/src/tui/folder.rs b/crates/carwash/src/tui/folder.rs new file mode 100644 index 0000000..51b78ce --- /dev/null +++ b/crates/carwash/src/tui/folder.rs @@ -0,0 +1,274 @@ +//! Scanning another folder without leaving the UI: a path prompt with directory completion. + +use super::app::{App, Effect, Level, Mode}; +use super::store::Store; +use crate::config::expand_home; +use ratatui::crossterm::event::{Event, KeyCode, KeyEvent}; +use std::path::{Path, PathBuf}; +use tui_input::Input; +use tui_input::backend::crossterm::EventHandler; + +/// Completions offered at most, so a huge directory cannot flood the prompt. +const MAX_CANDIDATES: usize = 200; + +#[derive(Debug, Default)] +pub struct FolderPrompt { + /// Directories completing `for_input`, written the way the user typed the parent + /// (`~/wo` offers `~/work/`). + candidates: Vec, + for_input: String, +} + +impl FolderPrompt { + /// Candidates for `input`; none while its own are still being listed. + pub fn for_input(&self, input: &str) -> &[String] { + if self.for_input == input { + &self.candidates + } else { + &[] + } + } + + /// What Tab turns `input` into: the only candidate, or the longest prefix all candidates + /// share when it adds something. + fn complete(&self, input: &str) -> Option { + if self.for_input != input || self.candidates.is_empty() { + return None; + } + let common = common_prefix(&self.candidates); + (common.chars().count() > input.chars().count()).then_some(common) + } +} + +fn common_prefix(items: &[String]) -> String { + let Some(first) = items.first() else { + return String::new(); + }; + let mut prefix: Vec = first.chars().collect(); + for item in &items[1..] { + let shared = prefix + .iter() + .zip(item.chars()) + .take_while(|(a, b)| a.eq_ignore_ascii_case(b)) + .count(); + prefix.truncate(shared); + } + prefix.into_iter().collect() +} + +/// `raw` as a path: `~` is the home directory, relative paths start at `base`. +pub fn resolve(raw: &str, base: &Path, home: Option<&Path>) -> PathBuf { + let path = expand_home(raw.trim(), home); + if path.is_absolute() { + path + } else { + base.join(path) + } +} + +/// Subdirectories completing `input`, case-insensitively; hidden ones only when the typed +/// name starts with a dot. +pub fn complete_dirs(input: &str, base: &Path, home: Option<&Path>) -> Vec { + let (parent, prefix) = match input.rfind('/') { + Some(i) => input.split_at(i + 1), + None => ("", input), + }; + let dir = if parent.is_empty() { + base.to_path_buf() + } else { + resolve(parent, base, home) + }; + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + let wanted = prefix.to_lowercase(); + let mut out: Vec = entries + .filter_map(Result::ok) + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let visible = !name.starts_with('.') || prefix.starts_with('.'); + (visible && name.to_lowercase().starts_with(&wanted) && e.path().is_dir()) + .then(|| format!("{parent}{name}/")) + }) + .collect(); + out.sort_by_key(|c| c.to_lowercase()); + out.truncate(MAX_CANDIDATES); + out +} + +impl App { + /// Opens the prompt on the current folder. + pub fn open_folder_prompt(&mut self) -> Vec { + let current = format!("{}/", super::view::home_relative(&self.store.root)); + self.input = Input::new(current.clone()); + self.folder = FolderPrompt::default(); + self.mode = Mode::Folder; + vec![Effect::CompletePath(current)] + } + + pub fn on_folder_key(&mut self, key: KeyEvent) -> Vec { + match key.code { + KeyCode::Esc => { + self.mode = Mode::Browse; + Vec::new() + } + KeyCode::Enter => { + self.mode = Mode::Browse; + let value = self.input.value().trim().to_owned(); + if value.is_empty() { + return Vec::new(); + } + vec![Effect::ChangeRoot(value)] + } + KeyCode::Tab => match self.folder.complete(self.input.value()) { + Some(completed) => { + self.input = Input::new(completed.clone()); + vec![Effect::CompletePath(completed)] + } + None => { + self.dirty = false; + Vec::new() + } + }, + _ => { + if self.input.handle_event(&Event::Key(key)).is_some() { + vec![Effect::CompletePath(self.input.value().to_owned())] + } else { + Vec::new() + } + } + } + } + + /// Completions arrive after the fact; keep them only if the input has not moved on. + pub fn on_path_completions(&mut self, input: String, candidates: Vec) { + if matches!(self.mode, Mode::Folder) && input == self.input.value() { + self.folder = FolderPrompt { + candidates, + for_input: input, + }; + } else { + self.dirty = false; + } + } + + /// A new root replaces everything tied to the previous scan; running jobs carry on. + pub fn on_root_changed(&mut self, result: Result) -> Vec { + let root = match result { + Ok(root) => root, + Err(error) => { + self.toast(error, Level::Warn); + return Vec::new(); + } + }; + self.toast( + format!("Scanning {}", super::view::home_relative(&root)), + Level::Info, + ); + self.store = Store::new(root); + self.marked.clear(); + self.expanded.clear(); + self.set_query(String::new()); + self.tasks.forget_projects(); + self.updates.forget_projects(); + vec![Effect::Scan, Effect::RefreshDisk] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::Msg; + use crate::tui::app::tests::app; + use ratatui::crossterm::event::KeyModifiers; + use std::fs; + + fn press(app: &mut App, code: KeyCode) -> Vec { + app.update(Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))) + } + + #[test] + fn completes_like_a_shell() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path(); + for sub in ["work", "Workshop", "web", ".hidden", "work/carwash"] { + fs::create_dir_all(base.join(sub)).unwrap(); + } + fs::write(base.join("wobble.txt"), b"").unwrap(); + + assert_eq!(complete_dirs("wo", base, None), ["work/", "Workshop/"]); + assert_eq!(complete_dirs("work/c", base, None), ["work/carwash/"]); + assert_eq!(complete_dirs(".h", base, None), [".hidden/"]); + assert!(!complete_dirs("", base, None).contains(&".hidden/".to_owned())); + let home = complete_dirs("~/we", Path::new("/elsewhere"), Some(base)); + assert_eq!(home, ["~/web/"]); + + let prompt = FolderPrompt { + candidates: complete_dirs("wo", base, None), + for_input: "wo".into(), + }; + assert_eq!(prompt.complete("wo").as_deref(), Some("work")); + assert_eq!(prompt.complete("stale"), None); + } + + #[test] + fn resolves_home_and_relative_paths() { + let home = Path::new("/home/me"); + let base = Path::new("/home/me/work"); + assert_eq!( + resolve("~/x", base, Some(home)), + PathBuf::from("/home/me/x") + ); + assert_eq!( + resolve("sub", base, Some(home)), + PathBuf::from("/home/me/work/sub") + ); + assert_eq!(resolve("/abs", base, Some(home)), PathBuf::from("/abs")); + } + + #[test] + fn the_prompt_changes_the_root_and_resets_scan_state() { + let mut app = app(); + press(&mut app, KeyCode::Char('a')); + assert!(!app.marked.is_empty()); + + let effects = press(&mut app, KeyCode::Char('p')); + assert!(matches!(app.mode, Mode::Folder)); + assert!(matches!(effects.as_slice(), [Effect::CompletePath(_)])); + app.input = Input::new("/tmp/elsewhere".into()); + let effects = press(&mut app, KeyCode::Enter); + assert!(matches!(effects.as_slice(), [Effect::ChangeRoot(p)] if p == "/tmp/elsewhere")); + + let effects = app.update(Msg::RootChanged(Ok(PathBuf::from("/tmp/elsewhere")))); + assert!(matches!(effects.first(), Some(Effect::Scan))); + assert_eq!(app.store.root, PathBuf::from("/tmp/elsewhere")); + assert!(app.marked.is_empty()); + assert!(app.store.entries.is_empty()); + + app.update(Msg::RootChanged(Err("cannot open /nope".into()))); + assert_eq!(app.toast.as_ref().unwrap().text, "cannot open /nope"); + } + + #[test] + fn tab_uses_completions_only_for_the_current_input() { + let mut app = app(); + press(&mut app, KeyCode::Char('p')); + app.input = Input::new("~/wo".into()); + app.update(Msg::PathCompletions { + input: "~/old".into(), + candidates: vec!["~/older/".into()], + }); + assert!( + press(&mut app, KeyCode::Tab).is_empty(), + "stale completions ignored" + ); + app.update(Msg::PathCompletions { + input: "~/wo".into(), + candidates: vec!["~/work/".into()], + }); + press(&mut app, KeyCode::Tab); + assert_eq!(app.input.value(), "~/work/"); + press(&mut app, KeyCode::Esc); + assert!(matches!(app.mode, Mode::Browse)); + } +} diff --git a/crates/carwash/src/tui/keymap.rs b/crates/carwash/src/tui/keymap.rs index f35704b..273041c 100644 --- a/crates/carwash/src/tui/keymap.rs +++ b/crates/carwash/src/tui/keymap.rs @@ -40,6 +40,7 @@ pub enum Action { ShowTasks, ShowUpdates, ShowCaches, + ChangeRoot, Theme, Help, Quit, @@ -237,6 +238,14 @@ pub const GLOBAL: &[Binding] = &[ S::General, "global caches", ), + bind( + &[(Char('p'), NONE)], + "p", + A::ChangeRoot, + S::General, + "scan another folder (Tab completes)", + ) + .hint("folder"), bind( &[(Char('t'), NONE)], "t", diff --git a/crates/carwash/src/tui/mod.rs b/crates/carwash/src/tui/mod.rs index 377dec0..6d14841 100644 --- a/crates/carwash/src/tui/mod.rs +++ b/crates/carwash/src/tui/mod.rs @@ -5,6 +5,7 @@ mod app; mod caches; +mod folder; mod keymap; mod pty; mod query; @@ -69,6 +70,7 @@ pub fn run(ctx: &Context, root: PathBuf, options: ScanOptions) -> Result<()> { options, tx, scan_cancel: None, + scan_generation: 0, clean_cancel: None, jobs: HashMap::new(), next_job: 0, @@ -162,6 +164,8 @@ struct Runtime<'a> { options: ScanOptions, tx: Sender, scan_cancel: Option, + /// Numbers scans, so events of a superseded one can be told apart. + scan_generation: u64, clean_cancel: Option, jobs: HashMap, next_job: u64, @@ -221,16 +225,43 @@ impl Runtime<'_> { let cancel = Cancel::new(); self.scan_cancel = Some(cancel.clone()); let counters = Arc::new(Counters::default()); - let _ = self.tx.send(Msg::ScanStarted(counters.clone())); + self.scan_generation += 1; + let generation = self.scan_generation; + let _ = self.tx.send(Msg::ScanStarted(generation, counters.clone())); let engine = self.ctx.engine.clone(); let (root, options, tx) = (self.root.clone(), self.options.clone(), self.tx.clone()); std::thread::spawn(move || { engine.scan(&root, &options, &counters, &cancel, &|event| { - let _ = tx.send(Msg::Scan(event)); + let _ = tx.send(Msg::Scan(generation, event)); }); }); } + Effect::CompletePath(input) => { + let (base, tx) = (self.root.clone(), self.tx.clone()); + std::thread::spawn(move || { + let home = carwash_core::paths::home(); + let candidates = folder::complete_dirs(&input, &base, home.as_deref()); + let _ = tx.send(Msg::PathCompletions { input, candidates }); + }); + } + Effect::ChangeRoot(raw) => { + let home = carwash_core::paths::home(); + let path = folder::resolve(&raw, &self.root, home.as_deref()); + let walk = crate::cli::WalkArgs::for_path(path); + let result = match self.ctx.scan_options(&walk, true, true) { + Ok((root, options)) => { + if let Some(cancel) = self.scan_cancel.take() { + cancel.cancel(); + } + self.root = root.clone(); + self.options = options; + Ok(root) + } + Err(error) => Err(format!("{error:#}")), + }; + let _ = self.tx.send(Msg::RootChanged(result)); + } Effect::CancelScan => { if let Some(cancel) = self.scan_cancel.take() { cancel.cancel(); @@ -340,21 +371,33 @@ impl Runtime<'_> { } } Effect::DiscoverCaches => { - let caches = crate::commands::caches::present(self.ctx).unwrap_or_else(|error| { - tracing::warn!(%error, "cannot list caches"); - Vec::new() + // Fingerprinting reads the top of each cache: keep it off the event loop. + let (dirs, tx) = (self.ctx.dirs.clone(), self.tx.clone()); + std::thread::spawn(move || { + let caches = + crate::commands::caches::present(dirs.as_ref()).unwrap_or_else(|error| { + tracing::warn!(%error, "cannot list caches"); + Vec::new() + }); + let _ = tx.send(Msg::CachesDiscovered(caches)); }); - let _ = self.tx.send(Msg::CachesDiscovered(caches)); } Effect::MeasureCaches(targets) => { let (engine, tx) = (self.ctx.engine.clone(), self.tx.clone()); std::thread::spawn(move || { for (id, path) in targets { - let size = engine.measure_path(&path, &Cancel::new()); - let _ = tx.send(Msg::CacheMeasured(id, size)); + let _ = tx.send(measure_cache(&engine, id, &path)); } }); } + Effect::SaveSizes(sizes) => { + if let Some(dirs) = &self.ctx.dirs { + let mut sizes = sizes; + if let Err(error) = sizes.save(&dirs.size_cache_file()) { + tracing::warn!(%error, "cannot save size cache"); + } + } + } Effect::CleanCaches { caches, size } => { for cache in caches { if let Some(task) = cache.prune_task() { @@ -399,12 +442,7 @@ impl Runtime<'_> { let _ = history::append(&file, &[record]); } } - let size = if cache.path.exists() { - engine.measure_path(&cache.path, &Cancel::new()) - } else { - carwash_core::Size::default() - }; - let _ = tx.send(Msg::CacheMeasured(cache.id, size)); + let _ = tx.send(measure_cache(&engine, cache.id, &cache.path)); }); } } @@ -428,6 +466,21 @@ impl Runtime<'_> { } /// Shows `path` in the platform file manager, detached from the terminal. +/// Fingerprints then measures one global cache (a missing directory measures as empty). +fn measure_cache(engine: &carwash_core::Engine, id: String, path: &Path) -> Msg { + let fingerprint = carwash_core::cache::Fingerprint::of(path); + let size = if path.exists() { + engine.measure_path(path, &Cancel::new()) + } else { + carwash_core::Size::default() + }; + Msg::CacheMeasured { + id, + size, + fingerprint, + } +} + fn reveal(path: &Path) { use std::process::{Command, Stdio}; let mut command = if cfg!(target_os = "macos") { diff --git a/crates/carwash/src/tui/tasks.rs b/crates/carwash/src/tui/tasks.rs index bf89e80..6b6830c 100644 --- a/crates/carwash/src/tui/tasks.rs +++ b/crates/carwash/src/tui/tasks.rs @@ -122,6 +122,16 @@ impl Default for TasksState { } impl TasksState { + /// Drops what refers to the previous scan's projects; jobs keep running. + pub fn forget_projects(&mut self) { + self.projects.clear(); + self.built_for = None; + self.project_cursor = 0; + self.project_key = None; + self.marked.clear(); + self.task_cursor = 0; + } + pub fn running(&self) -> usize { self.jobs .iter() diff --git a/crates/carwash/src/tui/updates.rs b/crates/carwash/src/tui/updates.rs index 23f5a01..f2789c2 100644 --- a/crates/carwash/src/tui/updates.rs +++ b/crates/carwash/src/tui/updates.rs @@ -66,6 +66,17 @@ impl Default for UpdatesState { } impl UpdatesState { + /// Drops what refers to the previous scan's projects; results are kept by path. + pub fn forget_projects(&mut self) { + self.projects.clear(); + self.built_for = None; + self.project_cursor = 0; + self.project_key = None; + self.marked_projects.clear(); + self.marked.clear(); + self.dep_cursor = 0; + } + pub fn set_filter(&mut self, filter: String) { self.filter = filter; self.project_cursor = 0; diff --git a/crates/carwash/src/tui/view.rs b/crates/carwash/src/tui/view.rs index d2fbd46..8b8fe5c 100644 --- a/crates/carwash/src/tui/view.rs +++ b/crates/carwash/src/tui/view.rs @@ -56,11 +56,59 @@ pub fn render(frame: &mut Frame, app: &mut App) { Mode::Review(review) => render_review(frame, app, review), Mode::Help => render_help(frame, app), Mode::Browse => render_tooltip(frame, app), + Mode::Folder => render_folder_candidates(frame, app, footer), Mode::Search => {} } } -fn home_relative(path: &std::path::Path) -> String { +/// Folders matching the prompt, just above it. +fn render_folder_candidates(frame: &mut Frame, app: &App, footer: Rect) { + const SHOWN: usize = 12; + let t = &app.theme; + let input = app.input.value(); + let candidates = app.folder.for_input(input); + if candidates.is_empty() { + return; + } + let parent_len = input.rfind('/').map_or(0, |i| i + 1); + let mut lines: Vec = candidates + .iter() + .take(SHOWN) + .map(|c| { + let name = c.get(parent_len..).unwrap_or(c); + Line::from(Span::styled(name.to_owned(), t.text())) + }) + .collect(); + if candidates.len() > SHOWN { + lines.push(Line::from(Span::styled( + format!("+{} more, keep typing", candidates.len() - SHOWN), + t.muted(), + ))); + } + let width = lines.iter().map(Line::width).max().unwrap_or(0) as u16 + 4; + let height = lines.len() as u16 + 2; + let x = footer.x + " folder ".len() as u16 + parent_len as u16; + let popup = Rect::new( + x.min(footer.right().saturating_sub(width)), + footer.y.saturating_sub(height), + width, + height, + ) + .intersection(frame.area()); + frame.render_widget(Clear, popup); + frame.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(t.fg(t.accent)) + .padding(ratatui::widgets::Padding::horizontal(1)), + ), + popup, + ); +} + +pub(super) fn home_relative(path: &std::path::Path) -> String { if let Some(home) = carwash_core::paths::home() && let Ok(rest) = path.strip_prefix(&home) { @@ -714,9 +762,16 @@ fn git_state(state: GitState) -> String { fn render_footer(frame: &mut Frame, app: &App, area: Rect) -> Vec { let t = &app.theme; - if matches!(app.mode, Mode::Search) { - let prompt = "/ "; - let width = area.width.saturating_sub(prompt.len() as u16 + 1) as usize; + let prompt = match app.mode { + Mode::Search => Some(("/ ", "")), + Mode::Folder => Some((" folder ", "Tab completes · Enter scans · Esc cancels ")), + _ => None, + }; + if let Some((prompt, help)) = prompt { + let help_width = (help.width() as u16).min(area.width / 2); + let [input_area, help_area] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(help_width)]).areas(area); + let width = input_area.width.saturating_sub(prompt.width() as u16 + 1) as usize; let scroll = app.input.visual_scroll(width); let line = Line::from(vec![ Span::styled(prompt, t.bold(t.accent)), @@ -725,9 +780,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) -> Vec { t.text(), ), ]); - frame.render_widget(Paragraph::new(line), area); + frame.render_widget(Paragraph::new(line), input_area); + frame.render_widget(Paragraph::new(Span::styled(help, t.muted())), help_area); let cursor = (app.input.visual_cursor().saturating_sub(scroll)) as u16; - frame.set_cursor_position((area.x + prompt.len() as u16 + cursor, area.y)); + frame.set_cursor_position((area.x + prompt.width() as u16 + cursor, area.y)); return Vec::new(); } if let Some(toast) = &app.toast { @@ -747,8 +803,9 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) -> Vec { // This tab's actions on the left, as many as fit; the keys that work everywhere on the // right, labelled as such. const GAP: u16 = 2; - const GLOBAL_LABEL: &str = "│ any tab "; - let (local, global) = keymap::footer(app.tab); + /// Room left for this tab's keys, at the least. + const MIN_LOCAL: u16 = 20; + let (local, mut global) = keymap::footer(app.tab); let width = |b: &Binding| (b.key().width() + 1 + b.hint.unwrap_or_default().width()) as u16; let item = |b: &'static Binding, key_style: Style| { [ @@ -756,11 +813,22 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) -> Vec { Span::styled(format!(" {}", b.hint.unwrap_or_default()), t.muted()), ] }; - let global_width = GLOBAL_LABEL.width() as u16 - + global.iter().map(|b| width(b)).sum::() - + GAP * (global.len() as u16).saturating_sub(1) - + 1; - let show_global = global_width + 20 <= area.width; + let group_width = |global: &[&'static Binding], label: &str| { + label.width() as u16 + + global.iter().map(|b| width(b)).sum::() + + GAP * (global.len() as u16).saturating_sub(1) + + 1 + }; + // When space runs short, help and quit outlast the other global keys, and the label goes. + let mut label = "│ any tab "; + if group_width(&global, label) + MIN_LOCAL > area.width { + global.retain(|b| matches!(b.action, keymap::Action::Help | keymap::Action::Quit)); + } + if group_width(&global, label) + MIN_LOCAL > area.width { + label = "│ "; + } + let global_width = group_width(&global, label); + let show_global = global_width + MIN_LOCAL <= area.width; let limit = if show_global { area.right().saturating_sub(global_width + GAP) } else { @@ -789,8 +857,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) -> Vec { if show_global { let right_area = Rect::new(area.right() - global_width, area.y, global_width, 1); - let mut spans = vec![Span::styled(GLOBAL_LABEL, t.muted())]; - let mut x = right_area.x + GLOBAL_LABEL.width() as u16; + let mut spans = vec![Span::styled(label, t.muted())]; + let mut x = right_area.x + label.width() as u16; for (i, b) in global.into_iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); @@ -1305,9 +1373,15 @@ mod tests { "{screen}" ); assert!(screen.contains("Nothing marked yet"), "{screen}"); - // Moving within the same hotspot does not redraw. + // Moving within the same hotspot does not redraw (the event loop clears `dirty` + // after drawing). + app.dirty = false; mouse(&mut app, MouseEventKind::Moved, clean.area); assert!(!app.dirty); + // ...but it does not cancel a redraw an earlier message of the batch asked for. + press(&mut app, KeyCode::Char('j')); + mouse(&mut app, MouseEventKind::Moved, clean.area); + assert!(app.dirty); let caches = app .hotspots From 9c5011e952c1a3abe5aa7be803c3aa5b55157e5d Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:17:50 -0400 Subject: [PATCH 21/22] chore: prepare 0.4.0 Date the changelog and cover the last additions, ship LICENSE in both packages, and give carwash-core a README for its crates.io page. --- CHANGELOG.md | 11 +++++++++-- crates/carwash-core/Cargo.toml | 1 + crates/carwash-core/LICENSE | 1 + crates/carwash-core/README.md | 14 ++++++++++++++ crates/carwash/LICENSE | 1 + 5 files changed, 26 insertions(+), 2 deletions(-) create mode 120000 crates/carwash-core/LICENSE create mode 100644 crates/carwash-core/README.md create mode 120000 crates/carwash/LICENSE diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0cbd3..a409d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to CarWash will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.0] - Unreleased +## [0.4.0] - 2026-09-25 A rewrite. carwash is no longer a Rust project manager: it reclaims disk space across projects in any of 40 ecosystems, and checks dependencies and runs tasks along the way. Nothing from 0.3 @@ -29,9 +29,16 @@ carries over, including the configuration file. - Task discovery from `package.json`, `deno.json`, justfiles, Makefiles, Taskfiles, mise, Poe/PDM, Composer and Cargo aliases, plus standard commands per ecosystem. - 32 global caches (package stores, toolchains, Xcode and simulator data, IDE caches) with the - tool's own prune command where one exists. + tool's own prune command where one exists. Sizes are remembered between runs, so the Caches + tab opens instantly; a cache is measured again when the top of its tree changes, after a + week otherwise, or on request. +- `p` scans another folder without restarting, with directory completion. +- A footer that separates the current tab's keys from those that work in any tab. Hovering a + key or tab explains it (for `d`, exactly what will be deleted or run); clicking runs it. +- Help per tab that sizes itself to the terminal. - User-extensible rules in `ecosystems.toml` and `caches.toml`. - Themes: gestalt (default), latte, nord, dracula, and a 16-color fallback. +- A configuration file left by 0.3 is ignored with a warning instead of stopping every command. ### Changed - Configuration moved to `~/.config/carwash/config.toml` with a new schema. diff --git a/crates/carwash-core/Cargo.toml b/crates/carwash-core/Cargo.toml index 9696e28..791cb56 100644 --- a/crates/carwash-core/Cargo.toml +++ b/crates/carwash-core/Cargo.toml @@ -8,6 +8,7 @@ authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true +readme = "README.md" keywords = ["cleanup", "disk-usage", "monorepo", "node-modules", "cargo"] categories = ["development-tools", "filesystem"] diff --git a/crates/carwash-core/LICENSE b/crates/carwash-core/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/carwash-core/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/carwash-core/README.md b/crates/carwash-core/README.md new file mode 100644 index 0000000..213081d --- /dev/null +++ b/crates/carwash-core/README.md @@ -0,0 +1,14 @@ +# carwash-core + +The engine behind [carwash](https://github.com/epistates/carwash), with no UI code. + +It finds projects of 40 ecosystems under a directory, recognises their build outputs, +dependency installs, caches and environments, measures what deleting them would really free +(allocated blocks, hard links counted once), asks git what is tracked or ignored, and deletes +safely. It also discovers project tasks, checks dependencies against crates.io, npm, PyPI and +the Go proxy (with OSV advisories), and lists per-user caches outside projects. + +Most users want the [`carwash`](https://crates.io/crates/carwash) command-line tool instead. +The API follows carwash's needs and may change between minor versions. + +License: MIT diff --git a/crates/carwash/LICENSE b/crates/carwash/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/carwash/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file From 316fce0d6b210dcf7874cf4ad0ff091a37e13049 Mon Sep 17 00:00:00 2001 From: Nick Paterno <43416138+nicholasjpaterno@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:27:39 -0400 Subject: [PATCH 22/22] test(cli): compare paths with the platform separator carwash prints native paths (app\target on Windows); the tests hardcoded forward slashes. --- crates/carwash/tests/cli.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/carwash/tests/cli.rs b/crates/carwash/tests/cli.rs index d386a57..dc69ece 100644 --- a/crates/carwash/tests/cli.rs +++ b/crates/carwash/tests/cli.rs @@ -7,6 +7,11 @@ use std::path::Path; const CACHEDIR_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n"; +/// `rel` with the platform's separator, as carwash prints paths. +fn native(rel: &str) -> String { + rel.replace('/', std::path::MAIN_SEPARATOR_STR) +} + fn write(root: &Path, rel: &str, contents: &str) { let path = root.join(rel); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -66,7 +71,7 @@ fn scan_json_reports_artifacts_and_policy() { let find = |suffix: &str| { artifacts .iter() - .find(|a| a["path"].as_str().unwrap().ends_with(suffix)) + .find(|a| a["path"].as_str().unwrap().ends_with(&native(suffix))) .unwrap_or_else(|| panic!("{suffix} missing")) }; assert_eq!(find("app/target")["kind"], "build"); @@ -86,7 +91,7 @@ fn scan_table_summarises() { .arg(tree.path()) .assert() .success() - .stdout(predicate::str::contains("app/target")) + .stdout(predicate::str::contains(native("app/target"))) .stdout(predicate::str::contains("reclaimable from 4 artifacts")); } @@ -197,7 +202,7 @@ fn user_rules_extend_the_registry() { .arg(tree.path()) .assert() .success() - .stdout(predicate::str::contains("acme/.acme-cache")); + .stdout(predicate::str::contains(native("acme/.acme-cache"))); } #[test]