From a45f0424f4a2313db8ccff53efa31a7a259ab8ec Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sat, 7 Mar 2026 00:53:13 +0100 Subject: [PATCH] feat: add interactive conventional commit workflow with jj-lib backend Replace CLI executor with jj-lib integration, implement full interactive commit workflow via prompts, and add mock infrastructure for testing. Add CLI integration tests and error handling tests. --- Cargo.lock | 586 ++++++++++++++++++++++---------- Cargo.toml | 15 +- deny.toml | 7 +- justfile | 10 +- src/cli/mod.rs | 16 + src/commit/types/commit_type.rs | 2 +- src/commit/types/description.rs | 118 ++----- src/commit/types/message.rs | 77 ++++- src/commit/types/mod.rs | 2 +- src/commit/types/scope.rs | 4 +- src/error.rs | 32 +- src/jj/executor.rs | 458 ------------------------- src/jj/lib_executor.rs | 288 ++++++++++++++++ src/jj/mock.rs | 234 +++++++++++++ src/jj/mod.rs | 245 +------------ src/lib.rs | 19 ++ src/main.rs | 75 +++- src/prompts/mock.rs | 284 ++++++++++++++++ src/prompts/mod.rs | 6 + src/prompts/prompter.rs | 184 ++++++++++ src/prompts/workflow.rs | 512 ++++++++++++++++++++++++++++ tests/cli.rs | 99 ++++++ tests/error_tests.rs | 135 ++++++++ 23 files changed, 2392 insertions(+), 1016 deletions(-) delete mode 100644 src/jj/executor.rs create mode 100644 src/jj/lib_executor.rs create mode 100644 src/jj/mock.rs create mode 100644 src/prompts/mock.rs create mode 100644 src/prompts/prompter.rs create mode 100644 src/prompts/workflow.rs create mode 100644 tests/cli.rs create mode 100644 tests/error_tests.rs diff --git a/Cargo.lock b/Cargo.lock index a0bdac5..803a446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,15 +78,15 @@ 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 = "arc-swap" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ "rustversion", ] @@ -187,9 +187,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -205,9 +205,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "shlex", @@ -220,10 +220,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "chrono" -version = "0.4.43" +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -273,9 +284,12 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "clru" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd0f76e066e64fdc5631e3bb46381254deab9ef1158292f27c8c57e3bf3fe59" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "colorchoice" @@ -307,6 +321,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -570,9 +593,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -585,9 +608,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -595,15 +618,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -612,15 +635,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -629,21 +652,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -653,7 +676,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -684,10 +706,24 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasip2", + "wasip3", +] + [[package]] name = "git-conventional" version = "0.12.9" @@ -700,9 +736,9 @@ dependencies = [ [[package]] name = "gix" -version = "0.78.0" +version = "0.80.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3428a03ace494ae40308bd3df0b37e7eb7403e24389f27abdff30abf2b5adf17" +checksum = "5aa56fdbfe98258af2759818ddc3175cc581112660e74c3fd55669836d29a994" dependencies = [ "gix-actor", "gix-attributes", @@ -742,29 +778,28 @@ dependencies = [ "gix-utils", "gix-validate", "gix-worktree", + "nonempty", "smallvec", "thiserror", ] [[package]] name = "gix-actor" -version = "0.38.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50ce5433eaa46187349e59089eea71b0397caa71991b2fa3e124120426d7d15" +checksum = "0e5e5b518339d5e6718af108fd064d4e9ba33caf728cf487352873d76411df35" dependencies = [ "bstr", "gix-date", - "gix-utils", - "itoa", - "thiserror", + "gix-error", "winnow", ] [[package]] name = "gix-attributes" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f868f013fee0ebb5c85fae848c34a0b9ef7438acfbaec0c82a3cdbd5eac730a0" +checksum = "c233d6eaa098c0ca5ce03236fd7a96e27f1abe72fad74b46003fbd11fe49563c" dependencies = [ "bstr", "gix-glob", @@ -779,27 +814,27 @@ dependencies = [ [[package]] name = "gix-bitmap" -version = "0.2.15" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e150161b8a75b5860521cb876b506879a3376d3adc857ec7a9d35e7c6a5e531" +checksum = "e7add20f40d060db8c9b1314d499bac6ed7480f33eb113ce3e1cf5d6ff85d989" dependencies = [ - "thiserror", + "gix-error", ] [[package]] name = "gix-chunk" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e516efaac951ed21115b11d5514b120c26ccb493d0c0b9ea6cc10edf4fdf44" +checksum = "1096b6608fbe5d27fb4984e20f992b4e76fb8c613f6acb87d07c5831b53a6959" dependencies = [ "gix-error", ] [[package]] name = "gix-command" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "745bc165b7da500acc26d24888379ae0dfd1ecabe3a47420cdcb92feefb0561d" +checksum = "b849c65a609f50d02f8a2774fe371650b3384a743c79c2a070ce0da49b7fb7da" dependencies = [ "bstr", "gix-path", @@ -810,22 +845,23 @@ dependencies = [ [[package]] name = "gix-commitgraph" -version = "0.32.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0dda2e4d5a61d4a16a780f61f2b7e9406ad1f8da97c35c09ef501f3fdf74de0" +checksum = "aea2fcfa6bc7329cd094696ba76682b89bdb61cafc848d91b34abba1c1d7e040" dependencies = [ "bstr", "gix-chunk", "gix-error", "gix-hash", "memmap2", + "nonempty", ] [[package]] name = "gix-config" -version = "0.51.0" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a153dd4f5789fdf242e19e3f7105f2a114df198570225976fe4a108bac9dee4" +checksum = "8c24b190bd42b55724368c28ae750840b48e2038b9b5281202de6fca4ec1fce1" dependencies = [ "bstr", "gix-config-value", @@ -843,9 +879,9 @@ dependencies = [ [[package]] name = "gix-config-value" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563361198101cedc975fe5760c91ac2e4126eec22216e81b659b45289feaf1ea" +checksum = "441a300bc3645a1f45cba495b9175f90f47256ce43f2ee161da0031e3ac77c92" dependencies = [ "bitflags", "bstr", @@ -856,9 +892,9 @@ dependencies = [ [[package]] name = "gix-date" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12553b32d1da25671f31c0b084bf1e5cb6d5ef529254d04ec33cdc890bd7f687" +checksum = "6c2f2155782090fd947c2f7904166b9f3c3da0d91358adb011f753ea3a55c0ff" dependencies = [ "bstr", "gix-error", @@ -869,9 +905,9 @@ dependencies = [ [[package]] name = "gix-diff" -version = "0.58.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26bcd367b2c5dbf6bec9ce02ca59eab179fc82cf39f15ec83549ee25c255c99f" +checksum = "60592771b104eda4e537c311e8239daef0df651d61e0e21855f7e6166416ff12" dependencies = [ "bstr", "gix-command", @@ -890,9 +926,9 @@ dependencies = [ [[package]] name = "gix-discover" -version = "0.46.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "950b027b861c6863ddf1b075672ec1ef2006b95c4d12284fc1ec4cdb1ab6639e" +checksum = "810764b92e8cb95e4d91b7adfc5a14666434fd32ace02900dfb66aae71f845df" dependencies = [ "bstr", "dunce", @@ -905,18 +941,18 @@ dependencies = [ [[package]] name = "gix-error" -version = "0.0.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dffc9ca4dfa4f519a3d2cf1c038919160544923577ac60f45bcb602a24d82c6" +checksum = "f2dfe8025209bf2a72d97a6f2dff105b93e5ebcf131ffa3d3f1728ce4ac3767b" dependencies = [ "bstr", ] [[package]] name = "gix-features" -version = "0.46.0" +version = "0.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a407957e21dc5e6c87086e50e5114a2f9240f9cb11699588a6d900d53cb6c70" +checksum = "a83a5fe8927de3bb02b0cfb87165dbfb49f04d4c297767443f2e1011ecc15bdd" dependencies = [ "crc32fast", "crossbeam-channel", @@ -934,9 +970,9 @@ dependencies = [ [[package]] name = "gix-filter" -version = "0.25.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7240442915cdd74e1f889566695ce0d0c23c7185b13318a1232ce646af0d18ad" +checksum = "7eda328750accaac05ce7637298fd7d6ba0d5d7bdf49c21f899d0b97e3df822d" dependencies = [ "bstr", "encoding_rs", @@ -955,9 +991,9 @@ dependencies = [ [[package]] name = "gix-fs" -version = "0.19.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba74fa163d3b2ba821d5cd207d55fe3daac3d1099613a8559c812d2b15b3c39a" +checksum = "de4bd0d8e6c6ef03485205f8eecc0359042a866d26dba569075db1ebcc005970" dependencies = [ "bstr", "fastrand", @@ -981,9 +1017,9 @@ dependencies = [ [[package]] name = "gix-hash" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8e11ea6bbd0fd4ab4a1c66812dd3cc25921a41315b120f352997725a4c79d6" +checksum = "d8ced05d2d7b13bff08b2f7eb4e47cfeaf00b974c2ddce08377c4fe1f706b3eb" dependencies = [ "faster-hex", "gix-features", @@ -1017,9 +1053,9 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.46.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31c6b3664efe5916c539c50e610f9958f2993faf8e29fa5a40fb80b6ac8486a" +checksum = "13b28482b86662c8b78160e0750b097a35fd61185803a960681351b3a07de07e" dependencies = [ "bitflags", "bstr", @@ -1045,9 +1081,9 @@ dependencies = [ [[package]] name = "gix-lock" -version = "21.0.0" +version = "21.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d406220ef9df105645a9ddcaa42e8c882ba920344ace866d0403570aea599" +checksum = "cbe09cf05ba7c679bba189acc29eeea137f643e7fff1b5dff879dfd45248be31" dependencies = [ "gix-tempfile", "gix-utils", @@ -1056,9 +1092,9 @@ dependencies = [ [[package]] name = "gix-object" -version = "0.55.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3f705c977d90ace597049252ae1d7fec907edc0fa7616cc91bf5508d0f4006" +checksum = "013eae8e072c6155191ac266950dfbc8d162408642571b32e2c6b3e4b03740fb" dependencies = [ "bstr", "gix-actor", @@ -1077,9 +1113,9 @@ dependencies = [ [[package]] name = "gix-odb" -version = "0.75.0" +version = "0.77.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d59882d2fdab5e609b0c452a6ef9a3bd12ef6b694be4f82ab8f126ad0969864" +checksum = "f8901a182923799e8857ac01bff6d7c6fecea999abd79a86dab638aadbb843f3" dependencies = [ "arc-swap", "gix-features", @@ -1097,9 +1133,9 @@ dependencies = [ [[package]] name = "gix-pack" -version = "0.65.0" +version = "0.67.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c44db57ebbbeaad9972c2a60662142660427a1f0a7529314d53fefb4fedad24" +checksum = "194a9f96f4058359d6874123f160e5b2044974829a29f3a71bb9c9218d1916c3" dependencies = [ "clru", "gix-chunk", @@ -1117,9 +1153,9 @@ dependencies = [ [[package]] name = "gix-packetline" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c333badf342e9c2392800a96b9f2cf5bcb33906d2577d6ec923756ff4008a3f" +checksum = "25429ee1ef792d9b653ee5de09bb525489fc8e6908334cfd5d5824269f0b7073" dependencies = [ "bstr", "faster-hex", @@ -1129,9 +1165,9 @@ dependencies = [ [[package]] name = "gix-path" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c3cd795cad18c7acbc6bafe34bfb34ac7273ee81133793f9d1516dd9faf922" +checksum = "7163b1633d35846a52ef8093f390cec240e2d55da99b60151883035e5169cd85" dependencies = [ "bstr", "gix-trace", @@ -1141,9 +1177,9 @@ dependencies = [ [[package]] name = "gix-pathspec" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df6fd8e514d8b99ec5042ee17909a17750ccf54d0b8b30c850954209c800322" +checksum = "40e7636782b35bb1d3ade19ea7387278e96fd49f6963ab41bfca81cef4b61b20" dependencies = [ "bitflags", "bstr", @@ -1156,9 +1192,9 @@ dependencies = [ [[package]] name = "gix-protocol" -version = "0.56.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54f20837b0c70b65f6ac77886be033de3b69d5879f99128b47c42665ab0a17c2" +checksum = "5c64ec7b04c57df6e97a2ac4738a4a09897b88febd6ec4bd2c5d3ff3ad3849df" dependencies = [ "bstr", "gix-date", @@ -1169,26 +1205,27 @@ dependencies = [ "gix-transport", "gix-utils", "maybe-async", + "nonempty", "thiserror", "winnow", ] [[package]] name = "gix-quote" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e912ec04b7b1566a85ad486db0cab6b9955e3e32bcd3c3a734542ab3af084c5b" +checksum = "68533db71259c8776dd4e770d2b7b98696213ecdc1f5c9e3507119e274e0c578" dependencies = [ "bstr", + "gix-error", "gix-utils", - "thiserror", ] [[package]] name = "gix-ref" -version = "0.58.0" +version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf780dcd9ac99fd3fcfc8523479a0e2ffd55f5e0be63e5e3248fb7e46cff966" +checksum = "7cc7b230945f02d706a49bcf823b671785ecd9e88e713b8bd2ca5db104c97add" dependencies = [ "gix-actor", "gix-features", @@ -1207,9 +1244,9 @@ dependencies = [ [[package]] name = "gix-refspec" -version = "0.36.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60ce400a770a7952e45267803192cc2d1fe0afa08e2c08dde32e04c7908c6e61" +checksum = "bb3dc194cdc1176fc20f39f233d0d516f83df843ea14a9eb758a2690f3e38d1e" dependencies = [ "bstr", "gix-error", @@ -1223,9 +1260,9 @@ dependencies = [ [[package]] name = "gix-revision" -version = "0.40.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c719cf7d669439e1fca735bd1c4de54d43c5d30e8883fd6063c4924b213d70c9" +checksum = "df9e31cd402edae08c3fdb67917b9fb75b0c9c9bd2fbed0c2dd9c0847039c556" dependencies = [ "bstr", "gix-commitgraph", @@ -1234,13 +1271,14 @@ dependencies = [ "gix-hash", "gix-object", "gix-revwalk", + "nonempty", ] [[package]] name = "gix-revwalk" -version = "0.26.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a50b30aa0c6e6de43c723359c5809a96275a3aa92d323ef7f58b1cdd60f16" +checksum = "573f6e471d76c0796f0b8ed5a431521ea5d121a7860121a2a9703e9434ab1d52" dependencies = [ "gix-commitgraph", "gix-date", @@ -1254,9 +1292,9 @@ dependencies = [ [[package]] name = "gix-sec" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beeb3bc63696cf7acb5747a361693ebdbcaf25b5d27d2308f38e9782983e7bce" +checksum = "e014df75f3d7f5c98b18b45c202422da6236a1c0c0a50997c3f41e601f3ad511" dependencies = [ "bitflags", "gix-path", @@ -1266,21 +1304,22 @@ dependencies = [ [[package]] name = "gix-shallow" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f4660fed3786d28e7e57d31b2de9ab3bf846068e187ccc52ee513de19a0073" +checksum = "4ee51037c8a27ddb1c7a6d6db2553d01e501d5b1dae7dc65e41905a70960e658" dependencies = [ "bstr", "gix-hash", "gix-lock", + "nonempty", "thiserror", ] [[package]] name = "gix-submodule" -version = "0.25.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db1840fe723c6264ee596e5a179e1b9a2df59351f09bae9cea570a472a790bc0" +checksum = "6cba2022599491d620fbc77b3729dba0120862ce9b4af6e3c47d19a9f2a5d884" dependencies = [ "bstr", "gix-config", @@ -1293,9 +1332,9 @@ dependencies = [ [[package]] name = "gix-tempfile" -version = "21.0.0" +version = "21.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d280bba7c547170e42d5228fc6e76c191fb5a7c88808ff61af06460404d1fd91" +checksum = "9d9ab2c89fe4bfd4f1d8700aa4516534c170d8a21ae2c554167374607c2eaf16" dependencies = [ "dashmap", "gix-fs", @@ -1306,15 +1345,15 @@ dependencies = [ [[package]] name = "gix-trace" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e42a4c2583357721ba2d887916e78df504980f22f1182df06997ce197b89504" +checksum = "f69a13643b8437d4ca6845e08143e847a36ca82903eed13303475d0ae8b162e0" [[package]] name = "gix-transport" -version = "0.53.0" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de1064c7ffa5a915014a6a5b71fbc5299462ae655348bed23e083b4a735076c3" +checksum = "b4d72f5094b9f851e348f2cbb840d026ffd8119fc28bc2bca1387eecd171c815" dependencies = [ "bstr", "gix-command", @@ -1328,9 +1367,9 @@ dependencies = [ [[package]] name = "gix-traverse" -version = "0.52.0" +version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37f8b53b4c56b01c43a4491c4edfe2ce66c654eb86232205172ceb1650d21c55" +checksum = "c99b3cf9dc87c13f1404e7b0e8c5e4bff4975d6f788831c02d6c006f3c76b4a0" dependencies = [ "bitflags", "gix-commitgraph", @@ -1345,9 +1384,9 @@ dependencies = [ [[package]] name = "gix-url" -version = "0.35.0" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca2e50308a8373069e71970939f43ea4a1b5f422cf807d048ebcf07dcc02b2c" +checksum = "d28e8af3d42581190da884f013caf254d2fd4d6ab102408f08d21bfa11de6c8d" dependencies = [ "bstr", "gix-path", @@ -1376,9 +1415,9 @@ dependencies = [ [[package]] name = "gix-worktree" -version = "0.47.0" +version = "0.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2ad658586ec0039b03e96c664f08b7cb7a2b7cca6947a9c856c9ed59b807b1" +checksum = "005627fc149315f39473e3e94a50058dd5d345c490a23723f67f32ee9c505232" dependencies = [ "bstr", "gix-attributes", @@ -1491,6 +1530,12 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ignore" version = "0.4.25" @@ -1575,9 +1620,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.19" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -1590,9 +1635,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.19" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", @@ -1601,9 +1646,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" [[package]] name = "jiff-tzdb-platform" @@ -1633,9 +1678,9 @@ dependencies = [ [[package]] name = "jj-lib" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2874dcc2160a488b645098adc79761415c68ae7a6bb48a98fae3f388cb436e" +checksum = "4f70302ae78e8dbb6aad7df472b3cdfb034649155cf8b7329240b6e79c38d659" dependencies = [ "async-trait", "blake2", @@ -1681,9 +1726,9 @@ dependencies = [ [[package]] name = "jj-lib-proc-macros" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a5ee9aaf262f7cb4a4880c59d493b6e1e525a734c901e003c856c681e4f9cca" +checksum = "b8139c6755c9a8666ea01a75b4d817df838c8ceacd607f8e553b5cb4e9327836" dependencies = [ "proc-macro2", "quote", @@ -1692,9 +1737,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1740,27 +1785,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libc" -version = "0.2.180" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags", "libc", - "redox_syscall 0.7.0", + "plain", + "redox_syscall 0.7.3", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litrs" @@ -1842,9 +1894,9 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ "libc", ] @@ -1861,6 +1913,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1967,10 +2025,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "pollster" @@ -2032,6 +2090,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2089,20 +2157,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rand" -version = "0.9.2" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ - "rand_chacha", + "chacha20", + "getrandom 0.4.2", "rand_core", ] [[package]] name = "rand_chacha" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" dependencies = [ "ppv-lite86", "rand_core", @@ -2110,12 +2185,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom", -] +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "rayon" @@ -2148,9 +2220,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags", ] @@ -2221,9 +2293,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -2289,6 +2361,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -2305,7 +2390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2326,7 +2411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2425,12 +2510,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2519,9 +2604,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.24.0+spec-1.1.0" +version = "0.24.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" +checksum = "01f2eadbbc6b377a847be05f60791ef1058d9f696ecb51d2c07fe911d8569d8e" dependencies = [ "indexmap", "serde_core", @@ -2534,9 +2619,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -2638,6 +2723,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2685,10 +2776,19 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen" -version = "0.2.108" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -2699,9 +2799,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2709,9 +2809,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -2722,13 +2822,47 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2925,21 +3059,103 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", @@ -2948,6 +3164,12 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index c4f62af..a7be1f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,18 +15,25 @@ path = "src/lib.rs" path = "src/main.rs" name = "jj-cz" +[features] +## Exposes MockJjExecutor and MockPrompts for use in integration tests. +## Enable with: cargo test --features test-utils +test-utils = [] + [dependencies] -assert_cmd = "2.1.2" -assert_fs = "1.1.3" async-trait = "0.1.89" clap = { version = "4.5.57", features = ["derive"] } git-conventional = "0.12.9" inquire = "0.9.2" -jj-lib = { version = "0.38.0", features = ["testing"] } +jj-lib = "0.39.0" lazy-regex = { version = "3.5.1", features = ["lite"] } -predicates = "3.1.3" thiserror = "2.0.18" tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } +[dev-dependencies] +assert_cmd = "2.1.2" +assert_fs = "1.1.3" +predicates = "3.1.3" + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/deny.toml b/deny.toml index 56a20cf..a557108 100644 --- a/deny.toml +++ b/deny.toml @@ -10,8 +10,11 @@ ignore = [] allow = [ "Apache-2.0 WITH LLVM-exception", "Apache-2.0", + "BSD-3-Clause", "MIT", + "MPL-2.0", "Unicode-3.0", + "Zlib", ] confidence-threshold = 0.8 exceptions = [] @@ -22,9 +25,9 @@ registries = [] [bans] multiple-versions = "allow" -wildcards = "allow" +wildcards = "deny" highlight = "all" -workspace-default-features = "allow" +workspace-default-features = "deny" external-default-features = "allow" allow = [] deny = [] diff --git a/justfile b/justfile index 80ecdef..60fafbd 100644 --- a/justfile +++ b/justfile @@ -22,21 +22,21 @@ build-release: cargo build --release lint: - cargo clippy --all-targets + cargo clippy --all-targets --features test-utils lint-report: - cargo clippy --all-targets --message-format=json > coverage/clippy.json 2> /dev/null + cargo clippy --all-targets --features test-utils --message-format=json > coverage/clippy.json 2> /dev/null test: - cargo test + cargo test --features test-utils coverage: mkdir -p coverage - cargo tarpaulin --config .tarpaulin.local.toml + cargo tarpaulin --config .tarpaulin.local.toml --features test-utils coverage-ci: mkdir -p coverage - cargo tarpaulin --config .tarpaulin.ci.toml + cargo tarpaulin --config .tarpaulin.ci.toml --features test-utils check-all: format-check lint coverage audit diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8b13789..77c6ced 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1 +1,17 @@ +use clap::Parser; +/// Interactive conventional commit tool for Jujutsu +/// +/// Guides you through creating a properly formatted conventional commit message +/// and applies it to the current change in your Jujutsu repository. +#[derive(Debug, Parser)] +#[command( + name = "jj-cz", + version, + about = "Interactive conventional commit tool for Jujutsu", + long_about = "Guides you through creating a properly formatted conventional \ + commit message and applies it to the current change in your \ + Jujutsu repository.\n\n\ + This tool requires an interactive terminal (TTY)." +)] +pub struct Cli; diff --git a/src/commit/types/commit_type.rs b/src/commit/types/commit_type.rs index 85c33a5..abb05fa 100644 --- a/src/commit/types/commit_type.rs +++ b/src/commit/types/commit_type.rs @@ -14,7 +14,7 @@ pub enum CommitType { } impl CommitType { - pub fn all() -> &'static [Self] { + pub const fn all() -> &'static [Self] { &[ Self::Feat, Self::Fix, diff --git a/src/commit/types/description.rs b/src/commit/types/description.rs index c411b0e..b2ee206 100644 --- a/src/commit/types/description.rs +++ b/src/commit/types/description.rs @@ -3,6 +3,11 @@ pub struct Description(String); impl Description { + /// Soft limit for description length. + /// + /// Descriptions over this length are warned about at the prompt layer but + /// are not rejected here — the hard limit is the 72-character total first + /// line enforced by [`ConventionalCommit`]. pub const MAX_LENGTH: usize = 50; /// Parse and validate a description string @@ -10,17 +15,14 @@ impl Description { /// # Validation /// - Trims leading/trailing whitespace /// - Rejects empty or whitespace-only input - /// - Validates maximum length (50 chars after trim - soft limit) + /// + /// The 50-character soft limit is enforced at the prompt layer with a + /// warning rather than here, to allow descriptions slightly over the + /// limit where the 72-character total first-line limit is still satisfied. pub fn parse(value: impl Into) -> Result { let value = value.into().trim().to_owned(); if value.is_empty() { - return Err(DescriptionError::Empty); - } - if value.len() > Self::MAX_LENGTH { - Err(DescriptionError::TooLong { - actual: value.len(), - max: Self::MAX_LENGTH, - }) + Err(DescriptionError::Empty) } else { Ok(Self(value)) } @@ -32,15 +34,13 @@ impl Description { } /// Returns the length in characters + /// + /// `is_empty()` is intentionally absent: `Description` is guaranteed + /// non-empty by its constructor, so the concept does not apply. + #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { self.0.len() } - - /// Always returns false for a valid `Description` - /// (included for API completeness, but logically always false) - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } } impl AsRef for Description { @@ -59,9 +59,6 @@ impl std::fmt::Display for Description { pub enum DescriptionError { #[error("Description cannot be empty")] Empty, - - #[error("Description too long ({actual} characters, maximum is {max})")] - TooLong { actual: usize, max: usize }, } #[cfg(test)] @@ -180,49 +177,21 @@ mod tests { assert_eq!(result.unwrap().as_str(), "add multiple spaces"); } - /// Test that 72 characters (old limit) is now rejected + /// Test that descriptions over the 50-char soft limit are accepted + /// + /// The 50-char limit is enforced as a prompt-layer warning only. + /// The hard limit is the 72-char total first line (ConventionalCommit). #[test] - fn seventy_two_characters_now_rejected() { - let desc_72 = "a".repeat(72); - let result = Description::parse(&desc_72); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - DescriptionError::TooLong { - actual: 72, - max: 50 - } - ); - } - - /// Test that 51 characters is rejected (boundary) - #[test] - fn fifty_one_characters_rejected() { + fn description_over_soft_limit_accepted() { let desc_51 = "a".repeat(51); let result = Description::parse(&desc_51); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - DescriptionError::TooLong { - actual: 51, - max: 50 - } - ); - } + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 51); - /// Test that 100 characters is rejected - #[test] - fn hundred_characters_rejected() { - let desc_100 = "a".repeat(100); - let result = Description::parse(&desc_100); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - DescriptionError::TooLong { - actual: 100, - max: 50 - } - ); + let desc_72 = "a".repeat(72); + let result = Description::parse(&desc_72); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 72); } /// Test that length is checked after trimming @@ -264,13 +233,6 @@ mod tests { assert_eq!(desc.len(), 5); } - /// Test is_empty() always returns false for valid Description - #[test] - fn is_empty_always_false_for_valid() { - let desc = Description::parse("x").unwrap(); - assert!(!desc.is_empty()); - } - /// Test Display trait implementation #[test] fn display_outputs_inner_string() { @@ -321,19 +283,6 @@ mod tests { assert!(msg.contains("cannot be empty")); } - /// Test DescriptionError::TooLong displays correctly - #[test] - fn too_long_error_display() { - let err = DescriptionError::TooLong { - actual: 51, - max: 50, - }; - let msg = format!("{}", err); - assert!(msg.contains("too long")); - assert!(msg.contains("51")); - assert!(msg.contains("50")); - } - /// Test description with only whitespace after trim becomes empty #[test] fn whitespace_after_trim_is_empty() { @@ -361,19 +310,14 @@ mod tests { assert_eq!(result.unwrap().len(), 50); } - /// Test description just over boundary after trimming + /// Test description just over soft limit is accepted after trimming + /// + /// 51 chars (trimmed) is over the soft limit but still valid as a Description. #[test] - fn over_boundary_after_trim() { - // 51 chars + spaces = should fail even after trim + fn over_soft_limit_after_trim_accepted() { let desc = format!(" {} ", "x".repeat(51)); let result = Description::parse(&desc); - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - DescriptionError::TooLong { - actual: 51, - max: 50 - } - ); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 51); } } diff --git a/src/commit/types/message.rs b/src/commit/types/message.rs index 3fd6772..4e4d72b 100644 --- a/src/commit/types/message.rs +++ b/src/commit/types/message.rs @@ -7,6 +7,13 @@ pub enum CommitMessageError { /// The complete first line exceeds the maximum allowed length #[error("first line too long: {actual} characters (max {max})")] FirstLineTooLong { actual: usize, max: usize }, + + /// The formatted message is not parseable as a conventional commit + /// + /// This should never occur in normal use — it indicates a bug in the + /// formatting logic. + #[error("output failed git-conventional validation: {reason}")] + InvalidConventionalFormat { reason: String }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -46,6 +53,12 @@ impl ConventionalCommit { max: Self::FIRST_LINE_MAX_LENGTH, }); } + let formatted = commit.format(); + git_conventional::Commit::parse(&formatted).map_err(|e| { + CommitMessageError::InvalidConventionalFormat { + reason: e.to_string(), + } + })?; Ok(commit) } @@ -71,10 +84,23 @@ impl ConventionalCommit { /// Returns `type(scope): description` if scope is non-empty, or /// `type: description` if scope is empty pub fn format(&self) -> String { - if self.scope.is_empty() { - format!("{}: {}", self.commit_type, self.description) + Self::format_preview(self.commit_type, &self.scope, &self.description) + } + + /// Format a preview of the commit message without creating a validated instance + /// + /// This is useful for showing what the message would look like before validation + /// Returns `type(scope): description` if scope is non-empty, or + /// `type: description` if scope is empty + pub fn format_preview( + commit_type: CommitType, + scope: &Scope, + description: &Description, + ) -> String { + if scope.is_empty() { + format!("{}: {}", commit_type, description) } else { - format!("{}({}): {}", self.commit_type, self.scope, self.description) + format!("{}({}): {}", commit_type, scope, description) } } @@ -707,4 +733,49 @@ mod tests { // Just verify it's a Result by using is_ok() assert!(result.is_ok()); } + + /// Test that all valid commits produce messages parseable by git-conventional (SC-002) + /// + /// This verifies that 100% of commit messages produced by this tool conform to + /// the conventional commit specification. + #[test] + fn all_valid_commits_parse_with_git_conventional() { + let cases: &[(&str, Option<&str>)] = &[ + ("add new feature", None), + ("fix critical bug", Some("api")), + ("update README", Some("docs")), + ("remove unused code", Some("core")), + ]; + + for commit_type in CommitType::all() { + for (desc_str, scope_str) in cases { + let scope = match scope_str { + Some(s) => Scope::parse(*s).unwrap(), + None => Scope::empty(), + }; + let desc = Description::parse(*desc_str).unwrap(); + let commit = ConventionalCommit::new(*commit_type, scope, desc); + // new() itself calls git_conventional::Commit::parse internally, so + // if this is Ok, SC-002 is satisfied for this case. + assert!( + commit.is_ok(), + "git-conventional rejected {:?}/{:?}/{:?}", + commit_type, + scope_str, + desc_str + ); + } + } + } + + /// Test InvalidConventionalFormat error displays correctly + #[test] + fn invalid_conventional_format_error_display() { + let err = CommitMessageError::InvalidConventionalFormat { + reason: "missing type".to_string(), + }; + let msg = format!("{}", err); + assert!(msg.contains("git-conventional")); + assert!(msg.contains("missing type")); + } } diff --git a/src/commit/types/mod.rs b/src/commit/types/mod.rs index 840d8b9..2002720 100644 --- a/src/commit/types/mod.rs +++ b/src/commit/types/mod.rs @@ -8,4 +8,4 @@ mod description; pub use description::{Description, DescriptionError}; mod message; -pub use message::CommitMessageError; +pub use message::{CommitMessageError, ConventionalCommit}; diff --git a/src/commit/types/scope.rs b/src/commit/types/scope.rs index 7ebbef6..338383a 100644 --- a/src/commit/types/scope.rs +++ b/src/commit/types/scope.rs @@ -1,5 +1,3 @@ - - #[derive(Debug, Clone, PartialEq, Eq)] #[repr(transparent)] pub struct Scope(String); @@ -26,7 +24,7 @@ impl Scope { max: Self::MAX_LENGTH, }); } - match lazy_regex::regex_find!(r"[^a-zA-Z0-9_/-]", &value) { + match lazy_regex::regex_find!(r"[^-a-zA-Z0-9_/]", &value) { Some(val) => Err(ScopeError::InvalidCharacter(val.chars().next().unwrap())), None => Ok(Self(value)), } diff --git a/src/error.rs b/src/error.rs index a82902b..c6a1bc7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,6 @@ use crate::commit::types::{CommitMessageError, DescriptionError, ScopeError}; -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum Error { // Domain errors #[error("Invalid scope: {0}")] @@ -50,33 +50,3 @@ impl From for Error { Self::FailedGettingCurrentDir } } - -impl From for Error { - fn from(_: jj_lib::config::ConfigGetError) -> Self { - Self::FailedReadingConfig - } -} - -impl From for Error { - fn from(error: jj_lib::repo::RepoLoaderError) -> Self { - Self::JjOperation { - context: format!("Failed to load repository: {}", error), - } - } -} - -impl From for Error { - fn from(error: jj_lib::backend::BackendError) -> Self { - Self::JjOperation { - context: format!("Backend operation failed: {}", error), - } - } -} - -impl From for Error { - fn from(error: jj_lib::transaction::TransactionCommitError) -> Self { - Self::JjOperation { - context: format!("Transaction commit failed: {}", error), - } - } -} diff --git a/src/jj/executor.rs b/src/jj/executor.rs deleted file mode 100644 index 29ae474..0000000 --- a/src/jj/executor.rs +++ /dev/null @@ -1,458 +0,0 @@ -use std::path::Path; - -use jj_lib::config::StackedConfig; -use jj_lib::repo::{Repo, StoreFactories}; -use jj_lib::settings::UserSettings; -use jj_lib::workspace::{Workspace, default_working_copy_factories}; - -use crate::error::Error; -use crate::jj::JjExecutor; - -/// JjLib provides jj repository operations using jj-lib -/// -/// This implementation uses the jj-lib crate directly for all operations, -/// providing native Rust integration with Jujutsu repositories. -pub struct JjLib { - /// The working directory path for repository operations - working_dir: std::path::PathBuf, -} - -impl JjLib { - /// Create a new JjLib instance using the current working directory - pub fn new() -> Self { - Self { - working_dir: std::env::current_dir().unwrap_or_default(), - } - } - - /// Create a new JjLib instance with a specific working directory - pub fn with_working_dir(path: impl AsRef) -> Self { - Self { - working_dir: path.as_ref().to_path_buf(), - } - } -} - -impl Default for JjLib { - fn default() -> Self { - Self::new() - } -} - -#[async_trait::async_trait] -impl JjExecutor for JjLib { - async fn is_repository(&self) -> Result { - let config = StackedConfig::with_defaults(); - let settings = UserSettings::from_config(config)?; - let store_factories = StoreFactories::default(); - let wc_factories = default_working_copy_factories(); - - // Check if the directory exists first - if !self.working_dir.exists() { - return Ok(false); - } - - // Try to load workspace from the working directory - // Walk up the directory tree until we find a repository or reach the root - let mut current_dir = self.working_dir.clone(); - loop { - match Workspace::load(&settings, ¤t_dir, &store_factories, &wc_factories) { - Ok(_) => return Ok(true), - Err(_) => { - // Move up to parent directory - if !current_dir.pop() { - // Reached root directory - break; - } - } - } - } - - Ok(false) - } - - async fn describe(&self, message: &str) -> Result<(), Error> { - // Load the repository - let config = StackedConfig::with_defaults(); - let settings = UserSettings::from_config(config)?; - let store_factories = StoreFactories::default(); - let wc_factories = default_working_copy_factories(); - - let workspace = Workspace::load( - &settings, - &self.working_dir, - &store_factories, - &wc_factories, - ) - .map_err(|_| Error::NotARepository)?; - - let repo = workspace.repo_loader().load_at_head()?; - - // Start a transaction - let mut tx = repo.start_transaction(); - tx.set_tag("args".to_string(), "jj-cz describe".to_string()); - - // Get the current working copy commit (equivalent to @ revset) - let view = tx.repo().view(); - let wc_commit_ids = view.wc_commit_ids(); - - if wc_commit_ids.is_empty() { - return Err(Error::JjOperation { - context: "No working copy commit found".to_string(), - }); - } - - // Get the first working copy commit (usually there's only one) - let wc_commit_id = wc_commit_ids.values().next().unwrap(); - let wc_commit = tx.repo().store().get_commit(wc_commit_id)?; - - // Rewrite the working copy commit with the new description - let commit_builder = tx - .repo_mut() - .rewrite_commit(&wc_commit) - .set_description(message); - - // Write the modified commit - let _new_commit = commit_builder.write()?; - - // Rebase descendants after the rewrite - tx.repo_mut().rebase_descendants()?; - - // Finish the transaction - tx.commit("jj-cz: update commit description")?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use assert_fs::prelude::*; - use std::process::Command; - - /// Initialize a jj repository in the given directory using `jj git init` - fn init_jj_repo(dir: &std::path::Path) -> std::io::Result<()> { - let output = Command::new("jj") - .args(["git", "init"]) - .current_dir(dir) - .output()?; - - if !output.status.success() { - return Err(std::io::Error::other( - format!( - "jj git init failed: {}", - String::from_utf8_lossy(&output.stderr) - ), - )); - } - Ok(()) - } - - /// Get the current commit description from a jj repository - fn get_commit_description(dir: &std::path::Path) -> std::io::Result { - let output = Command::new("jj") - .args(["log", "-r", "@", "--no-graph", "-T", "description"]) - .current_dir(dir) - .output()?; - - if !output.status.success() { - return Err(std::io::Error::other( - format!("jj log failed: {}", String::from_utf8_lossy(&output.stderr)), - )); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } - - /// Test that is_repository() returns true inside a jj repository - /// - /// This test: - /// 1. Creates a temporary directory - /// 2. Initializes a jj repository with `jj git init` - /// 3. Verifies is_repository() returns Ok(true) - #[tokio::test] - async fn is_repository_returns_true_inside_jj_repo() { - // Create a temporary directory - let temp_dir = assert_fs::TempDir::new().unwrap(); - - // Initialize a jj repository - init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); - - // Create JjLib pointing to the temp directory - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - // Verify is_repository returns true - let result = jj_lib.is_repository().await; - assert!(result.is_ok(), "Expected Ok, got {:?}", result); - assert!( - result.unwrap(), - "Expected true for directory inside jj repo" - ); - } - - /// Test that is_repository() returns true from a subdirectory of a jj repo - /// - /// This verifies that jj-lib correctly walks up the directory tree - /// to find the repository root. - #[tokio::test] - async fn is_repository_returns_true_from_subdirectory() { - // Create a temporary directory with a subdirectory - let temp_dir = assert_fs::TempDir::new().unwrap(); - let sub_dir = temp_dir.child("subdir/nested"); - sub_dir.create_dir_all().unwrap(); - - // Initialize jj repo at the root - init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); - - // Create JjLib pointing to the subdirectory - let jj_lib = JjLib::with_working_dir(sub_dir.path()); - - // Verify is_repository returns true from subdirectory - let result = jj_lib.is_repository().await; - assert!(result.is_ok(), "Expected Ok, got {:?}", result); - assert!( - result.unwrap(), - "Expected true for subdirectory inside jj repo" - ); - } - - /// Test that is_repository() returns false outside a jj repository - /// - /// This test: - /// 1. Creates an empty temporary directory (no jj init) - /// 2. Verifies is_repository() returns Ok(false) - #[tokio::test] - async fn is_repository_returns_false_outside_jj_repo() { - // Create an empty temporary directory (not a jj repo) - let temp_dir = assert_fs::TempDir::new().unwrap(); - - // Create JjLib pointing to the temp directory - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - // Verify is_repository returns false - let result = jj_lib.is_repository().await; - assert!(result.is_ok(), "Expected Ok, got {:?}", result); - assert!( - !result.unwrap(), - "Expected false for directory outside jj repo" - ); - } - - /// Test that is_repository() returns false for non-existent directory - /// - /// This verifies graceful handling of invalid paths - #[tokio::test] - async fn is_repository_returns_false_for_nonexistent_directory() { - // Create a path that doesn't exist - let nonexistent = std::path::PathBuf::from("/tmp/jj_cz_nonexistent_test_dir_12345"); - - // Make sure it doesn't exist - if nonexistent.exists() { - std::fs::remove_dir_all(&nonexistent).ok(); - } - - let jj_lib = JjLib::with_working_dir(&nonexistent); - - // Verify is_repository returns false (not an error) - let result = jj_lib.is_repository().await; - assert!(result.is_ok(), "Expected Ok, got {:?}", result); - assert!( - !result.unwrap(), - "Expected false for non-existent directory" - ); - } - - /// Test that describe() updates the commit description - /// - /// This test: - /// 1. Creates a temp jj repo - /// 2. Calls describe() with a test message - /// 3. Verifies the commit description was updated via `jj log` - #[tokio::test] - async fn describe_updates_commit_description() { - // Create a temporary directory and init jj repo - let temp_dir = assert_fs::TempDir::new().unwrap(); - init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); - - // Create JjLib pointing to the temp directory - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - // Call describe with a test message - let test_message = "feat(scope): add new feature"; - let result = jj_lib.describe(test_message).await; - - assert!( - result.is_ok(), - "describe() should succeed, got {:?}", - result - ); - - // Verify the commit description was updated - let actual_description = - get_commit_description(temp_dir.path()).expect("Failed to get commit description"); - - assert_eq!( - actual_description, test_message, - "Commit description should match the message passed to describe()" - ); - } - - /// Test that describe() handles multiline messages - #[tokio::test] - async fn describe_handles_multiline_message() { - let temp_dir = assert_fs::TempDir::new().unwrap(); - init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); - - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - let multiline_message = - "feat: add feature\n\nThis is the body of the commit.\nIt spans multiple lines."; - let result = jj_lib.describe(multiline_message).await; - - assert!( - result.is_ok(), - "describe() should succeed with multiline message" - ); - - let actual_description = get_commit_description(temp_dir.path()).unwrap(); - assert_eq!(actual_description, multiline_message); - } - - /// Test that describe() handles empty message (clears description) - #[tokio::test] - async fn describe_handles_empty_message() { - let temp_dir = assert_fs::TempDir::new().unwrap(); - init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); - - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - // First set a description - jj_lib - .describe("initial message") - .await - .expect("First describe should succeed"); - - // Then clear it with empty message - let result = jj_lib.describe("").await; - assert!( - result.is_ok(), - "describe() should succeed with empty message" - ); - - let actual_description = get_commit_description(temp_dir.path()).unwrap(); - assert_eq!(actual_description, "", "Description should be cleared"); - } - - /// Test that describe() handles special characters in message - #[tokio::test] - async fn describe_handles_special_characters() { - let temp_dir = assert_fs::TempDir::new().unwrap(); - init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); - - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - let special_message = "fix: handle \"quotes\" and 'apostrophes' & "; - let result = jj_lib.describe(special_message).await; - - assert!( - result.is_ok(), - "describe() should handle special characters" - ); - - let actual_description = get_commit_description(temp_dir.path()).unwrap(); - assert_eq!(actual_description, special_message); - } - - /// Test that describe() handles unicode characters - #[tokio::test] - async fn describe_handles_unicode() { - let temp_dir = assert_fs::TempDir::new().unwrap(); - init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); - - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - let unicode_message = "feat: support émojis 🚀 and ünïcödé characters 中文"; - let result = jj_lib.describe(unicode_message).await; - - assert!(result.is_ok(), "describe() should handle unicode"); - - let actual_description = get_commit_description(temp_dir.path()).unwrap(); - assert_eq!(actual_description, unicode_message); - } - - /// Test that describe() returns NotARepository error outside jj repo - #[tokio::test] - async fn describe_fails_outside_repository() { - // Create an empty temp directory (not a jj repo) - let temp_dir = assert_fs::TempDir::new().unwrap(); - - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - let result = jj_lib.describe("test message").await; - - assert!(result.is_err(), "describe() should fail outside repository"); - assert!( - matches!(result.unwrap_err(), Error::NotARepository), - "Expected NotARepository error" - ); - } - - /// Test that describe() can be called multiple times - #[tokio::test] - async fn describe_can_be_called_multiple_times() { - let temp_dir = assert_fs::TempDir::new().unwrap(); - init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); - - let jj_lib = JjLib::with_working_dir(temp_dir.path()); - - // Call describe multiple times - jj_lib.describe("first").await.unwrap(); - jj_lib.describe("second").await.unwrap(); - jj_lib.describe("third").await.unwrap(); - - // Only the last description should persist - let actual_description = get_commit_description(temp_dir.path()).unwrap(); - assert_eq!(actual_description, "third"); - } - - /// Test that JjLib::new() creates instance with current directory - #[test] - fn new_uses_current_directory() { - let jj_lib = JjLib::new(); - let current_dir = std::env::current_dir().unwrap(); - assert_eq!(jj_lib.working_dir, current_dir); - } - - /// Test that JjLib::with_working_dir() uses specified directory - #[test] - fn with_working_dir_uses_specified_directory() { - let custom_path = std::path::PathBuf::from("/tmp/custom"); - let jj_lib = JjLib::with_working_dir(&custom_path); - assert_eq!(jj_lib.working_dir, custom_path); - } - - /// Test that JjLib implements Default - #[test] - fn jjlib_implements_default() { - let jj_lib = JjLib::default(); - let current_dir = std::env::current_dir().unwrap(); - assert_eq!(jj_lib.working_dir, current_dir); - } - - /// Test that JjLib implements JjExecutor trait - #[test] - fn jjlib_implements_jj_executor() { - fn _accepts_executor(_e: E) {} - let jj_lib = JjLib::new(); - _accepts_executor(jj_lib); - } - - /// Test that JjLib is Send + Sync - #[test] - fn jjlib_is_send_sync() { - fn _assert_send() {} - fn _assert_sync() {} - _assert_send::(); - _assert_sync::(); - } -} diff --git a/src/jj/lib_executor.rs b/src/jj/lib_executor.rs new file mode 100644 index 0000000..9dc2e69 --- /dev/null +++ b/src/jj/lib_executor.rs @@ -0,0 +1,288 @@ +//! jj-lib implementation of JjExecutor +//! +//! This implementation uses jj-lib 0.39.0 directly for repository detection +//! and commit description, replacing the earlier shell-out approach. + +use std::path::{Path, PathBuf}; + +use jj_lib::{ + config::StackedConfig, + ref_name::WorkspaceName, + repo::{Repo, StoreFactories}, + settings::UserSettings, + workspace::{Workspace, default_working_copy_factories}, +}; + +use crate::error::Error; +use crate::jj::JjExecutor; + +/// JjLib provides jj repository operations via jj-lib 0.39.0 +#[derive(Debug)] +pub struct JjLib { + working_dir: PathBuf, +} + +impl JjLib { + /// Create a new JjLib instance using the current working directory + pub fn new() -> Result { + let working_dir = std::env::current_dir()?; + Ok(Self { working_dir }) + } + + /// Create a new JjLib instance with a specific working directory + pub fn with_working_dir(path: impl AsRef) -> Self { + Self { + working_dir: path.as_ref().to_path_buf(), + } + } + + fn load_settings() -> Result { + let config = StackedConfig::with_defaults(); + UserSettings::from_config(config).map_err(|_| Error::FailedReadingConfig) + } +} + +#[async_trait::async_trait(?Send)] +impl JjExecutor for JjLib { + async fn is_repository(&self) -> Result { + let settings = Self::load_settings()?; + let store_factories = StoreFactories::default(); + let wc_factories = default_working_copy_factories(); + Ok(Workspace::load( + &settings, + &self.working_dir, + &store_factories, + &wc_factories, + ) + .is_ok()) + } + + async fn describe(&self, message: &str) -> Result<(), Error> { + let settings = Self::load_settings()?; + let store_factories = StoreFactories::default(); + let wc_factories = default_working_copy_factories(); + + let workspace = Workspace::load( + &settings, + &self.working_dir, + &store_factories, + &wc_factories, + ) + .map_err(|_| Error::NotARepository)?; + + let repo = + workspace + .repo_loader() + .load_at_head() + .await + .map_err(|e| Error::JjOperation { + context: e.to_string(), + })?; + + let mut tx = repo.start_transaction(); + + let wc_commit_id = tx + .repo() + .view() + .get_wc_commit_id(WorkspaceName::DEFAULT) + .ok_or_else(|| Error::JjOperation { + context: "No working copy commit found".to_string(), + })? + .clone(); + + let wc_commit = + tx.repo() + .store() + .get_commit(&wc_commit_id) + .map_err(|e| Error::JjOperation { + context: e.to_string(), + })?; + + tx.repo_mut() + .rewrite_commit(&wc_commit) + .set_description(message) + .write() + .await + .map_err(|e| Error::JjOperation { + context: e.to_string(), + })?; + + tx.repo_mut() + .rebase_descendants() + .await + .map_err(|e| Error::JjOperation { + context: format!("{e:?}"), + })?; + + tx.commit("jj-cz: update commit description") + .await + .map_err(|e| Error::JjOperation { + context: e.to_string(), + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + /// Initialize a jj repository in the given directory using `jj git init` + fn init_jj_repo(dir: &Path) -> std::io::Result<()> { + let output = Command::new("jj") + .args(["git", "init"]) + .current_dir(dir) + .output()?; + + if !output.status.success() { + return Err(std::io::Error::other(format!( + "jj git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(()) + } + + /// Get the current commit description from a jj repository + fn get_commit_description(dir: &Path) -> std::io::Result { + let output = Command::new("jj") + .args(["log", "-r", "@", "--no-graph", "-T", "description"]) + .current_dir(dir) + .output()?; + + if !output.status.success() { + return Err(std::io::Error::other(format!( + "jj log failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + #[tokio::test] + async fn is_repository_returns_true_inside_jj_repo() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); + + let executor = JjLib::with_working_dir(temp_dir.path()); + let result = executor.is_repository().await; + + assert!(result.is_ok()); + assert!(result.unwrap()); + } + + #[tokio::test] + async fn is_repository_returns_false_outside_jj_repo() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + + let executor = JjLib::with_working_dir(temp_dir.path()); + let result = executor.is_repository().await; + + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + #[tokio::test] + async fn describe_updates_commit_description() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); + + let test_message = "test: initial commit"; + let executor = JjLib::with_working_dir(temp_dir.path()); + + let result = executor.describe(test_message).await; + assert!(result.is_ok(), "describe failed: {result:?}"); + + let actual = get_commit_description(temp_dir.path()).expect("Failed to get description"); + assert_eq!(actual, test_message); + } + + #[tokio::test] + async fn describe_handles_special_characters() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); + + let test_message = "feat: add feature with special chars !@#$%^&*()"; + let executor = JjLib::with_working_dir(temp_dir.path()); + + let result = executor.describe(test_message).await; + assert!(result.is_ok()); + + let actual = get_commit_description(temp_dir.path()).expect("Failed to get description"); + assert_eq!(actual, test_message); + } + + #[tokio::test] + async fn describe_handles_unicode() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); + + let test_message = "docs: add unicode support 🎉 🚀"; + let executor = JjLib::with_working_dir(temp_dir.path()); + + let result = executor.describe(test_message).await; + assert!(result.is_ok()); + + let actual = get_commit_description(temp_dir.path()).expect("Failed to get description"); + assert_eq!(actual, test_message); + } + + #[tokio::test] + async fn describe_handles_multiline_message() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); + + let test_message = "feat: add feature\n\nThis is a multiline\ndescription"; + let executor = JjLib::with_working_dir(temp_dir.path()); + + let result = executor.describe(test_message).await; + assert!(result.is_ok()); + + let actual = get_commit_description(temp_dir.path()).expect("Failed to get description"); + assert_eq!(actual, test_message); + } + + #[tokio::test] + async fn describe_fails_outside_repo() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + + let executor = JjLib::with_working_dir(temp_dir.path()); + let result = executor.describe("test: should fail").await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::NotARepository)); + } + + #[tokio::test] + async fn describe_can_be_called_multiple_times() { + let temp_dir = assert_fs::TempDir::new().unwrap(); + init_jj_repo(temp_dir.path()).expect("Failed to init jj repo"); + + let executor = JjLib::with_working_dir(temp_dir.path()); + + executor + .describe("feat: first commit") + .await + .expect("First describe failed"); + let desc1 = + get_commit_description(temp_dir.path()).expect("Failed to get first description"); + assert_eq!(desc1, "feat: first commit"); + + executor + .describe("feat: updated commit") + .await + .expect("Second describe failed"); + let desc2 = + get_commit_description(temp_dir.path()).expect("Failed to get second description"); + assert_eq!(desc2, "feat: updated commit"); + } + + #[test] + fn jj_lib_implements_jj_executor_trait() { + let lib = JjLib::with_working_dir(std::path::Path::new(".")); + fn accepts_executor(_: impl JjExecutor) {} + accepts_executor(lib); + } +} diff --git a/src/jj/mock.rs b/src/jj/mock.rs new file mode 100644 index 0000000..13cd8cb --- /dev/null +++ b/src/jj/mock.rs @@ -0,0 +1,234 @@ +//! Mock implementation of JjExecutor for testing +//! +//! This mock allows configuring responses for each method and tracks method calls +//! for verification. It's used extensively in workflow tests. + +use super::JjExecutor; +use crate::error::Error; +use async_trait::async_trait; +use std::sync::{Mutex, atomic::AtomicBool}; + +/// Mock implementation of JjExecutor for testing +#[derive(Debug)] +pub struct MockJjExecutor { + /// Response to return from is_repository() + is_repo_response: Result, + /// Response to return from describe() + describe_response: Result<(), Error>, + /// Track calls to is_repository() + is_repo_called: AtomicBool, + /// Track calls to describe() with the message passed + describe_calls: Mutex>, +} + +impl Default for MockJjExecutor { + fn default() -> Self { + Self { + is_repo_response: Ok(true), + describe_response: Ok(()), + is_repo_called: AtomicBool::new(false), + describe_calls: Mutex::new(Vec::new()), + } + } +} + +impl MockJjExecutor { + /// Create a new mock with default success responses + pub fn new() -> Self { + Self::default() + } + + /// Configure is_repository() to return a specific value + pub fn with_is_repo_response(mut self, response: Result) -> Self { + self.is_repo_response = response; + self + } + + /// Configure describe() to return a specific value + pub fn with_describe_response(mut self, response: Result<(), Error>) -> Self { + self.describe_response = response; + self + } + + /// Check if is_repository() was called + pub fn was_is_repo_called(&self) -> bool { + self.is_repo_called + .load(std::sync::atomic::Ordering::SeqCst) + } + + /// Get all messages passed to describe() + pub fn describe_messages(&self) -> Vec { + self.describe_calls.lock().unwrap().clone() + } +} + +#[async_trait(?Send)] +impl JjExecutor for MockJjExecutor { + async fn is_repository(&self) -> Result { + self.is_repo_called + .store(true, std::sync::atomic::Ordering::SeqCst); + match &self.is_repo_response { + Ok(v) => Ok(*v), + Err(e) => Err(e.clone()), + } + } + + async fn describe(&self, message: &str) -> Result<(), Error> { + self.describe_calls + .lock() + .unwrap() + .push(message.to_string()); + match &self.describe_response { + Ok(()) => Ok(()), + Err(e) => Err(e.clone()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + + /// Test that mock can implement JjExecutor trait + #[test] + fn mock_implements_trait() { + let mock = MockJjExecutor::new(); + fn _accepts_executor(_e: impl JjExecutor) {} + _accepts_executor(mock); + } + + /// Test mock is_repository() returns configured true response + #[tokio::test] + async fn mock_is_repository_returns_true() { + let mock = MockJjExecutor::new().with_is_repo_response(Ok(true)); + let result = mock.is_repository().await; + assert!(result.is_ok()); + assert!(result.unwrap()); + assert!(mock.was_is_repo_called()); + } + + /// Test mock is_repository() returns configured false response + #[tokio::test] + async fn mock_is_repository_returns_false() { + let mock = MockJjExecutor::new().with_is_repo_response(Ok(false)); + let result = mock.is_repository().await; + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + + /// Test mock is_repository() returns configured error + #[tokio::test] + async fn mock_is_repository_returns_error() { + let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository)); + let result = mock.is_repository().await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::NotARepository)); + } + + /// Test mock describe() records the message + #[tokio::test] + async fn mock_describe_records_message() { + let mock = MockJjExecutor::new(); + let result = mock.describe("test message").await; + assert!(result.is_ok()); + let messages = mock.describe_messages(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0], "test message"); + } + + /// Test mock describe() records multiple messages + #[tokio::test] + async fn mock_describe_records_multiple_messages() { + let mock = MockJjExecutor::new(); + mock.describe("first message").await.unwrap(); + mock.describe("second message").await.unwrap(); + let messages = mock.describe_messages(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0], "first message"); + assert_eq!(messages[1], "second message"); + } + + /// Test mock describe() returns configured error + #[tokio::test] + async fn mock_describe_returns_error() { + let mock = MockJjExecutor::new().with_describe_response(Err(Error::RepositoryLocked)); + let result = mock.describe("test").await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::RepositoryLocked)); + } + + /// Test mock describe() returns JjOperation error with context + #[tokio::test] + async fn mock_describe_returns_jj_operation_error() { + let mock = MockJjExecutor::new().with_describe_response(Err(Error::JjOperation { + context: "transaction failed".to_string(), + })); + let result = mock.describe("test").await; + assert!(result.is_err()); + match result.unwrap_err() { + Error::JjOperation { context } => { + assert_eq!(context, "transaction failed"); + } + _ => panic!("Expected JjOperation error"), + } + } + + /// Test mock can be used through trait object + #[tokio::test] + async fn mock_works_as_trait_object() { + let mock = MockJjExecutor::new(); + let executor: Box = Box::new(mock); + let result = executor.is_repository().await; + assert!(result.is_ok()); + } + + /// Test mock can be used through trait reference + #[tokio::test] + async fn mock_works_as_trait_reference() { + let mock = MockJjExecutor::new(); + let executor: &dyn JjExecutor = &mock; + let result = executor.is_repository().await; + assert!(result.is_ok()); + } + + /// Test mock satisfies Send + Sync bounds (compile-time check) + /// + /// JjExecutor requires Send + Sync on implementors even though returned + /// futures are !Send (due to jj-lib internals). + #[test] + fn mock_is_send_sync() { + fn assert_send() {} + fn assert_sync() {} + assert_send::(); + assert_sync::(); + } + + /// Test that empty message can be passed to describe + #[tokio::test] + async fn mock_describe_accepts_empty_message() { + let mock = MockJjExecutor::new(); + let result = mock.describe("").await; + assert!(result.is_ok()); + assert_eq!(mock.describe_messages()[0], ""); + } + + /// Test that long message can be passed to describe + #[tokio::test] + async fn mock_describe_accepts_long_message() { + let mock = MockJjExecutor::new(); + let long_message = "a".repeat(1000); + let result = mock.describe(&long_message).await; + assert!(result.is_ok()); + assert_eq!(mock.describe_messages()[0].len(), 1000); + } + + /// Test mock tracks is_repository calls + #[tokio::test] + async fn mock_tracks_is_repository_call() { + let mock = MockJjExecutor::new(); + assert!(!mock.was_is_repo_called()); + mock.is_repository().await.unwrap(); + assert!(mock.was_is_repo_called()); + } +} diff --git a/src/jj/mod.rs b/src/jj/mod.rs index 3bed694..848b4a5 100644 --- a/src/jj/mod.rs +++ b/src/jj/mod.rs @@ -1,11 +1,14 @@ use crate::error::Error; -pub mod executor; +pub mod lib_executor; + +#[cfg(any(test, feature = "test-utils"))] +pub mod mock; /// Trait for executing jj operations /// /// All methods are async for native jj-lib compatibility. -#[async_trait::async_trait] +#[async_trait::async_trait(?Send)] pub trait JjExecutor: Send + Sync { /// Check if current directory is within a jj repository async fn is_repository(&self) -> Result; @@ -18,97 +21,6 @@ pub trait JjExecutor: Send + Sync { mod tests { use super::*; - /// Mock implementation of JjExecutor for testing - /// - /// This mock allows configuring responses for each method - /// and tracks method calls for verification. - struct MockJjExecutor { - /// Response to return from is_repository() - is_repo_response: Result, - /// Response to return from describe() - describe_response: Result<(), Error>, - /// Track calls to is_repository() - is_repo_called: std::sync::atomic::AtomicBool, - /// Track calls to describe() with the message passed - describe_calls: std::sync::Mutex>, - } - - impl MockJjExecutor { - /// Create a new mock with default success responses - fn new() -> Self { - Self { - is_repo_response: Ok(true), - describe_response: Ok(()), - is_repo_called: std::sync::atomic::AtomicBool::new(false), - describe_calls: std::sync::Mutex::new(Vec::new()), - } - } - - /// Configure is_repository() to return a specific value - fn with_is_repo_response(mut self, response: Result) -> Self { - self.is_repo_response = response; - self - } - - /// Configure describe() to return a specific value - fn with_describe_response(mut self, response: Result<(), Error>) -> Self { - self.describe_response = response; - self - } - - /// Check if is_repository() was called - fn was_is_repo_called(&self) -> bool { - self.is_repo_called - .load(std::sync::atomic::Ordering::SeqCst) - } - - /// Get all messages passed to describe() - fn describe_messages(&self) -> Vec { - self.describe_calls.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl JjExecutor for MockJjExecutor { - async fn is_repository(&self) -> Result { - self.is_repo_called - .store(true, std::sync::atomic::Ordering::SeqCst); - match &self.is_repo_response { - Ok(v) => Ok(*v), - Err(e) => Err(match e { - Error::NotARepository => Error::NotARepository, - Error::JjOperation { context } => Error::JjOperation { - context: context.clone(), - }, - Error::RepositoryLocked => Error::RepositoryLocked, - _ => Error::JjOperation { - context: "mock error".to_string(), - }, - }), - } - } - - async fn describe(&self, message: &str) -> Result<(), Error> { - self.describe_calls - .lock() - .unwrap() - .push(message.to_string()); - match &self.describe_response { - Ok(()) => Ok(()), - Err(e) => Err(match e { - Error::NotARepository => Error::NotARepository, - Error::JjOperation { context } => Error::JjOperation { - context: context.clone(), - }, - Error::RepositoryLocked => Error::RepositoryLocked, - _ => Error::JjOperation { - context: "mock error".to_string(), - }, - }), - } - } - } - /// Test that JjExecutor trait definition compiles /// /// This test verifies: @@ -144,8 +56,8 @@ mod tests { fn _assert_sync() {} // MockJjExecutor implements the trait, so it must satisfy Send + Sync - _assert_send::(); - _assert_sync::(); + _assert_send::(); + _assert_sync::(); } /// Test that mock can implement JjExecutor trait @@ -153,150 +65,9 @@ mod tests { /// This is a compile-time check that the mock properly implements the trait #[test] fn mock_implements_trait() { - let mock = MockJjExecutor::new(); + let mock = mock::MockJjExecutor::new(); // If this compiles, the mock implements the trait fn _accepts_executor(_e: impl JjExecutor) {} _accepts_executor(mock); } - - /// Test mock is_repository() returns configured true response - #[tokio::test] - async fn mock_is_repository_returns_true() { - let mock = MockJjExecutor::new().with_is_repo_response(Ok(true)); - let result = mock.is_repository().await; - assert!(result.is_ok()); - assert!(result.unwrap()); - assert!(mock.was_is_repo_called()); - } - - /// Test mock is_repository() returns configured false response - #[tokio::test] - async fn mock_is_repository_returns_false() { - let mock = MockJjExecutor::new().with_is_repo_response(Ok(false)); - let result = mock.is_repository().await; - assert!(result.is_ok()); - assert!(!result.unwrap()); - } - - /// Test mock is_repository() returns configured error - #[tokio::test] - async fn mock_is_repository_returns_error() { - let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository)); - let result = mock.is_repository().await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::NotARepository)); - } - - /// Test mock describe() records the message - #[tokio::test] - async fn mock_describe_records_message() { - let mock = MockJjExecutor::new(); - let result = mock.describe("test message").await; - assert!(result.is_ok()); - let messages = mock.describe_messages(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0], "test message"); - } - - /// Test mock describe() records multiple messages - #[tokio::test] - async fn mock_describe_records_multiple_messages() { - let mock = MockJjExecutor::new(); - mock.describe("first message").await.unwrap(); - mock.describe("second message").await.unwrap(); - let messages = mock.describe_messages(); - assert_eq!(messages.len(), 2); - assert_eq!(messages[0], "first message"); - assert_eq!(messages[1], "second message"); - } - - /// Test mock describe() returns configured error - #[tokio::test] - async fn mock_describe_returns_error() { - let mock = MockJjExecutor::new().with_describe_response(Err(Error::RepositoryLocked)); - let result = mock.describe("test").await; - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), Error::RepositoryLocked)); - } - - /// Test mock describe() returns JjOperation error with context - #[tokio::test] - async fn mock_describe_returns_jj_operation_error() { - let mock = MockJjExecutor::new().with_describe_response(Err(Error::JjOperation { - context: "transaction failed".to_string(), - })); - let result = mock.describe("test").await; - assert!(result.is_err()); - match result.unwrap_err() { - Error::JjOperation { context } => { - assert_eq!(context, "transaction failed"); - } - _ => panic!("Expected JjOperation error"), - } - } - - /// Test mock can be used through trait object - #[tokio::test] - async fn mock_works_as_trait_object() { - let mock = MockJjExecutor::new(); - let executor: Box = Box::new(mock); - let result = executor.is_repository().await; - assert!(result.is_ok()); - } - - /// Test mock can be used through trait reference - #[tokio::test] - async fn mock_works_as_trait_reference() { - let mock = MockJjExecutor::new(); - let executor: &dyn JjExecutor = &mock; - let result = executor.is_repository().await; - assert!(result.is_ok()); - } - - /// Test mock satisfies Send + Sync for concurrent use - #[tokio::test] - async fn mock_is_thread_safe() { - use std::sync::Arc; - - let mock = Arc::new(MockJjExecutor::new()); - let mock_clone = Arc::clone(&mock); - - // Spawn a task that uses the mock - let handle = tokio::spawn(async move { mock_clone.is_repository().await }); - - // Use the mock from the main task - let result1 = mock.is_repository().await; - let result2 = handle.await.unwrap(); - - assert!(result1.is_ok()); - assert!(result2.is_ok()); - } - - /// Test that empty message can be passed to describe - #[tokio::test] - async fn mock_describe_accepts_empty_message() { - let mock = MockJjExecutor::new(); - let result = mock.describe("").await; - assert!(result.is_ok()); - assert_eq!(mock.describe_messages()[0], ""); - } - - /// Test that long message can be passed to describe - #[tokio::test] - async fn mock_describe_accepts_long_message() { - let mock = MockJjExecutor::new(); - let long_message = "a".repeat(1000); - let result = mock.describe(&long_message).await; - assert!(result.is_ok()); - assert_eq!(mock.describe_messages()[0].len(), 1000); - } - - /// Test mock tracks is_repository calls - #[tokio::test] - async fn mock_tracks_is_repository_call() { - let mock = MockJjExecutor::new(); - assert!(!mock.was_is_repo_called()); - mock.is_repository().await.unwrap(); - assert!(mock.was_is_repo_called()); - } } diff --git a/src/lib.rs b/src/lib.rs index 36bdc97..2055f33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,22 @@ mod commit; mod error; mod jj; mod prompts; + +pub use crate::{ + commit::types::{ + CommitMessageError, CommitType, ConventionalCommit, Description, DescriptionError, Scope, + ScopeError, + }, + error::Error, + jj::{JjExecutor, lib_executor::JjLib}, + prompts::{CommitWorkflow, Prompter}, +}; + +/// Test utilities: mock implementations for `JjExecutor` and `MockPrompts`. +/// +/// Enable with `--features test-utils` (e.g. `cargo test --features test-utils`). +#[cfg(feature = "test-utils")] +pub use crate::{ + jj::mock::MockJjExecutor, + prompts::mock::MockPrompts, +}; diff --git a/src/main.rs b/src/main.rs index 47ad8c6..0f52dda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,74 @@ -fn main() { - println!("Hello World!"); +mod cli; + +use clap::Parser as _; +use jj_cz::{CommitWorkflow, Error, JjLib}; +use std::process; + +/// Exit codes used by jj-cz +const EXIT_SUCCESS: i32 = 0; +const EXIT_CANCELLED: i32 = 130; // Same as SIGINT (Ctrl+C) +const EXIT_ERROR: i32 = 1; + +/// Map application errors to appropriate exit codes +fn error_to_exit_code(error: &Error) -> i32 { + match error { + Error::Cancelled => EXIT_CANCELLED, + Error::NotARepository => EXIT_ERROR, + Error::RepositoryLocked => EXIT_ERROR, + Error::JjOperation { .. } => EXIT_ERROR, + Error::InvalidScope(_) => EXIT_ERROR, + Error::InvalidDescription(_) => EXIT_ERROR, + Error::InvalidCommitMessage(_) => EXIT_ERROR, + Error::NonInteractive => EXIT_ERROR, + Error::FailedGettingCurrentDir => EXIT_ERROR, + Error::FailedReadingConfig => EXIT_ERROR, + } +} + +/// Check if we're running in an interactive terminal +fn is_interactive_terminal() -> bool { + use std::io::IsTerminal; + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() +} + +#[tokio::main] +async fn main() { + // Parse CLI arguments; --help and --version are handled automatically by clap + cli::Cli::parse(); + + if !is_interactive_terminal() { + eprintln!("❌ Error: jj-cz requires an interactive terminal (TTY)"); + eprintln!(" This tool cannot be used in non-interactive mode or when piping input."); + eprintln!(" Use --help for usage information."); + process::exit(EXIT_ERROR); + } + + // Create the jj executor + let executor = match JjLib::new() { + Ok(e) => e, + Err(e) => { + eprintln!("❌ Error: {}", e); + process::exit(EXIT_ERROR); + } + }; + + // Create and run the workflow + let workflow = CommitWorkflow::new(executor); + let result = workflow.run().await; + + // Handle the result + match result { + Ok(()) => { + println!("✅ Commit message applied successfully!"); + process::exit(EXIT_SUCCESS); + } + Err(Error::Cancelled) => { + println!("🟡 Operation cancelled by user."); + process::exit(EXIT_CANCELLED); + } + Err(e) => { + eprintln!("❌ Error: {}", e); + process::exit(error_to_exit_code(&e)); + } + } } diff --git a/src/prompts/mock.rs b/src/prompts/mock.rs new file mode 100644 index 0000000..9244327 --- /dev/null +++ b/src/prompts/mock.rs @@ -0,0 +1,284 @@ +//! Mock implementation of [`Prompter`] for testing +//! +//! This module is gated via `#[cfg(any(test, feature = "test-utils"))]` on its +//! declaration in `mod.rs`, so it is never compiled into production binaries. +//! +//! [`Prompter`]: super::prompter::Prompter + +use std::sync::{Arc, Mutex}; + +use crate::{ + commit::types::{CommitType, Description, Scope}, + error::Error, + prompts::prompter::Prompter, +}; + +/// Enum representing different types of mock responses +#[derive(Debug)] +enum MockResponse { + CommitType(CommitType), + Scope(Scope), + Description(Description), + Confirm(bool), + Error(Error), +} + +/// Mock implementation of [`Prompter`] for testing +/// +/// This struct allows configuring responses for each prompt type and tracks +/// which prompts were called during test execution. +#[derive(Debug, Default, Clone)] +pub struct MockPrompts { + /// Queue of responses to return for each prompt call + responses: Arc>>, + /// Track which prompts were called (for verification) + prompts_called: Arc>>, + /// Messages emitted via emit_message() for test assertion + messages: Arc>>, +} + +impl MockPrompts { + /// Create a new MockPrompts with empty response queue + pub fn new() -> Self { + Self::default() + } + + /// Configure the mock to return a specific commit type + pub fn with_commit_type(self, commit_type: CommitType) -> Self { + self.responses + .lock() + .unwrap() + .push(MockResponse::CommitType(commit_type)); + self + } + + /// Configure the mock to return a specific scope + pub fn with_scope(self, scope: Scope) -> Self { + self.responses + .lock() + .unwrap() + .push(MockResponse::Scope(scope)); + self + } + + /// Configure the mock to return a specific description + pub fn with_description(self, description: Description) -> Self { + self.responses + .lock() + .unwrap() + .push(MockResponse::Description(description)); + self + } + + /// Configure the mock to return a specific confirmation response + pub fn with_confirm(self, confirm: bool) -> Self { + self.responses + .lock() + .unwrap() + .push(MockResponse::Confirm(confirm)); + self + } + + /// Configure the mock to return an error + pub fn with_error(self, error: Error) -> Self { + self.responses + .lock() + .unwrap() + .push(MockResponse::Error(error)); + self + } + + /// Check if select_commit_type was called + pub fn was_commit_type_called(&self) -> bool { + self.prompts_called + .lock() + .unwrap() + .contains(&"select_commit_type".to_string()) + } + + /// Check if input_scope was called + pub fn was_scope_called(&self) -> bool { + self.prompts_called + .lock() + .unwrap() + .contains(&"input_scope".to_string()) + } + + /// Check if input_description was called + pub fn was_description_called(&self) -> bool { + self.prompts_called + .lock() + .unwrap() + .contains(&"input_description".to_string()) + } + + /// Check if confirm_apply was called + pub fn was_confirm_called(&self) -> bool { + self.prompts_called + .lock() + .unwrap() + .contains(&"confirm_apply".to_string()) + } + + /// Get all messages emitted via emit_message() + pub fn emitted_messages(&self) -> Vec { + self.messages.lock().unwrap().clone() + } +} + +impl Prompter for MockPrompts { + fn select_commit_type(&self) -> Result { + self.prompts_called + .lock() + .unwrap() + .push("select_commit_type".to_string()); + + match self.responses.lock().unwrap().remove(0) { + MockResponse::CommitType(ct) => Ok(ct), + MockResponse::Error(e) => Err(e), + _ => panic!("MockPrompts: Expected CommitType response, got different type"), + } + } + + fn input_scope(&self) -> Result { + self.prompts_called + .lock() + .unwrap() + .push("input_scope".to_string()); + + match self.responses.lock().unwrap().remove(0) { + MockResponse::Scope(scope) => Ok(scope), + MockResponse::Error(e) => Err(e), + _ => panic!("MockPrompts: Expected Scope response, got different type"), + } + } + + fn input_description(&self) -> Result { + self.prompts_called + .lock() + .unwrap() + .push("input_description".to_string()); + + match self.responses.lock().unwrap().remove(0) { + MockResponse::Description(desc) => Ok(desc), + MockResponse::Error(e) => Err(e), + _ => panic!("MockPrompts: Expected Description response, got different type"), + } + } + + fn confirm_apply(&self, _message: &str) -> Result { + self.prompts_called + .lock() + .unwrap() + .push("confirm_apply".to_string()); + + match self.responses.lock().unwrap().remove(0) { + MockResponse::Confirm(confirm) => Ok(confirm), + MockResponse::Error(e) => Err(e), + _ => panic!("MockPrompts: Expected Confirm response, got different type"), + } + } + + fn emit_message(&self, msg: &str) { + self.messages.lock().unwrap().push(msg.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commit::types::{CommitType, Description, Scope}; + + #[test] + fn mock_prompts_creation() { + let mock = MockPrompts::new(); + assert!(matches!(mock, MockPrompts { .. })); + } + + #[test] + fn mock_prompts_implements_trait() { + let mock = MockPrompts::new(); + fn _accepts_prompter(_p: impl Prompter) {} + _accepts_prompter(mock); + } + + #[test] + fn mock_select_commit_type() { + let mock = MockPrompts::new().with_commit_type(CommitType::Feat); + let result = mock.select_commit_type(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), CommitType::Feat); + assert!(mock.was_commit_type_called()); + } + + #[test] + fn mock_input_scope() { + let scope = Scope::parse("test-scope").unwrap(); + let mock = MockPrompts::new().with_scope(scope.clone()); + let result = mock.input_scope(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), scope); + assert!(mock.was_scope_called()); + } + + #[test] + fn mock_input_description() { + let desc = Description::parse("test description").unwrap(); + let mock = MockPrompts::new().with_description(desc.clone()); + let result = mock.input_description(); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), desc); + assert!(mock.was_description_called()); + } + + #[test] + fn mock_confirm_apply() { + let mock = MockPrompts::new().with_confirm(true); + let result = mock.confirm_apply("test message"); + assert!(result.is_ok()); + assert!(result.unwrap()); + assert!(mock.was_confirm_called()); + } + + #[test] + fn mock_error_response() { + let mock = MockPrompts::new().with_error(Error::Cancelled); + let result = mock.select_commit_type(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::Cancelled)); + } + + #[test] + fn mock_tracks_prompt_calls() { + let mock = MockPrompts::new() + .with_commit_type(CommitType::Fix) + .with_scope(Scope::empty()) + .with_description(Description::parse("test").unwrap()) + .with_confirm(true); + + mock.select_commit_type().unwrap(); + mock.input_scope().unwrap(); + mock.input_description().unwrap(); + mock.confirm_apply("test").unwrap(); + + assert!(mock.was_commit_type_called()); + assert!(mock.was_scope_called()); + assert!(mock.was_description_called()); + assert!(mock.was_confirm_called()); + } + + #[test] + fn mock_emit_message_records_messages() { + let mock = MockPrompts::new(); + mock.emit_message("hello"); + mock.emit_message("world"); + let msgs = mock.emitted_messages(); + assert_eq!(msgs, vec!["hello", "world"]); + } + + #[test] + fn mock_emit_message_starts_empty() { + let mock = MockPrompts::new(); + assert!(mock.emitted_messages().is_empty()); + } +} diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 8b13789..6ee0fdb 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -1 +1,7 @@ +#[cfg(any(test, feature = "test-utils"))] +pub mod mock; +pub mod prompter; +pub mod workflow; +pub use prompter::Prompter; +pub use workflow::CommitWorkflow; diff --git a/src/prompts/prompter.rs b/src/prompts/prompter.rs new file mode 100644 index 0000000..68c5113 --- /dev/null +++ b/src/prompts/prompter.rs @@ -0,0 +1,184 @@ +//! Prompt abstraction for the interactive commit workflow +//! +//! This module provides the [`Prompter`] trait and its production +//! implementation [`RealPrompts`]. The trait is the seam that allows +//! [`CommitWorkflow`](super::CommitWorkflow) to use real interactive prompts +//! in production while accepting mock implementations in tests. + +use crate::{ + commit::types::{CommitType, Description, Scope}, + error::Error, +}; + +/// Abstraction over prompt operations used by the commit workflow +/// +/// Implement this trait to supply a custom front-end (interactive TUI, mock, +/// headless, etc.) to [`CommitWorkflow`](super::CommitWorkflow). +pub trait Prompter: Send + Sync { + /// Prompt the user to select a commit type + fn select_commit_type(&self) -> Result; + + /// Prompt the user to input an optional scope + fn input_scope(&self) -> Result; + + /// Prompt the user to input a required description + fn input_description(&self) -> Result; + + /// Prompt the user to confirm applying the commit message + fn confirm_apply(&self, message: &str) -> Result; + + /// Display a message to the user (errors, feedback, status) + /// + /// In production this prints to stdout. In tests, implementations + /// typically record the message for later assertion. + fn emit_message(&self, msg: &str); +} + +/// Production implementation of [`Prompter`] using the `inquire` crate +#[derive(Debug)] +pub struct RealPrompts; + +impl Prompter for RealPrompts { + fn select_commit_type(&self) -> Result { + use inquire::Select; + + let options: Vec<_> = CommitType::all() + .iter() + .map(|ct| format!("{}: {}", ct, ct.description())) + .collect(); + + let answer = Select::new("Select commit type:", options) + .with_page_size(11) + .with_help_message( + "Use arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.", + ) + .prompt() + .map_err(|_| Error::Cancelled)?; + + // Extract the commit type from the selected option + let selected_type = answer + .split(':') + .next() + .ok_or_else(|| Error::JjOperation { + context: "Failed to parse selected commit type".to_string(), + })? + .trim(); + + CommitType::all() + .iter() + .find(|ct| ct.as_str() == selected_type) + .copied() + .ok_or_else(|| Error::JjOperation { + context: format!("Unknown commit type: {}", selected_type), + }) + } + + fn input_scope(&self) -> Result { + use inquire::Text; + + let answer = Text::new("Enter scope (optional):") + .with_help_message( + "Scope is optional. If provided, it should be a noun representing the section of code affected (e.g., 'api', 'ui', 'config'). Max 30 characters.", + ) + .with_placeholder("Leave empty if no scope") + .prompt_skippable() + .map_err(|_| Error::Cancelled)?; + + // Empty input is valid (no scope) + let answer_str = match answer { + Some(s) => s, + None => return Ok(Scope::empty()), + }; + + if answer_str.trim().is_empty() { + return Ok(Scope::empty()); + } + + // Parse and validate the scope + Scope::parse(answer_str.trim()).map_err(|e| Error::InvalidScope(e.to_string())) + } + + fn input_description(&self) -> Result { + use inquire::Text; + + loop { + let answer = Text::new("Enter description (required):") + .with_help_message( + "Description is required. Short summary in imperative mood \ + (e.g., 'add feature', 'fix bug'). Soft limit: 50 characters.", + ) + .prompt() + .map_err(|_| Error::Cancelled)?; + + let trimmed = answer.trim(); + if trimmed.is_empty() { + println!("❌ Description cannot be empty. Please provide a description."); + continue; + } + + // parse() only fails on empty — already handled above + let Ok(desc) = Description::parse(trimmed) else { + println!("❌ Description cannot be empty. Please provide a description."); + continue; + }; + + // Soft limit warning: over 50 chars is allowed but may push the + // combined first line over 72 characters. + if desc.len() > Description::MAX_LENGTH { + println!( + "⚠️ Description is {} characters (soft limit is {}). \ + The combined commit line must still be ≤ 72 characters.", + desc.len(), + Description::MAX_LENGTH + ); + } + + return Ok(desc); + } + } + + fn confirm_apply(&self, message: &str) -> Result { + use inquire::Confirm; + + // Show preview + println!(); + println!("📝 Commit Message Preview:"); + println!( + "┌─────────────────────────────────────────────────────────────────────────────────────────────────┐" + ); + println!("│ {}│", message); + // Pad with spaces to fill the box + let padding = 72_usize.saturating_sub(message.chars().count()); + if padding > 0 { + println!("│{:padding$}│", ""); + } + println!( + "└─────────────────────────────────────────────────────────────────────────────────────────────────┘" + ); + println!(); + + // Get confirmation + Confirm::new("Apply this commit message?") + .with_default(true) + .with_help_message("Select 'No' to cancel and start over") + .prompt() + .map_err(|_| Error::Cancelled) + } + + fn emit_message(&self, msg: &str) { + println!("{}", msg); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test RealPrompts implements Prompter trait + #[test] + fn real_prompts_implements_trait() { + let real = RealPrompts; + fn _accepts_prompter(_p: impl Prompter) {} + _accepts_prompter(real); + } +} diff --git a/src/prompts/workflow.rs b/src/prompts/workflow.rs new file mode 100644 index 0000000..29c37ea --- /dev/null +++ b/src/prompts/workflow.rs @@ -0,0 +1,512 @@ +//! Interactive commit workflow orchestration +//! +//! This module provides the CommitWorkflow struct that guides users through +//! creating a conventional commit message using interactive prompts. + +use crate::{ + commit::types::{CommitMessageError, CommitType, ConventionalCommit, Description, Scope}, + error::Error, + jj::JjExecutor, + prompts::prompter::{Prompter, RealPrompts}, +}; + +/// Orchestrates the interactive commit workflow +/// +/// This struct handles the complete user interaction flow: +/// 1. Check if we're in a jj repository +/// 2. Select commit type from 11 options +/// 3. Optionally input scope (validated) +/// 4. Input required description (validated) +/// 5. Preview formatted message and confirm +/// 6. Apply the message to the current change +/// +/// Uses dependency injection for prompts to enable testing without TUI. +#[derive(Debug)] +pub struct CommitWorkflow { + executor: J, + prompts: P, +} + +impl CommitWorkflow { + /// Create a new CommitWorkflow with the given executor + /// + /// Uses RealPrompts by default for interactive TUI prompts. + pub fn new(executor: J) -> Self { + Self::with_prompts(executor, RealPrompts) + } +} + +impl CommitWorkflow { + /// Create a new CommitWorkflow with custom prompts + /// + /// This allows using MockPrompts in tests to avoid TUI hanging. + pub fn with_prompts(executor: J, prompts: P) -> Self { + Self { executor, prompts } + } + + /// Run the complete interactive workflow + /// + /// Returns Ok(()) on successful completion, or an error if: + /// - Not in a jj repository + /// - User cancels the workflow + /// - Repository operation fails + /// - Message validation fails + pub async fn run(&self) -> Result<(), Error> { + // Verify we're in a jj repository + if !self.executor.is_repository().await? { + return Err(Error::NotARepository); + } + + // Step 1: Select commit type (kept across retries) + let commit_type = self.type_selection().await?; + + // Steps 2–4 loop: re-prompt scope and description when the combined + // first line would exceed 72 characters (issue 3.4). + loop { + // Step 2: Input scope (optional) + let scope = self.scope_input().await?; + + // Step 3: Input description (required) + let description = self.description_input().await?; + + // Step 4: Preview and confirm + match self + .preview_and_confirm(commit_type, scope, description) + .await + { + Ok(conventional_commit) => { + // Step 5: Apply the message + self.executor + .describe(&conventional_commit.to_string()) + .await?; + return Ok(()); + } + Err(Error::InvalidCommitMessage(_)) => { + // The scope/description combination exceeds 72 characters. + // The user has already been shown the error via emit_message. + // Loop back to re-prompt scope and description (type is kept). + continue; + } + Err(e) => return Err(e), + } + } + } + + /// Prompt user to select a commit type from the 11 available options + async fn type_selection(&self) -> Result { + self.prompts.select_commit_type() + } + + /// Prompt user to input an optional scope + /// + /// Returns Ok(Scope) with the validated scope, or Error::Cancelled if user cancels + async fn scope_input(&self) -> Result { + self.prompts.input_scope() + } + + /// Prompt user to input a required description + /// + /// Returns Ok(Description) with the validated description, or Error::Cancelled if user cancels + async fn description_input(&self) -> Result { + self.prompts.input_description() + } + + /// Preview the formatted conventional commit message and get user confirmation + /// + /// This method also validates that the complete first line doesn't exceed 72 characters + async fn preview_and_confirm( + &self, + commit_type: CommitType, + scope: Scope, + description: Description, + ) -> Result { + // Format the message for preview + let message = ConventionalCommit::format_preview(commit_type, &scope, &description); + + // Try to build the conventional commit (this validates the 72-char limit) + let conventional_commit: ConventionalCommit = match ConventionalCommit::new( + commit_type, + scope.clone(), + description.clone(), + ) { + Ok(cc) => cc, + Err(CommitMessageError::FirstLineTooLong { actual, max }) => { + self.prompts.emit_message("❌ Message too long!"); + self.prompts.emit_message(&format!( + "The complete first line must be ≤ {} characters.", + max + )); + self.prompts + .emit_message(&format!("Current length: {} characters", actual)); + self.prompts.emit_message(""); + self.prompts.emit_message("Formatted message would be:"); + self.prompts.emit_message(&message); + self.prompts.emit_message(""); + self.prompts + .emit_message("Please try again with a shorter scope or description."); + return Err(Error::InvalidCommitMessage(format!( + "First line too long: {} > {}", + actual, max + ))); + } + Err(CommitMessageError::InvalidConventionalFormat { reason }) => { + return Err(Error::InvalidCommitMessage(format!( + "Internal error: generated message failed conventional commit validation: {}", + reason + ))); + } + }; + + // Get confirmation from user + let confirmed = self.prompts.confirm_apply(&message)?; + + if confirmed { + Ok(conventional_commit) + } else { + Err(Error::Cancelled) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::Error; + use crate::jj::mock::MockJjExecutor; + use crate::prompts::mock::MockPrompts; + + /// Test that CommitWorkflow can be created with a mock executor + #[test] + fn workflow_creation() { + let mock = MockJjExecutor::new(); + let workflow = CommitWorkflow::new(mock); + // If this compiles, the workflow is properly typed + assert!(matches!(workflow, CommitWorkflow { .. })); + } + + /// Test workflow returns NotARepository when is_repository() returns false + #[tokio::test] + async fn workflow_returns_not_a_repository() { + let mock = MockJjExecutor::new().with_is_repo_response(Ok(false)); + let workflow = CommitWorkflow::new(mock); + let result: Result<(), Error> = workflow.run().await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::NotARepository)); + } + + /// Test workflow returns NotARepository when is_repository() returns error + #[tokio::test] + async fn workflow_returns_repository_error() { + let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository)); + let workflow = CommitWorkflow::new(mock); + let result: Result<(), Error> = workflow.run().await; + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::NotARepository)); + } + + /// Test that type_selection returns a valid CommitType + #[tokio::test] + async fn type_selection_returns_valid_type() { + // Updated to use mock prompts to avoid TUI hanging + let mock_executor = MockJjExecutor::new(); + let mock_prompts = MockPrompts::new().with_commit_type(CommitType::Feat); + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + + // Now we can actually test the method with mock prompts + let result = workflow.type_selection().await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), CommitType::Feat); + } + + /// Test that scope_input returns a valid Scope + #[tokio::test] + async fn scope_input_returns_valid_scope() { + let mock_executor = MockJjExecutor::new(); + let mock_prompts = MockPrompts::new().with_scope(Scope::parse("test").unwrap()); + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + + let result = workflow.scope_input().await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Scope::parse("test").unwrap()); + } + + /// Test that description_input returns a valid Description + #[tokio::test] + async fn description_input_returns_valid_description() { + let mock_executor = MockJjExecutor::new(); + let mock_prompts = MockPrompts::new().with_description(Description::parse("test").unwrap()); + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + + let result = workflow.description_input().await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Description::parse("test").unwrap()); + } + + /// Test that preview_and_confirm returns a ConventionalCommit + #[tokio::test] + async fn preview_and_confirm_returns_conventional_commit() { + let mock_executor = MockJjExecutor::new(); + let mock_prompts = MockPrompts::new().with_confirm(true); + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + + let commit_type = CommitType::Feat; + let scope = Scope::empty(); + let description = Description::parse("test description").unwrap(); + + let result = workflow + .preview_and_confirm(commit_type, scope, description) + .await; + assert!(result.is_ok()); + } + + /// Test workflow error handling for describe failure + #[tokio::test] + async fn workflow_handles_describe_error() { + // Test the mock executor methods directly + let mock = MockJjExecutor::new() + .with_is_repo_response(Ok(true)) + .with_describe_response(Err(Error::RepositoryLocked)); + + // Verify the mock behaves as expected + assert!(mock.is_repository().await.is_ok()); + assert!(mock.describe("test").await.is_err()); + + // Also test with a working mock + let working_mock = MockJjExecutor::new(); + let workflow = CommitWorkflow::new(working_mock); + // We can't complete the full workflow without mocking prompts, + // but we can verify the workflow was created successfully + assert!(matches!(workflow, CommitWorkflow { .. })); + } + + /// Test that workflow implements Debug trait + #[test] + fn workflow_implements_debug() { + let mock = MockJjExecutor::new(); + let workflow = CommitWorkflow::new(mock); + let debug_output = format!("{:?}", workflow); + assert!(debug_output.contains("CommitWorkflow")); + } + + /// Test complete workflow with mock prompts (happy path) + #[tokio::test] + async fn test_complete_workflow_happy_path() { + // Create mock executor that returns true for is_repository + let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + + // Create mock prompts with successful responses + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Feat) + .with_scope(Scope::empty()) + .with_description(Description::parse("add new feature").unwrap()) + .with_confirm(true); + + // Create workflow with both mocks + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + + // Run the workflow - should succeed + let result: Result<(), Error> = workflow.run().await; + assert!(result.is_ok()); + } + + /// Test workflow cancellation at type selection + #[tokio::test] + async fn test_workflow_cancellation_at_type_selection() { + let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + let mock_prompts = MockPrompts::new().with_error(Error::Cancelled); + + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + let result: Result<(), Error> = workflow.run().await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::Cancelled)); + } + + /// Test workflow cancellation at confirmation + #[tokio::test] + async fn test_workflow_cancellation_at_confirmation() { + let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Fix) + .with_scope(Scope::parse("api").unwrap()) + .with_description(Description::parse("fix bug").unwrap()) + .with_confirm(false); // User cancels at confirmation + + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + let result: Result<(), Error> = workflow.run().await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::Cancelled)); + } + + /// Test workflow loops back on line length error, re-prompting scope and description + /// + /// "feat(very-long-scope-name): " + 45 'a's = 4+1+20+3+45 = 73 chars → too long (first pass) + /// "feat: short description" = 4+2+17 = 23 chars → fine (second pass) + #[tokio::test] + async fn test_workflow_line_length_validation() { + let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Feat) + // First iteration: scope + description exceed 72 chars combined + .with_scope(Scope::parse("very-long-scope-name").unwrap()) + .with_description(Description::parse("a".repeat(45)).unwrap()) + // Second iteration: short enough to succeed + .with_scope(Scope::empty()) + .with_description(Description::parse("short description").unwrap()) + .with_confirm(true); + + // Clone before moving into workflow so we can inspect emitted messages after + let mock_prompts_handle = mock_prompts.clone(); + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + let result: Result<(), Error> = workflow.run().await; + + // Should succeed after the retry + assert!( + result.is_ok(), + "Workflow should succeed after retry, got: {:?}", + result + ); + + // Error messages about the line being too long must have been emitted + // (via emit_message, not bare println) during the first iteration + let messages = mock_prompts_handle.emitted_messages(); + assert!( + messages.iter().any(|m| m.contains("too long")), + "Expected a 'too long' message, got: {:?}", + messages + ); + assert!( + messages.iter().any(|m| m.contains("72")), + "Expected a message about the 72-char limit, got: {:?}", + messages + ); + } + + /// Test workflow with invalid scope + #[tokio::test] + async fn test_workflow_invalid_scope() { + let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + + // Create mock prompts that would return invalid scope + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Docs) + .with_error(Error::InvalidScope( + "Invalid characters in scope".to_string(), + )); + + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + let result: Result<(), Error> = workflow.run().await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::InvalidScope(_))); + } + + /// Test workflow with invalid description + #[tokio::test] + async fn test_workflow_invalid_description() { + let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Refactor) + .with_scope(Scope::empty()) + .with_error(Error::InvalidDescription( + "Description cannot be empty".to_string(), + )); + + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + let result: Result<(), Error> = workflow.run().await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_))); + } + + /// Test that mock prompts track method calls correctly + #[tokio::test] + async fn test_mock_prompts_track_calls() { + let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Feat) + .with_scope(Scope::empty()) + .with_description(Description::parse("test").unwrap()) + .with_confirm(true); + + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + // We don't need to run the full workflow, just verify the mock was created correctly + assert!(matches!(workflow, CommitWorkflow { .. })); + } + + /// Test workflow with all commit types + #[tokio::test] + async fn test_all_commit_types() { + let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + + for commit_type in CommitType::all() { + let mock_prompts = MockPrompts::new() + .with_commit_type(*commit_type) + .with_scope(Scope::empty()) + .with_description(Description::parse("test").unwrap()) + .with_confirm(true); + + let workflow = CommitWorkflow::with_prompts( + MockJjExecutor::new().with_is_repo_response(Ok(true)), + mock_prompts, + ); + let result: Result<(), Error> = workflow.run().await; + assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type); + } + } + + /// Test workflow with various scope formats + #[tokio::test] + async fn test_various_scope_formats() { + let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true)); + + // Test empty scope + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Feat) + .with_scope(Scope::empty()) + .with_description(Description::parse("test").unwrap()) + .with_confirm(true); + + let workflow = CommitWorkflow::with_prompts( + MockJjExecutor::new().with_is_repo_response(Ok(true)), + mock_prompts, + ); + { + let result: Result<(), Error> = workflow.run().await; + assert!(result.is_ok()); + } + + // Test valid scope + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Feat) + .with_scope(Scope::parse("api").unwrap()) + .with_description(Description::parse("test").unwrap()) + .with_confirm(true); + + let workflow = CommitWorkflow::with_prompts( + MockJjExecutor::new().with_is_repo_response(Ok(true)), + mock_prompts, + ); + { + let result: Result<(), Error> = workflow.run().await; + assert!(result.is_ok()); + } + } + + /// Test that workflow can be used with trait objects for both executor and prompts + #[test] + fn workflow_works_with_trait_objects() { + let mock_executor = MockJjExecutor::new(); + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Feat) + .with_scope(Scope::empty()) + .with_description(Description::parse("test").unwrap()) + .with_confirm(true); + + let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); + assert!(matches!(workflow, CommitWorkflow { .. })); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..b2d002e --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,99 @@ +use assert_fs::TempDir; +#[cfg(feature = "test-utils")] +use jj_cz::{CommitType, Description, MockPrompts, Scope}; +use jj_cz::{CommitWorkflow, Error, JjLib}; +#[cfg(feature = "test-utils")] +use std::process::Command; + +/// Helper to initialize a temporary jj repository +#[cfg(feature = "test-utils")] +fn init_jj_repo(temp_dir: &TempDir) { + let status = Command::new("jj") + .args(["git", "init"]) + .current_dir(temp_dir) + .status() + .expect("Failed to initialize jj repository"); + assert!(status.success(), "jj git init failed"); +} + +#[cfg(feature = "test-utils")] +#[tokio::test] +async fn test_happy_path_integration() { + // T037: Happy path integration test + let temp_dir = TempDir::new().unwrap(); + init_jj_repo(&temp_dir); + + // Create mock prompts that simulate a successful workflow + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Feat) + .with_scope(Scope::empty()) + .with_description(Description::parse("add new feature").unwrap()) + .with_confirm(true); + + // Create a mock executor that tracks calls + let executor = JjLib::with_working_dir(temp_dir.path()); + let workflow = CommitWorkflow::with_prompts(executor, mock_prompts); + + let result = workflow.run().await; + + // The workflow should complete successfully + assert!( + result.is_ok(), + "Workflow should complete successfully: {:?}", + result + ); +} + +#[tokio::test] +async fn test_not_in_repo() { + // T038: Not-in-repo integration test + let temp_dir = TempDir::new().unwrap(); + // Don't initialize jj repo + + // Create executor with the temp directory (which is not a jj repo) + let executor = JjLib::with_working_dir(temp_dir.path()); + let workflow = CommitWorkflow::new(executor); + + let result = workflow.run().await; + + // Should fail with NotARepository error + assert!(matches!(result, Err(Error::NotARepository))); +} + +#[cfg(feature = "test-utils")] +#[tokio::test] +async fn test_cancellation() { + // T039: Cancellation integration test + // This is tricky to test directly without a TTY + // We'll test the error handling path instead + + let temp_dir = TempDir::new().unwrap(); + init_jj_repo(&temp_dir); + + // Create a mock executor that simulates cancellation + struct CancelMock; + + #[async_trait::async_trait(?Send)] + impl jj_cz::JjExecutor for CancelMock { + async fn is_repository(&self) -> Result { + Ok(true) + } + + async fn describe(&self, _message: &str) -> Result<(), Error> { + Err(Error::Cancelled) + } + } + + let executor = CancelMock; + let mock_prompts = MockPrompts::new() + .with_commit_type(CommitType::Feat) + .with_scope(Scope::empty()) + .with_description(Description::parse("test").unwrap()) + .with_confirm(true); + let workflow = CommitWorkflow::with_prompts(executor, mock_prompts); + + let result = workflow.run().await; + + // Should fail with Cancelled error + assert!(matches!(result, Err(Error::Cancelled))); +} diff --git a/tests/error_tests.rs b/tests/error_tests.rs new file mode 100644 index 0000000..52b2996 --- /dev/null +++ b/tests/error_tests.rs @@ -0,0 +1,135 @@ +//! Comprehensive tests for error handling +//! +//! These tests ensure all error variants are properly handled +//! and that error conversions work correctly. + +use jj_cz::{CommitMessageError, DescriptionError, Error, ScopeError}; + +/// Test that all error variants can be created and displayed +#[test] +fn test_all_error_variants() { + // Domain errors + let invalid_scope = Error::InvalidScope("test".to_string()); + let _invalid_desc = Error::InvalidDescription("test".to_string()); + let _invalid_msg = Error::InvalidCommitMessage("test".to_string()); + + // Infrastructure errors + let not_repo = Error::NotARepository; + let _jj_op = Error::JjOperation { + context: "test".to_string(), + }; + let _repo_locked = Error::RepositoryLocked; + let _failed_dir = Error::FailedGettingCurrentDir; + let _failed_config = Error::FailedReadingConfig; + + // Application errors + let cancelled = Error::Cancelled; + let _non_interactive = Error::NonInteractive; + + // Verify all variants can be displayed + assert_eq!(format!("{}", invalid_scope), "Invalid scope: test"); + assert_eq!(format!("{}", not_repo), "Not a Jujutsu repository"); + assert_eq!(format!("{}", cancelled), "Operation cancelled by user"); +} + +/// Test error conversions from domain types +#[test] +fn test_error_conversions() { + // ScopeError -> Error::InvalidScope + let scope_err = ScopeError::TooLong { + actual: 31, + max: 30, + }; + let error: Error = scope_err.into(); + assert!(matches!(error, Error::InvalidScope(_))); + + // DescriptionError -> Error::InvalidDescription + let desc_err = DescriptionError::Empty; + let error: Error = desc_err.into(); + assert!(matches!(error, Error::InvalidDescription(_))); + + // CommitMessageError -> Error::InvalidCommitMessage + let msg_err = CommitMessageError::FirstLineTooLong { + actual: 73, + max: 72, + }; + let error: Error = msg_err.into(); + assert!(matches!(error, Error::InvalidCommitMessage(_))); +} + +/// Test error equality and partial equality +#[test] +fn test_error_equality() { + let err1 = Error::NotARepository; + let err2 = Error::NotARepository; + assert_eq!(err1, err2); + + let err3 = Error::Cancelled; + assert_ne!(err1, err3); +} + +/// Test error debugging +#[test] +fn test_error_debug() { + let error = Error::JjOperation { + context: "test operation".to_string(), + }; + let debug_str = format!("{:?}", error); + assert!(debug_str.contains("JjOperation")); + assert!(debug_str.contains("test operation")); +} + +/// Test error cloning +#[test] +fn test_error_clone() { + let original = Error::JjOperation { + context: "original".to_string(), + }; + let cloned = original.clone(); + assert_eq!(original, cloned); +} + +/// Test error send and sync traits +#[test] +fn test_error_send_sync() { + fn assert_send() {} + fn assert_sync() {} + + let _error = Error::NotARepository; + assert_send::(); + assert_sync::(); + + // Test with owned data + let _owned_error = Error::JjOperation { + context: "test".to_string(), + }; + assert_send::(); + assert_sync::(); +} + +/// Test error matching patterns +#[test] +fn test_error_matching() { + let error = Error::Cancelled; + + match error { + Error::Cancelled => {} + Error::NotARepository => panic!("Should not match"), + Error::JjOperation { context } => panic!("Should not match: {}", context), + _ => panic!("Should not match other variants"), + } +} + +/// Test error context extraction +#[test] +fn test_jj_operation_context() { + let error = Error::JjOperation { + context: "repository locked".to_string(), + }; + + if let Error::JjOperation { context } = error { + assert_eq!(context, "repository locked"); + } else { + panic!("Expected JjOperation variant"); + } +}