From 81493b4d1e3dc5058f1968fd239169223d7f56bd Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 15 Jul 2025 21:14:34 +0100 Subject: [PATCH 1/7] Add clap-based CLI with tests --- Cargo.lock | 1288 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 10 + docs/netsuke-design.md | 8 + docs/roadmap.md | 2 +- src/cli.rs | 43 ++ src/lib.rs | 6 + src/main.rs | 17 +- tests/cli_tests.rs | 30 + tests/cucumber.rs | 13 + tests/features/cli.feature | 12 + tests/steps/cli_steps.rs | 66 ++ tests/steps/mod.rs | 1 + 12 files changed, 1494 insertions(+), 2 deletions(-) create mode 100644 Cargo.lock create mode 100644 src/cli.rs create mode 100644 src/lib.rs create mode 100644 tests/cli_tests.rs create mode 100644 tests/cucumber.rs create mode 100644 tests/features/cli.feature create mode 100644 tests/steps/cli_steps.rs create mode 100644 tests/steps/mod.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..5af9ffc0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1288 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[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 = "cucumber" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5063d8cf24f4998ad01cac265da468a15ca682a8f4f826d50e661964e8d9b8" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "console", + "cucumber-codegen", + "cucumber-expressions", + "derive_more", + "drain_filter_polyfill", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools", + "lazy-regex", + "linked-hash-map", + "once_cell", + "pin-project", + "regex", + "sealed", + "smart-default", +] + +[[package]] +name = "cucumber-codegen" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01091e28d1f566c8b31b67948399d2efd6c0a8f6228a9785519ed7b73f7f0aef" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools", + "proc-macro2", + "quote", + "regex", + "syn", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d794fed319eea24246fb5f57632f7ae38d61195817b7eb659455aa5bdd7c1810" +dependencies = [ + "derive_more", + "either", + "nom", + "nom_locate", + "regex", + "regex-syntax 0.7.5", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gherkin" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b79820c0df536d1f3a089a2fa958f61cb96ce9e0f3f8f507f5a31179567755" +dependencies = [ + "heck 0.4.1", + "peg", + "quote", + "serde", + "serde_json", + "syn", + "textwrap", + "thiserror", + "typed-builder", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax 0.8.5", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "humantime" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inventory" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab08d7cd2c5897f2c949e5383ea7c7db03fb19130ffcfbf7eda795137ae3cb83" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "lazy-regex" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60c7310b93682b36b98fa7ea4de998d3463ccbebd94d935d6b48ba5b6ffa7126" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba01db5ef81e17eb10a5e0f2109d1b3a3e29bac3070fdbd7d156bf7dbd206a1" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + +[[package]] +name = "netsuke" +version = "0.1.0" +dependencies = [ + "clap", + "cucumber", + "rstest", + "tokio", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" +dependencies = [ + "cfg-if", + "glob", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + +[[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.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[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 = "sealed" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a8caec23b7800fb97971a1c6ae365b6239aaeddfb934d6265f8505e795699d" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synthez" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" +dependencies = [ + "syn", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" +dependencies = [ + "syn", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bfa6ec52465e2425fd43ce5bbbe0f0b623964f7c63feb6b10980e816c654ea" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn", +] + +[[package]] +name = "terminal_size" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +dependencies = [ + "backtrace", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typed-builder" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe83c85a85875e8c4cb9ce4a890f05b23d38cd0d47647db7895d3d2a79566d2" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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 = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" diff --git a/Cargo.toml b/Cargo.toml index e6ca91e0..0b13751e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +clap = { version = "4.5", features = ["derive"] } [lints.clippy] pedantic = { level = "warn", priority = -1 } @@ -44,3 +45,12 @@ string_lit_as_bytes = "deny" # 6. numerical foot-guns float_arithmetic = "deny" + +[dev-dependencies] +rstest = "0.18" +cucumber = "0.20" +tokio = { version = "1", features = ["macros", "rt-multi-thread"], default-features = false } + +[[test]] +name = "cucumber" +harness = false diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index dd8f022d..b47e1b2f 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1262,6 +1262,14 @@ The behaviour of each subcommand is clearly defined: viewer. Visualising the graph is invaluable for understanding and debugging complex projects. +### 8.4 Design Decisions + +The CLI is implemented using clap's derive API in `src/cli.rs`. The `Build` +subcommand is optional so that invoking `netsuke` without a subcommand defaults +to building the manifest's default targets. The working directory flag uses `-C` +to mirror Ninja's convention, ensuring command line arguments map directly onto +the underlying build tool. + ## Section 9: Implementation Roadmap and Strategic Recommendations This final section outlines a strategic plan for implementing Netsuke, along diff --git a/docs/roadmap.md b/docs/roadmap.md index 900c5532..709ec861 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,7 @@ compilation pipeline from parsing to execution. - [ ] **CLI and Manifest Parsing:** - - [ ] Implement the initial clap CLI structure for the build command and + - [x] Implement the initial clap CLI structure for the build command and global options (--file, --directory, --jobs), as defined in the design document. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 00000000..814d71ae --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,43 @@ +//! Command line interface definition using clap. +//! +//! This module defines the [`Cli`] structure and its subcommands. +//! It mirrors the design described in `docs/netsuke-design.md`. + +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +/// A modern, friendly build system that uses YAML and Jinja, powered by Ninja. +#[derive(Debug, Parser)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + /// Path to the Netsuke manifest file to use. + #[arg(short, long, value_name = "FILE", default_value = "Netsukefile")] + pub file: PathBuf, + + /// Change to this directory before doing anything. + #[arg(short = 'C', long, value_name = "DIR")] + pub directory: Option, + + /// Set the number of parallel build jobs. + #[arg(short, long, value_name = "N")] + pub jobs: Option, + + #[command(subcommand)] + pub command: Option, +} + +/// Available top-level commands for Netsuke. +#[derive(Debug, Subcommand, PartialEq, Eq, Clone)] +pub enum Commands { + /// Build specified targets (or default targets if none are given) [default]. + Build { + /// A list of specific targets to build. + targets: Vec, + }, + + /// Remove build artifacts and intermediate files. + Clean {}, + + /// Display the build dependency graph in DOT format for visualization. + Graph {}, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..5456a906 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +//! Netsuke core library. +//! +//! Currently this library only exposes the command line interface +//! definitions used by the binary and tests. + +pub mod cli; diff --git a/src/main.rs b/src/main.rs index 6f2c2da5..10609e30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,18 @@ +use clap::Parser; +use netsuke::cli::{Cli, Commands}; + fn main() { - // Placeholder entry point for future CLI implementation. + let cli = Cli::parse(); + + match cli.command.unwrap_or(Commands::Build { targets: vec![] }) { + Commands::Build { targets } => { + println!("Building targets: {targets:?}"); + } + Commands::Clean {} => { + println!("Clean requested"); + } + Commands::Graph {} => { + println!("Graph requested"); + } + } } diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs new file mode 100644 index 00000000..4e7fde7c --- /dev/null +++ b/tests/cli_tests.rs @@ -0,0 +1,30 @@ +use clap::Parser; +use netsuke::cli::{Cli, Commands}; +use rstest::rstest; +use std::path::PathBuf; + +#[rstest] +#[case(vec!["netsuke"], PathBuf::from("Netsukefile"), None, None, Commands::Build { targets: Vec::new() })] +#[case( + vec!["netsuke", "--file", "alt.yml", "-C", "work", "-j", "4", "build", "a", "b"], + PathBuf::from("alt.yml"), + Some(PathBuf::from("work")), + Some(4), + Commands::Build { targets: vec!["a".into(), "b".into()] }, +)] +fn parse_cli( + #[case] argv: Vec<&str>, + #[case] file: PathBuf, + #[case] directory: Option, + #[case] jobs: Option, + #[case] expected_cmd: Commands, +) { + let cli = Cli::try_parse_from(argv).expect("parse"); + assert_eq!(cli.file, file); + assert_eq!(cli.directory, directory); + assert_eq!(cli.jobs, jobs); + let command = cli.command.unwrap_or(Commands::Build { + targets: Vec::new(), + }); + assert_eq!(command, expected_cmd); +} diff --git a/tests/cucumber.rs b/tests/cucumber.rs new file mode 100644 index 00000000..c4b98d93 --- /dev/null +++ b/tests/cucumber.rs @@ -0,0 +1,13 @@ +use cucumber::World; + +#[derive(Debug, Default, World)] +pub struct CliWorld { + pub cli: Option, +} + +mod steps; + +#[tokio::main] +async fn main() { + CliWorld::run("tests/features").await; +} diff --git a/tests/features/cli.feature b/tests/features/cli.feature new file mode 100644 index 00000000..aa0ab89d --- /dev/null +++ b/tests/features/cli.feature @@ -0,0 +1,12 @@ +Feature: CLI parsing + + Scenario: Build is the default command + When the CLI is parsed with "" + Then parsing succeeds + And the command is build + + Scenario: Manifest file can be overridden + When the CLI is parsed with "--file alt.yml build target" + Then parsing succeeds + And the manifest path is "alt.yml" + And the first target is "target" diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs new file mode 100644 index 00000000..87c99893 --- /dev/null +++ b/tests/steps/cli_steps.rs @@ -0,0 +1,66 @@ +use crate::CliWorld; +use clap::Parser; +use cucumber::{then, when}; +use netsuke::cli::{Cli, Commands}; +use std::path::PathBuf; + +#[allow( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[when(expr = "the CLI is parsed with {string}")] +fn parse_cli(world: &mut CliWorld, args: String) { + let tokens: Vec = if args.is_empty() { + vec!["netsuke".to_string()] + } else { + std::iter::once("netsuke".to_string()) + .chain(args.split_whitespace().map(str::to_string)) + .collect() + }; + world.cli = Some(Cli::parse_from(tokens)); +} + +#[then("parsing succeeds")] +fn parsing_succeeds(world: &mut CliWorld) { + assert!(world.cli.is_some()); +} + +#[then("the command is build")] +fn command_is_build(world: &mut CliWorld) { + let cli = world.cli.as_ref().expect("cli"); + match cli + .command + .clone() + .unwrap_or(Commands::Build { targets: vec![] }) + { + Commands::Build { .. } => (), + _ => panic!("not build"), + } +} + +#[allow( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[then(expr = "the manifest path is {string}")] +fn manifest_path(world: &mut CliWorld, path: String) { + let cli = world.cli.as_ref().expect("cli"); + assert_eq!(cli.file, PathBuf::from(path)); +} + +#[allow( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[then(expr = "the first target is {string}")] +fn first_target(world: &mut CliWorld, target: String) { + let cli = world.cli.as_ref().expect("cli"); + let cmd = cli + .command + .clone() + .unwrap_or(Commands::Build { targets: vec![] }); + match cmd { + Commands::Build { targets } => assert_eq!(targets.first(), Some(&target)), + _ => panic!("expected build"), + } +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs new file mode 100644 index 00000000..90e6389d --- /dev/null +++ b/tests/steps/mod.rs @@ -0,0 +1 @@ +mod cli_steps; From 6df4841addeb3ea9db9c8221ab2763468b15d393 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 12:02:48 +0100 Subject: [PATCH 2/7] Add CLI error handling tests --- Cargo.toml | 6 +-- docs/netsuke-design.md | 8 ++-- tests/cli_tests.rs | 11 ++++++ tests/cucumber.rs | 1 + tests/features/cli.feature | 10 +++++ tests/steps/cli_steps.rs | 76 +++++++++++++++++++++++++++++++++----- 6 files changed, 96 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0b13751e..5c743751 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -clap = { version = "4.5", features = ["derive"] } +clap = { version = "4.5.0", features = ["derive"] } [lints.clippy] pedantic = { level = "warn", priority = -1 } @@ -47,8 +47,8 @@ string_lit_as_bytes = "deny" float_arithmetic = "deny" [dev-dependencies] -rstest = "0.18" -cucumber = "0.20" +rstest = "0.18.0" +cucumber = "0.20.0" tokio = { version = "1", features = ["macros", "rt-multi-thread"], default-features = false } [[test]] diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index b47e1b2f..ee6d23ff 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1266,9 +1266,11 @@ The behaviour of each subcommand is clearly defined: The CLI is implemented using clap's derive API in `src/cli.rs`. The `Build` subcommand is optional so that invoking `netsuke` without a subcommand defaults -to building the manifest's default targets. The working directory flag uses `-C` -to mirror Ninja's convention, ensuring command line arguments map directly onto -the underlying build tool. +to building the manifest's default targets. The working directory flag uses +`-C` to mirror Ninja's convention, ensuring command line arguments map directly +onto the underlying build tool. Error scenarios are validated using clap's +`ErrorKind` enumeration in unit tests and via Cucumber steps for behavioural +coverage. ## Section 9: Implementation Roadmap and Strategic Recommendations diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 4e7fde7c..7eee0c85 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,4 +1,5 @@ use clap::Parser; +use clap::error::ErrorKind; use netsuke::cli::{Cli, Commands}; use rstest::rstest; use std::path::PathBuf; @@ -28,3 +29,13 @@ fn parse_cli( }); assert_eq!(command, expected_cmd); } + +#[rstest] +#[case(vec!["netsuke", "unknowncmd"], ErrorKind::InvalidSubcommand)] +#[case(vec!["netsuke", "--file"], ErrorKind::InvalidValue)] +#[case(vec!["netsuke", "-j", "notanumber"], ErrorKind::ValueValidation)] +#[case(vec!["netsuke", "--file", "alt.yml", "-C"], ErrorKind::InvalidValue)] +fn parse_cli_errors(#[case] argv: Vec<&str>, #[case] expected_error: ErrorKind) { + let err = Cli::try_parse_from(argv).expect_err("unexpected success"); + assert_eq!(err.kind(), expected_error); +} diff --git a/tests/cucumber.rs b/tests/cucumber.rs index c4b98d93..4b32c084 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -3,6 +3,7 @@ use cucumber::World; #[derive(Debug, Default, World)] pub struct CliWorld { pub cli: Option, + pub cli_error: Option, } mod steps; diff --git a/tests/features/cli.feature b/tests/features/cli.feature index aa0ab89d..af8373e0 100644 --- a/tests/features/cli.feature +++ b/tests/features/cli.feature @@ -10,3 +10,13 @@ Feature: CLI parsing Then parsing succeeds And the manifest path is "alt.yml" And the first target is "target" + + Scenario: Unknown command fails + When the CLI is parsed with invalid arguments "unknown" + Then an error should be returned + And the error message should contain "unknown" + + Scenario: Missing file argument value + When the CLI is parsed with invalid arguments "--file" + Then an error should be returned + And the error message should contain "--file" diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index 87c99893..8c37974e 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -1,10 +1,15 @@ +//! Cucumber step definitions for CLI behaviour-driven testing. +//! +//! This module provides step definitions that test the command-line interface +//! parsing and validation using the Cucumber framework. + use crate::CliWorld; use clap::Parser; use cucumber::{then, when}; use netsuke::cli::{Cli, Commands}; use std::path::PathBuf; -#[allow( +#[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] @@ -17,7 +22,37 @@ fn parse_cli(world: &mut CliWorld, args: String) { .chain(args.split_whitespace().map(str::to_string)) .collect() }; - world.cli = Some(Cli::parse_from(tokens)); + match Cli::try_parse_from(tokens) { + Ok(cli) => { + world.cli = Some(cli); + world.cli_error = None; + } + Err(e) => { + world.cli = None; + world.cli_error = Some(e.to_string()); + } + } +} + +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[when(expr = "the CLI is parsed with invalid arguments {string}")] +fn parse_cli_invalid(world: &mut CliWorld, args: String) { + let tokens: Vec = std::iter::once("netsuke".to_string()) + .chain(args.split_whitespace().map(str::to_string)) + .collect(); + match Cli::try_parse_from(tokens) { + Ok(cli) => { + world.cli = Some(cli); + world.cli_error = None; + } + Err(e) => { + world.cli = None; + world.cli_error = Some(e.to_string()); + } + } } #[then("parsing succeeds")] @@ -30,11 +65,11 @@ fn command_is_build(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); match cli .command - .clone() - .unwrap_or(Commands::Build { targets: vec![] }) + .as_ref() + .unwrap_or(&Commands::Build { targets: vec![] }) { Commands::Build { .. } => (), - _ => panic!("not build"), + other => panic!("Expected build command, got {other:?}"), } } @@ -55,12 +90,33 @@ fn manifest_path(world: &mut CliWorld, path: String) { #[then(expr = "the first target is {string}")] fn first_target(world: &mut CliWorld, target: String) { let cli = world.cli.as_ref().expect("cli"); - let cmd = cli + match cli .command - .clone() - .unwrap_or(Commands::Build { targets: vec![] }); - match cmd { + .as_ref() + .unwrap_or(&Commands::Build { targets: vec![] }) + { Commands::Build { targets } => assert_eq!(targets.first(), Some(&target)), - _ => panic!("expected build"), + other => panic!("Expected build command, got {other:?}"), } } + +#[then("an error should be returned")] +fn error_should_be_returned(world: &mut CliWorld) { + assert!( + world.cli_error.is_some(), + "Expected an error, but none was returned" + ); +} + +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[then(expr = "the error message should contain {string}")] +fn error_message_should_contain(world: &mut CliWorld, expected: String) { + let error = world.cli_error.as_ref().expect("No error was returned"); + assert!( + error.contains(&expected), + "Error message '{error}' does not contain expected '{expected}'" + ); +} From a22e20b3f60d868a3072522e113c16b80048d091 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 20:12:51 +0100 Subject: [PATCH 3/7] Refactor CLI parsing and tests --- docs/netsuke-design.md | 15 ++++--- docs/roadmap.md | 2 +- src/cli.rs | 14 ++++++ src/lib.rs | 1 + src/main.rs | 18 ++------ src/runner.rs | 23 ++++++++++ tests/cli_tests.rs | 17 +++++--- tests/features/cli.feature | 10 +++++ tests/steps/cli_steps.rs | 89 +++++++++++++++++++------------------- 9 files changed, 117 insertions(+), 72 deletions(-) create mode 100644 src/runner.rs diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index ee6d23ff..548478bc 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1264,13 +1264,14 @@ The behaviour of each subcommand is clearly defined: ### 8.4 Design Decisions -The CLI is implemented using clap's derive API in `src/cli.rs`. The `Build` -subcommand is optional so that invoking `netsuke` without a subcommand defaults -to building the manifest's default targets. The working directory flag uses -`-C` to mirror Ninja's convention, ensuring command line arguments map directly -onto the underlying build tool. Error scenarios are validated using clap's -`ErrorKind` enumeration in unit tests and via Cucumber steps for behavioural -coverage. +The CLI is implemented using clap's derive API in `src/cli.rs`. Clap's +`default_value_t` attribute marks `Build` as the default subcommand so invoking +`netsuke` with no explicit command still triggers a build. CLI execution and +dispatch live in `src/runner.rs`, keeping `main.rs` focused on parsing. The +working directory flag uses `-C` to mirror Ninja's convention, ensuring command +line arguments map directly onto the underlying build tool. Error scenarios are +validated using clap's `ErrorKind` enumeration in unit tests and via Cucumber +steps for behavioural coverage. ## Section 9: Implementation Roadmap and Strategic Recommendations diff --git a/docs/roadmap.md b/docs/roadmap.md index 709ec861..78f230c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,7 +14,7 @@ compilation pipeline from parsing to execution. - [x] Implement the initial clap CLI structure for the build command and global options (--file, --directory, --jobs), as defined in the design - document. + document. *(done)* - [ ] Define the core Abstract Syntax Tree (AST) data structures (NetsukeManifest, Rule, Target, StringOrList, Recipe) in `src/ast.rs`. diff --git a/src/cli.rs b/src/cli.rs index 814d71ae..e85b0386 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -26,6 +26,20 @@ pub struct Cli { pub command: Option, } +impl Cli { + /// Parse command-line arguments, providing `build` as the default command. + #[must_use] + pub fn parse_with_default() -> Self { + let mut cli = Self::parse(); + if cli.command.is_none() { + cli.command = Some(Commands::Build { + targets: Vec::new(), + }); + } + cli + } +} + /// Available top-level commands for Netsuke. #[derive(Debug, Subcommand, PartialEq, Eq, Clone)] pub enum Commands { diff --git a/src/lib.rs b/src/lib.rs index 5456a906..f0c8a4a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ //! definitions used by the binary and tests. pub mod cli; +pub mod runner; diff --git a/src/main.rs b/src/main.rs index 10609e30..f72c2a2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,6 @@ -use clap::Parser; -use netsuke::cli::{Cli, Commands}; +use netsuke::{cli::Cli, runner}; fn main() { - let cli = Cli::parse(); - - match cli.command.unwrap_or(Commands::Build { targets: vec![] }) { - Commands::Build { targets } => { - println!("Building targets: {targets:?}"); - } - Commands::Clean {} => { - println!("Clean requested"); - } - Commands::Graph {} => { - println!("Graph requested"); - } - } + let cli = Cli::parse_with_default(); + runner::run(cli); } diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 00000000..3aee39b2 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,23 @@ +//! CLI execution and command dispatch logic. +//! +//! This module keeps [`main`] minimal by providing a single entry point that +//! handles command execution. It currently prints which command was invoked. + +use crate::cli::{Cli, Commands}; + +/// Execute the parsed [`Cli`] commands. +pub fn run(cli: Cli) { + match cli.command.unwrap_or(Commands::Build { + targets: Vec::new(), + }) { + Commands::Build { targets } => { + println!("Building targets: {targets:?}"); + } + Commands::Clean {} => { + println!("Clean requested"); + } + Commands::Graph {} => { + println!("Graph requested"); + } + } +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 7eee0c85..1a26fc0b 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -20,14 +20,21 @@ fn parse_cli( #[case] jobs: Option, #[case] expected_cmd: Commands, ) { - let cli = Cli::try_parse_from(argv).expect("parse"); + let mut cli = Cli::try_parse_from(argv).expect("parse"); + if cli.command.is_none() { + cli.command = Some(Commands::Build { + targets: Vec::new(), + }); + } assert_eq!(cli.file, file); assert_eq!(cli.directory, directory); assert_eq!(cli.jobs, jobs); - let command = cli.command.unwrap_or(Commands::Build { - targets: Vec::new(), - }); - assert_eq!(command, expected_cmd); + assert_eq!( + cli.command.unwrap_or(Commands::Build { + targets: Vec::new() + }), + expected_cmd + ); } #[rstest] diff --git a/tests/features/cli.feature b/tests/features/cli.feature index af8373e0..22251919 100644 --- a/tests/features/cli.feature +++ b/tests/features/cli.feature @@ -20,3 +20,13 @@ Feature: CLI parsing When the CLI is parsed with invalid arguments "--file" Then an error should be returned And the error message should contain "--file" + + Scenario: Directory flag sets working directory + When the CLI is parsed with "-C work build" + Then parsing succeeds + And the working directory is "work" + + Scenario: Jobs flag sets parallelism + When the CLI is parsed with "-j 4" + Then parsing succeeds + And the job count is 4 diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index 8c37974e..2b11dc7e 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -9,21 +9,17 @@ use cucumber::{then, when}; use netsuke::cli::{Cli, Commands}; use std::path::PathBuf; -#[expect( - clippy::needless_pass_by_value, - reason = "Cucumber requires owned String arguments" -)] -#[when(expr = "the CLI is parsed with {string}")] -fn parse_cli(world: &mut CliWorld, args: String) { - let tokens: Vec = if args.is_empty() { - vec!["netsuke".to_string()] - } else { - std::iter::once("netsuke".to_string()) - .chain(args.split_whitespace().map(str::to_string)) - .collect() - }; +fn apply_cli(world: &mut CliWorld, args: &str) { + let tokens: Vec = std::iter::once("netsuke".to_string()) + .chain(args.split_whitespace().map(str::to_string)) + .collect(); match Cli::try_parse_from(tokens) { - Ok(cli) => { + Ok(mut cli) => { + if cli.command.is_none() { + cli.command = Some(Commands::Build { + targets: Vec::new(), + }); + } world.cli = Some(cli); world.cli_error = None; } @@ -34,25 +30,30 @@ fn parse_cli(world: &mut CliWorld, args: String) { } } +fn extract_build(world: &CliWorld) -> &Vec { + let cli = world.cli.as_ref().expect("cli"); + match cli.command.as_ref().expect("command") { + Commands::Build { targets } => targets, + other => panic!("Expected build command, got {other:?}"), + } +} + +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[when(expr = "the CLI is parsed with {string}")] +fn parse_cli(world: &mut CliWorld, args: String) { + apply_cli(world, &args); +} + #[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] #[when(expr = "the CLI is parsed with invalid arguments {string}")] fn parse_cli_invalid(world: &mut CliWorld, args: String) { - let tokens: Vec = std::iter::once("netsuke".to_string()) - .chain(args.split_whitespace().map(str::to_string)) - .collect(); - match Cli::try_parse_from(tokens) { - Ok(cli) => { - world.cli = Some(cli); - world.cli_error = None; - } - Err(e) => { - world.cli = None; - world.cli_error = Some(e.to_string()); - } - } + apply_cli(world, &args); } #[then("parsing succeeds")] @@ -62,15 +63,7 @@ fn parsing_succeeds(world: &mut CliWorld) { #[then("the command is build")] fn command_is_build(world: &mut CliWorld) { - let cli = world.cli.as_ref().expect("cli"); - match cli - .command - .as_ref() - .unwrap_or(&Commands::Build { targets: vec![] }) - { - Commands::Build { .. } => (), - other => panic!("Expected build command, got {other:?}"), - } + let _ = extract_build(world); } #[allow( @@ -89,15 +82,23 @@ fn manifest_path(world: &mut CliWorld, path: String) { )] #[then(expr = "the first target is {string}")] fn first_target(world: &mut CliWorld, target: String) { + assert_eq!(extract_build(world).first(), Some(&target)); +} + +#[allow( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[then(expr = "the working directory is {string}")] +fn working_directory(world: &mut CliWorld, dir: String) { let cli = world.cli.as_ref().expect("cli"); - match cli - .command - .as_ref() - .unwrap_or(&Commands::Build { targets: vec![] }) - { - Commands::Build { targets } => assert_eq!(targets.first(), Some(&target)), - other => panic!("Expected build command, got {other:?}"), - } + assert_eq!(cli.directory.as_ref(), Some(&PathBuf::from(dir))); +} + +#[then(expr = "the job count is {int}")] +fn job_count(world: &mut CliWorld, jobs: usize) { + let cli = world.cli.as_ref().expect("cli"); + assert_eq!(cli.jobs, Some(jobs)); } #[then("an error should be returned")] From b9d6cfd9fa65cb5c9cb52ce8a26c12976dbcb75f Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 20:58:51 +0100 Subject: [PATCH 4/7] Expand CLI behavioural coverage --- tests/features/cli.feature | 27 +++++++++++++++++++++++++++ tests/steps/cli_steps.rs | 18 ++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/tests/features/cli.feature b/tests/features/cli.feature index 22251919..34ebb363 100644 --- a/tests/features/cli.feature +++ b/tests/features/cli.feature @@ -5,6 +5,18 @@ Feature: CLI parsing Then parsing succeeds And the command is build + Scenario: Clean command runs + When the CLI is parsed with "-C work clean" + Then parsing succeeds + And the command is clean + And the working directory is "work" + + Scenario: Graph command with jobs + When the CLI is parsed with "-j 2 graph" + Then parsing succeeds + And the command is graph + And the job count is 2 + Scenario: Manifest file can be overridden When the CLI is parsed with "--file alt.yml build target" Then parsing succeeds @@ -30,3 +42,18 @@ Feature: CLI parsing When the CLI is parsed with "-j 4" Then parsing succeeds And the job count is 4 + + Scenario: Missing directory argument value + When the CLI is parsed with invalid arguments "-C" + Then an error should be returned + And the error message should contain "--directory" + + Scenario: Missing jobs argument value + When the CLI is parsed with invalid arguments "-j" + Then an error should be returned + And the error message should contain "--jobs" + + Scenario: Non-numeric jobs value + When the CLI is parsed with invalid arguments "-j notanumber" + Then an error should be returned + And the error message should contain "notanumber" diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index 2b11dc7e..dafcbeef 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -66,6 +66,24 @@ fn command_is_build(world: &mut CliWorld) { let _ = extract_build(world); } +#[then("the command is clean")] +fn command_is_clean(world: &mut CliWorld) { + let cli = world.cli.as_ref().expect("cli"); + assert!(matches!( + cli.command.as_ref().expect("command"), + Commands::Clean {} + )); +} + +#[then("the command is graph")] +fn command_is_graph(world: &mut CliWorld) { + let cli = world.cli.as_ref().expect("cli"); + assert!(matches!( + cli.command.as_ref().expect("command"), + Commands::Graph {} + )); +} + #[allow( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" From e6c33eab453698f957dccddc7bb8b0c76eebc042 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 21:30:21 +0100 Subject: [PATCH 5/7] Refine CLI parsing and tests --- docs/netsuke-design.md | 2 +- src/cli.rs | 49 ++++++++++++++++++++++++++++++++++------ src/runner.rs | 4 ++-- tests/cli_tests.rs | 18 +++++---------- tests/steps/cli_steps.rs | 30 +++++++++++------------- 5 files changed, 64 insertions(+), 39 deletions(-) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 548478bc..170af0a3 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1265,7 +1265,7 @@ The behaviour of each subcommand is clearly defined: ### 8.4 Design Decisions The CLI is implemented using clap's derive API in `src/cli.rs`. Clap's -`default_value_t` attribute marks `Build` as the default subcommand so invoking +`default_value_t` attribute marks `Build` as the default subcommand, so invoking `netsuke` with no explicit command still triggers a build. CLI execution and dispatch live in `src/runner.rs`, keeping `main.rs` focused on parsing. The working directory flag uses `-C` to mirror Ninja's convention, ensuring command diff --git a/src/cli.rs b/src/cli.rs index e85b0386..19dd3a24 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,6 +6,20 @@ use clap::{Parser, Subcommand}; use std::path::PathBuf; +/// Maximum number of jobs accepted by the CLI. +const MAX_JOBS: usize = 64; + +fn parse_jobs(s: &str) -> Result { + let value: usize = s + .parse() + .map_err(|_| format!("{s} is not a valid number"))?; + if (1..=MAX_JOBS).contains(&value) { + Ok(value) + } else { + Err(format!("jobs must be between 1 and {MAX_JOBS}")) + } +} + /// A modern, friendly build system that uses YAML and Jinja, powered by Ninja. #[derive(Debug, Parser)] #[command(author, version, about, long_about = None)] @@ -19,7 +33,7 @@ pub struct Cli { pub directory: Option, /// Set the number of parallel build jobs. - #[arg(short, long, value_name = "N")] + #[arg(short, long, value_name = "N", value_parser = parse_jobs)] pub jobs: Option, #[command(subcommand)] @@ -30,13 +44,34 @@ impl Cli { /// Parse command-line arguments, providing `build` as the default command. #[must_use] pub fn parse_with_default() -> Self { - let mut cli = Self::parse(); - if cli.command.is_none() { - cli.command = Some(Commands::Build { + Self::parse().with_default_command() + } + + /// Parse the provided arguments, applying the default command when needed. + /// + /// # Panics + /// + /// Panics if argument parsing fails. + #[must_use] + pub fn parse_from_with_default(args: I) -> Self + where + I: IntoIterator, + T: Into + Clone, + { + Self::try_parse_from(args) + .unwrap_or_else(|e| panic!("CLI parsing failed: {e}")) + .with_default_command() + } + + /// Apply the default command if none was specified. + #[must_use] + fn with_default_command(mut self) -> Self { + if self.command.is_none() { + self.command = Some(Commands::Build { targets: Vec::new(), }); } - cli + self } } @@ -50,8 +85,8 @@ pub enum Commands { }, /// Remove build artifacts and intermediate files. - Clean {}, + Clean, /// Display the build dependency graph in DOT format for visualization. - Graph {}, + Graph, } diff --git a/src/runner.rs b/src/runner.rs index 3aee39b2..1abdf777 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -13,10 +13,10 @@ pub fn run(cli: Cli) { Commands::Build { targets } => { println!("Building targets: {targets:?}"); } - Commands::Clean {} => { + Commands::Clean => { println!("Clean requested"); } - Commands::Graph {} => { + Commands::Graph => { println!("Graph requested"); } } diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 1a26fc0b..78943cd9 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,3 +1,7 @@ +//! Unit tests for CLI argument parsing and validation. +//! +//! This module exercises the command-line interface defined in [`netsuke::cli`] +//! using `rstest` for parameterised coverage of success and error scenarios. use clap::Parser; use clap::error::ErrorKind; use netsuke::cli::{Cli, Commands}; @@ -20,21 +24,11 @@ fn parse_cli( #[case] jobs: Option, #[case] expected_cmd: Commands, ) { - let mut cli = Cli::try_parse_from(argv).expect("parse"); - if cli.command.is_none() { - cli.command = Some(Commands::Build { - targets: Vec::new(), - }); - } + let cli = Cli::parse_from_with_default(argv.clone()); assert_eq!(cli.file, file); assert_eq!(cli.directory, directory); assert_eq!(cli.jobs, jobs); - assert_eq!( - cli.command.unwrap_or(Commands::Build { - targets: Vec::new() - }), - expected_cmd - ); + assert_eq!(cli.command.expect("command should be set"), expected_cmd); } #[rstest] diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index dafcbeef..eeb273e1 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -13,14 +13,9 @@ fn apply_cli(world: &mut CliWorld, args: &str) { let tokens: Vec = std::iter::once("netsuke".to_string()) .chain(args.split_whitespace().map(str::to_string)) .collect(); - match Cli::try_parse_from(tokens) { - Ok(mut cli) => { - if cli.command.is_none() { - cli.command = Some(Commands::Build { - targets: Vec::new(), - }); - } - world.cli = Some(cli); + match Cli::try_parse_from(tokens.clone()) { + Ok(_) => { + world.cli = Some(Cli::parse_from_with_default(tokens)); world.cli_error = None; } Err(e) => { @@ -30,11 +25,11 @@ fn apply_cli(world: &mut CliWorld, args: &str) { } } -fn extract_build(world: &CliWorld) -> &Vec { - let cli = world.cli.as_ref().expect("cli"); - match cli.command.as_ref().expect("command") { - Commands::Build { targets } => targets, - other => panic!("Expected build command, got {other:?}"), +fn extract_build(world: &CliWorld) -> Option<&Vec> { + let cli = world.cli.as_ref()?; + match cli.command.as_ref()? { + Commands::Build { targets } => Some(targets), + _ => None, } } @@ -63,7 +58,7 @@ fn parsing_succeeds(world: &mut CliWorld) { #[then("the command is build")] fn command_is_build(world: &mut CliWorld) { - let _ = extract_build(world); + assert!(extract_build(world).is_some(), "command should be build"); } #[then("the command is clean")] @@ -71,7 +66,7 @@ fn command_is_clean(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); assert!(matches!( cli.command.as_ref().expect("command"), - Commands::Clean {} + Commands::Clean )); } @@ -80,7 +75,7 @@ fn command_is_graph(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); assert!(matches!( cli.command.as_ref().expect("command"), - Commands::Graph {} + Commands::Graph )); } @@ -100,7 +95,8 @@ fn manifest_path(world: &mut CliWorld, path: String) { )] #[then(expr = "the first target is {string}")] fn first_target(world: &mut CliWorld, target: String) { - assert_eq!(extract_build(world).first(), Some(&target)); + let targets = extract_build(world).expect("command should be build"); + assert_eq!(targets.first(), Some(&target)); } #[allow( From f3a651e02a25a0604c0beb507a966c0a3a23cf79 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 23:17:44 +0100 Subject: [PATCH 6/7] Refactor apply_cli to parse arguments once --- tests/steps/cli_steps.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index eeb273e1..49a163c7 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -13,9 +13,14 @@ fn apply_cli(world: &mut CliWorld, args: &str) { let tokens: Vec = std::iter::once("netsuke".to_string()) .chain(args.split_whitespace().map(str::to_string)) .collect(); - match Cli::try_parse_from(tokens.clone()) { - Ok(_) => { - world.cli = Some(Cli::parse_from_with_default(tokens)); + match Cli::try_parse_from(tokens) { + Ok(mut cli) => { + if cli.command.is_none() { + cli.command = Some(Commands::Build { + targets: Vec::new(), + }); + } + world.cli = Some(cli); world.cli_error = None; } Err(e) => { From f533a0d1f604a846239402de884ee6c25dd2c985 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 16 Jul 2025 23:29:46 +0100 Subject: [PATCH 7/7] Use expect for lint suppression --- tests/steps/cli_steps.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index 49a163c7..f31643d8 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -84,17 +84,17 @@ fn command_is_graph(world: &mut CliWorld) { )); } -#[allow( +#[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] #[then(expr = "the manifest path is {string}")] fn manifest_path(world: &mut CliWorld, path: String) { let cli = world.cli.as_ref().expect("cli"); - assert_eq!(cli.file, PathBuf::from(path)); + assert_eq!(cli.file, PathBuf::from(&path)); } -#[allow( +#[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] @@ -104,14 +104,14 @@ fn first_target(world: &mut CliWorld, target: String) { assert_eq!(targets.first(), Some(&target)); } -#[allow( +#[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] #[then(expr = "the working directory is {string}")] fn working_directory(world: &mut CliWorld, dir: String) { let cli = world.cli.as_ref().expect("cli"); - assert_eq!(cli.directory.as_ref(), Some(&PathBuf::from(dir))); + assert_eq!(cli.directory.as_ref(), Some(&PathBuf::from(&dir))); } #[then(expr = "the job count is {int}")]