Merge pull request 'feat: foundational domain types' (#1) from feature/001-mvp-interactive-commit into develop
All checks were successful
Publish Docker Images / coverage-and-sonar (push) Successful in 8m10s

Reviewed-on: #1
This commit is contained in:
2026-02-06 03:27:27 +01:00
13 changed files with 2785 additions and 14 deletions

928
Cargo.lock generated
View File

@@ -2,6 +2,934 @@
# 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",
"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"
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-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"
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"

View File

@@ -16,6 +16,14 @@ 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"
lazy-regex = { version = "3.5.1", features = ["lite"] }
predicates = "3.1.3"
thiserror = "2.0.18"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }

1
src/cli/mod.rs Normal file
View File

@@ -0,0 +1 @@

1
src/commit/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod types;

View File

@@ -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"));
}
}

View File

@@ -0,0 +1,379 @@
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct Description(String);
impl Description {
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 (50 chars after trim - soft limit)
pub fn parse(value: impl Into<String>) -> Result<Self, DescriptionError> {
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<str> 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 72 characters (old limit) is now rejected
#[test]
fn seventy_two_characters_now_rejected() {
let desc_72 = "a".repeat(72);
let result = Description::parse(&desc_72);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
DescriptionError::TooLong {
actual: 72,
max: 50
}
);
}
/// Test that 51 characters is rejected (boundary)
#[test]
fn fifty_one_characters_rejected() {
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
}
);
}
/// Test that 100 characters is rejected
#[test]
fn hundred_characters_rejected() {
let desc_100 = "a".repeat(100);
let result = Description::parse(&desc_100);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
DescriptionError::TooLong {
actual: 100,
max: 50
}
);
}
/// Test that length is checked after trimming
#[test]
fn length_checked_after_trimming() {
// 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(), 50);
}
/// 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 50 (soft limit)
#[test]
fn max_length_constant_is_50() {
assert_eq!(Description::MAX_LENGTH, 50);
}
/// 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<str> 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: 51,
max: 50,
};
let msg = format!("{}", err);
assert!(msg.contains("too long"));
assert!(msg.contains("51"));
assert!(msg.contains("50"));
}
/// Test description with only whitespace after trim becomes empty
#[test]
fn whitespace_after_trim_is_empty() {
// 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() {
// 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(), 50);
}
/// Test description just over boundary after trimming
#[test]
fn over_boundary_after_trim() {
// 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: 51,
max: 50
}
);
}
}

710
src/commit/types/message.rs Normal file
View File

@@ -0,0 +1,710 @@
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 {
commit_type: CommitType,
scope: Scope,
description: Description,
}
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, 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<Self, CommitMessageError> {
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()
}
}
/// 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")
}
/// 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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
CommitType::Fix,
Scope::empty(),
test_description("fix critical bug"),
);
assert_eq!(commit1.format(), "fix: fix critical bug");
let commit2 = test_commit(
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 = test_commit(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 = test_commit(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 = test_commit(
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 = test_commit(
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 = test_commit(
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 =
test_commit(*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 =
test_commit(*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 = test_commit(*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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("add feature"),
);
let commit2 = test_commit(
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 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("change"),
);
let commit2 = test_commit(
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 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("change"),
);
let commit2 = test_commit(
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 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("add feature"),
);
let commit2 = test_commit(
CommitType::Feat,
test_scope("cli"),
test_description("fix bug"),
);
assert_ne!(commit1, commit2);
}
/// Test Debug trait
#[test]
fn conventional_commit_has_debug() {
let commit = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
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 = test_commit(
CommitType::Ci,
test_scope("github"),
test_description("add release workflow"),
);
assert_eq!(commit.format(), "ci(github): add release workflow");
}
/// Test commit message with maximum description length (50 chars)
#[test]
fn format_with_max_length_description() {
let long_desc = "a".repeat(50);
let commit = test_commit(
CommitType::Feat,
Scope::empty(),
Description::parse(&long_desc).unwrap(),
);
// Format should be "feat: " + 50 chars = 56 total chars
let formatted = commit.format();
assert!(formatted.starts_with("feat: "));
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 = 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());
}
}

11
src/commit/types/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
mod commit_type;
pub use commit_type::CommitType;
mod scope;
pub use scope::{Scope, ScopeError};
mod description;
pub use description::{Description, DescriptionError};
mod message;
pub use message::{CommitMessageError, ConventionalCommit};

443
src/commit/types/scope.rs Normal file
View File

@@ -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 = 30;
/// Parse and validate a scope string
///
/// # Validation
/// - Trims leading/trailing whitespace
/// - Empty/whitespace-only input returns empty Scope
/// - Validates character set
/// - Validates maximum length (30 chars)
pub fn parse(value: impl Into<String>) -> Result<Self, ScopeError> {
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.is_empty()
}
/// 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<str> 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 30 characters is accepted (boundary)
#[test]
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(), 30);
}
/// Test that 31 characters is rejected
#[test]
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: 31,
max: 30
}
);
}
/// 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: 30
}
);
}
/// Test that length is checked after trimming
#[test]
fn length_checked_after_trimming() {
// 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(), 30);
}
/// Test MAX_LENGTH constant is 30
#[test]
fn max_length_constant_is_30() {
assert_eq!(Scope::MAX_LENGTH, 30);
}
/// 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<str> 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: 31,
max: 30,
};
let msg = format!("{}", err);
assert!(msg.contains("too long"));
assert!(msg.contains("31"));
assert!(msg.contains("30"));
}
}

20
src/error.rs Normal file
View File

@@ -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,
}

1
src/jj/mod.rs Normal file
View File

@@ -0,0 +1 @@

View File

@@ -1,14 +1,5 @@
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 error;
mod jj;
mod prompts;

1
src/prompts/mod.rs Normal file
View File

@@ -0,0 +1 @@