From 2853435e3b73a45317e9013ac10e8087b22abe4f Mon Sep 17 00:00:00 2001 From: Subin An Date: Thu, 11 Jun 2026 10:36:42 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore(deps):=20rusqlite=200.32=E2=86=920.40?= =?UTF-8?q?,=20toml=200.8=E2=86=921,=20declare=20rust-version=201.88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both major bumps compile with zero code changes (rusqlite's bundled SQLite moves to libsqlite3-sys 0.38; agf's toml surface — from_str, Table, Value variants — is stable across 0.8→1). Lockfile refreshed. rust-version starts at 1.88: the code already uses usize::is_multiple_of (1.87, caught by clippy::incompatible_msrv once an MSRV is declared) and the edition-2024 migration that follows adopts let-chains (1.88). Highest dependency MSRV is 1.85. sha2 stays 0.10: 0.11 breaks the gemini hash formatting (digest 0.11 output no longer implements LowerHex). superlighttui stays 0.20: 0.21 adds #[must_use] to separator_colored, wanting 29 call-site updates — deferred to its own change. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 329 +++++++++++++++++++++++++++++------------------------ Cargo.toml | 6 +- 2 files changed, 184 insertions(+), 151 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e494a2f..1ebf0c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,18 +23,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -46,9 +34,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", @@ -61,15 +49,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", ] @@ -96,21 +84,21 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -123,9 +111,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "castaway" @@ -138,9 +126,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -154,9 +142,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -168,9 +156,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.59" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -178,9 +166,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.59" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -190,9 +178,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", @@ -202,21 +190,21 @@ 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" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -334,9 +322,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "equivalent" @@ -372,6 +360,36 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -395,26 +413,29 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "ahash", + "foldhash", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.17.1", ] [[package]] @@ -449,12 +470,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", ] [[package]] @@ -465,41 +486,41 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[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.85" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[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.12" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags", "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" dependencies = [ "cc", "pkg-config", @@ -523,21 +544,21 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -577,9 +598,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" @@ -616,11 +637,17 @@ dependencies = [ "windows-link", ] +[[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.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "proc-macro2" @@ -633,18 +660,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[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", @@ -680,11 +707,21 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + [[package]] name = "rusqlite" -version = "0.32.1" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ "bitflags", "fallible-iterator", @@ -692,6 +729,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -766,9 +804,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -779,11 +817,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -799,9 +837,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -834,12 +872,30 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -866,9 +922,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.116" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -897,50 +953,48 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_parser" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -950,15 +1004,15 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "utf8parse" @@ -996,9 +1050,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -1009,9 +1063,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1019,9 +1073,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -1032,9 +1086,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -1213,32 +1267,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index badbbed..bd224b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ name = "agf" version = "0.11.4" edition = "2021" +# 1.88 = let-chains (edition 2024); also covers usize::is_multiple_of (1.87). +rust-version = "1.88" description = "Find and resume local AI coding-agent sessions across Claude Code, Codex, Gemini, Cursor CLI, OpenCode, Kiro, pi, and Hermes" license = "MIT" repository = "https://github.com/subinium/agf" @@ -13,7 +15,7 @@ readme = "README.md" superlighttui = "0.20" serde = { version = "1", features = ["derive"] } serde_json = "1" -rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled"] } nucleo = "0.5" clap = { version = "4", features = ["derive"] } thiserror = "2" @@ -23,7 +25,7 @@ chrono = { version = "0.4", features = ["serde"] } walkdir = "2" rayon = "1" unicode-width = "0.2" -toml = "0.8" +toml = "1" sha2 = "0.10" [profile.release] From 934bd3b5c153ee642dddb31c03dd98d71cf33b73 Mon Sep 17 00:00:00 2001 From: Subin An Date: Thu, 11 Jun 2026 10:37:33 +0900 Subject: [PATCH 2/7] chore: migrate to Rust edition 2024 Mechanical migration, zero behavior change: - cargo fix --edition: wraps the test-only HOME env mutations in src/scanner/pi.rs in unsafe {} (env mutation is unsafe in 2024) - cargo clippy --fix: collapses ~37 nested if-let staircases into let-chains across the scanners and TUI (collapsible_if), now that edition 2024 stabilizes them - cargo fmt over the result Co-Authored-By: Claude Fable 5 --- Cargo.toml | 2 +- src/action.rs | 24 +++--- src/cache.rs | 40 +++++---- src/delete.rs | 41 +++++---- src/fuzzy.rs | 2 +- src/model.rs | 8 +- src/scanner/claude.rs | 34 ++++---- src/scanner/codex.rs | 30 +++---- src/scanner/cursor_agent.rs | 8 +- src/scanner/hermes.rs | 40 ++++----- src/scanner/pi.rs | 28 +++--- src/tui/mod.rs | 165 +++++++++++++++++------------------- src/watch.rs | 2 +- 13 files changed, 205 insertions(+), 219 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bd224b8..737070b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "agf" version = "0.11.4" -edition = "2021" +edition = "2024" # 1.88 = let-chains (edition 2024); also covers usize::is_multiple_of (1.87). rust-version = "1.88" description = "Find and resume local AI coding-agent sessions across Claude Code, Codex, Gemini, Cursor CLI, OpenCode, Kiro, pi, and Hermes" diff --git a/src/action.rs b/src/action.rs index b27d0a9..321d482 100644 --- a/src/action.rs +++ b/src/action.rs @@ -43,20 +43,20 @@ pub fn action_preview(session: &Session, action: Action) -> String { /// Detect editor from config, then $EDITOR, then $VISUAL, fallback to "vim". pub fn detect_editor() -> String { let config = crate::settings::Settings::load(); - if let Some(ref editor) = config.editor { - if !editor.is_empty() { - return editor.clone(); - } + if let Some(ref editor) = config.editor + && !editor.is_empty() + { + return editor.clone(); } - if let Ok(editor) = std::env::var("EDITOR") { - if !editor.is_empty() { - return editor; - } + if let Ok(editor) = std::env::var("EDITOR") + && !editor.is_empty() + { + return editor; } - if let Ok(editor) = std::env::var("VISUAL") { - if !editor.is_empty() { - return editor; - } + if let Ok(editor) = std::env::var("VISUAL") + && !editor.is_empty() + { + return editor; } "vim".to_string() } diff --git a/src/cache.rs b/src/cache.rs index 7150f56..fa091aa 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -151,12 +151,11 @@ fn get_max_mtime(paths: &[PathBuf]) -> u64 { .into_iter() .filter_map(|e| e.ok()) { - if let Ok(m) = entry.metadata() { - if let Ok(t) = m.modified() { - if let Ok(d) = t.duration_since(std::time::SystemTime::UNIX_EPOCH) { - max = max.max(d.as_secs()); - } - } + if let Ok(m) = entry.metadata() + && let Ok(t) = m.modified() + && let Ok(d) = t.duration_since(std::time::SystemTime::UNIX_EPOCH) + { + max = max.max(d.as_secs()); } } } @@ -263,21 +262,20 @@ pub fn write_cache(sessions: &[Session], skip_agents: &std::collections::HashSet // gates this on schema AND binary version: entries written by another // binary are exactly the stale data issue #37 is about, so on upgrade we // drop them and let the next launch rescan those agents instead. - if !skip_agents.is_empty() { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(prior) = parse_cache(&content, AGF_VERSION) { - for skip in skip_agents { - let key = agent_to_str(*skip).to_string(); - if let Some(entry) = prior.agents.get(&key) { - agents.insert( - key, - AgentCache { - mtime: entry.mtime, - sessions: entry.sessions.iter().map(clone_cached).collect(), - }, - ); - } - } + if !skip_agents.is_empty() + && let Ok(content) = fs::read_to_string(&path) + && let Ok(prior) = parse_cache(&content, AGF_VERSION) + { + for skip in skip_agents { + let key = agent_to_str(*skip).to_string(); + if let Some(entry) = prior.agents.get(&key) { + agents.insert( + key, + AgentCache { + mtime: entry.mtime, + sessions: entry.sessions.iter().map(clone_cached).collect(), + }, + ); } } } diff --git a/src/delete.rs b/src/delete.rs index e012dbc..d250008 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -61,10 +61,10 @@ fn rewrite_jsonl_excluding(path: &Path, json_key: &str, value: &str) -> Result<( /// Check if a JSON line contains `"key": "value"`. fn line_has_field_value(line: &str, key: &str, value: &str) -> bool { - if let Ok(parsed) = serde_json::from_str::(line) { - if let Some(v) = parsed.get(key).and_then(|v| v.as_str()) { - return v == value; - } + if let Ok(parsed) = serde_json::from_str::(line) + && let Some(v) = parsed.get(key).and_then(|v| v.as_str()) + { + return v == value; } false } @@ -292,13 +292,12 @@ fn delete_pi_session(session: &Session) -> Result<(), io::Error> { _ => continue, }; - if let Ok(value) = serde_json::from_str::(first_line) { - if value.get("type").and_then(|v| v.as_str()) == Some("session") - && value.get("id").and_then(|v| v.as_str()) == Some(&session.session_id) - { - fs::remove_file(path)?; - return Ok(()); - } + if let Ok(value) = serde_json::from_str::(first_line) + && value.get("type").and_then(|v| v.as_str()) == Some("session") + && value.get("id").and_then(|v| v.as_str()) == Some(&session.session_id) + { + fs::remove_file(path)?; + return Ok(()); } } @@ -396,16 +395,15 @@ fn delete_gemini_session(session: &Session) -> Result<(), io::Error> { continue; }; - if let Ok(json) = serde_json::from_str::(&content) { - if json + if let Ok(json) = serde_json::from_str::(&content) + && json .get("sessionId") .and_then(|v| v.as_str()) .map(|id| id == session.session_id) .unwrap_or(false) - { - fs::remove_file(&path)?; - return Ok(()); - } + { + fs::remove_file(&path)?; + return Ok(()); } } } @@ -473,10 +471,11 @@ fn delete_hermes_session(session: &Session) -> Result<(), io::Error> { let prefix = format!("session_{}", session.session_id); if let Ok(entries) = fs::read_dir(&sessions_dir) { for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() { - if name.starts_with(&prefix) && name.ends_with(".json") { - let _ = fs::remove_file(entry.path()); - } + if let Some(name) = entry.file_name().to_str() + && name.starts_with(&prefix) + && name.ends_with(".json") + { + let _ = fs::remove_file(entry.path()); } } } diff --git a/src/fuzzy.rs b/src/fuzzy.rs index 3c623d0..1d3bc7b 100644 --- a/src/fuzzy.rs +++ b/src/fuzzy.rs @@ -1,6 +1,6 @@ use nucleo::{ - pattern::{AtomKind, CaseMatching, Normalization, Pattern}, Config, Matcher, Utf32Str, + pattern::{AtomKind, CaseMatching, Normalization, Pattern}, }; use crate::model::Session; diff --git a/src/model.rs b/src/model.rs index ce62a09..2b359dd 100644 --- a/src/model.rs +++ b/src/model.rs @@ -213,10 +213,10 @@ impl Session { if self.project_path.is_empty() { return "—".to_string(); } - if let Some(home) = dirs::home_dir() { - if let Some(rest) = self.project_path.strip_prefix(home.to_str().unwrap_or("")) { - return format!("~{rest}"); - } + if let Some(home) = dirs::home_dir() + && let Some(rest) = self.project_path.strip_prefix(home.to_str().unwrap_or("")) + { + return format!("~{rest}"); } self.project_path.clone() } diff --git a/src/scanner/claude.rs b/src/scanner/claude.rs index 6abfcc1..c696733 100644 --- a/src/scanner/claude.rs +++ b/src/scanner/claude.rs @@ -149,20 +149,19 @@ fn extract_worktree(val: &Value, worktree: &mut Option) { if worktree.is_some() { return; } - if let Some(cwd) = val.get("cwd").and_then(|c| c.as_str()) { - if let Some((_, wt)) = cwd.split_once("/.claude/worktrees/") { - if !wt.is_empty() { - *worktree = Some(wt.to_string()); - } - } + if let Some(cwd) = val.get("cwd").and_then(|c| c.as_str()) + && let Some((_, wt)) = cwd.split_once("/.claude/worktrees/") + && !wt.is_empty() + { + *worktree = Some(wt.to_string()); } } fn extract_ai_title(val: &Value, ai_title: &mut Option) { - if val.get("type").and_then(|t| t.as_str()) == Some("ai-title") { - if let Some(title) = val.get("aiTitle").and_then(|t| t.as_str()) { - *ai_title = Some(title.to_string()); - } + if val.get("type").and_then(|t| t.as_str()) == Some("ai-title") + && let Some(title) = val.get("aiTitle").and_then(|t| t.as_str()) + { + *ai_title = Some(title.to_string()); } } @@ -187,15 +186,14 @@ fn extract_recap( if latest_recap_ts .as_deref() .is_none_or(|prev| ts.as_str() > prev) + && let Some(content) = val.get("content").and_then(|c| c.as_str()) { - if let Some(content) = val.get("content").and_then(|c| c.as_str()) { - // Strip the "(disable recaps in /config)" suffix - let clean = content - .trim_end_matches("(disable recaps in /config)") - .trim(); - *latest_recap = Some(clean.to_string()); - *latest_recap_ts = Some(ts); - } + // Strip the "(disable recaps in /config)" suffix + let clean = content + .trim_end_matches("(disable recaps in /config)") + .trim(); + *latest_recap = Some(clean.to_string()); + *latest_recap_ts = Some(ts); } } diff --git a/src/scanner/codex.rs b/src/scanner/codex.rs index 2ddd76e..501c5b9 100644 --- a/src/scanner/codex.rs +++ b/src/scanner/codex.rs @@ -79,16 +79,14 @@ fn collect_live_session_ids(codex_dir: &std::path::Path) -> Option line, None => continue, }; - if let Ok(value) = serde_json::from_str::(first_line.trim()) { - if let Some(id) = value + if let Ok(value) = serde_json::from_str::(first_line.trim()) + && let Some(id) = value .get("payload") .and_then(|p| p.get("id")) .and_then(|v| v.as_str()) - { - if !id.is_empty() { - ids.insert(id.to_string()); - } - } + && !id.is_empty() + { + ids.insert(id.to_string()); } } // If the walk itself failed (permission denied, transient I/O), treat @@ -199,11 +197,11 @@ fn scan_sqlite( // Only filter/prune when we have a trustworthy live set. If the // sessions tree could not be enumerated (`None`), surface the row // as-is — better stale than nuked. - if let Some(live) = live_session_ids { - if !live.contains(&session_id) { - orphan_ids.push(session_id); - continue; - } + if let Some(live) = live_session_ids + && !live.contains(&session_id) + { + orphan_ids.push(session_id); + continue; } let project_name = std::path::Path::new(&cwd) @@ -421,10 +419,10 @@ fn read_history_summaries( Some(id) if !id.is_empty() => id, _ => continue, }; - if let Some(live) = live_session_ids { - if !live.contains(&session_id) { - continue; - } + if let Some(live) = live_session_ids + && !live.contains(&session_id) + { + continue; } let ts = entry.ts.unwrap_or(0.0); let text = match entry.text { diff --git a/src/scanner/cursor_agent.rs b/src/scanner/cursor_agent.rs index c803ae5..8299b67 100644 --- a/src/scanner/cursor_agent.rs +++ b/src/scanner/cursor_agent.rs @@ -353,10 +353,10 @@ fn solve(parts: &[&str], idx: usize, current: &Path) -> Option { if candidate.is_dir() { return Some(candidate); } - } else if candidate.is_dir() { - if let Some(result) = solve(parts, end, &candidate) { - return Some(result); - } + } else if candidate.is_dir() + && let Some(result) = solve(parts, end, &candidate) + { + return Some(result); } } None diff --git a/src/scanner/hermes.rs b/src/scanner/hermes.rs index 634d0f4..fcd3e79 100644 --- a/src/scanner/hermes.rs +++ b/src/scanner/hermes.rs @@ -137,10 +137,10 @@ pub fn scan() -> Result, AgfError> { // shows how the conversation actually went, not just one // line. let mut summaries: Vec = Vec::new(); - if let Some(ref t) = title { - if !t.is_empty() { - summaries.push(t.clone()); - } + if let Some(ref t) = title + && !t.is_empty() + { + summaries.push(t.clone()); } if let Some(ref children) = child_titles { let mut seen = std::collections::HashSet::new(); @@ -157,16 +157,16 @@ pub fn scan() -> Result, AgfError> { // crons, dashboard callers), so their `role='user'` rows // are not the user's own prompts and would mislead the // TUI summary. - if is_user_cli_session(&id) { - if let Some(ref blob) = user_msgs { - let mut seen: std::collections::HashSet = - summaries.iter().cloned().collect(); - for raw in blob.split("|||") { - if let Some(preview) = message_preview(raw) { - if seen.insert(preview.clone()) { - summaries.push(preview); - } - } + if is_user_cli_session(&id) + && let Some(ref blob) = user_msgs + { + let mut seen: std::collections::HashSet = + summaries.iter().cloned().collect(); + for raw in blob.split("|||") { + if let Some(preview) = message_preview(raw) + && seen.insert(preview.clone()) + { + summaries.push(preview); } } } @@ -175,12 +175,12 @@ pub fn scan() -> Result, AgfError> { // so the row is never blank. if summaries.is_empty() { let mut fallback = format!("{source} session"); - if let Some(ref m) = model { - if !m.is_empty() { - // Extract short model name (e.g. "claude-opus-4-6" from "anthropic/claude-opus-4-6") - let short = m.rsplit('/').next().unwrap_or(m); - fallback = format!("{fallback} ({short})"); - } + if let Some(ref m) = model + && !m.is_empty() + { + // Extract short model name (e.g. "claude-opus-4-6" from "anthropic/claude-opus-4-6") + let short = m.rsplit('/').next().unwrap_or(m); + fallback = format!("{fallback} ({short})"); } if message_count > 0 { fallback = format!("{fallback} — {message_count} msgs"); diff --git a/src/scanner/pi.rs b/src/scanner/pi.rs index 0cdd93f..d58a48a 100644 --- a/src/scanner/pi.rs +++ b/src/scanner/pi.rs @@ -79,16 +79,15 @@ fn parse_session(path: &std::path::Path) -> Option { bytes_read += line.len() + 1; let line = line.trim(); - if !line.is_empty() { - if let Ok(value) = serde_json::from_str::(line) { - if header.is_none() && value.get("type").and_then(Value::as_str) == Some("session") - { - header = serde_json::from_value::(value.clone()).ok(); - } - - if let Some(summary) = extract_user_summary(&value) { - summaries.push(summary); - } + if !line.is_empty() + && let Ok(value) = serde_json::from_str::(line) + { + if header.is_none() && value.get("type").and_then(Value::as_str) == Some("session") { + header = serde_json::from_value::(value.clone()).ok(); + } + + if let Some(summary) = extract_user_summary(&value) { + summaries.push(summary); } } @@ -305,14 +304,17 @@ mod tests { ) .unwrap(); } - std::env::set_var("HOME", &home); + // TODO: Audit that the environment access only happens in single-threaded code. + unsafe { std::env::set_var("HOME", &home) }; let sessions = scan().unwrap(); if let Some(old_home) = old_home { - std::env::set_var("HOME", old_home); + // TODO: Audit that the environment access only happens in single-threaded code. + unsafe { std::env::set_var("HOME", old_home) }; } else { - std::env::remove_var("HOME"); + // TODO: Audit that the environment access only happens in single-threaded code. + unsafe { std::env::remove_var("HOME") }; } let ids: Vec<_> = sessions.iter().map(|s| s.session_id.as_str()).collect(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f3ab395..d282e9a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -224,13 +224,7 @@ impl App { // Pinning is still honored because it's an explicit user action. let pinned = self.pinned_sessions.clone(); self.sessions.sort_by(|a, b| { - let rank = |s: &Session| -> u8 { - if pinned.contains(&s.session_id) { - 0 - } else { - 1 - } - }; + let rank = |s: &Session| -> u8 { if pinned.contains(&s.session_id) { 0 } else { 1 } }; rank(a).cmp(&rank(b)) }); @@ -240,15 +234,14 @@ impl App { self.update_filter(); // Restore the selection to the same session after reordering. - if let Some(id) = pivot_id { - if let Some(new_pos) = self + if let Some(id) = pivot_id + && let Some(new_pos) = self .filtered_indices .iter() .position(|&i| self.sessions[i].session_id == id) - { - self.selected = new_pos; - self.adjust_scroll(); - } + { + self.selected = new_pos; + self.adjust_scroll(); } } @@ -531,12 +524,12 @@ impl App { } // Restore selection if the same id is still present after the swap; // apply_sort() (run by the caller) will re-derive the index. - if let Some(id) = selected_id { - if let Some(pos) = self.sessions.iter().position(|s| s.session_id == id) { - // Stash via a side channel: filtered_indices is stale here, so - // we just record the id; apply_sort() restores cursor. - let _ = pos; - } + if let Some(id) = selected_id + && let Some(pos) = self.sessions.iter().position(|s| s.session_id == id) + { + // Stash via a side channel: filtered_indices is stale here, so + // we just record the id; apply_sort() restores cursor. + let _ = pos; } } @@ -852,14 +845,12 @@ fn ui_grouped_browse(ui: &mut slt::Context, app: &mut App) { app.mode = Mode::Browse; return; } - if ctrl_right { - if let Some((gi, Some(ci))) = app.grouped_row_at(app.grouped_selected) { - let session_idx = app.groups[gi].sessions[ci]; - if let Some(vi) = app.filtered_indices.iter().position(|&i| i == session_idx) { - app.selected = vi; - app.mode = Mode::Preview; - return; - } + if ctrl_right && let Some((gi, Some(ci))) = app.grouped_row_at(app.grouped_selected) { + let session_idx = app.groups[gi].sessions[ci]; + if let Some(vi) = app.filtered_indices.iter().position(|&i| i == session_idx) { + app.selected = vi; + app.mode = Mode::Preview; + return; } } @@ -872,25 +863,25 @@ fn ui_grouped_browse(ui: &mut slt::Context, app: &mut App) { } // Enter/Space on header: toggle expand. Enter on child: open action menu. - if enter || space { - if let Some((gi, child)) = app.grouped_row_at(app.grouped_selected) { - match child { - None => { - let path = app.groups[gi].project_path.clone(); - if app.group_expanded.contains(&path) { - app.group_expanded.remove(&path); - } else { - app.group_expanded.insert(path); - } + if (enter || space) + && let Some((gi, child)) = app.grouped_row_at(app.grouped_selected) + { + match child { + None => { + let path = app.groups[gi].project_path.clone(); + if app.group_expanded.contains(&path) { + app.group_expanded.remove(&path); + } else { + app.group_expanded.insert(path); } - Some(ci) => { - let session_idx = app.groups[gi].sessions[ci]; - // Find this session in filtered_indices to set app.selected - if let Some(vi) = app.filtered_indices.iter().position(|&i| i == session_idx) { - app.selected = vi; - app.action_index = 0; - app.mode = Mode::ActionSelect; - } + } + Some(ci) => { + let session_idx = app.groups[gi].sessions[ci]; + // Find this session in filtered_indices to set app.selected + if let Some(vi) = app.filtered_indices.iter().position(|&i| i == session_idx) { + app.selected = vi; + app.action_index = 0; + app.mode = Mode::ActionSelect; } } } @@ -1314,11 +1305,11 @@ fn dispatch_action( app.mode = Mode::Browse; } _ => { - if let Some(session) = app.selected_session().cloned() { - if let Some(cmd) = action::generate_command(&session, selected_action, None) { - result.replace(cmd); - ui.quit(); - } + if let Some(session) = app.selected_session().cloned() + && let Some(cmd) = action::generate_command(&session, selected_action, None) + { + result.replace(cmd); + ui.quit(); } } } @@ -1462,11 +1453,11 @@ fn dispatch_agent_option(ui: &mut slt::Context, app: &mut App, result: &mut Opti if let Some(opt) = app.new_session_options.get(app.agent_index) { let agent = opt.agent; let suffix = opt.command_suffix; - if let Some(session) = app.selected_session().cloned() { - if let Some(cmd) = action::new_session_with_flags(&session, agent, suffix) { - result.replace(cmd); - ui.quit(); - } + if let Some(session) = app.selected_session().cloned() + && let Some(cmd) = action::new_session_with_flags(&session, agent, suffix) + { + result.replace(cmd); + ui.quit(); } } } @@ -1572,15 +1563,15 @@ fn ui_permission_select(ui: &mut slt::Context, app: &mut App, result: &mut Optio } fn dispatch_mode_option(ui: &mut slt::Context, app: &mut App, result: &mut Option) { - if let Some((_, flags)) = app.mode_options.get(app.mode_index) { - if let Some(opt) = app.new_session_options.get(app.agent_index) { - let agent = opt.agent; - if let Some(session) = app.selected_session().cloned() { - if let Some(cmd) = action::new_session_with_flags(&session, agent, flags) { - result.replace(cmd); - ui.quit(); - } - } + if let Some((_, flags)) = app.mode_options.get(app.mode_index) + && let Some(opt) = app.new_session_options.get(app.agent_index) + { + let agent = opt.agent; + if let Some(session) = app.selected_session().cloned() + && let Some(cmd) = action::new_session_with_flags(&session, agent, flags) + { + result.replace(cmd); + ui.quit(); } } } @@ -1682,12 +1673,12 @@ fn ui_resume_select(ui: &mut slt::Context, app: &mut App, result: &mut Option) { - if let Some((_, flags)) = app.resume_mode_options.get(app.resume_mode_index) { - if let Some(session) = app.selected_session().cloned() { - let cmd = action::resume_with_flags(&session, flags); - result.replace(cmd); - ui.quit(); - } + if let Some((_, flags)) = app.resume_mode_options.get(app.resume_mode_index) + && let Some(session) = app.selected_session().cloned() + { + let cmd = action::resume_with_flags(&session, flags); + result.replace(cmd); + ui.quit(); } } @@ -1717,10 +1708,10 @@ fn ui_bulk_delete(ui: &mut slt::Context, app: &mut App) { } if ui.key(' ') { - if let Some(idx) = app.filtered_indices.get(app.selected).copied() { - if !app.selected_set.remove(&idx) { - app.selected_set.insert(idx); - } + if let Some(idx) = app.filtered_indices.get(app.selected).copied() + && !app.selected_set.remove(&idx) + { + app.selected_set.insert(idx); } if !app.filtered_indices.is_empty() && app.selected < app.filtered_indices.len() - 1 { app.selected += 1; @@ -2513,19 +2504,19 @@ fn build_session_row( let left_used = indicator_width + chunk_width(&chunks); let available = total_width.saturating_sub(left_used + git_info_width + right_display_width); - if available > 7 { - if let Some(summary) = summary_text { - let sep = " "; - let max_summary = available.saturating_sub(sep.len()); - if max_summary > 5 { - let truncated = truncate_str(summary, max_summary); - chunks.push((sep.to_string(), slt::Style::new().bg(bg))); - if let Some(rest) = truncated.strip_prefix("recap: ") { - chunks.push(("recap: ".to_string(), slt::Style::new().fg(VIOLET).bg(bg))); - chunks.push((rest.to_string(), slt::Style::new().fg(GRAY_400).bg(bg))); - } else { - chunks.push((truncated, slt::Style::new().fg(GRAY_400).bg(bg))); - } + if available > 7 + && let Some(summary) = summary_text + { + let sep = " "; + let max_summary = available.saturating_sub(sep.len()); + if max_summary > 5 { + let truncated = truncate_str(summary, max_summary); + chunks.push((sep.to_string(), slt::Style::new().bg(bg))); + if let Some(rest) = truncated.strip_prefix("recap: ") { + chunks.push(("recap: ".to_string(), slt::Style::new().fg(VIOLET).bg(bg))); + chunks.push((rest.to_string(), slt::Style::new().fg(GRAY_400).bg(bg))); + } else { + chunks.push((truncated, slt::Style::new().fg(GRAY_400).bg(bg))); } } } diff --git a/src/watch.rs b/src/watch.rs index 1c2445e..745eec7 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -1,5 +1,5 @@ use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc}; +use std::sync::{Arc, mpsc}; use std::time::{Duration, Instant}; use crate::model::{Agent, Session}; From cf006043db6bc85e0c93502ba088958f8a439955 Mon Sep 17 00:00:00 2001 From: Subin An Date: Thu, 11 Jun 2026 10:40:47 +0900 Subject: [PATCH 3/7] ci: align gates with local checks, cache builds, harden release ci.yml: - clippy runs --all-targets so tests are linted like the local gate; it subsumes cargo check, so the separate check job is dropped - Swatinem/rust-cache@v2 on compiling jobs (bundled SQLite C build was recompiled from scratch every run) - workflow concurrency cancels superseded runs on the same ref - windows job now lints (#[cfg(windows)] paths were never clippy'd) - --locked everywhere cargo resolves, so CI tests the committed lockfile release.yml: - verify job fails fast when the tag does not match Cargo.toml version - checksums step fails loudly when an artifact is missing instead of silently skipping it - publish job skips gracefully when CARGO_REGISTRY_TOKEN is unset instead of painting every release red (the secret is not configured) - --locked on release builds and publish Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 29 +++++++++++++---------- .github/workflows/release.yml | 44 ++++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba17c41..2e8a56e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,15 +6,12 @@ on: pull_request: branches: [main] -jobs: - check: - name: Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: cargo check +# Rapid pushes to the same PR/branch cancel superseded runs. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true +jobs: clippy: name: Clippy runs-on: ubuntu-latest @@ -23,7 +20,10 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy - - run: cargo clippy -- -D warnings + - uses: Swatinem/rust-cache@v2 + # --all-targets matches the local gate (lints tests too); clippy + # subsumes cargo check, so there is no separate check job. + - run: cargo clippy --all-targets --locked -- -D warnings fmt: name: Format @@ -41,7 +41,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - run: cargo test + - uses: Swatinem/rust-cache@v2 + - run: cargo test --locked windows: name: Windows @@ -49,5 +50,9 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - run: cargo check - - run: cargo test + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + # Lint here too so #[cfg(windows)]-gated code is covered. + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ec06e8..74e7089 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,8 +9,23 @@ permissions: contents: write jobs: + verify: + name: Verify tag matches Cargo.toml + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check version + run: | + pkg_version=$(cargo pkgid | sed 's/.*[@#]//') + tag_version="${GITHUB_REF_NAME#v}" + if [ "$pkg_version" != "$tag_version" ]; then + echo "Cargo.toml version ($pkg_version) != tag ($tag_version)" >&2 + exit 1 + fi + build: name: Build ${{ matrix.target }} + needs: verify runs-on: ${{ matrix.os }} strategy: matrix: @@ -36,7 +51,7 @@ jobs: targets: ${{ matrix.target }} - name: Build - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --release --locked --target ${{ matrix.target }} - name: Package (Unix) if: runner.os != 'Windows' @@ -80,12 +95,15 @@ jobs: - name: Generate checksums run: | cd artifacts - for dir in */; do - cd "$dir" - for f in *.tar.gz *.zip; do - [ -f "$f" ] && sha256sum "$f" >> ../checksums.txt - done - cd .. + shopt -s nullglob + files=(*/*.tar.gz */*.zip) + # Keep in sync with the build matrix size. + if [ "${#files[@]}" -ne 4 ]; then + echo "expected 4 release archives, found ${#files[@]}: ${files[*]}" >&2 + exit 1 + fi + for f in "${files[@]}"; do + (cd "$(dirname "$f")" && sha256sum "$(basename "$f")") >> checksums.txt done - name: Create release @@ -104,4 +122,14 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + # cargo reads CARGO_REGISTRY_TOKEN from the env; when the secret is + # unset we skip gracefully instead of failing the whole release red. + - name: Publish (skips when CARGO_REGISTRY_TOKEN is unset) + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + if [ -z "$CARGO_REGISTRY_TOKEN" ]; then + echo "CARGO_REGISTRY_TOKEN not set — skipping crates.io publish." + exit 0 + fi + cargo publish --locked From ac195882fd11bb67cf6286759ddb0c4c3922c37a Mon Sep 17 00:00:00 2001 From: Subin An Date: Thu, 11 Jun 2026 10:47:23 +0900 Subject: [PATCH 4/7] refactor(scanner): dedupe helpers, let-else flattening, hot-loop allocs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(gemini): char-boundary-safe 1 KB window in extract_summary_partial — a CJK char straddling byte 1024 panicked and silently erased every Gemini session; + regression test - fix(cursor): hex_decode rejects non-ASCII input before byte-slicing so a corrupt store.db yields None instead of killing the scanner thread; + regression test - perf(claude): pre-filter history.jsonl entries against existing_ids so orphaned sessions never accumulate SessionData/summary tuples (mirrors codex v0.11.4 pre-filter) - perf(claude/codex): drop per-line String alloc in history.jsonl hot loops (borrowed trim instead of to_owned) - perf(mod): cap read_first_line at 512 KiB via Read::take so a pathological newline-free .jsonl is no longer slurped whole; + tests - new shared helpers in scanner/mod.rs, each unit-tested: collapse_whitespace (claude/gemini x3/hermes), project_name_from_path (codex x2/kiro/opencode/pi/cursor), push_concat_titles (opencode/hermes, also drops alloc-per-duplicate) - idiom(codex): 15 match-Some/None/Ok/Err early-exit sites flattened to let-else; find_state_db collect+sort+next replaced with Iterator::max - idiom(cursor): 10 match-continue sites flattened to let-else; encoded_dir now stays a &str (one less alloc per session file) - hermes::message_preview reduced to collapse_whitespace + existing char_prefix - pi tests: replaced edition-migration TODO comments on unsafe env access with HOME_LOCK serialization notes - edition-2024 let-chain flattening verified already applied by migration commit 934bd3b (kiro/gemini pyramids correctly excluded — intermediate bindings make them non-chainable) Co-Authored-By: Claude Fable 5 --- src/scanner/claude.rs | 16 +++-- src/scanner/codex.rs | 113 +++++++++++------------------ src/scanner/cursor_agent.rs | 137 ++++++++++++++++++------------------ src/scanner/gemini.rs | 47 +++++++++++-- src/scanner/hermes.rs | 22 +++--- src/scanner/kiro.rs | 8 +-- src/scanner/mod.rs | 103 ++++++++++++++++++++++++++- src/scanner/opencode.rs | 18 ++--- src/scanner/pi.rs | 14 ++-- 9 files changed, 287 insertions(+), 191 deletions(-) diff --git a/src/scanner/claude.rs b/src/scanner/claude.rs index c696733..d1afe26 100644 --- a/src/scanner/claude.rs +++ b/src/scanner/claude.rs @@ -9,7 +9,7 @@ use serde_json::Value; use crate::error::AgfError; use crate::model::{Agent, Session}; -use crate::scanner::read_head_tail; +use crate::scanner::{collapse_whitespace, read_head_tail}; /// Per-file I/O cap for `scan_session_metadata`. Files larger than the sum /// fall back to head + tail reads; smaller files are read in full. Sized so @@ -232,11 +232,11 @@ pub fn scan() -> Result, AgfError> { Ok(l) => l, Err(_) => continue, }; - let line = line.trim().to_owned(); + let line = line.trim(); if line.is_empty() { continue; } - let entry: ClaudeEntry = match serde_json::from_str(&line) { + let entry: ClaudeEntry = match serde_json::from_str(line) { Ok(e) => e, Err(_) => continue, }; @@ -244,6 +244,14 @@ pub fn scan() -> Result, AgfError> { Some(id) if !id.is_empty() => id.clone(), _ => continue, }; + if !existing_ids.contains(&session_id) { + // Orphans (no per-session JSONL under ~/.claude/projects/) are + // dropped by the final filter anyway; skip early so unbounded + // history.jsonl growth (#27) doesn't accumulate dead SessionData + // and summary tuples for the whole scan. Mirrors the + // codex::read_history_summaries pre-filter from v0.11.4. + continue; + } let ts = entry.timestamp.unwrap_or(0.0); let data = sessions_map @@ -264,7 +272,7 @@ pub fn scan() -> Result, AgfError> { if let Some(display) = entry.display { // Collapse multi-line content (e.g. pasted text) into a single line. - let display: String = display.split_whitespace().collect::>().join(" "); + let display = collapse_whitespace(&display); if !display.is_empty() { data.summaries.push((ts, display)); } diff --git a/src/scanner/codex.rs b/src/scanner/codex.rs index 501c5b9..8c9ff8f 100644 --- a/src/scanner/codex.rs +++ b/src/scanner/codex.rs @@ -7,7 +7,7 @@ use walkdir::WalkDir; use crate::error::AgfError; use crate::model::{Agent, Session}; -use crate::scanner::{first_line_truncated, read_first_line}; +use crate::scanner::{first_line_truncated, project_name_from_path, read_first_line}; pub fn scan() -> Result, AgfError> { let codex_dir = crate::config::codex_dir()?; @@ -64,20 +64,16 @@ fn collect_live_session_ids(codex_dir: &std::path::Path) -> Option e, - Err(_) => { - had_walk_error = true; - continue; - } + let Ok(entry) = entry else { + had_walk_error = true; + continue; }; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } - let first_line = match read_first_line(path) { - Some(line) => line, - None => continue, + let Some(first_line) = read_first_line(path) else { + continue; }; if let Ok(value) = serde_json::from_str::(first_line.trim()) && let Some(id) = value @@ -105,9 +101,8 @@ fn prune_orphan_threads(codex_dir: &std::path::Path, orphan_ids: &[String]) { if orphan_ids.is_empty() { return; } - let entries = match std::fs::read_dir(codex_dir) { - Ok(e) => e, - Err(_) => return, + let Ok(entries) = std::fs::read_dir(codex_dir) else { + return; }; for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); @@ -119,13 +114,11 @@ fn prune_orphan_threads(codex_dir: &std::path::Path, orphan_ids: &[String]) { if !is_state_db { continue; } - let conn = match Connection::open(&path) { - Ok(c) => c, - Err(_) => continue, + let Ok(conn) = Connection::open(&path) else { + continue; }; - let tx = match conn.unchecked_transaction() { - Ok(t) => t, - Err(_) => continue, + let Ok(tx) = conn.unchecked_transaction() else { + continue; }; for id in orphan_ids { let _ = tx.execute("DELETE FROM threads WHERE id = ?1", [id]); @@ -150,28 +143,26 @@ fn scan_sqlite( live_session_ids: Option<&HashSet>, ) -> Vec { // Find the latest state_*.sqlite file - let db_path = match find_state_db(codex_dir) { - Some(p) => p, - None => return Vec::new(), + let Some(db_path) = find_state_db(codex_dir) else { + return Vec::new(); }; - let conn = - match Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; + let Ok(conn) = + Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + else { + return Vec::new(); + }; - let mut stmt = match conn.prepare( + let Ok(mut stmt) = conn.prepare( "SELECT id, cwd, title, updated_at, git_branch, first_user_message FROM threads WHERE archived = 0 AND cwd != '' ORDER BY updated_at DESC", - ) { - Ok(s) => s, - Err(_) => return Vec::new(), + ) else { + return Vec::new(); }; - let rows = match stmt.query_map([], |row| { + let Ok(rows) = stmt.query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -180,9 +171,8 @@ fn scan_sqlite( row.get::<_, Option>(4)?, row.get::<_, String>(5).unwrap_or_default(), )) - }) { - Ok(r) => r, - Err(_) => return Vec::new(), + }) else { + return Vec::new(); }; let mut sessions = Vec::new(); @@ -204,11 +194,7 @@ fn scan_sqlite( continue; } - let project_name = std::path::Path::new(&cwd) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); + let project_name = project_name_from_path(&cwd); // updated_at is Unix seconds — convert to millis let timestamp = updated_at * 1000; @@ -253,7 +239,7 @@ fn scan_sqlite( /// Find the latest state_*.sqlite file in the codex directory. fn find_state_db(codex_dir: &std::path::Path) -> Option { let entries = std::fs::read_dir(codex_dir).ok()?; - let mut candidates: Vec = entries + entries .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| { @@ -262,11 +248,8 @@ fn find_state_db(codex_dir: &std::path::Path) -> Option { .map(|n| n.starts_with("state_") && n.ends_with(".sqlite")) .unwrap_or(false) }) - .collect(); - - // Sort descending by name so state_5 > state_4 etc. - candidates.sort_by(|a, b| b.cmp(a)); - candidates.into_iter().next() + // Lexicographic max picks the latest db (state_5 > state_4 etc.). + .max() } /// Fallback: scan JSONL session files via walkdir (legacy format). @@ -312,23 +295,20 @@ fn scan_jsonl( continue; } - let first_line = match read_first_line(path) { - Some(line) => line, - None => continue, + let Some(first_line) = read_first_line(path) else { + continue; }; - let meta: SessionMeta = match serde_json::from_str(first_line.trim()) { - Ok(m) => m, - Err(_) => continue, + let Ok(meta) = serde_json::from_str::(first_line.trim()) else { + continue; }; if meta.entry_type.as_deref() != Some("session_meta") { continue; } - let payload = match meta.payload { - Some(p) => p, - None => continue, + let Some(payload) = meta.payload else { + continue; }; let session_id = match payload.id { @@ -341,11 +321,7 @@ fn scan_jsonl( _ => continue, }; - let project_name = std::path::Path::new(&cwd) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); + let project_name = project_name_from_path(&cwd); let timestamp = payload .timestamp @@ -397,23 +373,20 @@ fn read_history_summaries( let path = codex_dir.join("history.jsonl"); let mut summaries: HashMap> = HashMap::new(); - let file = match File::open(&path) { - Ok(f) => f, - Err(_) => return HashMap::new(), + let Ok(file) = File::open(&path) else { + return HashMap::new(); }; for line in BufReader::new(file).lines() { - let line = match line { - Ok(l) => l, - Err(_) => continue, + let Ok(line) = line else { + continue; }; - let line = line.trim().to_owned(); + let line = line.trim(); if line.is_empty() { continue; } - let entry: HistoryEntry = match serde_json::from_str(&line) { - Ok(e) => e, - Err(_) => continue, + let Ok(entry) = serde_json::from_str::(line) else { + continue; }; let session_id = match entry.session_id { Some(id) if !id.is_empty() => id, diff --git a/src/scanner/cursor_agent.rs b/src/scanner/cursor_agent.rs index 8299b67..abdf322 100644 --- a/src/scanner/cursor_agent.rs +++ b/src/scanner/cursor_agent.rs @@ -6,7 +6,7 @@ use walkdir::WalkDir; use crate::error::AgfError; use crate::model::{Agent, Session}; -use super::truncate; +use super::{project_name_from_path, truncate}; /// Max chars stored per session summary. const SUMMARY_MAX_CHARS: usize = 100; @@ -55,62 +55,54 @@ fn scan_from(cursor_dir: &Path) -> Result, AgfError> { let ext = path.extension().and_then(|e| e.to_str()); // Resolve (agent_transcripts_dir, session_id) for each format. - let (agent_transcripts_dir, session_id) = - match ext { - Some("txt") => { - // Legacy: parent must be "agent-transcripts" - let parent = match path.parent().filter(|p| { - p.file_name().and_then(|n| n.to_str()) == Some("agent-transcripts") - }) { - Some(p) => p, - None => continue, - }; - let id = match path.file_stem().and_then(|n| n.to_str()) { - Some(id) => id.to_string(), - None => continue, - }; - (parent, id) - } - Some("jsonl") => { - // Current layout: agent-transcripts//.jsonl - // The parent dir name must equal the file stem (both are the - // same session UUID). Without this invariant a stray jsonl - // would produce a session_id that mismatches both the - // store.db lookup key and what `cursor-agent --resume` expects. - let parent = match path.parent() { - Some(p) => p, - None => continue, - }; - let parent_name = match parent.file_name().and_then(|n| n.to_str()) { - Some(n) => n, - None => continue, - }; - let id = match path.file_stem().and_then(|n| n.to_str()) { - Some(id) => id, - None => continue, - }; - if parent_name != id { - continue; - } - let grandparent = match parent.parent().filter(|p| { - p.file_name().and_then(|n| n.to_str()) == Some("agent-transcripts") - }) { - Some(p) => p, - None => continue, - }; - (grandparent, id.to_string()) + let (agent_transcripts_dir, session_id) = match ext { + Some("txt") => { + // Legacy: parent must be "agent-transcripts" + let Some(parent) = path.parent().filter(|p| { + p.file_name().and_then(|n| n.to_str()) == Some("agent-transcripts") + }) else { + continue; + }; + let Some(id) = path.file_stem().and_then(|n| n.to_str()) else { + continue; + }; + (parent, id.to_string()) + } + Some("jsonl") => { + // Current layout: agent-transcripts//.jsonl + // The parent dir name must equal the file stem (both are the + // same session UUID). Without this invariant a stray jsonl + // would produce a session_id that mismatches both the + // store.db lookup key and what `cursor-agent --resume` expects. + let Some(parent) = path.parent() else { + continue; + }; + let Some(parent_name) = parent.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some(id) = path.file_stem().and_then(|n| n.to_str()) else { + continue; + }; + if parent_name != id { + continue; } - _ => continue, - }; + let Some(grandparent) = parent.parent().filter(|p| { + p.file_name().and_then(|n| n.to_str()) == Some("agent-transcripts") + }) else { + continue; + }; + (grandparent, id.to_string()) + } + _ => continue, + }; // Parent of agent-transcripts is the dash-encoded project path - let encoded_dir = match agent_transcripts_dir + let Some(encoded_dir) = agent_transcripts_dir .parent() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) - { - Some(name) => name.to_string(), - None => continue, + else { + continue; }; // Skip macOS temp directories @@ -118,16 +110,11 @@ fn scan_from(cursor_dir: &Path) -> Result, AgfError> { continue; } - let project_path = match decode_dash_path(&encoded_dir) { - Some(p) => p, - None => continue, + let Some(project_path) = decode_dash_path(encoded_dir) else { + continue; }; - let project_name = project_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); + let project_name = project_name_from_path(&project_path); let project_path_str = project_path.to_string_lossy().to_string(); @@ -239,7 +226,11 @@ fn read_store_db(store_path: &Path) -> Option { /// Decode a hex-encoded string to bytes. fn hex_decode(hex: &str) -> Option> { - if !hex.len().is_multiple_of(2) { + // Non-ASCII input would make `&hex[i..i + 2]` panic mid-codepoint; a + // corrupt store.db must yield None, not kill the scanner thread (a panic + // here would silently erase every Cursor session via scan_all's + // join-swallow). + if !hex.len().is_multiple_of(2) || !hex.is_ascii() { return None; } (0..hex.len()) @@ -285,22 +276,20 @@ fn extract_first_prompt(jsonl_path: &Path) -> Option { if value.get("role").and_then(|v| v.as_str()) != Some("user") { continue; } - let parts = match value + let Some(parts) = value .get("message") .and_then(|m| m.get("content")) .and_then(|c| c.as_array()) - { - Some(p) => p, - None => continue, + else { + continue; }; for part in parts { if part.get("type").and_then(|t| t.as_str()) != Some("text") { continue; } - let text = match part.get("text").and_then(|t| t.as_str()) { - Some(t) => t, - None => continue, + let Some(text) = part.get("text").and_then(|t| t.as_str()) else { + continue; }; if text.trim_start().starts_with("") { continue; @@ -562,6 +551,20 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + /// Regression: `&hex[i..i + 2]` slices at byte offsets, so a multibyte + /// char spanning an even offset (e.g. '€' at 0, or a 2-byte char at an + /// odd offset) panicked on a char boundary before `from_str_radix` + /// could reject it. Corrupt store.db content must yield None — a panic + /// in this thread silently erases every Cursor session. + #[test] + fn hex_decode_returns_none_for_non_ascii_input() { + assert_eq!(hex_decode("\u{20AC}a"), None); // 3-byte char spans even offset + assert_eq!(hex_decode("a\u{0100}b"), None); // 2-byte char at odd offset + assert_eq!(hex_decode("\u{0100}ab"), None); // aligned non-ASCII: None before and after + assert_eq!(hex_decode("abc"), None); // odd length still rejected + assert_eq!(hex_decode("48656c6c6f"), Some(b"Hello".to_vec())); + } + #[test] fn decode_dash_path_returns_none_for_nonexistent() { assert_eq!( diff --git a/src/scanner/gemini.rs b/src/scanner/gemini.rs index 287cb16..323b01c 100644 --- a/src/scanner/gemini.rs +++ b/src/scanner/gemini.rs @@ -8,7 +8,7 @@ use sha2::{Digest, Sha256}; use crate::error::AgfError; use crate::model::{Agent, Session}; -use super::truncate; +use super::{collapse_whitespace, truncate}; /// Maximum bytes to read from a single session file. /// Gemini session files can balloon to 28 MB+ when tool calls embed full file @@ -202,14 +202,14 @@ fn extract_summary(json: &serde_json::Value) -> Option { if let Some(arr) = msg.get("content").and_then(|v| v.as_array()) { for part in arr { if let Some(text) = part.get("text").and_then(|v| v.as_str()) { - let normalized = text.split_whitespace().collect::>().join(" "); + let normalized = collapse_whitespace(text); if !normalized.is_empty() { return Some(truncate(&normalized, 100)); } } } } else if let Some(text) = msg.get("content").and_then(|v| v.as_str()) { - let normalized = text.split_whitespace().collect::>().join(" "); + let normalized = collapse_whitespace(text); if !normalized.is_empty() { return Some(truncate(&normalized, 100)); } @@ -226,10 +226,15 @@ fn extract_summary_partial(s: &str) -> Option { let user_pos = s.find("\"type\":\"user\"")?; let after = &s[user_pos..]; - // Look for "text":"..." within the next 1 KB - let window = &after[..after.len().min(1024)]; + // Look for "text":"..." within the next 1 KB (char-boundary safe: + // byte 1024 may fall inside a multi-byte codepoint). + let mut end = after.len().min(1024); + while !after.is_char_boundary(end) { + end -= 1; + } + let window = &after[..end]; let text = extract_str_field(window, "text")?; - let normalized = text.split_whitespace().collect::>().join(" "); + let normalized = collapse_whitespace(&text); if normalized.is_empty() { return None; } @@ -274,3 +279,33 @@ fn sha256_hex(data: &[u8]) -> String { hasher.update(data); format!("{:x}", hasher.finalize()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression: the 1 KB lookahead window used to slice at a raw byte + /// offset (`&after[..after.len().min(1024)]`), which panics when byte + /// 1024 past the `"type":"user"` marker falls inside a multi-byte + /// codepoint (CJK prompts make this common). A panic here kills the + /// whole Gemini scanner thread via scan_all's join-swallow, silently + /// erasing every Gemini session. + #[test] + fn extract_summary_partial_survives_char_boundary_at_window_edge() { + let mut s = + String::from(r#"{"messages":[{"type":"user","content":[{"text":"hello world"}]},"#); + let marker = s.find("\"type\":\"user\"").unwrap(); + // Pad so the next char pushed starts at byte offset 1023 from the + // marker, making offset 1024 land mid-codepoint in the 3-byte '한'. + while s.len() < marker + 1023 { + s.push('a'); + } + s.push_str("한한한한"); + assert!( + !s[marker..].is_char_boundary(1024), + "fixture must place byte 1024 inside a multi-byte char" + ); + + assert_eq!(extract_summary_partial(&s).as_deref(), Some("hello world")); + } +} diff --git a/src/scanner/hermes.rs b/src/scanner/hermes.rs index fcd3e79..dbf86d9 100644 --- a/src/scanner/hermes.rs +++ b/src/scanner/hermes.rs @@ -3,6 +3,8 @@ use rusqlite::Connection; use crate::error::AgfError; use crate::model::{Agent, Session}; +use super::{char_prefix, collapse_whitespace, push_concat_titles}; + /// Trim a chunk of message text down to a single-line preview that fits in /// a TUI summary row. Collapses whitespace, drops common wrapper tags, and /// caps to ~160 chars. @@ -10,18 +12,16 @@ fn message_preview(raw: &str) -> Option { let stripped = raw .replace("", " ") .replace("", " "); - let collapsed: String = stripped.split_whitespace().collect::>().join(" "); + let collapsed = collapse_whitespace(&stripped); if collapsed.is_empty() { return None; } let max_chars = 160; - let truncated: String = if collapsed.chars().count() > max_chars { - let head: String = collapsed.chars().take(max_chars).collect(); - format!("{head}…") + if collapsed.chars().count() > max_chars { + Some(format!("{}…", char_prefix(&collapsed, max_chars))) } else { - collapsed - }; - Some(truncated) + Some(collapsed) + } } /// True iff the id looks like a session a user actually started from the @@ -143,13 +143,7 @@ pub fn scan() -> Result, AgfError> { summaries.push(t.clone()); } if let Some(ref children) = child_titles { - let mut seen = std::collections::HashSet::new(); - for t in children.split("|||") { - let t = t.trim(); - if !t.is_empty() && seen.insert(t.to_string()) { - summaries.push(t.to_string()); - } - } + push_concat_titles(&mut summaries, children); } // Only surface user-message previews for ids that look like // CLI/TUI sessions. dashboard:*/api-*/named ids get messages diff --git a/src/scanner/kiro.rs b/src/scanner/kiro.rs index 8fc62ec..73693de 100644 --- a/src/scanner/kiro.rs +++ b/src/scanner/kiro.rs @@ -3,7 +3,7 @@ use rusqlite::Connection; use crate::error::AgfError; use crate::model::{Agent, Session}; -use super::truncate; +use super::{project_name_from_path, truncate}; pub fn scan() -> Result, AgfError> { let db_path = crate::config::kiro_data_dir()?.join("data.sqlite3"); @@ -39,11 +39,7 @@ pub fn scan() -> Result, AgfError> { })? .filter_map(|r| r.ok()) .map(|(directory, conversation_id, value, updated_at)| { - let project_name = std::path::Path::new(&directory) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); + let project_name = project_name_from_path(&directory); let summaries = match extract_summary(&value) { Some(s) => vec![s], diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 75d034b..afd65a5 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -24,9 +24,13 @@ pub(crate) fn truncate(s: &str, max: usize) -> String { /// Read only the first non-empty line of a file without loading the rest. pub(crate) fn read_first_line(path: &std::path::Path) -> Option { use std::fs::File; - use std::io::{BufRead, BufReader}; + use std::io::{BufRead, BufReader, Read}; + /// Defensive cap matching the 512 KiB budgets used by cursor/pi scanners; + /// a real first line is ~1 KB, and a truncated line just fails JSON + /// parse upstream and is skipped. + const MAX_FIRST_LINE_BYTES: u64 = 512 * 1024; let file = File::open(path).ok()?; - let mut reader = BufReader::new(file); + let mut reader = BufReader::new(file).take(MAX_FIRST_LINE_BYTES); let mut line = String::new(); loop { line.clear(); @@ -45,6 +49,20 @@ pub(crate) fn char_prefix(s: &str, max: usize) -> String { s.chars().take(max).collect() } +/// Collapse all whitespace runs (incl. newlines/tabs) into single spaces. +/// Equivalent to `s.split_whitespace().collect::>().join(" ")` without +/// the intermediate Vec. +pub(crate) fn collapse_whitespace(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for w in s.split_whitespace() { + if !out.is_empty() { + out.push(' '); + } + out.push_str(w); + } + out +} + /// Extract first non-empty line, truncated to `max_len` chars with '…' suffix. pub(crate) fn first_line_truncated(s: &str, max_len: usize) -> Option { let line = s.lines().next().unwrap_or("").trim(); @@ -58,6 +76,30 @@ pub(crate) fn first_line_truncated(s: &str, max_len: usize) -> Option { } } +/// Last path component as the display project name ("unknown" when absent +/// or non-UTF-8). +pub(crate) fn project_name_from_path(path: impl AsRef) -> String { + path.as_ref() + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() +} + +/// Split a GROUP_CONCAT('|||') blob into trimmed, non-empty titles, +/// deduplicated within the blob only, appended to `out`. Entries already in +/// `out` are intentionally NOT considered: a child title equal to the +/// already-pushed parent title is re-pushed (preserves existing behavior). +pub(crate) fn push_concat_titles(out: &mut Vec, blob: &str) { + let mut seen = std::collections::HashSet::new(); + for t in blob.split("|||") { + let t = t.trim(); + if !t.is_empty() && seen.insert(t) { + out.push(t.to_string()); + } + } +} + /// Read up to `head_bytes` from the start and `tail_bytes` from the end of /// `path`, returning UTF-8-safe complete lines (no partial lines on the slice /// boundary). For files ≤ `head_bytes + tail_bytes`, the whole file is read. @@ -171,6 +213,63 @@ mod tests { path } + #[test] + fn read_first_line_returns_first_non_empty_line() { + let path = write_tmp("agf-test-first-line.jsonl", b"\n\n{\"a\":1}\n{\"b\":2}\n"); + assert_eq!(read_first_line(&path).unwrap(), "{\"a\":1}\n"); + } + + #[test] + fn read_first_line_caps_newline_free_files() { + // A pathological newline-free file must not be slurped whole: the + // `take` cap returns at most 512 KiB, which then fails JSON parse + // upstream and is skipped — the defensive outcome. + let content = vec![b'x'; 600 * 1024]; + let path = write_tmp("agf-test-first-line-cap.jsonl", &content); + let line = read_first_line(&path).expect("truncated line should be returned"); + assert_eq!(line.len(), 512 * 1024); + } + + #[test] + fn collapse_whitespace_matches_split_join_semantics() { + assert_eq!(collapse_whitespace(" hello\n\tworld "), "hello world"); + assert_eq!(collapse_whitespace(""), ""); + assert_eq!(collapse_whitespace(" \n\t "), ""); + assert_eq!(collapse_whitespace("one"), "one"); + // Unicode whitespace (ideographic space) collapses like split_whitespace. + assert_eq!(collapse_whitespace("a\u{3000}b"), "a b"); + } + + #[test] + fn project_name_from_path_takes_last_component() { + assert_eq!( + project_name_from_path("/home/user/my-project"), + "my-project" + ); + assert_eq!(project_name_from_path("relative/dir"), "dir"); + // No final component → "unknown". + assert_eq!(project_name_from_path("/"), "unknown"); + assert_eq!(project_name_from_path(""), "unknown"); + // Also accepts owned PathBuf (the cursor_agent call site). + assert_eq!( + project_name_from_path(std::path::PathBuf::from("/tmp/proj")), + "proj" + ); + } + + #[test] + fn push_concat_titles_dedups_within_blob_only() { + let mut out = vec!["parent".to_string()]; + push_concat_titles(&mut out, " a ||| b |||a||| ||| c "); + // "a" deduped within the blob; existing "parent" is not considered. + assert_eq!(out, vec!["parent", "a", "b", "c"]); + + let mut out2 = vec!["dup".to_string()]; + push_concat_titles(&mut out2, "dup"); + // Blob-only dedup scope: a title equal to the parent is re-pushed. + assert_eq!(out2, vec!["dup", "dup"]); + } + #[test] fn read_head_tail_small_file_returns_full_content() { let path = write_tmp("agf-test-small.jsonl", b"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n"); diff --git a/src/scanner/opencode.rs b/src/scanner/opencode.rs index e0d4cd8..c10163c 100644 --- a/src/scanner/opencode.rs +++ b/src/scanner/opencode.rs @@ -3,6 +3,8 @@ use rusqlite::Connection; use crate::error::AgfError; use crate::model::{Agent, Session}; +use super::{project_name_from_path, push_concat_titles}; + pub fn scan() -> Result, AgfError> { let db_path = crate::config::opencode_data_dir()?.join("opencode.db"); @@ -39,25 +41,15 @@ pub fn scan() -> Result, AgfError> { })? .filter_map(|r| r.ok()) .map(|(id, title, directory, time_updated, sub_titles)| { - let project_name = std::path::Path::new(&directory) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); + let project_name = project_name_from_path(&directory); // Parent title first, then deduplicated subagent titles. let mut summaries: Vec = Vec::new(); if !title.is_empty() { summaries.push(title); } - if let Some(sub) = sub_titles { - let mut seen = std::collections::HashSet::new(); - for t in sub.split("|||") { - let t = t.trim(); - if !t.is_empty() && seen.insert(t.to_string()) { - summaries.push(t.to_string()); - } - } + if let Some(ref blob) = sub_titles { + push_concat_titles(&mut summaries, blob); } Session { diff --git a/src/scanner/pi.rs b/src/scanner/pi.rs index d58a48a..87a07ba 100644 --- a/src/scanner/pi.rs +++ b/src/scanner/pi.rs @@ -6,7 +6,7 @@ use walkdir::WalkDir; use crate::error::AgfError; use crate::model::{Agent, Session}; -use crate::scanner::first_line_truncated; +use crate::scanner::{first_line_truncated, project_name_from_path}; const SUMMARY_MAX_CHARS: usize = 120; @@ -118,11 +118,7 @@ fn parse_session(path: &std::path::Path) -> Option { .unwrap_or(0) }); - let project_name = std::path::Path::new(&cwd) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); + let project_name = project_name_from_path(&cwd); Some(Session { agent: Agent::Pi, @@ -304,16 +300,16 @@ mod tests { ) .unwrap(); } - // TODO: Audit that the environment access only happens in single-threaded code. + // Serialized by the HOME_LOCK guard above. unsafe { std::env::set_var("HOME", &home) }; let sessions = scan().unwrap(); if let Some(old_home) = old_home { - // TODO: Audit that the environment access only happens in single-threaded code. + // Serialized by the HOME_LOCK guard above. unsafe { std::env::set_var("HOME", old_home) }; } else { - // TODO: Audit that the environment access only happens in single-threaded code. + // Serialized by the HOME_LOCK guard above. unsafe { std::env::remove_var("HOME") }; } From 8fdc225fa0f41f0e224773e0f27e06c0aaae1189 Mon Sep 17 00:00:00 2001 From: Subin An Date: Thu, 11 Jun 2026 10:46:23 +0900 Subject: [PATCH 5/7] refactor(tui): per-frame alloc cuts, honest delete errors, idiom cleanup - render_chunks consumes its Vec, dropping a String clone per chunk per visible row per frame - update_filter moves match positions out of fuzzy results instead of cloning each Vec per keystroke - apply_sort borrows pinned_sessions and uses stable sort_by_key(!contains) instead of clone + 0/1 rank closure - detect_editor caches the config/env editor lookup in a process-wide OnceLock (was disk read + TOML parse per frame while the action menu is open) - delete confirm (single + bulk) only removes rows and decrements agent counts when delete_session returns Ok, so failed deletes stay visible - watch: clamp selected when a background refresh shrinks the session list (stale cursor blanked the viewport) - merge_agent_sessions: delete dead selected_id snapshot + discarded position scan (apply_sort already restores the cursor) - new_session_with_flags returns String (the Option was never None); both dispatchers unwrapped - dispatch_agent_option / dispatch_mode_option flattened to single let-chains - App::new: drop redundant installed_agents() clone - cycle_summary: let-else + entry API (one hash lookup instead of two), wrap-around semantics preserved - map(..).unwrap_or(..) chains -> is_some_and / is_ok_and / is_none_or / map_or at the audited tui/watch sites Co-Authored-By: Claude Fable 5 --- src/action.rs | 41 +++++++++-------- src/tui/mod.rs | 120 ++++++++++++++++++++----------------------------- src/watch.rs | 8 +++- 3 files changed, 76 insertions(+), 93 deletions(-) diff --git a/src/action.rs b/src/action.rs index 321d482..6cc02bd 100644 --- a/src/action.rs +++ b/src/action.rs @@ -41,24 +41,27 @@ pub fn action_preview(session: &Session, action: Action) -> String { } /// Detect editor from config, then $EDITOR, then $VISUAL, fallback to "vim". +/// +/// Resolved once per process and cached: `save_editable()` never writes the +/// `editor` key, so the value cannot change observably mid-run, and this is +/// called from the action-menu render path every frame (same rationale as +/// `CommandShell::from_env` in shell.rs). pub fn detect_editor() -> String { - let config = crate::settings::Settings::load(); - if let Some(ref editor) = config.editor - && !editor.is_empty() - { - return editor.clone(); - } - if let Ok(editor) = std::env::var("EDITOR") - && !editor.is_empty() - { - return editor; - } - if let Ok(editor) = std::env::var("VISUAL") - && !editor.is_empty() - { - return editor; - } - "vim".to_string() + use std::sync::OnceLock; + static EDITOR: OnceLock = OnceLock::new(); + EDITOR + .get_or_init(|| { + let config = crate::settings::Settings::load(); + if let Some(editor) = config.editor.filter(|e| !e.is_empty()) { + return editor; + } + std::env::var("EDITOR") + .ok() + .filter(|e| !e.is_empty()) + .or_else(|| std::env::var("VISUAL").ok().filter(|e| !e.is_empty())) + .unwrap_or_else(|| "vim".to_string()) + }) + .clone() } pub fn resume_with_flags(session: &Session, flags: &str) -> String { @@ -68,9 +71,9 @@ pub fn resume_with_flags(session: &Session, flags: &str) -> String { shell.cd_and("ed_path, &format!("{base_cmd}{flags}")) } -pub fn new_session_with_flags(session: &Session, agent: Agent, flags: &str) -> Option { +pub fn new_session_with_flags(session: &Session, agent: Agent, flags: &str) -> String { let shell = CommandShell::from_env(); let quoted_path = shell.quote(&session.project_path); let base = agent.new_session_cmd(); - Some(shell.cd_and("ed_path, &format!("{base}{flags}"))) + shell.cd_and("ed_path, &format!("{base}{flags}")) } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d282e9a..277365c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -114,13 +114,11 @@ impl App { scan_rx: Option>, scanning_agents: HashSet, ) -> Self { - let agents = installed_agents(); - let mut agent_counts: HashMap = HashMap::new(); for s in &sessions { *agent_counts.entry(s.agent).or_insert(0) += 1; } - let mut sorted_agents = agents.clone(); + let mut sorted_agents = installed_agents(); sorted_agents.sort_by(|a, b| { agent_counts .get(b) @@ -222,11 +220,9 @@ impl App { // which made the listing look "out of time order" without any // visible cause — `agf` has no UI hint that cwd boost is active. // Pinning is still honored because it's an explicit user action. - let pinned = self.pinned_sessions.clone(); - self.sessions.sort_by(|a, b| { - let rank = |s: &Session| -> u8 { if pinned.contains(&s.session_id) { 0 } else { 1 } }; - rank(a).cmp(&rank(b)) - }); + let pinned = &self.pinned_sessions; + self.sessions + .sort_by_key(|s| !pinned.contains(&s.session_id)); // Sessions reordered → cached column width no longer valid. self.name_col_width_cache = None; @@ -250,10 +246,7 @@ impl App { .sessions .iter() .enumerate() - .filter(|(_, s)| match self.agent_filter { - Some(agent) => s.agent == agent, - None => true, - }) + .filter(|(_, s)| self.agent_filter.is_none_or(|agent| s.agent == agent)) .map(|(i, _)| i) .collect(); @@ -272,7 +265,7 @@ impl App { ); self.filtered_indices = results.iter().map(|r| agent_filtered[r.index]).collect(); - self.match_positions = results.iter().map(|r| r.positions.clone()).collect(); + self.match_positions = results.into_iter().map(|r| r.positions).collect(); } if self.filtered_indices.is_empty() { @@ -301,24 +294,22 @@ impl App { } pub fn cycle_summary(&mut self, forward: bool) { - let session = match self.selected_session() { - Some(s) => s, - None => return, + let Some(session) = self.selected_session() else { + return; }; let count = session.summaries.len(); if count <= 1 { return; } let id = session.session_id.clone(); - let offset = self.summary_offsets.get(&id).copied().unwrap_or(0); - let new_offset = if forward { - (offset + 1) % count - } else if offset == 0 { + let offset = self.summary_offsets.entry(id).or_insert(0); + *offset = if forward { + (*offset + 1) % count + } else if *offset == 0 { count - 1 } else { - offset - 1 + *offset - 1 }; - self.summary_offsets.insert(id, new_offset); } pub fn save_settings(&self) { @@ -382,13 +373,11 @@ impl App { let a_ts = a .sessions .first() - .map(|&i| self.sessions[i].timestamp) - .unwrap_or(0); + .map_or(0, |&i| self.sessions[i].timestamp); let b_ts = b .sessions .first() - .map(|&i| self.sessions[i].timestamp) - .unwrap_or(0); + .map_or(0, |&i| self.sessions[i].timestamp); b_ts.cmp(&a_ts) }); } @@ -506,8 +495,6 @@ impl App { /// Replace all sessions for `agent` with `new_sessions`. Caller is /// responsible for re-sorting / re-filtering. fn merge_agent_sessions(&mut self, agent: Agent, new_sessions: Vec) { - // Drop preserved selection across the swap by remembering the id. - let selected_id = self.selected_session().map(|s| s.session_id.clone()); self.sessions.retain(|s| s.agent != agent); // agent_counts: subtract old, add new. self.agent_counts.remove(&agent); @@ -522,15 +509,6 @@ impl App { .sort_by_key(|s| std::cmp::Reverse(s.timestamp)); self.sessions.truncate(max); } - // Restore selection if the same id is still present after the swap; - // apply_sort() (run by the caller) will re-derive the index. - if let Some(id) = selected_id - && let Some(pos) = self.sessions.iter().position(|s| s.session_id == id) - { - // Stash via a side channel: filtered_indices is stale here, so - // we just record the id; apply_sort() restores cursor. - let _ = pos; - } } pub fn run(&mut self) -> anyhow::Result> { @@ -1012,7 +990,7 @@ fn ui_grouped_browse(ui: &mut slt::Context, app: &mut App) { // Calculate available space for summary let fixed_width = 5 + 1 + 12 + 2 + 16; // tree + pin + agent + gap + time - let git_width = s.git_branch.as_ref().map(|b| b.len() + 2).unwrap_or(0); + let git_width = s.git_branch.as_ref().map_or(0, |b| b.len() + 2); let summary_max = total_width.saturating_sub(fixed_width + git_width + 2); @@ -1205,8 +1183,7 @@ fn ui_action_select(ui: &mut slt::Context, app: &mut App, result: &mut Option Vec<(&'static str, &'static str)> { } fn dispatch_agent_option(ui: &mut slt::Context, app: &mut App, result: &mut Option) { - if let Some(opt) = app.new_session_options.get(app.agent_index) { - let agent = opt.agent; - let suffix = opt.command_suffix; - if let Some(session) = app.selected_session().cloned() - && let Some(cmd) = action::new_session_with_flags(&session, agent, suffix) - { - result.replace(cmd); - ui.quit(); - } + if let Some(opt) = app.new_session_options.get(app.agent_index) + && let Some(session) = app.selected_session().cloned() + { + let cmd = action::new_session_with_flags(&session, opt.agent, opt.command_suffix); + result.replace(cmd); + ui.quit(); } } @@ -1505,8 +1479,7 @@ fn ui_permission_select(ui: &mut slt::Context, app: &mut App, result: &mut Optio let agent_label = app .new_session_options .get(app.agent_index) - .map(|o| o.label.as_str()) - .unwrap_or("agent"); + .map_or("agent", |o| o.label.as_str()); let _ = ui.col(|ui| { ui.separator_colored(SEPARATOR); @@ -1565,14 +1538,11 @@ fn ui_permission_select(ui: &mut slt::Context, app: &mut App, result: &mut Optio fn dispatch_mode_option(ui: &mut slt::Context, app: &mut App, result: &mut Option) { if let Some((_, flags)) = app.mode_options.get(app.mode_index) && let Some(opt) = app.new_session_options.get(app.agent_index) + && let Some(session) = app.selected_session().cloned() { - let agent = opt.agent; - if let Some(session) = app.selected_session().cloned() - && let Some(cmd) = action::new_session_with_flags(&session, agent, flags) - { - result.replace(cmd); - ui.quit(); - } + let cmd = action::new_session_with_flags(&session, opt.agent, flags); + result.replace(cmd); + ui.quit(); } } @@ -1789,9 +1759,12 @@ fn ui_delete_confirm(ui: &mut slt::Context, app: &mut App) { let mut indices: Vec = app.selected_set.drain().collect(); indices.sort_unstable_by(|a, b| b.cmp(a)); for idx in indices { - if idx < app.sessions.len() { + // Only drop the row from the UI when the on-disk delete + // actually succeeded; failed deletes stay visible. + if idx < app.sessions.len() + && crate::delete::delete_session(&app.sessions[idx]).is_ok() + { let agent = app.sessions[idx].agent; - let _ = crate::delete::delete_session(&app.sessions[idx]); app.sessions.remove(idx); decrement_agent_count(&mut app.agent_counts, agent); } @@ -1799,10 +1772,13 @@ fn ui_delete_confirm(ui: &mut slt::Context, app: &mut App) { app.selected_set.clear(); app.update_filter(); } else if let Some(idx) = app.filtered_indices.get(app.selected).copied() { - let agent = app.sessions[idx].agent; - let _ = crate::delete::delete_session(&app.sessions[idx]); - app.sessions.remove(idx); - decrement_agent_count(&mut app.agent_counts, agent); + // Only drop the row from the UI when the on-disk delete + // actually succeeded; a failed delete stays visible. + if crate::delete::delete_session(&app.sessions[idx]).is_ok() { + let agent = app.sessions[idx].agent; + app.sessions.remove(idx); + decrement_agent_count(&mut app.agent_counts, agent); + } app.update_filter(); } app.mode = Mode::Browse; @@ -2341,7 +2317,7 @@ fn render_session_list(ui: &mut slt::Context, app: &App, bulk_mode: bool) { let _ = ui.row(|ui| { ui.styled(indicator.to_string(), indicator_style); - render_chunks(ui, &chunks); + render_chunks(ui, chunks); }); } else { let is_pinned = app.pinned_sessions.contains(&session.session_id); @@ -2383,7 +2359,7 @@ fn render_session_list(ui: &mut slt::Context, app: &App, bulk_mode: bool) { slt::Style::new().fg(slt::Color::White).bg(bg) }; ui.styled(indicator.to_string(), ind_style); - render_chunks(ui, &chunks); + render_chunks(ui, chunks); }); } } @@ -2472,10 +2448,7 @@ fn build_session_row( } else { session.git_branch.as_ref().map(|b| format!(" {b}")) }; - let git_info_width = git_info_str - .as_deref() - .map(UnicodeWidthStr::width) - .unwrap_or(0); + let git_info_width = git_info_str.as_deref().map_or(0, UnicodeWidthStr::width); // Use fixed column width for project name (padded to align columns) let fixed_left = indicator_width + 14; @@ -2553,9 +2526,12 @@ fn chunk_width(chunks: &[StyledChunk]) -> usize { .sum() } -fn render_chunks(ui: &mut slt::Context, chunks: &[StyledChunk]) { +/// Emit pre-built row chunks. Takes the Vec by value: chunks are built fresh +/// per visible row each frame, so consuming them avoids a String clone per +/// chunk on the hot render path. +fn render_chunks(ui: &mut slt::Context, chunks: Vec) { for (text, style) in chunks { - ui.styled(text.clone(), *style); + ui.styled(text, style); } } diff --git a/src/watch.rs b/src/watch.rs index 745eec7..e6b38b9 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -35,6 +35,11 @@ pub fn run_watch(interval_secs: u64) -> anyhow::Result<()> { if let Ok((new_sessions, new_running)) = rx.try_recv() { state.sessions = new_sessions; state.running_agents = new_running; + // Clamp the cursor: a refresh can shrink the list (sessions + // deleted elsewhere), and a stale `selected` past the end + // would push the scroll offset past the list and blank the + // viewport until the user pressed Up repeatedly. + state.selected = state.selected.min(state.sessions.len().saturating_sub(1)); state.last_refresh = Instant::now(); } @@ -180,8 +185,7 @@ fn detect_running_agents() -> Vec { std::process::Command::new("pgrep") .args(["-x", agent.cli_name()]) .output() - .map(|o| o.status.success()) - .unwrap_or(false) + .is_ok_and(|o| o.status.success()) }) .collect() } From c76fbedf8bac61f9bc983f95ecfe758e68d5dbba Mon Sep 17 00:00:00 2001 From: Subin An Date: Thu, 11 Jun 2026 10:46:18 +0900 Subject: [PATCH 6/7] refactor(core): Agent serde derive, cache cleanup, panic guards, shell tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model.rs: derive serde::Serialize/Deserialize on Agent (unit variants keep the exact on-disk strings) - cache.rs: re-type CachedSession.agent as Agent and CacheFile.agents as HashMap; delete agent_from_str/agent_to_str; from_cached now returns Session (no Option) - cache.rs: add round-trip test pinning the on-disk format (CacheFile round-trips, Agent serializes as "ClaudeCode") - cache.rs: delete clone_cached — write_cache carry-over moves entries out of the owned prior CacheFile via HashMap::remove - cache.rs: cache the AGF_DEBUG probe in a OnceLock-backed debug_enabled() helper (both cache.rs call sites) - main.rs: guard `agf resume --list 0 ` usize underflow/panic with take(n.max(1)) - main.rs: replace manual --version argv Vec collect with std::env::args().any(...) - delete.rs: convert four match-Ok/Err-continue ladders to let-else; .filter_map(|e| e.ok()) -> .flatten() at all 8 sites; map().unwrap_or(false) -> is_some_and at 2 sites - shell.rs: MSYSTEM empty-but-set no longer routes native Windows to Posix (is_some_and) - shell.rs: add default_shell_handles_uppercase_exe_and_empty_shell test (lowercase-before-trim order, empty SHELL = unset) Co-Authored-By: Claude Fable 5 --- src/cache.rs | 137 +++++++++++++++++++++++++------------------------- src/delete.rs | 51 +++++++------------ src/main.rs | 9 ++-- src/model.rs | 5 +- src/shell.rs | 24 ++++++++- 5 files changed, 118 insertions(+), 108 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index fa091aa..5c9e801 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -53,7 +53,7 @@ struct CacheFile { /// match in `parse_cache` and forces the rescan we want. #[serde(default)] agf_version: String, - agents: HashMap, + agents: HashMap, } #[derive(Debug, Serialize, Deserialize)] @@ -64,7 +64,7 @@ struct AgentCache { #[derive(Debug, Serialize, Deserialize)] struct CachedSession { - agent: String, + agent: Agent, session_id: String, project_name: String, project_path: String, @@ -82,36 +82,15 @@ fn cache_path() -> PathBuf { .join("sessions.json") } -fn agent_from_str(s: &str) -> Option { - match s { - "ClaudeCode" => Some(Agent::ClaudeCode), - "Codex" => Some(Agent::Codex), - "OpenCode" => Some(Agent::OpenCode), - "Pi" => Some(Agent::Pi), - "Kiro" => Some(Agent::Kiro), - "CursorAgent" => Some(Agent::CursorAgent), - "Gemini" => Some(Agent::Gemini), - "Hermes" => Some(Agent::Hermes), - _ => None, - } -} - -fn agent_to_str(a: Agent) -> &'static str { - match a { - Agent::ClaudeCode => "ClaudeCode", - Agent::Codex => "Codex", - Agent::OpenCode => "OpenCode", - Agent::Pi => "Pi", - Agent::Kiro => "Kiro", - Agent::CursorAgent => "CursorAgent", - Agent::Gemini => "Gemini", - Agent::Hermes => "Hermes", - } +/// True when the `AGF_DEBUG` env var is set (any value), probed once per process. +fn debug_enabled() -> bool { + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + *CACHE.get_or_init(|| std::env::var("AGF_DEBUG").is_ok()) } fn to_cached(s: &Session) -> CachedSession { CachedSession { - agent: agent_to_str(s.agent).to_string(), + agent: s.agent, session_id: s.session_id.clone(), project_name: s.project_name.clone(), project_path: s.project_path.clone(), @@ -123,10 +102,9 @@ fn to_cached(s: &Session) -> CachedSession { } } -fn from_cached(c: &CachedSession) -> Option { - let agent = agent_from_str(&c.agent)?; - Some(Session { - agent, +fn from_cached(c: &CachedSession) -> Session { + Session { + agent: c.agent, session_id: c.session_id.clone(), project_name: c.project_name.clone(), project_path: c.project_path.clone(), @@ -135,7 +113,7 @@ fn from_cached(c: &CachedSession) -> Option { git_branch: c.git_branch.clone(), worktree: c.worktree.clone(), recap: c.recap.clone(), - }) + } } fn get_max_mtime(paths: &[PathBuf]) -> u64 { @@ -201,7 +179,7 @@ pub fn load_cache() -> (Vec, Vec) { let cache = match parse_cache(&content, AGF_VERSION) { Ok(c) => c, Err(why) => { - if std::env::var("AGF_DEBUG").is_ok() { + if debug_enabled() { eprintln!("[agf] {why} → rescanning"); } return (Vec::new(), Agent::all().to_vec()); @@ -218,17 +196,12 @@ pub fn load_cache() -> (Vec, Vec) { if !installed.contains(&p.agent()) { continue; } - let key = agent_to_str(p.agent()); let current_mtime = get_max_mtime(&p.data_sources()); - match cache.agents.get(key) { + match cache.agents.get(&p.agent()) { Some(ac) if ac.mtime >= current_mtime && current_mtime > 0 => { // Cache is fresh - for cs in &ac.sessions { - if let Some(s) = from_cached(cs) { - sessions.push(s); - } - } + sessions.extend(ac.sessions.iter().map(from_cached)); } _ => { stale.push(p.agent()); @@ -256,7 +229,7 @@ pub fn write_cache(sessions: &[Session], skip_agents: &std::collections::HashSet let installed: std::collections::HashSet = crate::config::installed_agents().into_iter().collect(); let plugins = plugin::all_plugins(); - let mut agents: HashMap = HashMap::new(); + let mut agents: HashMap = HashMap::new(); // Carry over prior cache entries for in-flight agents. `parse_cache` // gates this on schema AND binary version: entries written by another @@ -264,18 +237,13 @@ pub fn write_cache(sessions: &[Session], skip_agents: &std::collections::HashSet // drop them and let the next launch rescan those agents instead. if !skip_agents.is_empty() && let Ok(content) = fs::read_to_string(&path) - && let Ok(prior) = parse_cache(&content, AGF_VERSION) + && let Ok(mut prior) = parse_cache(&content, AGF_VERSION) { for skip in skip_agents { - let key = agent_to_str(*skip).to_string(); - if let Some(entry) = prior.agents.get(&key) { - agents.insert( - key, - AgentCache { - mtime: entry.mtime, - sessions: entry.sessions.iter().map(clone_cached).collect(), - }, - ); + // `prior` is owned and dropped right after this block, so move + // the carried-over entries out instead of cloning them. + if let Some(entry) = prior.agents.remove(skip) { + agents.insert(*skip, entry); } } } @@ -287,7 +255,6 @@ pub fn write_cache(sessions: &[Session], skip_agents: &std::collections::HashSet if skip_agents.contains(&p.agent()) { continue; } - let key = agent_to_str(p.agent()).to_string(); let agent_sessions: Vec = sessions .iter() .filter(|s| s.agent == p.agent()) @@ -295,7 +262,7 @@ pub fn write_cache(sessions: &[Session], skip_agents: &std::collections::HashSet .collect(); let mtime = get_max_mtime(&p.data_sources()); agents.insert( - key, + p.agent(), AgentCache { mtime, sessions: agent_sessions, @@ -317,20 +284,6 @@ pub fn write_cache(sessions: &[Session], skip_agents: &std::collections::HashSet } } -fn clone_cached(c: &CachedSession) -> CachedSession { - CachedSession { - agent: c.agent.clone(), - session_id: c.session_id.clone(), - project_name: c.project_name.clone(), - project_path: c.project_path.clone(), - summaries: c.summaries.clone(), - timestamp: c.timestamp, - git_branch: c.git_branch.clone(), - worktree: c.worktree.clone(), - recap: c.recap.clone(), - } -} - /// One agent's scan result, streamed back from a worker thread. pub struct ScanResult { pub agent: Agent, @@ -348,7 +301,7 @@ pub fn start_stale_scan(stale: &[Agent]) -> std::sync::mpsc::Receiver = crate::config::installed_agents().into_iter().collect(); let stale: Vec = stale @@ -438,4 +391,50 @@ mod tests { let err = parse_cache("not json", AGF_VERSION).unwrap_err(); assert!(err.contains("cache parse failed"), "unexpected: {err}"); } + + #[test] + fn cache_file_round_trips_and_agent_serializes_as_variant_name() { + // Protects the on-disk format: `Agent` unit variants must serialize + // as their exact variant-name strings — the same strings the old + // hand-rolled agent_to_str/agent_from_str mapping produced. + assert_eq!( + serde_json::to_string(&Agent::ClaudeCode).unwrap(), + "\"ClaudeCode\"" + ); + + let mut agents = HashMap::new(); + agents.insert( + Agent::ClaudeCode, + AgentCache { + mtime: 42, + sessions: vec![CachedSession { + agent: Agent::ClaudeCode, + session_id: "sid".to_string(), + project_name: "proj".to_string(), + project_path: "/p".to_string(), + summaries: vec!["hello".to_string()], + timestamp: 7, + git_branch: Some("main".to_string()), + worktree: None, + recap: None, + }], + }, + ); + let cache = CacheFile { + version: CACHE_VERSION, + agf_version: AGF_VERSION.to_string(), + agents, + }; + + let json = serde_json::to_string(&cache).unwrap(); + assert!(json.contains("\"ClaudeCode\""), "unexpected json: {json}"); + + let parsed = parse_cache(&json, AGF_VERSION).expect("round-trip parse"); + let entry = &parsed.agents[&Agent::ClaudeCode]; + assert_eq!(entry.mtime, 42); + assert_eq!(entry.sessions.len(), 1); + assert_eq!(entry.sessions[0].agent, Agent::ClaudeCode); + assert_eq!(entry.sessions[0].session_id, "sid"); + assert_eq!(entry.sessions[0].git_branch.as_deref(), Some("main")); + } } diff --git a/src/delete.rs b/src/delete.rs index d250008..8bf9e59 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -98,7 +98,7 @@ fn remove_dirs_matching_name(base: &Path, name: &str) -> Result<(), io::Error> { if !base.is_dir() { return Ok(()); } - for entry in WalkDir::new(base).into_iter().filter_map(|e| e.ok()) { + for entry in WalkDir::new(base).into_iter().flatten() { let path = entry.path(); if path.is_dir() && path.file_name().and_then(|n| n.to_str()) == Some(name) { fs::remove_dir_all(path)?; @@ -113,7 +113,7 @@ fn remove_files_matching_name(base: &Path, name: &str) -> Result<(), io::Error> if !base.is_dir() { return Ok(()); } - for entry in WalkDir::new(base).into_iter().filter_map(|e| e.ok()) { + for entry in WalkDir::new(base).into_iter().flatten() { let path = entry.path(); if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some(name) { fs::remove_file(path)?; @@ -162,23 +162,20 @@ fn delete_codex_session(session: &Session) -> Result<(), io::Error> { /// file are swallowed so one corrupt or older-schema db cannot block the /// delete on the others. fn delete_codex_sqlite_rows(codex_dir: &Path, session_id: &str) -> Result<(), io::Error> { - let entries = match fs::read_dir(codex_dir) { - Ok(e) => e, - Err(_) => return Ok(()), + let Ok(entries) = fs::read_dir(codex_dir) else { + return Ok(()); }; - for entry in entries.filter_map(|e| e.ok()) { + for entry in entries.flatten() { let path = entry.path(); let is_state_db = path .file_name() .and_then(|n| n.to_str()) - .map(|n| n.starts_with("state_") && n.ends_with(".sqlite")) - .unwrap_or(false); + .is_some_and(|n| n.starts_with("state_") && n.ends_with(".sqlite")); if !is_state_db { continue; } - let conn = match rusqlite::Connection::open(&path) { - Ok(c) => c, - Err(_) => continue, + let Ok(conn) = rusqlite::Connection::open(&path) else { + continue; }; // `threads` is the only table the Codex scanner reads from. Older // Codex CLI versions may not have this table — ignore the error. @@ -189,18 +186,14 @@ fn delete_codex_sqlite_rows(codex_dir: &Path, session_id: &str) -> Result<(), io /// Find and delete the Codex rollout JSONL file matching the given session ID. fn delete_codex_session_file(sessions_dir: &Path, session_id: &str) -> Result<(), io::Error> { - for entry in WalkDir::new(sessions_dir) - .into_iter() - .filter_map(|e| e.ok()) - { + for entry in WalkDir::new(sessions_dir).into_iter().flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => continue, + let Ok(content) = fs::read_to_string(path) else { + continue; }; let first_line = match content.lines().next() { @@ -245,10 +238,7 @@ fn delete_opencode_session(session: &Session) -> Result<(), io::Error> { // Also remove JSON storage mirror if it exists let session_storage = opencode_dir.join("storage/session"); if session_storage.exists() { - for entry in WalkDir::new(&session_storage) - .into_iter() - .filter_map(|e| e.ok()) - { + for entry in WalkDir::new(&session_storage).into_iter().flatten() { let path = entry.path(); if path.is_file() && path.file_stem().and_then(|n| n.to_str()) == Some(&session.session_id) @@ -273,18 +263,14 @@ fn delete_pi_session(session: &Session) -> Result<(), io::Error> { return Ok(()); } - for entry in WalkDir::new(&sessions_dir) - .into_iter() - .filter_map(|e| e.ok()) - { + for entry in WalkDir::new(&sessions_dir).into_iter().flatten() { let path = entry.path(); if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => continue, + let Ok(content) = fs::read_to_string(path) else { + continue; }; let first_line = match content.lines().next() { @@ -379,13 +365,13 @@ fn delete_gemini_session(session: &Session) -> Result<(), io::Error> { return Ok(()); } - for project_entry in fs::read_dir(&tmp_dir)?.filter_map(|e| e.ok()) { + for project_entry in fs::read_dir(&tmp_dir)?.flatten() { let chats_dir = project_entry.path().join("chats"); if !chats_dir.is_dir() { continue; } - for chat_entry in fs::read_dir(&chats_dir)?.filter_map(|e| e.ok()) { + for chat_entry in fs::read_dir(&chats_dir)?.flatten() { let path = chat_entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { continue; @@ -399,8 +385,7 @@ fn delete_gemini_session(session: &Session) -> Result<(), io::Error> { && json .get("sessionId") .and_then(|v| v.as_str()) - .map(|id| id == session.session_id) - .unwrap_or(false) + .is_some_and(|id| id == session.session_id) { fs::remove_file(&path)?; return Ok(()); diff --git a/src/main.rs b/src/main.rs index 5250b94..082bb53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,8 +85,7 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); fn main() -> anyhow::Result<()> { // Handle --version / -V manually (clap hides it due to args_conflicts_with_subcommands) - let args: Vec = std::env::args().collect(); - if args.iter().any(|a| a == "--version" || a == "-V") { + if std::env::args().any(|a| a == "--version" || a == "-V") { println!("agf {VERSION}"); return Ok(()); } @@ -123,8 +122,10 @@ fn main() -> anyhow::Result<()> { } let chosen = if let Some(n) = list_count { - // Interactive: show top N and let user pick - let top_n = results.iter().take(n).collect::>(); + // Interactive: show top N and let user pick. `n.max(1)` guards + // `--list 0`: an empty top_n would underflow `top_n.len() - 1` + // below (results is already non-empty here). + let top_n = results.iter().take(n.max(1)).collect::>(); for (i, r) in top_n.iter().enumerate() { let s = &sessions[all_indices[r.index]]; eprintln!( diff --git a/src/model.rs b/src/model.rs index 2b359dd..5b32737 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,6 +1,9 @@ use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +// Serde derives are load-bearing for the session cache: unit variants +// serialize as their exact variant names ("ClaudeCode", "Codex", ...), which +// is the on-disk format of ~/.cache/agf/sessions.json. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[allow(clippy::enum_variant_names)] pub enum Agent { ClaudeCode, diff --git a/src/shell.rs b/src/shell.rs index fcdd026..7edf733 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -24,7 +24,7 @@ impl CommandShell { Some(name) if !name.is_empty() => Self::from_name(Some(name)), _ => Self::default_shell( cfg!(windows), - std::env::var_os("MSYSTEM").is_some(), + std::env::var_os("MSYSTEM").is_some_and(|v| !v.is_empty()), std::env::var("SHELL").ok().as_deref(), ), }) @@ -426,6 +426,28 @@ mod tests { ); } + #[test] + fn default_shell_handles_uppercase_exe_and_empty_shell() { + // Lowercasing must happen before `.exe` trimming. + assert_eq!( + CommandShell::default_shell(true, false, Some(r"C:\PowerShell\7\PWSH.EXE")), + CommandShell::PowerShell + ); + assert_eq!( + CommandShell::default_shell( + true, + false, + Some(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe") + ), + CommandShell::PowerShell + ); + // Empty SHELL means "unset", not a POSIX-layer signal. + assert_eq!( + CommandShell::default_shell(true, false, Some("")), + CommandShell::PowerShell + ); + } + #[test] fn from_name_classifies_shells() { assert_eq!( From 3a1daa9ad818e0c0206406e786e164e145887e24 Mon Sep 17 00:00:00 2001 From: Subin An Date: Thu, 11 Jun 2026 10:53:38 +0900 Subject: [PATCH 7/7] fix(scanner/cursor): gate test-only Write import to unix The only user of the Write trait is the unix-gated place_session test fixture; the ungated import was an unused-import error on the new Windows clippy CI job (which is exactly the platform gap that job exists to catch). Co-Authored-By: Claude Fable 5 --- src/scanner/cursor_agent.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/scanner/cursor_agent.rs b/src/scanner/cursor_agent.rs index abdf322..58f5998 100644 --- a/src/scanner/cursor_agent.rs +++ b/src/scanner/cursor_agent.rs @@ -355,6 +355,9 @@ fn solve(parts: &[&str], idx: usize, current: &Path) -> Option { mod tests { use super::*; use std::fs; + // Only the unix-gated fixture helpers use the Write trait; gate the + // import the same way so Windows clippy doesn't flag it as unused. + #[cfg(unix)] use std::io::Write; /// Build a (cursor_dir, real_project_dir, dash_encoded_slug) fixture.