From 2740e805c1f3ffdc6504162df21a548872caa1b4 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Feb 2026 19:37:36 +0100 Subject: [PATCH 1/8] feat(deps): add project dependencies --- Cargo.lock | 897 +++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 7 + 2 files changed, 904 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fe71878..e2788f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,903 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "assert_fs" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652f6cb1f516886fcfee5e7a5c078b9ade62cfcb889524efe5a64d682dd27a9" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "git-conventional" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6a949b7fcc81df22526032dcddb006e78c8575e47b0e7ba57d9960570a57bc4" +dependencies = [ + "unicase", + "winnow", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "inquire" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae51d5da01ce7039024fbdec477767c102c454dbdb09d4e2a432ece705b1b25d" +dependencies = [ + "bitflags", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "jj-cz" version = "0.1.0" +dependencies = [ + "assert_cmd", + "assert_fs", + "clap", + "git-conventional", + "inquire", + "predicates", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/Cargo.toml b/Cargo.toml index 8614897..df3132d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,13 @@ path = "src/main.rs" name = "jj-cz" [dependencies] +assert_cmd = "2.1.2" +assert_fs = "1.1.3" +clap = { version = "4.5.57", features = ["derive"] } +git-conventional = "0.12.9" +inquire = "0.9.2" +predicates = "3.1.3" +thiserror = "2.0.18" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } -- 2.49.1 From c7239cb1b07ca01352db7b2b55cb526ce0231f32 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Feb 2026 19:37:36 +0100 Subject: [PATCH 2/8] feat: create module structure --- src/cli/mod.rs | 0 src/commit/mod.rs | 0 src/jj/mod.rs | 0 src/lib.rs | 18 ++++-------------- src/prompts/mod.rs | 0 5 files changed, 4 insertions(+), 14 deletions(-) create mode 100644 src/cli/mod.rs create mode 100644 src/commit/mod.rs create mode 100644 src/jj/mod.rs create mode 100644 src/prompts/mod.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/commit/mod.rs b/src/commit/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/jj/mod.rs b/src/jj/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/lib.rs b/src/lib.rs index b93cf3f..0e15477 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,4 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +mod cli; +mod commit; +mod jj; +mod prompts; diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs new file mode 100644 index 0000000..e69de29 -- 2.49.1 From 0910761da89f3532bed831d22b82b9c1fd74cfbc Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Feb 2026 19:37:36 +0100 Subject: [PATCH 3/8] feat(error): create base Error enum --- src/error.rs | 20 ++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 21 insertions(+) create mode 100644 src/error.rs diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..48a5f40 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,20 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + // Domain errors + #[error("Invalid scope: {0}")] + InvalidScope(String), + #[error("Invalid description: {0}")] + InvalidDescription(String), + // Infrastructure errors + #[error("Not a Jujutsu repository")] + NotARepository, + #[error("jj is not installed or not in PATH")] + JjNotFound, + #[error("jj command failed: {message}")] + JjCommand { message: String, stderr: String }, + // Application errors + #[error("Operation cancelled by user")] + Cancelled, + #[error("Non-interactive terminal detected")] + NonInteractive +} diff --git a/src/lib.rs b/src/lib.rs index 0e15477..1b4308e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,3 +2,4 @@ mod cli; mod commit; mod jj; mod prompts; +mod error; -- 2.49.1 From 7a07186b0af9e23a59bdaebba771a9fec411f908 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Feb 2026 20:34:57 +0100 Subject: [PATCH 4/8] feat(CommitType): implement CommitType and tests --- src/cli/mod.rs | 1 + src/commit/mod.rs | 1 + src/commit/types.rs | 277 ++++++++++++++++++++++++++++++++++++++++++++ src/error.rs | 2 +- src/jj/mod.rs | 1 + src/lib.rs | 2 +- src/prompts/mod.rs | 1 + 7 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 src/commit/types.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e69de29..8b13789 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -0,0 +1 @@ + diff --git a/src/commit/mod.rs b/src/commit/mod.rs index e69de29..cd40856 100644 --- a/src/commit/mod.rs +++ b/src/commit/mod.rs @@ -0,0 +1 @@ +pub mod types; diff --git a/src/commit/types.rs b/src/commit/types.rs new file mode 100644 index 0000000..85c33a5 --- /dev/null +++ b/src/commit/types.rs @@ -0,0 +1,277 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CommitType { + Feat, + Fix, + Docs, + Style, + Refactor, + Perf, + Test, + Build, + Ci, + Chore, + Revert, +} + +impl CommitType { + pub fn all() -> &'static [Self] { + &[ + Self::Feat, + Self::Fix, + Self::Docs, + Self::Style, + Self::Refactor, + Self::Perf, + Self::Test, + Self::Build, + Self::Ci, + Self::Chore, + Self::Revert, + ] + } + + pub const fn description(&self) -> &'static str { + match self { + Self::Feat => "A new feature", + Self::Fix => "A bug fix", + Self::Docs => "Documentation only changes", + Self::Style => "Changes that do not affect the meaning of the code", + Self::Refactor => "A code change that neither fixes a bug nor adds a feature", + Self::Perf => "A code change that improves performance", + Self::Test => "Adding missing tests or correcting existing tests", + Self::Build => "Changes that affect the build system or external dependencies", + Self::Ci => "Changes to CI configuration files and scripts", + Self::Chore => "Other changes that don't modify src or test files", + Self::Revert => "Reverts a previous commit", + } + } + + pub const fn as_str(&self) -> &'static str { + match self { + Self::Feat => "feat", + Self::Fix => "fix", + Self::Docs => "docs", + Self::Style => "style", + Self::Refactor => "refactor", + Self::Perf => "perf", + Self::Test => "test", + Self::Build => "build", + Self::Ci => "ci", + Self::Chore => "chore", + Self::Revert => "revert", + } + } +} + +impl std::fmt::Display for CommitType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test that all 11 commit types exist and can be constructed + #[test] + fn all_eleven_variants_exist() { + // Exhaustive pattern matching ensures all variants exist at compile time + let variants = [ + CommitType::Feat, + CommitType::Fix, + CommitType::Docs, + CommitType::Style, + CommitType::Refactor, + CommitType::Perf, + CommitType::Test, + CommitType::Build, + CommitType::Ci, + CommitType::Chore, + CommitType::Revert, + ]; + assert_eq!(variants.len(), 11); + } + + /// Test that as_str() returns the correct lowercase string for each variant + #[test] + fn as_str_returns_lowercase_string() { + assert_eq!(CommitType::Feat.as_str(), "feat"); + assert_eq!(CommitType::Fix.as_str(), "fix"); + assert_eq!(CommitType::Docs.as_str(), "docs"); + assert_eq!(CommitType::Style.as_str(), "style"); + assert_eq!(CommitType::Refactor.as_str(), "refactor"); + assert_eq!(CommitType::Perf.as_str(), "perf"); + assert_eq!(CommitType::Test.as_str(), "test"); + assert_eq!(CommitType::Build.as_str(), "build"); + assert_eq!(CommitType::Ci.as_str(), "ci"); + assert_eq!(CommitType::Chore.as_str(), "chore"); + assert_eq!(CommitType::Revert.as_str(), "revert"); + } + + /// Test that as_str() output is always lowercase (property-based check) + #[test] + fn as_str_is_always_lowercase() { + for commit_type in CommitType::all() { + let s = commit_type.as_str(); + assert_eq!( + s, + s.to_lowercase(), + "as_str() should return lowercase for {:?}", + commit_type + ); + } + } + + /// Test that description() returns a non-empty string for each variant + #[test] + fn description_returns_non_empty_string() { + for commit_type in CommitType::all() { + let desc = commit_type.description(); + assert!( + !desc.is_empty(), + "description() should not be empty for {:?}", + commit_type + ); + } + } + + /// Test that description() returns the expected descriptions per spec + #[test] + fn description_returns_expected_values() { + assert_eq!(CommitType::Feat.description(), "A new feature"); + assert_eq!(CommitType::Fix.description(), "A bug fix"); + assert_eq!(CommitType::Docs.description(), "Documentation only changes"); + assert_eq!( + CommitType::Style.description(), + "Changes that do not affect the meaning of the code" + ); + assert_eq!( + CommitType::Refactor.description(), + "A code change that neither fixes a bug nor adds a feature" + ); + assert_eq!( + CommitType::Perf.description(), + "A code change that improves performance" + ); + assert_eq!( + CommitType::Test.description(), + "Adding missing tests or correcting existing tests" + ); + assert_eq!( + CommitType::Build.description(), + "Changes that affect the build system or external dependencies" + ); + assert_eq!( + CommitType::Ci.description(), + "Changes to CI configuration files and scripts" + ); + assert_eq!( + CommitType::Chore.description(), + "Other changes that don't modify src or test files" + ); + assert_eq!( + CommitType::Revert.description(), + "Reverts a previous commit" + ); + } + + /// Test that all() returns exactly 11 types + #[test] + fn all_returns_eleven_types() { + assert_eq!(CommitType::all().len(), 11); + } + + /// Test that all() returns types in the expected order (feat first, revert last) + #[test] + fn all_returns_types_in_expected_order() { + let all = CommitType::all(); + assert_eq!(all[0], CommitType::Feat); + assert_eq!(all[1], CommitType::Fix); + assert_eq!(all[2], CommitType::Docs); + assert_eq!(all[3], CommitType::Style); + assert_eq!(all[4], CommitType::Refactor); + assert_eq!(all[5], CommitType::Perf); + assert_eq!(all[6], CommitType::Test); + assert_eq!(all[7], CommitType::Build); + assert_eq!(all[8], CommitType::Ci); + assert_eq!(all[9], CommitType::Chore); + assert_eq!(all[10], CommitType::Revert); + } + + /// Test that all() contains all unique variants (no duplicates) + #[test] + fn all_contains_unique_variants() { + let all = CommitType::all(); + for (i, variant) in all.iter().enumerate() { + for (j, other) in all.iter().enumerate() { + if i != j { + assert_ne!(variant, other, "all() should not contain duplicates"); + } + } + } + } + + /// Test that Display implementation delegates to as_str() + #[test] + fn display_delegates_to_as_str() { + for commit_type in CommitType::all() { + let display_output = format!("{}", commit_type); + let as_str_output = commit_type.as_str(); + assert_eq!( + display_output, as_str_output, + "Display should delegate to as_str() for {:?}", + commit_type + ); + } + } + + /// Test Display for specific variants + #[test] + fn display_shows_lowercase_type() { + assert_eq!(format!("{}", CommitType::Feat), "feat"); + assert_eq!(format!("{}", CommitType::Fix), "fix"); + assert_eq!(format!("{}", CommitType::Docs), "docs"); + assert_eq!(format!("{}", CommitType::Style), "style"); + assert_eq!(format!("{}", CommitType::Refactor), "refactor"); + assert_eq!(format!("{}", CommitType::Perf), "perf"); + assert_eq!(format!("{}", CommitType::Test), "test"); + assert_eq!(format!("{}", CommitType::Build), "build"); + assert_eq!(format!("{}", CommitType::Ci), "ci"); + assert_eq!(format!("{}", CommitType::Chore), "chore"); + assert_eq!(format!("{}", CommitType::Revert), "revert"); + } + + /// Test that CommitType implements Copy (can be used after move) + #[test] + fn commit_type_is_copy() { + let original = CommitType::Feat; + let copied = original; // Copy, not move + assert_eq!(original, copied); // original still usable + } + + /// Test that CommitType implements PartialEq correctly + #[test] + fn commit_type_equality() { + assert_eq!(CommitType::Feat, CommitType::Feat); + assert_ne!(CommitType::Feat, CommitType::Fix); + } + + /// Test that CommitType can be used as HashMap key (Hash + Eq) + #[test] + fn commit_type_can_be_hash_key() { + use std::collections::HashMap; + let mut map = HashMap::new(); + map.insert(CommitType::Feat, "feature"); + map.insert(CommitType::Fix, "bugfix"); + assert_eq!(map.get(&CommitType::Feat), Some(&"feature")); + assert_eq!(map.get(&CommitType::Fix), Some(&"bugfix")); + } + + /// Test that Debug is implemented + #[test] + fn commit_type_has_debug() { + let debug_output = format!("{:?}", CommitType::Feat); + assert!(debug_output.contains("Feat")); + } +} diff --git a/src/error.rs b/src/error.rs index 48a5f40..32910d9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,5 +16,5 @@ pub enum Error { #[error("Operation cancelled by user")] Cancelled, #[error("Non-interactive terminal detected")] - NonInteractive + NonInteractive, } diff --git a/src/jj/mod.rs b/src/jj/mod.rs index e69de29..8b13789 100644 --- a/src/jj/mod.rs +++ b/src/jj/mod.rs @@ -0,0 +1 @@ + diff --git a/src/lib.rs b/src/lib.rs index 1b4308e..36bdc97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ mod cli; mod commit; +mod error; mod jj; mod prompts; -mod error; diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index e69de29..8b13789 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -0,0 +1 @@ + -- 2.49.1 From 37e5a41ad0b0014c91fcecfa23c0767c142edd61 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Feb 2026 22:34:22 +0100 Subject: [PATCH 5/8] feat(Scope): implement Scope and tests --- Cargo.lock | 31 ++ Cargo.toml | 1 + src/commit/{types.rs => types/commit_type.rs} | 0 src/commit/types/mod.rs | 5 + src/commit/types/scope.rs | 443 ++++++++++++++++++ 5 files changed, 480 insertions(+) rename src/commit/{types.rs => types/commit_type.rs} (100%) create mode 100644 src/commit/types/mod.rs create mode 100644 src/commit/types/scope.rs diff --git a/Cargo.lock b/Cargo.lock index e2788f7..3a18bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,10 +407,35 @@ dependencies = [ "clap", "git-conventional", "inquire", + "lazy-regex", "predicates", "thiserror", ] +[[package]] +name = "lazy-regex" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5c13b6857ade4c8ee05c3c3dc97d2ab5415d691213825b90d3211c425c1f907" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", + "regex-lite", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a95c68db5d41694cea563c86a4ba4dc02141c16ef64814108cb23def4d5438" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + [[package]] name = "libc" version = "0.2.180" @@ -598,6 +623,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.9" diff --git a/Cargo.toml b/Cargo.toml index df3132d..2172abf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ assert_fs = "1.1.3" clap = { version = "4.5.57", features = ["derive"] } git-conventional = "0.12.9" inquire = "0.9.2" +lazy-regex = { version = "3.5.1", features = ["lite"] } predicates = "3.1.3" thiserror = "2.0.18" diff --git a/src/commit/types.rs b/src/commit/types/commit_type.rs similarity index 100% rename from src/commit/types.rs rename to src/commit/types/commit_type.rs diff --git a/src/commit/types/mod.rs b/src/commit/types/mod.rs new file mode 100644 index 0000000..b57e687 --- /dev/null +++ b/src/commit/types/mod.rs @@ -0,0 +1,5 @@ +mod commit_type; +pub use commit_type::CommitType; + +mod scope; +pub use scope::{Scope, ScopeError}; diff --git a/src/commit/types/scope.rs b/src/commit/types/scope.rs new file mode 100644 index 0000000..c24b9e4 --- /dev/null +++ b/src/commit/types/scope.rs @@ -0,0 +1,443 @@ +use lazy_regex::regex_find; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[repr(transparent)] +pub struct Scope(String); + +impl Scope { + /// Maximum allowed length for a scope + pub const MAX_LENGTH: usize = 50; + + /// Parse and validate a scope string + /// + /// # Validation + /// - Trims leading/trailing whitespace + /// - Empty/whitespace-only input returns empty Scope + /// - Validates character set + /// - Validates maximum length (50 chars) + pub fn parse(value: impl Into) -> Result { + let value: String = value.into().trim().to_owned(); + if value.is_empty() { + return Ok(Self::empty()); + } + if value.len() > Self::MAX_LENGTH { + return Err(ScopeError::TooLong { + actual: value.len(), + max: Self::MAX_LENGTH, + }); + } + match lazy_regex::regex_find!(r"[^a-zA-Z0-9_/-]", &value) { + Some(val) => Err(ScopeError::InvalidCharacter(val.chars().next().unwrap())), + None => Ok(Self(value)), + } + } + + /// Create an empty scope (convenience constructor) + pub fn empty() -> Self { + Self(String::new()) + } + + /// Returns true if the scope is empty + pub fn is_empty(&self) -> bool { + self.0 == String::new() + } + + /// Returns the inner string slice + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl std::fmt::Display for Scope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for Scope { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Error type for Scope validation failures +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ScopeError { + #[error("Invalid character '{0}' in scope (allowed: a-z, A-Z, 0-9, -, _, /)")] + InvalidCharacter(char), + + #[error("Scope too long ({actual} characters, maximum is {max})")] + TooLong { actual: usize, max: usize }, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test that valid alphanumeric scope is accepted + #[test] + fn valid_alphanumeric_scope_accepted() { + let result = Scope::parse("cli"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "cli"); + } + + /// Test that valid scope with uppercase letters is accepted + #[test] + fn valid_uppercase_scope_accepted() { + let result = Scope::parse("CLI"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "CLI"); + } + + /// Test that valid scope with mixed case is accepted + #[test] + fn valid_mixed_case_scope_accepted() { + let result = Scope::parse("AuthModule"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "AuthModule"); + } + + /// Test that valid scope with numbers is accepted + #[test] + fn valid_scope_with_numbers_accepted() { + let result = Scope::parse("api2"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "api2"); + } + + /// Test that valid scope with hyphens is accepted + #[test] + fn valid_scope_with_hyphens_accepted() { + let result = Scope::parse("user-auth"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "user-auth"); + } + + /// Test that valid scope with underscores is accepted + #[test] + fn valid_scope_with_underscores_accepted() { + let result = Scope::parse("user_auth"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "user_auth"); + } + + /// Test that valid scope with slashes is accepted (Jira refs) + #[test] + fn valid_scope_with_slashes_accepted() { + let result = Scope::parse("PROJ-123/feature"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "PROJ-123/feature"); + } + + /// Test another Jira-style scope with slashes + #[test] + fn valid_jira_style_scope_accepted() { + let result = Scope::parse("TEAM-456/bugfix"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "TEAM-456/bugfix"); + } + + /// Test scope with all allowed special characters combined + #[test] + fn valid_scope_with_all_special_chars() { + let result = Scope::parse("my-scope_v2/test"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "my-scope_v2/test"); + } + + /// Test that empty string returns valid empty Scope + #[test] + fn empty_string_returns_valid_empty_scope() { + let result = Scope::parse(""); + assert!(result.is_ok()); + let scope = result.unwrap(); + assert!(scope.is_empty()); + assert_eq!(scope.as_str(), ""); + } + + /// Test that whitespace-only input returns valid empty Scope + #[test] + fn whitespace_only_returns_valid_empty_scope() { + let result = Scope::parse(" "); + assert!(result.is_ok()); + let scope = result.unwrap(); + assert!(scope.is_empty()); + assert_eq!(scope.as_str(), ""); + } + + /// Test that tabs-only input returns valid empty Scope + #[test] + fn tabs_only_returns_valid_empty_scope() { + let result = Scope::parse("\t\t"); + assert!(result.is_ok()); + let scope = result.unwrap(); + assert!(scope.is_empty()); + } + + /// Test that mixed whitespace returns valid empty Scope + #[test] + fn mixed_whitespace_returns_valid_empty_scope() { + let result = Scope::parse(" \t \n "); + assert!(result.is_ok()); + let scope = result.unwrap(); + assert!(scope.is_empty()); + } + + /// Test that leading whitespace is trimmed + #[test] + fn leading_whitespace_trimmed() { + let result = Scope::parse(" cli"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "cli"); + } + + /// Test that trailing whitespace is trimmed + #[test] + fn trailing_whitespace_trimmed() { + let result = Scope::parse("cli "); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "cli"); + } + + /// Test that both leading and trailing whitespace is trimmed + #[test] + fn leading_and_trailing_whitespace_trimmed() { + let result = Scope::parse(" cli "); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "cli"); + } + + /// Test that spaces within scope are rejected + #[test] + fn space_in_scope_rejected() { + let result = Scope::parse("user auth"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter(' ')); + } + + /// Test that dot is rejected + #[test] + fn dot_rejected() { + let result = Scope::parse("user.auth"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.')); + } + + /// Test that colon is rejected + #[test] + fn colon_rejected() { + let result = Scope::parse("user:auth"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter(':')); + } + + /// Test that parentheses are rejected + #[test] + fn parentheses_rejected() { + let result = Scope::parse("user(auth)"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('(')); + } + + /// Test that exclamation mark is rejected + #[test] + fn exclamation_rejected() { + let result = Scope::parse("breaking!"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('!')); + } + + /// Test that @ symbol is rejected + #[test] + fn at_symbol_rejected() { + let result = Scope::parse("user@domain"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('@')); + } + + /// Test that hash is rejected + #[test] + fn hash_rejected() { + let result = Scope::parse("issue#123"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('#')); + } + + /// Test that emoji is rejected + #[test] + fn emoji_rejected() { + let result = Scope::parse("cliπŸš€"); + assert!(result.is_err()); + // The error should contain the emoji character + match result.unwrap_err() { + ScopeError::InvalidCharacter(c) => assert_eq!(c, 'πŸš€'), + _ => panic!("Expected InvalidCharacter error"), + } + } + + /// Test that first invalid character is reported + #[test] + fn first_invalid_character_reported() { + let result = Scope::parse("a.b:c"); + assert!(result.is_err()); + // Should report the first invalid character (dot) + assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.')); + } + + /// Test that exactly 50 characters is accepted (boundary) + #[test] + fn fifty_characters_accepted() { + let scope_50 = "a".repeat(50); + let result = Scope::parse(&scope_50); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str().len(), 50); + } + + /// Test that 51 characters is rejected + #[test] + fn fifty_one_characters_rejected() { + let scope_51 = "a".repeat(51); + let result = Scope::parse(&scope_51); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + ScopeError::TooLong { + actual: 51, + max: 50 + } + ); + } + + /// Test that 100 characters is rejected + #[test] + fn hundred_characters_rejected() { + let scope_100 = "a".repeat(100); + let result = Scope::parse(&scope_100); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + ScopeError::TooLong { + actual: 100, + max: 50 + } + ); + } + + /// Test that length is checked after trimming + #[test] + fn length_checked_after_trimming() { + // 50 chars + leading/trailing spaces = should be valid after trim + let scope_with_spaces = format!(" {} ", "a".repeat(50)); + let result = Scope::parse(&scope_with_spaces); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str().len(), 50); + } + + /// Test MAX_LENGTH constant is 50 + #[test] + fn max_length_constant_is_50() { + assert_eq!(Scope::MAX_LENGTH, 50); + } + + /// Test that empty() creates an empty Scope + #[test] + fn empty_constructor_creates_empty_scope() { + let scope = Scope::empty(); + assert!(scope.is_empty()); + assert_eq!(scope.as_str(), ""); + } + + /// Test is_empty() returns true for empty scope + #[test] + fn is_empty_returns_true_for_empty() { + let scope = Scope::parse("").unwrap(); + assert!(scope.is_empty()); + } + + /// Test is_empty() returns false for non-empty scope + #[test] + fn is_empty_returns_false_for_non_empty() { + let scope = Scope::parse("cli").unwrap(); + assert!(!scope.is_empty()); + } + + /// Test as_str() returns inner string + #[test] + fn as_str_returns_inner_string() { + let scope = Scope::parse("my-scope").unwrap(); + assert_eq!(scope.as_str(), "my-scope"); + } + + /// Test Display trait implementation + #[test] + fn display_outputs_inner_string() { + let scope = Scope::parse("cli").unwrap(); + assert_eq!(format!("{}", scope), "cli"); + } + + /// Test Display for empty scope + #[test] + fn display_empty_scope() { + let scope = Scope::empty(); + assert_eq!(format!("{}", scope), ""); + } + + /// Test Clone trait + #[test] + fn scope_is_cloneable() { + let original = Scope::parse("cli").unwrap(); + let cloned = original.clone(); + assert_eq!(original, cloned); + } + + /// Test PartialEq trait + #[test] + fn scope_equality() { + let scope1 = Scope::parse("cli").unwrap(); + let scope2 = Scope::parse("cli").unwrap(); + let scope3 = Scope::parse("api").unwrap(); + assert_eq!(scope1, scope2); + assert_ne!(scope1, scope3); + } + + /// Test Debug trait + #[test] + fn scope_has_debug() { + let scope = Scope::parse("cli").unwrap(); + let debug_output = format!("{:?}", scope); + assert!(debug_output.contains("Scope")); + assert!(debug_output.contains("cli")); + } + + /// Test AsRef trait + #[test] + fn scope_as_ref_str() { + let scope = Scope::parse("cli").unwrap(); + let s: &str = scope.as_ref(); + assert_eq!(s, "cli"); + } + + /// Test ScopeError::InvalidCharacter displays correctly + #[test] + fn invalid_character_error_display() { + let err = ScopeError::InvalidCharacter('.'); + let msg = format!("{}", err); + assert!(msg.contains("Invalid character")); + assert!(msg.contains("'.'")); + assert!(msg.contains("allowed: a-z, A-Z, 0-9, -, _, /")); + } + + /// Test ScopeError::TooLong displays correctly + #[test] + fn too_long_error_display() { + let err = ScopeError::TooLong { + actual: 51, + max: 50, + }; + let msg = format!("{}", err); + assert!(msg.contains("too long")); + assert!(msg.contains("51")); + assert!(msg.contains("50")); + } +} -- 2.49.1 From ab0d3f9061703e513f6e25a9c6d90347049870b0 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Feb 2026 23:35:02 +0100 Subject: [PATCH 6/8] feat(Description): implement Description and tests --- src/commit/types/description.rs | 376 ++++++++++++++++++++++++++++++++ src/commit/types/mod.rs | 3 + src/commit/types/scope.rs | 2 +- 3 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 src/commit/types/description.rs diff --git a/src/commit/types/description.rs b/src/commit/types/description.rs new file mode 100644 index 0000000..b031ddb --- /dev/null +++ b/src/commit/types/description.rs @@ -0,0 +1,376 @@ +use std::fmt::write; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[repr(transparent)] +pub struct Description(String); + +impl Description { + pub const MAX_LENGTH: usize = 72; + + /// Parse and validate a description string + /// + /// # Validation + /// - Trims leading/trailing whitespace + /// - Rejects empty or whitespace-only input + /// - Validates maximum length (72 chars after trim) + 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, + }) + } else { + Ok(Self(value)) + } + } + + /// Returns the inner string slice + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the length in characters + 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 { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Description { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +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)] +mod tests { + use super::*; + + /// Test that valid description is accepted + #[test] + fn valid_description_accepted() { + let result = Description::parse("add new feature"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "add new feature"); + } + + /// Test that single character description is accepted + #[test] + fn single_character_description_accepted() { + let result = Description::parse("a"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "a"); + } + + /// Test that description with numbers is accepted + #[test] + fn description_with_numbers_accepted() { + let result = Description::parse("fix issue #123"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "fix issue #123"); + } + + /// Test that description with special characters is accepted + #[test] + fn description_with_special_chars_accepted() { + let result = Description::parse("add @decorator support (beta)"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "add @decorator support (beta)"); + } + + /// Test that description with punctuation is accepted + #[test] + fn description_with_punctuation_accepted() { + let result = Description::parse("fix: handle edge case!"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "fix: handle edge case!"); + } + + /// Test that empty string is rejected with DescriptionError::Empty + #[test] + fn empty_string_rejected() { + let result = Description::parse(""); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), DescriptionError::Empty); + } + + /// Test that whitespace-only is rejected with DescriptionError::Empty + #[test] + fn whitespace_only_rejected() { + let result = Description::parse(" "); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), DescriptionError::Empty); + } + + /// Test that tabs-only is rejected + #[test] + fn tabs_only_rejected() { + let result = Description::parse("\t\t"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), DescriptionError::Empty); + } + + /// Test that mixed whitespace is rejected + #[test] + fn mixed_whitespace_rejected() { + let result = Description::parse(" \t \n "); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), DescriptionError::Empty); + } + + /// Test that newline-only is rejected + #[test] + fn newline_only_rejected() { + let result = Description::parse("\n"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), DescriptionError::Empty); + } + + /// Test that leading whitespace is trimmed + #[test] + fn leading_whitespace_trimmed() { + let result = Description::parse(" add feature"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "add feature"); + } + + /// Test that trailing whitespace is trimmed + #[test] + fn trailing_whitespace_trimmed() { + let result = Description::parse("add feature "); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "add feature"); + } + + /// Test that both leading and trailing whitespace is trimmed + #[test] + fn leading_and_trailing_whitespace_trimmed() { + let result = Description::parse(" add feature "); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "add feature"); + } + + /// Test that internal whitespace is preserved + #[test] + fn internal_whitespace_preserved() { + let result = Description::parse("add multiple spaces"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "add multiple spaces"); + } + + /// Test that exactly 72 characters is accepted (boundary) + #[test] + fn seventy_two_characters_accepted() { + let desc_72 = "a".repeat(72); + let result = Description::parse(&desc_72); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str().len(), 72); + } + + /// Test that 73 characters is rejected + #[test] + fn seventy_three_characters_rejected() { + let desc_73 = "a".repeat(73); + let result = Description::parse(&desc_73); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + DescriptionError::TooLong { + actual: 73, + max: 72 + } + ); + } + + /// 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: 72 + } + ); + } + + /// Test that length is checked after trimming + #[test] + fn length_checked_after_trimming() { + // 72 chars + leading/trailing spaces = should be valid after trim + let desc_with_spaces = format!(" {} ", "a".repeat(72)); + let result = Description::parse(&desc_with_spaces); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str().len(), 72); + } + + /// Test that 50 characters is accepted without issue + #[test] + fn fifty_characters_accepted() { + let desc_50 = "a".repeat(50); + let result = Description::parse(&desc_50); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str().len(), 50); + } + + /// Test MAX_LENGTH constant is 72 + #[test] + fn max_length_constant_is_72() { + assert_eq!(Description::MAX_LENGTH, 72); + } + + /// Test as_str() returns inner string + #[test] + fn as_str_returns_inner_string() { + let desc = Description::parse("my description").unwrap(); + assert_eq!(desc.as_str(), "my description"); + } + + /// Test len() returns correct length + #[test] + fn len_returns_correct_length() { + let desc = Description::parse("hello").unwrap(); + 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() { + let desc = Description::parse("add feature").unwrap(); + assert_eq!(format!("{}", desc), "add feature"); + } + + /// Test Clone trait + #[test] + fn description_is_cloneable() { + let original = Description::parse("add feature").unwrap(); + let cloned = original.clone(); + assert_eq!(original, cloned); + } + + /// Test PartialEq trait + #[test] + fn description_equality() { + let desc1 = Description::parse("add feature").unwrap(); + let desc2 = Description::parse("add feature").unwrap(); + let desc3 = Description::parse("fix bug").unwrap(); + assert_eq!(desc1, desc2); + assert_ne!(desc1, desc3); + } + + /// Test Debug trait + #[test] + fn description_has_debug() { + let desc = Description::parse("add feature").unwrap(); + let debug_output = format!("{:?}", desc); + assert!(debug_output.contains("Description")); + assert!(debug_output.contains("add feature")); + } + + /// Test AsRef trait + #[test] + fn description_as_ref_str() { + let desc = Description::parse("add feature").unwrap(); + let s: &str = desc.as_ref(); + assert_eq!(s, "add feature"); + } + + /// Test DescriptionError::Empty displays correctly + #[test] + fn empty_error_display() { + let err = DescriptionError::Empty; + let msg = format!("{}", err); + assert!(msg.contains("cannot be empty")); + } + + /// Test DescriptionError::TooLong displays correctly + #[test] + fn too_long_error_display() { + let err = DescriptionError::TooLong { + actual: 73, + max: 72, + }; + let msg = format!("{}", err); + assert!(msg.contains("too long")); + assert!(msg.contains("73")); + assert!(msg.contains("72")); + } + + /// Test description with only whitespace after trim becomes empty + #[test] + fn whitespace_after_trim_is_empty() { + // Ensure various whitespace combinations all result in Empty error + let whitespace_variants = [" ", " ", "\t", "\n", "\r\n", " \t \n "]; + for ws in whitespace_variants { + let result = Description::parse(ws); + assert!(result.is_err(), "Expected error for whitespace: {:?}", ws); + assert_eq!( + result.unwrap_err(), + DescriptionError::Empty, + "Expected Empty error for whitespace: {:?}", + ws + ); + } + } + + /// Test description at exact boundary after trimming + #[test] + fn boundary_length_after_trim() { + // 72 chars + 2 spaces on each side = 76 chars total, but 72 after trim + let desc = format!(" {} ", "x".repeat(72)); + let result = Description::parse(&desc); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 72); + } + + /// Test description just over boundary after trimming + #[test] + fn over_boundary_after_trim() { + // 73 chars + spaces = should fail even after trim + let desc = format!(" {} ", "x".repeat(73)); + let result = Description::parse(&desc); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + DescriptionError::TooLong { + actual: 73, + max: 72 + } + ); + } +} diff --git a/src/commit/types/mod.rs b/src/commit/types/mod.rs index b57e687..e4e0973 100644 --- a/src/commit/types/mod.rs +++ b/src/commit/types/mod.rs @@ -3,3 +3,6 @@ pub use commit_type::CommitType; mod scope; pub use scope::{Scope, ScopeError}; + +mod description; +pub use description::{Description, DescriptionError}; diff --git a/src/commit/types/scope.rs b/src/commit/types/scope.rs index c24b9e4..e283fdf 100644 --- a/src/commit/types/scope.rs +++ b/src/commit/types/scope.rs @@ -39,7 +39,7 @@ impl Scope { /// Returns true if the scope is empty pub fn is_empty(&self) -> bool { - self.0 == String::new() + self.0.is_empty() } /// Returns the inner string slice -- 2.49.1 From 06a08ae717c2338949c97f25c8237266609e5f42 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Feb 2026 23:39:29 +0100 Subject: [PATCH 7/8] feat(ConventionalCommit): implement ConventionalCommit and tests --- src/commit/types/message.rs | 509 ++++++++++++++++++++++++++++++++++++ src/commit/types/mod.rs | 2 + 2 files changed, 511 insertions(+) create mode 100644 src/commit/types/message.rs diff --git a/src/commit/types/message.rs b/src/commit/types/message.rs new file mode 100644 index 0000000..d7ab00f --- /dev/null +++ b/src/commit/types/message.rs @@ -0,0 +1,509 @@ +use super::{CommitType, Description, Scope}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConventionalCommit { + commit_type: CommitType, + scope: Scope, + description: Description, +} + +impl ConventionalCommit { + /// Create a new conventional commit message + /// + /// # Arguments + /// All arguments are pre-validated types, so this cannot fail + pub fn new(commit_type: CommitType, scope: Scope, description: Description) -> Self { + Self { + commit_type, + scope, + description, + } + } + + /// Format the complete commit messsage + /// + /// 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) + } else { + format!("{}({}): {}", self.commit_type, self.scope, self.description) + } + } + + /// Returns the commit type + pub fn commit_type(&self) -> CommitType { + self.commit_type + } + + /// Returns a reference to the scope + pub fn scope(&self) -> &Scope { + &self.scope + } + + /// Returns a reference to the description + pub fn description(&self) -> &Description { + &self.description + } +} + +impl std::fmt::Display for ConventionalCommit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.format()) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to create a valid Scope for testing + fn test_scope(value: &str) -> Scope { + Scope::parse(value).expect("test scope should be valid") + } + + /// Helper to create a valid Description for testing + fn test_description(value: &str) -> Description { + Description::parse(value).expect("test description should be valid") + } + + /// Test that ConventionalCommit::new() creates a valid commit with all fields + #[test] + fn new_creates_commit_with_all_fields() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add new feature"), + ); + assert_eq!(commit.commit_type(), CommitType::Feat); + assert_eq!(commit.scope().as_str(), "cli"); + assert_eq!(commit.description().as_str(), "add new feature"); + } + + /// Test that ConventionalCommit::new() works with empty scope + #[test] + fn new_creates_commit_with_empty_scope() { + let commit = ConventionalCommit::new( + CommitType::Fix, + Scope::empty(), + test_description("fix critical bug"), + ); + assert_eq!(commit.commit_type(), CommitType::Fix); + assert!(commit.scope().is_empty()); + assert_eq!(commit.description().as_str(), "fix critical bug"); + } + + /// Test that format() produces "type(scope): description" when scope is non-empty + #[test] + fn format_with_scope_produces_correct_output() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("auth"), + test_description("add login"), + ); + assert_eq!(commit.format(), "feat(auth): add login"); + } + + /// Test format with different scope values + #[test] + fn format_with_various_scopes() { + // Hyphenated scope + let commit1 = ConventionalCommit::new( + CommitType::Fix, + test_scope("user-auth"), + test_description("fix token refresh"), + ); + assert_eq!(commit1.format(), "fix(user-auth): fix token refresh"); + + // Underscored scope + let commit2 = ConventionalCommit::new( + CommitType::Docs, + test_scope("api_docs"), + test_description("update README"), + ); + assert_eq!(commit2.format(), "docs(api_docs): update README"); + + // Scope with slash (Jira-style) + let commit3 = ConventionalCommit::new( + CommitType::Chore, + test_scope("PROJ-123/cleanup"), + test_description("remove unused code"), + ); + assert_eq!( + commit3.format(), + "chore(PROJ-123/cleanup): remove unused code" + ); + } + + /// Test that format() produces "type: description" when scope is empty + #[test] + fn format_without_scope_produces_correct_output() { + let commit = ConventionalCommit::new( + CommitType::Feat, + Scope::empty(), + test_description("add login"), + ); + assert_eq!(commit.format(), "feat: add login"); + } + + /// Test format without scope for various descriptions + #[test] + fn format_without_scope_various_descriptions() { + let commit1 = ConventionalCommit::new( + CommitType::Fix, + Scope::empty(), + test_description("fix critical bug"), + ); + assert_eq!(commit1.format(), "fix: fix critical bug"); + + let commit2 = ConventionalCommit::new( + CommitType::Docs, + Scope::empty(), + test_description("update installation guide"), + ); + assert_eq!(commit2.format(), "docs: update installation guide"); + } + + /// Test that all 11 commit types format correctly with scope + #[test] + fn all_commit_types_format_correctly_with_scope() { + let scope = test_scope("cli"); + let desc = test_description("test change"); + + let expected_formats = [ + (CommitType::Feat, "feat(cli): test change"), + (CommitType::Fix, "fix(cli): test change"), + (CommitType::Docs, "docs(cli): test change"), + (CommitType::Style, "style(cli): test change"), + (CommitType::Refactor, "refactor(cli): test change"), + (CommitType::Perf, "perf(cli): test change"), + (CommitType::Test, "test(cli): test change"), + (CommitType::Build, "build(cli): test change"), + (CommitType::Ci, "ci(cli): test change"), + (CommitType::Chore, "chore(cli): test change"), + (CommitType::Revert, "revert(cli): test change"), + ]; + + for (commit_type, expected) in expected_formats { + let commit = ConventionalCommit::new(commit_type, scope.clone(), desc.clone()); + assert_eq!( + commit.format(), + expected, + "Format should be correct for {:?}", + commit_type + ); + } + } + + /// Test that all 11 commit types format correctly without scope + #[test] + fn all_commit_types_format_correctly_without_scope() { + let desc = test_description("test change"); + + let expected_formats = [ + (CommitType::Feat, "feat: test change"), + (CommitType::Fix, "fix: test change"), + (CommitType::Docs, "docs: test change"), + (CommitType::Style, "style: test change"), + (CommitType::Refactor, "refactor: test change"), + (CommitType::Perf, "perf: test change"), + (CommitType::Test, "test: test change"), + (CommitType::Build, "build: test change"), + (CommitType::Ci, "ci: test change"), + (CommitType::Chore, "chore: test change"), + (CommitType::Revert, "revert: test change"), + ]; + + for (commit_type, expected) in expected_formats { + let commit = ConventionalCommit::new(commit_type, Scope::empty(), desc.clone()); + assert_eq!( + commit.format(), + expected, + "Format should be correct for {:?}", + commit_type + ); + } + } + + /// Test that Display implementation delegates to format() + #[test] + fn display_delegates_to_format() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("auth"), + test_description("add login"), + ); + let display_output = format!("{}", commit); + let format_output = commit.format(); + assert_eq!(display_output, format_output); + } + + /// Test Display with scope + #[test] + fn display_with_scope() { + let commit = ConventionalCommit::new( + CommitType::Fix, + test_scope("api"), + test_description("handle null response"), + ); + assert_eq!(format!("{}", commit), "fix(api): handle null response"); + } + + /// Test Display without scope + #[test] + fn display_without_scope() { + let commit = ConventionalCommit::new( + CommitType::Docs, + Scope::empty(), + test_description("improve README"), + ); + assert_eq!(format!("{}", commit), "docs: improve README"); + } + + /// Test Display delegates to format for all commit types + #[test] + fn display_equals_format_for_all_types() { + for commit_type in CommitType::all() { + // With scope + let commit_with_scope = ConventionalCommit::new( + *commit_type, + test_scope("test"), + test_description("change"), + ); + assert_eq!( + format!("{}", commit_with_scope), + commit_with_scope.format(), + "Display should equal format() for {:?} with scope", + commit_type + ); + + // Without scope + let commit_without_scope = + ConventionalCommit::new(*commit_type, Scope::empty(), test_description("change")); + assert_eq!( + format!("{}", commit_without_scope), + commit_without_scope.format(), + "Display should equal format() for {:?} without scope", + commit_type + ); + } + } + + /// Test commit_type() returns the correct type + #[test] + fn commit_type_accessor_returns_correct_type() { + for commit_type in CommitType::all() { + let commit = + ConventionalCommit::new(*commit_type, Scope::empty(), test_description("test")); + assert_eq!(commit.commit_type(), *commit_type); + } + } + + /// Test scope() returns reference to scope + #[test] + fn scope_accessor_returns_reference() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("auth"), + test_description("add feature"), + ); + assert_eq!(commit.scope().as_str(), "auth"); + } + + /// Test scope() returns reference to empty scope + #[test] + fn scope_accessor_returns_empty_scope() { + let commit = ConventionalCommit::new( + CommitType::Feat, + Scope::empty(), + test_description("add feature"), + ); + assert!(commit.scope().is_empty()); + } + + /// Test description() returns reference to description + #[test] + fn description_accessor_returns_reference() { + let commit = ConventionalCommit::new( + CommitType::Feat, + Scope::empty(), + test_description("add new authentication flow"), + ); + assert_eq!(commit.description().as_str(), "add new authentication flow"); + } + + /// Test Clone trait + #[test] + fn conventional_commit_is_cloneable() { + let original = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + let cloned = original.clone(); + assert_eq!(original, cloned); + } + + /// Test PartialEq trait - equal commits + #[test] + fn conventional_commit_equality() { + let commit1 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + let commit2 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + assert_eq!(commit1, commit2); + } + + /// Test PartialEq trait - different commit types + #[test] + fn conventional_commit_inequality_different_type() { + let commit1 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("change"), + ); + let commit2 = ConventionalCommit::new( + CommitType::Fix, + test_scope("cli"), + test_description("change"), + ); + assert_ne!(commit1, commit2); + } + + /// Test PartialEq trait - different scopes + #[test] + fn conventional_commit_inequality_different_scope() { + let commit1 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("change"), + ); + let commit2 = ConventionalCommit::new( + CommitType::Feat, + test_scope("api"), + test_description("change"), + ); + assert_ne!(commit1, commit2); + } + + /// Test PartialEq trait - different descriptions + #[test] + fn conventional_commit_inequality_different_description() { + let commit1 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + let commit2 = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("fix bug"), + ); + assert_ne!(commit1, commit2); + } + + /// Test Debug trait + #[test] + fn conventional_commit_has_debug() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + let debug_output = format!("{:?}", commit); + assert!(debug_output.contains("ConventionalCommit")); + assert!(debug_output.contains("Feat")); + } + + /// Test real-world commit message example: feature with scope + #[test] + fn real_world_feature_with_scope() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("auth"), + test_description("implement OAuth2 login flow"), + ); + assert_eq!(commit.format(), "feat(auth): implement OAuth2 login flow"); + } + + /// Test real-world commit message example: bug fix without scope + #[test] + fn real_world_bugfix_without_scope() { + let commit = ConventionalCommit::new( + CommitType::Fix, + Scope::empty(), + test_description("prevent crash on empty input"), + ); + assert_eq!(commit.format(), "fix: prevent crash on empty input"); + } + + /// Test real-world commit message example: documentation + #[test] + fn real_world_docs() { + let commit = ConventionalCommit::new( + CommitType::Docs, + test_scope("README"), + test_description("add installation instructions"), + ); + assert_eq!( + commit.format(), + "docs(README): add installation instructions" + ); + } + + /// Test real-world commit message example: refactoring + #[test] + fn real_world_refactor() { + let commit = ConventionalCommit::new( + CommitType::Refactor, + test_scope("core"), + test_description("extract validation logic"), + ); + assert_eq!(commit.format(), "refactor(core): extract validation logic"); + } + + /// Test real-world commit message example: CI change + #[test] + fn real_world_ci() { + let commit = ConventionalCommit::new( + CommitType::Ci, + test_scope("github"), + test_description("add release workflow"), + ); + assert_eq!(commit.format(), "ci(github): add release workflow"); + } + + /// Test commit message with maximum length description (72 chars) + #[test] + fn format_with_max_length_description() { + let long_desc = "a".repeat(72); + let commit = ConventionalCommit::new( + CommitType::Feat, + Scope::empty(), + Description::parse(&long_desc).unwrap(), + ); + // Format should be "feat: " + 72 chars = 78 total chars + let formatted = commit.format(); + assert!(formatted.starts_with("feat: ")); + assert_eq!(formatted.len(), 78); // "feat: " (6) + 72 = 78 + } + + /// Test commit message with scope containing all valid special chars + #[test] + fn format_with_complex_scope() { + let commit = ConventionalCommit::new( + CommitType::Feat, + test_scope("my-scope_v2/feature"), + test_description("add support"), + ); + assert_eq!(commit.format(), "feat(my-scope_v2/feature): add support"); + } +} diff --git a/src/commit/types/mod.rs b/src/commit/types/mod.rs index e4e0973..5424949 100644 --- a/src/commit/types/mod.rs +++ b/src/commit/types/mod.rs @@ -6,3 +6,5 @@ pub use scope::{Scope, ScopeError}; mod description; pub use description::{Description, DescriptionError}; + +mod message; -- 2.49.1 From 21723c2c962dbb2506a9d0e10fe59da93407701e Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Fri, 6 Feb 2026 00:54:56 +0100 Subject: [PATCH 8/8] fix(commit): limit complete line limit to 72 chars --- src/commit/types/description.rs | 73 ++++---- src/commit/types/message.rs | 301 ++++++++++++++++++++++++++------ src/commit/types/mod.rs | 1 + src/commit/types/scope.rs | 48 ++--- 4 files changed, 315 insertions(+), 108 deletions(-) diff --git a/src/commit/types/description.rs b/src/commit/types/description.rs index b031ddb..ef91fe4 100644 --- a/src/commit/types/description.rs +++ b/src/commit/types/description.rs @@ -5,14 +5,14 @@ use std::fmt::write; pub struct Description(String); impl Description { - pub const MAX_LENGTH: usize = 72; + pub const MAX_LENGTH: usize = 50; /// Parse and validate a description string /// /// # Validation /// - Trims leading/trailing whitespace /// - Rejects empty or whitespace-only input - /// - Validates maximum length (72 chars after trim) + /// - Validates maximum length (50 chars after trim - soft limit) pub fn parse(value: impl Into) -> Result { let value = value.into().trim().to_owned(); if value.is_empty() { @@ -57,7 +57,6 @@ impl std::fmt::Display for Description { } } - #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum DescriptionError { #[error("Description cannot be empty")] @@ -183,26 +182,32 @@ mod tests { assert_eq!(result.unwrap().as_str(), "add multiple spaces"); } - /// Test that exactly 72 characters is accepted (boundary) + /// Test that 72 characters (old limit) is now rejected #[test] - fn seventy_two_characters_accepted() { + fn seventy_two_characters_now_rejected() { let desc_72 = "a".repeat(72); let result = Description::parse(&desc_72); - assert!(result.is_ok()); - assert_eq!(result.unwrap().as_str().len(), 72); - } - - /// Test that 73 characters is rejected - #[test] - fn seventy_three_characters_rejected() { - let desc_73 = "a".repeat(73); - let result = Description::parse(&desc_73); assert!(result.is_err()); assert_eq!( result.unwrap_err(), DescriptionError::TooLong { - actual: 73, - max: 72 + actual: 72, + max: 50 + } + ); + } + + /// Test that 51 characters is rejected (boundary) + #[test] + fn fifty_one_characters_rejected() { + 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 } ); } @@ -217,7 +222,7 @@ mod tests { result.unwrap_err(), DescriptionError::TooLong { actual: 100, - max: 72 + max: 50 } ); } @@ -225,11 +230,11 @@ mod tests { /// Test that length is checked after trimming #[test] fn length_checked_after_trimming() { - // 72 chars + leading/trailing spaces = should be valid after trim - let desc_with_spaces = format!(" {} ", "a".repeat(72)); + // 50 chars + leading/trailing spaces = should be valid after trim + let desc_with_spaces = format!(" {} ", "a".repeat(50)); let result = Description::parse(&desc_with_spaces); assert!(result.is_ok()); - assert_eq!(result.unwrap().as_str().len(), 72); + assert_eq!(result.unwrap().as_str().len(), 50); } /// Test that 50 characters is accepted without issue @@ -241,10 +246,10 @@ mod tests { assert_eq!(result.unwrap().as_str().len(), 50); } - /// Test MAX_LENGTH constant is 72 + /// Test MAX_LENGTH constant is 50 (soft limit) #[test] - fn max_length_constant_is_72() { - assert_eq!(Description::MAX_LENGTH, 72); + fn max_length_constant_is_50() { + assert_eq!(Description::MAX_LENGTH, 50); } /// Test as_str() returns inner string @@ -322,13 +327,13 @@ mod tests { #[test] fn too_long_error_display() { let err = DescriptionError::TooLong { - actual: 73, - max: 72, + actual: 51, + max: 50, }; let msg = format!("{}", err); assert!(msg.contains("too long")); - assert!(msg.contains("73")); - assert!(msg.contains("72")); + assert!(msg.contains("51")); + assert!(msg.contains("50")); } /// Test description with only whitespace after trim becomes empty @@ -351,25 +356,25 @@ mod tests { /// Test description at exact boundary after trimming #[test] fn boundary_length_after_trim() { - // 72 chars + 2 spaces on each side = 76 chars total, but 72 after trim - let desc = format!(" {} ", "x".repeat(72)); + // 50 chars + 2 spaces on each side = 54 chars total, but 50 after trim + let desc = format!(" {} ", "x".repeat(50)); let result = Description::parse(&desc); assert!(result.is_ok()); - assert_eq!(result.unwrap().len(), 72); + assert_eq!(result.unwrap().len(), 50); } /// Test description just over boundary after trimming #[test] fn over_boundary_after_trim() { - // 73 chars + spaces = should fail even after trim - let desc = format!(" {} ", "x".repeat(73)); + // 51 chars + spaces = should fail even after trim + let desc = format!(" {} ", "x".repeat(51)); let result = Description::parse(&desc); assert!(result.is_err()); assert_eq!( result.unwrap_err(), DescriptionError::TooLong { - actual: 73, - max: 72 + actual: 51, + max: 50 } ); } diff --git a/src/commit/types/message.rs b/src/commit/types/message.rs index d7ab00f..3fd6772 100644 --- a/src/commit/types/message.rs +++ b/src/commit/types/message.rs @@ -1,4 +1,13 @@ use super::{CommitType, Description, Scope}; +use thiserror::Error; + +/// Errors that can occur when creating a ConventionalCommit +#[derive(Debug, Clone, PartialEq, Eq, Error)] +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 }, +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConventionalCommit { @@ -8,15 +17,52 @@ pub struct ConventionalCommit { } impl ConventionalCommit { + /// Maximum allowed length for the complete first line (type + scope + description) + pub const FIRST_LINE_MAX_LENGTH: usize = 72; + /// Create a new conventional commit message /// /// # Arguments - /// All arguments are pre-validated types, so this cannot fail - pub fn new(commit_type: CommitType, scope: Scope, description: Description) -> Self { - Self { + /// All arguments are pre-validated types, but the combined first line + /// length is validated here (max 72 characters). + /// + /// # Errors + /// Returns `CommitMessageError::FirstLineTooLong` if the formatted first + /// line exceeds 72 characters. + pub fn new( + commit_type: CommitType, + scope: Scope, + description: Description, + ) -> Result { + let commit = Self { commit_type, scope, description, + }; + let len = commit.first_line_len(); + if len > Self::FIRST_LINE_MAX_LENGTH { + return Err(CommitMessageError::FirstLineTooLong { + actual: len, + max: Self::FIRST_LINE_MAX_LENGTH, + }); + } + Ok(commit) + } + + /// Calculate the length of the formatted first line + /// + /// Formula: + /// - With scope: `len(type) + len(scope) + 4 + len(description)` + /// (the 4 accounts for parentheses, colon, and space: "() ") + /// - Without scope: `len(type) + 2 + len(description)` + /// (the 2 accounts for colon and space: ": ") + pub fn first_line_len(&self) -> usize { + if self.scope.is_empty() { + // type: description + self.commit_type.as_str().len() + 2 + self.description.len() + } else { + // type(scope): description + self.commit_type.as_str().len() + self.scope.as_str().len() + 4 + self.description.len() } } @@ -54,7 +100,6 @@ impl std::fmt::Display for ConventionalCommit { } } - #[cfg(test)] mod tests { use super::*; @@ -69,10 +114,20 @@ mod tests { Description::parse(value).expect("test description should be valid") } + /// Helper to create a valid ConventionalCommit for testing + fn test_commit( + commit_type: CommitType, + scope: Scope, + description: Description, + ) -> ConventionalCommit { + ConventionalCommit::new(commit_type, scope, description) + .expect("test commit should have valid line length") + } + /// Test that ConventionalCommit::new() creates a valid commit with all fields #[test] fn new_creates_commit_with_all_fields() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, test_scope("cli"), test_description("add new feature"), @@ -85,7 +140,7 @@ mod tests { /// Test that ConventionalCommit::new() works with empty scope #[test] fn new_creates_commit_with_empty_scope() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Fix, Scope::empty(), test_description("fix critical bug"), @@ -98,7 +153,7 @@ mod tests { /// Test that format() produces "type(scope): description" when scope is non-empty #[test] fn format_with_scope_produces_correct_output() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, test_scope("auth"), test_description("add login"), @@ -110,7 +165,7 @@ mod tests { #[test] fn format_with_various_scopes() { // Hyphenated scope - let commit1 = ConventionalCommit::new( + let commit1 = test_commit( CommitType::Fix, test_scope("user-auth"), test_description("fix token refresh"), @@ -118,7 +173,7 @@ mod tests { assert_eq!(commit1.format(), "fix(user-auth): fix token refresh"); // Underscored scope - let commit2 = ConventionalCommit::new( + let commit2 = test_commit( CommitType::Docs, test_scope("api_docs"), test_description("update README"), @@ -126,7 +181,7 @@ mod tests { assert_eq!(commit2.format(), "docs(api_docs): update README"); // Scope with slash (Jira-style) - let commit3 = ConventionalCommit::new( + let commit3 = test_commit( CommitType::Chore, test_scope("PROJ-123/cleanup"), test_description("remove unused code"), @@ -140,7 +195,7 @@ mod tests { /// Test that format() produces "type: description" when scope is empty #[test] fn format_without_scope_produces_correct_output() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, Scope::empty(), test_description("add login"), @@ -151,14 +206,14 @@ mod tests { /// Test format without scope for various descriptions #[test] fn format_without_scope_various_descriptions() { - let commit1 = ConventionalCommit::new( + let commit1 = test_commit( CommitType::Fix, Scope::empty(), test_description("fix critical bug"), ); assert_eq!(commit1.format(), "fix: fix critical bug"); - let commit2 = ConventionalCommit::new( + let commit2 = test_commit( CommitType::Docs, Scope::empty(), test_description("update installation guide"), @@ -187,7 +242,7 @@ mod tests { ]; for (commit_type, expected) in expected_formats { - let commit = ConventionalCommit::new(commit_type, scope.clone(), desc.clone()); + let commit = test_commit(commit_type, scope.clone(), desc.clone()); assert_eq!( commit.format(), expected, @@ -217,7 +272,7 @@ mod tests { ]; for (commit_type, expected) in expected_formats { - let commit = ConventionalCommit::new(commit_type, Scope::empty(), desc.clone()); + let commit = test_commit(commit_type, Scope::empty(), desc.clone()); assert_eq!( commit.format(), expected, @@ -230,7 +285,7 @@ mod tests { /// Test that Display implementation delegates to format() #[test] fn display_delegates_to_format() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, test_scope("auth"), test_description("add login"), @@ -243,7 +298,7 @@ mod tests { /// Test Display with scope #[test] fn display_with_scope() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Fix, test_scope("api"), test_description("handle null response"), @@ -254,7 +309,7 @@ mod tests { /// Test Display without scope #[test] fn display_without_scope() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Docs, Scope::empty(), test_description("improve README"), @@ -267,11 +322,8 @@ mod tests { fn display_equals_format_for_all_types() { for commit_type in CommitType::all() { // With scope - let commit_with_scope = ConventionalCommit::new( - *commit_type, - test_scope("test"), - test_description("change"), - ); + let commit_with_scope = + test_commit(*commit_type, test_scope("test"), test_description("change")); assert_eq!( format!("{}", commit_with_scope), commit_with_scope.format(), @@ -281,7 +333,7 @@ mod tests { // Without scope let commit_without_scope = - ConventionalCommit::new(*commit_type, Scope::empty(), test_description("change")); + test_commit(*commit_type, Scope::empty(), test_description("change")); assert_eq!( format!("{}", commit_without_scope), commit_without_scope.format(), @@ -295,8 +347,7 @@ mod tests { #[test] fn commit_type_accessor_returns_correct_type() { for commit_type in CommitType::all() { - let commit = - ConventionalCommit::new(*commit_type, Scope::empty(), test_description("test")); + let commit = test_commit(*commit_type, Scope::empty(), test_description("test")); assert_eq!(commit.commit_type(), *commit_type); } } @@ -304,7 +355,7 @@ mod tests { /// Test scope() returns reference to scope #[test] fn scope_accessor_returns_reference() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, test_scope("auth"), test_description("add feature"), @@ -315,7 +366,7 @@ mod tests { /// Test scope() returns reference to empty scope #[test] fn scope_accessor_returns_empty_scope() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, Scope::empty(), test_description("add feature"), @@ -326,7 +377,7 @@ mod tests { /// Test description() returns reference to description #[test] fn description_accessor_returns_reference() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, Scope::empty(), test_description("add new authentication flow"), @@ -337,7 +388,7 @@ mod tests { /// Test Clone trait #[test] fn conventional_commit_is_cloneable() { - let original = ConventionalCommit::new( + let original = test_commit( CommitType::Feat, test_scope("cli"), test_description("add feature"), @@ -349,12 +400,12 @@ mod tests { /// Test PartialEq trait - equal commits #[test] fn conventional_commit_equality() { - let commit1 = ConventionalCommit::new( + let commit1 = test_commit( CommitType::Feat, test_scope("cli"), test_description("add feature"), ); - let commit2 = ConventionalCommit::new( + let commit2 = test_commit( CommitType::Feat, test_scope("cli"), test_description("add feature"), @@ -365,12 +416,12 @@ mod tests { /// Test PartialEq trait - different commit types #[test] fn conventional_commit_inequality_different_type() { - let commit1 = ConventionalCommit::new( + let commit1 = test_commit( CommitType::Feat, test_scope("cli"), test_description("change"), ); - let commit2 = ConventionalCommit::new( + let commit2 = test_commit( CommitType::Fix, test_scope("cli"), test_description("change"), @@ -381,12 +432,12 @@ mod tests { /// Test PartialEq trait - different scopes #[test] fn conventional_commit_inequality_different_scope() { - let commit1 = ConventionalCommit::new( + let commit1 = test_commit( CommitType::Feat, test_scope("cli"), test_description("change"), ); - let commit2 = ConventionalCommit::new( + let commit2 = test_commit( CommitType::Feat, test_scope("api"), test_description("change"), @@ -397,12 +448,12 @@ mod tests { /// Test PartialEq trait - different descriptions #[test] fn conventional_commit_inequality_different_description() { - let commit1 = ConventionalCommit::new( + let commit1 = test_commit( CommitType::Feat, test_scope("cli"), test_description("add feature"), ); - let commit2 = ConventionalCommit::new( + let commit2 = test_commit( CommitType::Feat, test_scope("cli"), test_description("fix bug"), @@ -413,7 +464,7 @@ mod tests { /// Test Debug trait #[test] fn conventional_commit_has_debug() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, test_scope("cli"), test_description("add feature"), @@ -426,7 +477,7 @@ mod tests { /// Test real-world commit message example: feature with scope #[test] fn real_world_feature_with_scope() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, test_scope("auth"), test_description("implement OAuth2 login flow"), @@ -437,7 +488,7 @@ mod tests { /// Test real-world commit message example: bug fix without scope #[test] fn real_world_bugfix_without_scope() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Fix, Scope::empty(), test_description("prevent crash on empty input"), @@ -448,7 +499,7 @@ mod tests { /// Test real-world commit message example: documentation #[test] fn real_world_docs() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Docs, test_scope("README"), test_description("add installation instructions"), @@ -462,7 +513,7 @@ mod tests { /// Test real-world commit message example: refactoring #[test] fn real_world_refactor() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Refactor, test_scope("core"), test_description("extract validation logic"), @@ -473,7 +524,7 @@ mod tests { /// Test real-world commit message example: CI change #[test] fn real_world_ci() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Ci, test_scope("github"), test_description("add release workflow"), @@ -481,29 +532,179 @@ mod tests { assert_eq!(commit.format(), "ci(github): add release workflow"); } - /// Test commit message with maximum length description (72 chars) + /// Test commit message with maximum description length (50 chars) #[test] fn format_with_max_length_description() { - let long_desc = "a".repeat(72); - let commit = ConventionalCommit::new( + let long_desc = "a".repeat(50); + let commit = test_commit( CommitType::Feat, Scope::empty(), Description::parse(&long_desc).unwrap(), ); - // Format should be "feat: " + 72 chars = 78 total chars + // Format should be "feat: " + 50 chars = 56 total chars let formatted = commit.format(); assert!(formatted.starts_with("feat: ")); - assert_eq!(formatted.len(), 78); // "feat: " (6) + 72 = 78 + assert_eq!(formatted.len(), 56); // "feat: " (6) + 50 = 56 } /// Test commit message with scope containing all valid special chars #[test] fn format_with_complex_scope() { - let commit = ConventionalCommit::new( + let commit = test_commit( CommitType::Feat, test_scope("my-scope_v2/feature"), test_description("add support"), ); assert_eq!(commit.format(), "feat(my-scope_v2/feature): add support"); } + + // ========================================================================= + // Line Length Validation Tests + // ========================================================================= + + /// Test FIRST_LINE_MAX_LENGTH constant is 72 + #[test] + fn first_line_max_length_constant_is_72() { + assert_eq!(ConventionalCommit::FIRST_LINE_MAX_LENGTH, 72); + } + + /// Test first_line_len() calculates correctly without scope + #[test] + fn first_line_len_without_scope() { + let commit = test_commit( + CommitType::Feat, + Scope::empty(), + test_description("add login"), + ); + // "feat: add login" = 4 + 2 + 9 = 15 + assert_eq!(commit.first_line_len(), 15); + } + + /// Test first_line_len() calculates correctly with scope + #[test] + fn first_line_len_with_scope() { + let commit = test_commit( + CommitType::Feat, + test_scope("auth"), + test_description("add login"), + ); + // "feat(auth): add login" = 4 + 4 + 4 + 9 = 21 + assert_eq!(commit.first_line_len(), 21); + } + + /// Test exactly 72 characters is accepted (boundary) + #[test] + fn exactly_72_characters_accepted() { + // Build a commit that's exactly 72 chars: + // feat(20chars): 44chars = 4 + 20 + 4 + 44 = 72 + let scope_20 = "a".repeat(20); + let desc_44 = "b".repeat(44); + let result = ConventionalCommit::new( + CommitType::Feat, + Scope::parse(&scope_20).unwrap(), + Description::parse(&desc_44).unwrap(), + ); + assert!(result.is_ok()); + let commit = result.unwrap(); + assert_eq!(commit.first_line_len(), 72); + } + + /// Test 73 characters is rejected (boundary) + #[test] + fn seventy_three_characters_rejected() { + // Build a commit that's 73 chars: + // "refactor: " = 10 chars, so we need 63 chars of description for 73 total + // But wait, we need to account for commit type and potentially scope + // Let's use "feat" (4) + ": " (2) + 67 chars = 73 + // However Description MAX_LENGTH is 50, so we need a different approach + // Use scope to pad: "refactor(scope): desc" + // refactor = 8, (scope) = 7, : = 1, space = 1, desc needed for 73 + // 8 + 7 + 2 + desc = 73, so desc = 56, but max is 50 + // Let's use a longer type: "refactor" (8) + longer scope + // Actually, let me use max scope (30) and appropriate description: + // type(scope): desc + // refactor(30chars): X = 8 + 30 + 4 + X = 73 + // X = 73 - 42 = 31 + let scope_30 = "a".repeat(30); + let desc_31 = "b".repeat(31); + let result = ConventionalCommit::new( + CommitType::Refactor, + Scope::parse(&scope_30).unwrap(), + Description::parse(&desc_31).unwrap(), + ); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + CommitMessageError::FirstLineTooLong { + actual: 73, + max: 72 + } + ); + } + + /// Test that valid components can still exceed 72 chars when combined + #[test] + fn valid_components_can_exceed_limit() { + // Use maximum valid scope (30 chars) and a 40-char description + // refactor(30): 40 = 8 + 30 + 4 + 40 = 82 chars (exceeds 72) + let scope_30 = "a".repeat(30); + let desc_40 = "b".repeat(40); + let result = ConventionalCommit::new( + CommitType::Refactor, + Scope::parse(&scope_30).unwrap(), + Description::parse(&desc_40).unwrap(), + ); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + CommitMessageError::FirstLineTooLong { + actual: 82, + max: 72 + } + ); + } + + /// Test short commit without scope is accepted + #[test] + fn short_commit_without_scope_accepted() { + let result = ConventionalCommit::new( + CommitType::Fix, + Scope::empty(), + test_description("quick fix"), + ); + assert!(result.is_ok()); + } + + /// Test short commit with scope is accepted + #[test] + fn short_commit_with_scope_accepted() { + let result = ConventionalCommit::new( + CommitType::Feat, + test_scope("cli"), + test_description("add feature"), + ); + assert!(result.is_ok()); + } + + /// Test CommitMessageError::FirstLineTooLong displays correctly + #[test] + fn first_line_too_long_error_display() { + let err = CommitMessageError::FirstLineTooLong { + actual: 80, + max: 72, + }; + let msg = format!("{}", err); + assert!(msg.contains("too long")); + assert!(msg.contains("80")); + assert!(msg.contains("72")); + } + + /// Test new() returns Result type + #[test] + fn new_returns_result() { + let result = + ConventionalCommit::new(CommitType::Feat, Scope::empty(), test_description("test")); + // Just verify it's a Result by using is_ok() + assert!(result.is_ok()); + } } diff --git a/src/commit/types/mod.rs b/src/commit/types/mod.rs index 5424949..2002720 100644 --- a/src/commit/types/mod.rs +++ b/src/commit/types/mod.rs @@ -8,3 +8,4 @@ mod description; pub use description::{Description, DescriptionError}; mod message; +pub use message::{CommitMessageError, ConventionalCommit}; diff --git a/src/commit/types/scope.rs b/src/commit/types/scope.rs index e283fdf..e4f143b 100644 --- a/src/commit/types/scope.rs +++ b/src/commit/types/scope.rs @@ -6,7 +6,7 @@ pub struct Scope(String); impl Scope { /// Maximum allowed length for a scope - pub const MAX_LENGTH: usize = 50; + pub const MAX_LENGTH: usize = 30; /// Parse and validate a scope string /// @@ -14,7 +14,7 @@ impl Scope { /// - Trims leading/trailing whitespace /// - Empty/whitespace-only input returns empty Scope /// - Validates character set - /// - Validates maximum length (50 chars) + /// - Validates maximum length (30 chars) pub fn parse(value: impl Into) -> Result { let value: String = value.into().trim().to_owned(); if value.is_empty() { @@ -285,26 +285,26 @@ mod tests { assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.')); } - /// Test that exactly 50 characters is accepted (boundary) + /// Test that exactly 30 characters is accepted (boundary) #[test] - fn fifty_characters_accepted() { - let scope_50 = "a".repeat(50); - let result = Scope::parse(&scope_50); + fn thirty_characters_accepted() { + let scope_30 = "a".repeat(30); + let result = Scope::parse(&scope_30); assert!(result.is_ok()); - assert_eq!(result.unwrap().as_str().len(), 50); + assert_eq!(result.unwrap().as_str().len(), 30); } - /// Test that 51 characters is rejected + /// Test that 31 characters is rejected #[test] - fn fifty_one_characters_rejected() { - let scope_51 = "a".repeat(51); - let result = Scope::parse(&scope_51); + fn thirty_one_characters_rejected() { + let scope_31 = "a".repeat(31); + let result = Scope::parse(&scope_31); assert!(result.is_err()); assert_eq!( result.unwrap_err(), ScopeError::TooLong { - actual: 51, - max: 50 + actual: 31, + max: 30 } ); } @@ -319,7 +319,7 @@ mod tests { result.unwrap_err(), ScopeError::TooLong { actual: 100, - max: 50 + max: 30 } ); } @@ -327,17 +327,17 @@ mod tests { /// Test that length is checked after trimming #[test] fn length_checked_after_trimming() { - // 50 chars + leading/trailing spaces = should be valid after trim - let scope_with_spaces = format!(" {} ", "a".repeat(50)); + // 30 chars + leading/trailing spaces = should be valid after trim + let scope_with_spaces = format!(" {} ", "a".repeat(30)); let result = Scope::parse(&scope_with_spaces); assert!(result.is_ok()); - assert_eq!(result.unwrap().as_str().len(), 50); + assert_eq!(result.unwrap().as_str().len(), 30); } - /// Test MAX_LENGTH constant is 50 + /// Test MAX_LENGTH constant is 30 #[test] - fn max_length_constant_is_50() { - assert_eq!(Scope::MAX_LENGTH, 50); + fn max_length_constant_is_30() { + assert_eq!(Scope::MAX_LENGTH, 30); } /// Test that empty() creates an empty Scope @@ -432,12 +432,12 @@ mod tests { #[test] fn too_long_error_display() { let err = ScopeError::TooLong { - actual: 51, - max: 50, + actual: 31, + max: 30, }; let msg = format!("{}", err); assert!(msg.contains("too long")); - assert!(msg.contains("51")); - assert!(msg.contains("50")); + assert!(msg.contains("31")); + assert!(msg.contains("30")); } } -- 2.49.1