diff --git a/Cargo.lock b/Cargo.lock index a3345db..d60f318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,7 +116,7 @@ checksum = "c0391754c09fab4eae3404d19d0d297aa1c670c1775ab51d8a5312afeca23157" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -204,6 +204,22 @@ dependencies = [ "term", ] +[[package]] +name = "assert_cmd" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "atomic" version = "0.6.0" @@ -319,6 +335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -333,7 +350,10 @@ name = "bulloak" version = "0.9.1" dependencies = [ "anyhow", + "assert_cmd", "bulloak-foundry", + "bulloak-noir", + "bulloak-rust", "bulloak-syntax", "clap", "criterion", @@ -362,6 +382,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "bulloak-noir" +version = "0.9.1" +dependencies = [ + "anyhow", + "bulloak-syntax", + "indoc", + "pretty_assertions", + "tempfile", + "thiserror", + "tree-sitter", + "tree-sitter-noir", +] + +[[package]] +name = "bulloak-rust" +version = "0.9.1" +dependencies = [ + "anyhow", + "bulloak-syntax", + "indoc", + "pretty_assertions", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.87", + "thiserror", +] + [[package]] name = "bulloak-syntax" version = "0.9.1" @@ -436,12 +485,14 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.0.83" +version = "1.2.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ + "find-msvc-tools", "jobserver", "libc", + "shlex", ] [[package]] @@ -527,7 +578,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -742,6 +793,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -796,6 +853,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "dunce" version = "1.0.4" @@ -867,7 +930,7 @@ checksum = "c2ad8cef1d801a4686bfd8919f0b30eac4c8e48968c437a6405ded4fb5272d2b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -1052,6 +1115,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + [[package]] name = "fixed-hash" version = "0.8.0" @@ -1187,7 +1256,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -1274,7 +1343,7 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.2", + "regex-syntax 0.8.8", ] [[package]] @@ -1565,10 +1634,11 @@ checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "jobserver" -version = "0.1.27" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ + "getrandom 0.3.2", "libc", ] @@ -1783,7 +1853,7 @@ dependencies = [ "proc-macro-crate 2.0.0", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -1948,7 +2018,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -1997,7 +2067,7 @@ dependencies = [ "phf_shared 0.11.2", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -2092,6 +2162,33 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[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 = "pretty_assertions" version = "1.4.0" @@ -2102,6 +2199,16 @@ dependencies = [ "yansi 0.5.1", ] +[[package]] +name = "prettyplease" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +dependencies = [ + "proc-macro2", + "syn 2.0.87", +] + [[package]] name = "primitive-types" version = "0.12.2" @@ -2176,7 +2283,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", "version_check", "yansi 1.0.0-rc.1", ] @@ -2193,7 +2300,7 @@ dependencies = [ "rand", "rand_chacha", "rand_xorshift", - "regex-syntax 0.8.2", + "regex-syntax 0.8.8", "unarray", ] @@ -2299,25 +2406,25 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.2" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.2", + "regex-syntax 0.8.8", ] [[package]] name = "regex-automata" -version = "0.4.3" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.2", + "regex-syntax 0.8.8", ] [[package]] @@ -2328,9 +2435,9 @@ checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" @@ -2634,7 +2741,7 @@ checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -2711,6 +2818,12 @@ dependencies = [ "keccak", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -2845,7 +2958,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -2900,9 +3013,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.66" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -2960,6 +3073,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.61" @@ -2977,7 +3096,7 @@ checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -3169,7 +3288,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] @@ -3181,6 +3300,26 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d" +dependencies = [ + "cc", + "regex", +] + +[[package]] +name = "tree-sitter-noir" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09a0f7c33f9ea1e0ca3e3469e6660fac2c7d9539ea37e5cc1b22d98f91a374a" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "try-lock" version = "0.2.4" @@ -3288,6 +3427,15 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[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.4.0" @@ -3343,7 +3491,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", "wasm-bindgen-shared", ] @@ -3377,7 +3525,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3640,7 +3788,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", + "syn 2.0.87", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c95a1be..c797c58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/bulloak", "crates/foundry", "crates/syntax"] +members = ["crates/bulloak", "crates/foundry", "crates/rust", "crates/syntax", "crates/noir"] [workspace.package] authors = ["Alexander Gonzalez "] @@ -35,6 +35,8 @@ all = "warn" [workspace.dependencies] bulloak-syntax = { path = "crates/syntax", version = "0.9.0" } bulloak-foundry = { path = "crates/foundry", version = "0.9.0" } +bulloak-rust = { path = "crates/rust", version = "0.9.0" } +bulloak-noir = { path = "crates/noir", version = "0.9.0" } anyhow = "1.0.75" clap = { version = "4.3.19", features = ["derive"] } diff --git a/README.md b/README.md index 7f4b371..45c5fa3 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,15 @@ # bulloak -A Solidity test generator based on the +A test generator based on the [Branching Tree Technique](https://twitter.com/PaulRBerg/status/1682346315806539776). +**Supported languages:** + +- **Solidity (Foundry)**: Generates `.t.sol` files with modifiers for conditions +- **Rust**: Generates `_test.rs` files with helper functions for conditions +- **Noir**: Generates `_test.nr` files with helper functions for conditions + - [Installation](#installation) - [VSCode](#vscode) - [Usage](#usage) @@ -60,7 +66,11 @@ better user experience: - `bulloak scaffold` - `bulloak check` -### Scaffold Solidity Files +### Scaffold Test Files + +By default, `bulloak scaffold` generates Solidity test files. You can specify a different language using the `--lang` (or `-l`) flag. + +#### Solidity (default) Say you have a `foo.tree` file with the following contents: @@ -98,7 +108,7 @@ Say we have a bunch of `.tree` files in the current working directory. If we run the following: ```text -$ bulloak scaffold -w ./**/*.tree +bulloak scaffold -w ./**/*.tree ``` `bulloak` will create a `.t.sol` file per `.tree` file and write the generated @@ -110,7 +120,7 @@ behavior with the `-f` flag. This will force `bulloak` to overwrite the contents of the file. ```text -$ bulloak scaffold -wf ./**/*.tree +bulloak scaffold -wf ./**/*.tree ``` Note all tests are showing as passing when their body is empty. To prevent this, @@ -130,10 +140,74 @@ When enabled, bulloak capitalizes the first letter of each branch description and ensures it ends with a dot, so you don't need to touch the `.tree` file to get consistent sentence casing in the scaffolded test bodies. +#### Rust + +To generate Rust test files, use `--lang rust`: + +```bash +bulloak scaffold --lang rust foo.tree +``` + +This will generate a `foo_test.rs` file with helper functions for conditions and `#[test]` functions for actions. The generated file will use `#[should_panic]` for actions containing panic keywords like "panic", "revert", "error", or "fail". + +```rust +// $ bulloak scaffold --lang rust foo.tree +// Generated by bulloak + +/// Helper function for condition +fn stuff_is_called() { +} + +/// Helper function for condition +fn a_condition_is_met() { +} + +#[test] +#[should_panic] +fn test_when_a_condition_is_met() { + stuff_is_called(); + a_condition_is_met(); + // It should revert. +} +``` + +#### Noir + +To generate Noir test files, use `--lang noir`: + +```bash +bulloak scaffold --lang noir foo.tree +``` + +This will generate a `foo_test.nr` file with helper functions for conditions and `#[test]` functions for actions. The generated file will use `#[test(should_fail)]` for actions containing panic keywords. + +```rust +// $ bulloak scaffold --lang noir foo.tree +// Generated by bulloak + +/// Helper function for condition +fn stuff_is_called() { +} + +/// Helper function for condition +fn a_condition_is_met() { +} + +#[test(should_fail)] +unconstrained fn test_when_a_condition_is_met() { + stuff_is_called(); + a_condition_is_met(); + // It should revert. +} +``` + +**Note:** The `-m` (skip helpers) and `-F` (format descriptions) flags work for all languages. + ### Check That Your Code And Spec Match -You can use `bulloak check` to make sure that your Solidity files match your -spec. For example, any missing tests will be reported to you. +You can use `bulloak check` to make sure that your test files match your +spec. For example, any missing tests will be reported to you. The `--lang` +flag works the same way as in `scaffold`. Say you have the following spec: @@ -453,6 +527,6 @@ This project has been possible thanks to the support of: This project is licensed under either of: - Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or - https://www.apache.org/licenses/LICENSE-2.0). + ). - MIT license ([LICENSE-MIT](LICENSE-MIT) or - https://opensource.org/licenses/MIT). + ). diff --git a/crates/bulloak/Cargo.toml b/crates/bulloak/Cargo.toml index 455003b..c29d63c 100644 --- a/crates/bulloak/Cargo.toml +++ b/crates/bulloak/Cargo.toml @@ -15,6 +15,8 @@ categories.workspace = true [dependencies] bulloak-syntax.workspace = true bulloak-foundry.workspace = true +bulloak-rust.workspace = true +bulloak-noir.workspace = true anyhow.workspace = true clap.workspace = true @@ -27,10 +29,19 @@ glob = "0.3.2" [dev-dependencies] pretty_assertions.workspace = true criterion.workspace = true +assert_cmd = "2.0" [[bench]] name = "emit" harness = false +[[bench]] +name = "emit_rust" +harness = false + +[[bench]] +name = "emit_noir" +harness = false + [lints] workspace = true diff --git a/crates/bulloak/benches/emit_noir.rs b/crates/bulloak/benches/emit_noir.rs new file mode 100644 index 0000000..81986d4 --- /dev/null +++ b/crates/bulloak/benches/emit_noir.rs @@ -0,0 +1,19 @@ +#![allow(missing_docs)] +use bulloak_noir::scaffold; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn emit_big_tree_noir(c: &mut Criterion) { + let tree = + std::fs::read_to_string("benches/bench_data/cancel.tree").unwrap(); + let ast = bulloak_syntax::parse_one(&tree).unwrap(); + + let cfg = Default::default(); + let mut group = c.benchmark_group("sample-size-10"); + group.bench_function("emit-big-tree-noir", |b| { + b.iter(|| scaffold::generate(black_box(&ast), &cfg)) + }); + group.finish(); +} + +criterion_group!(benches, emit_big_tree_noir); +criterion_main!(benches); diff --git a/crates/bulloak/benches/emit_rust.rs b/crates/bulloak/benches/emit_rust.rs new file mode 100644 index 0000000..d0a3db4 --- /dev/null +++ b/crates/bulloak/benches/emit_rust.rs @@ -0,0 +1,19 @@ +#![allow(missing_docs)] +use bulloak_rust::scaffold; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn emit_big_tree_rust(c: &mut Criterion) { + let tree = + std::fs::read_to_string("benches/bench_data/cancel.tree").unwrap(); + let ast = bulloak_syntax::parse_one(&tree).unwrap(); + + let cfg = Default::default(); + let mut group = c.benchmark_group("sample-size-10"); + group.bench_function("emit-big-tree-rust", |b| { + b.iter(|| scaffold::scaffold(black_box(&ast), &cfg)) + }); + group.finish(); +} + +criterion_group!(benches, emit_big_tree_rust); +criterion_main!(benches); diff --git a/crates/bulloak/src/check.rs b/crates/bulloak/src/check.rs index cc2bef8..ac3caf8 100644 --- a/crates/bulloak/src/check.rs +++ b/crates/bulloak/src/check.rs @@ -18,7 +18,10 @@ use clap::Parser; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::{cli::Cli, glob::expand_glob}; +use crate::{ + cli::{Backend, Cli}, + glob::expand_glob, +}; /// Check that the tests match the spec. #[doc(hidden)] @@ -41,6 +44,9 @@ pub struct Check { /// Whether to capitalize and punctuate branch descriptions. #[arg(long = "format-descriptions", default_value_t = false)] pub format_descriptions: bool, + /// The target language for checking. + #[arg(short = 'l', long = "lang", value_enum, default_value_t = Backend::Solidity)] + pub backend: Backend, } impl Default for Check { @@ -54,6 +60,15 @@ impl Check { /// /// Note that we don't deal with `solang_parser` errors at all. pub(crate) fn run(&self, cfg: &Cli) { + if self.backend == Backend::Rust { + return self.run_rust_check(); + } + + if self.backend == Backend::Noir { + return self.run_noir_check(); + } + + // Solidity check let mut specs = Vec::new(); for pattern in &self.files { match expand_glob(pattern.clone()) { @@ -163,6 +178,102 @@ impl Check { eprintln!("{}: {e}", "warn".yellow()); } } + + /// Expand glob patterns into file paths. + fn expand_specs(&self) -> Vec { + let mut specs = Vec::new(); + for pattern in &self.files { + match expand_glob(pattern.clone()) { + Ok(iter) => specs.extend(iter), + Err(e) => eprintln!( + "{}: could not expand {}: {}", + "warn".yellow(), + pattern.display(), + e + ), + } + } + specs + } + + /// Run check for Rust tests. + fn run_rust_check(&self) { + let specs = self.expand_specs(); + let cfg = bulloak_rust::Config { + files: self.files.iter().map(|p| p.display().to_string()).collect(), + skip_helpers: self.skip_modifiers, + format_descriptions: self.format_descriptions, + }; + + let violations = self.collect_violations(&specs, |path| { + bulloak_rust::check::check(path, &cfg) + }); + + self.report_violations(&violations); + } + + /// Run check for Noir tests. + fn run_noir_check(&self) { + let specs = self.expand_specs(); + let cfg = bulloak_noir::Config { + files: self.files.iter().map(|p| p.display().to_string()).collect(), + skip_helpers: self.skip_modifiers, + format_descriptions: self.format_descriptions, + }; + + let violations = self.collect_violations(&specs, |path| { + bulloak_noir::check::check(path, &cfg) + }); + + self.report_violations(&violations); + } + + /// Collect violations from checking multiple tree files. + fn collect_violations(&self, specs: &[PathBuf], check_fn: F) -> Vec + where + F: Fn(&PathBuf) -> anyhow::Result>, + V: std::fmt::Display, + { + let mut all_violations = Vec::new(); + for tree_path in specs { + match check_fn(tree_path) { + Ok(violations) => { + for violation in &violations { + eprintln!("{}", violation); + } + all_violations.extend(violations); + } + Err(e) => { + eprintln!( + "{}: Failed to check {}: {}", + "error".red(), + tree_path.display(), + e + ); + } + } + } + all_violations + } + + /// Report violations and exit if necessary. + fn report_violations(&self, violations: &[V]) { + if violations.is_empty() { + println!( + "{}", + "All checks completed successfully! No issues found.".green() + ); + } else { + let check_literal = pluralize(violations.len(), "check", "checks"); + eprintln!( + "\n{}: {} {} failed", + "warn".bold().yellow(), + violations.len(), + check_literal + ); + std::process::exit(1); + } + } } fn exit(violations: &[Violation]) { diff --git a/crates/bulloak/src/cli.rs b/crates/bulloak/src/cli.rs index 7953f17..f649d03 100644 --- a/crates/bulloak/src/cli.rs +++ b/crates/bulloak/src/cli.rs @@ -1,8 +1,31 @@ //! `bulloak`'s CLI config. -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use figment::{providers::Serialized, Figment}; use serde::{Deserialize, Serialize}; +/// The target backend/language for code generation. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + ValueEnum, + Serialize, + Deserialize, +)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + /// Solidity (Foundry) backend. + #[default] + Solidity, + /// Rust backend. + Rust, + /// Noir backend. + Noir, +} + /// `bulloak`'s configuration. #[derive(Parser, Debug, Clone, Default, Serialize, Deserialize)] #[command(author, version, about, long_about = None)] // Read from `Cargo.toml` @@ -50,6 +73,31 @@ impl From<&Cli> for bulloak_foundry::config::Config { } } +impl From<&Cli> for bulloak_noir::Config { + fn from(cli: &Cli) -> Self { + match &cli.command { + Commands::Scaffold(cmd) => Self { + files: cmd + .files + .iter() + .map(|p| p.display().to_string()) + .collect(), + skip_helpers: cmd.skip_modifiers, + format_descriptions: cmd.format_descriptions, + }, + Commands::Check(cmd) => Self { + files: cmd + .files + .iter() + .map(|p| p.display().to_string()) + .collect(), + skip_helpers: cmd.skip_modifiers, + format_descriptions: cmd.format_descriptions, + }, + } + } +} + /// Main entrypoint of `bulloak`'s execution. pub(crate) fn run() -> anyhow::Result<()> { let config: Cli = diff --git a/crates/bulloak/src/scaffold.rs b/crates/bulloak/src/scaffold.rs index dad34e8..ece981f 100644 --- a/crates/bulloak/src/scaffold.rs +++ b/crates/bulloak/src/scaffold.rs @@ -13,9 +13,12 @@ use forge_fmt::fmt; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::{cli::Cli, glob::expand_glob}; +use crate::{ + cli::{Backend, Cli}, + glob::expand_glob, +}; -/// Generate Solidity tests based on your spec. +/// Generate test files based on your spec. #[doc(hidden)] #[derive(Parser, Debug, Clone, Serialize, Deserialize)] pub struct Scaffold { @@ -53,6 +56,9 @@ pub struct Scaffold { /// Whether to capitalize and punctuate branch descriptions. #[arg(short = 'F', long = "format-descriptions", default_value_t = false)] pub format_descriptions: bool, + /// The target language for code generation. + #[arg(short = 'l', long = "lang", value_enum, default_value_t = Backend::Solidity)] + pub backend: Backend, } impl Default for Scaffold { @@ -101,24 +107,65 @@ impl Scaffold { /// Processes a single input file. /// - /// This method reads the input file, scaffolds the Solidity code, formats + /// This method reads the input file, scaffolds the code, formats /// it, and either writes it to a file or prints it to stdout. fn process_file(&self, file: &Path, cfg: &Cli) -> anyhow::Result<()> { let text = fs::read_to_string(file)?; - let emitted = scaffold(&text, &cfg.into())?; - let formatted = fmt(&emitted).unwrap_or_else(|err| { - eprintln!("{}: {}", "WARN".yellow(), err); - emitted - }); + let (emitted, output_file) = match self.backend { + Backend::Rust => { + let ast = bulloak_syntax::parse_one(&text)?; + let rust_cfg = bulloak_rust::Config { + files: self + .files + .iter() + .map(|p| p.display().to_string()) + .collect(), + skip_helpers: self.skip_modifiers, + format_descriptions: self.format_descriptions, + }; + let emitted = bulloak_rust::scaffold(&ast, &rust_cfg)?; + let output_file = Self::build_output_path(file, "_test.rs")?; + (emitted, output_file) + } + Backend::Noir => { + let forest = bulloak_syntax::parse(&text)?; + let noir_cfg: bulloak_noir::Config = cfg.into(); + let emitted = bulloak_noir::scaffold(&forest, &noir_cfg)?; + let output_file = Self::build_output_path(file, "_test.nr")?; + (emitted, output_file) + } + Backend::Solidity => { + let emitted = scaffold(&text, &cfg.into())?; + let formatted = fmt(&emitted).unwrap_or_else(|err| { + eprintln!("{}: {}", "WARN".yellow(), err); + emitted + }); + let output_file = file.with_extension("t.sol"); + (formatted, output_file) + } + }; + + self.output(&emitted, &output_file); + Ok(()) + } + + /// Builds the output file path for a given input file. + fn build_output_path(file: &Path, suffix: &str) -> anyhow::Result { + let file_stem = + file.file_stem().and_then(|s| s.to_str()).ok_or_else(|| { + anyhow::anyhow!("Invalid file name: {}", file.display()) + })?; + Ok(file.with_file_name(format!("{file_stem}{suffix}"))) + } + + /// Outputs the scaffolded text either to stdout or to a file. + fn output(&self, text: &str, file: &PathBuf) { if self.write_files { - let file = file.with_extension("t.sol"); - self.write_file(&formatted, &file); + self.write_file(text, file); } else { - println!("{formatted}"); + println!("{text}"); } - - Ok(()) } /// Writes the provided `text` to `file`. diff --git a/crates/bulloak/tests/check_noir.rs b/crates/bulloak/tests/check_noir.rs new file mode 100644 index 0000000..c1d8777 --- /dev/null +++ b/crates/bulloak/tests/check_noir.rs @@ -0,0 +1,107 @@ +#![allow(missing_docs)] +use std::{env, fs}; + +use common::{cmd, get_binary_path}; + +mod common; + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_passes_when_correct() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("basic.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("All checks completed successfully")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_fails_when_missing_file() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + + let temp_tree = tests_path.join("temp_missing.tree"); + fs::write(&temp_tree, "test_func\n└── It should work.").unwrap(); + + let output = cmd(&binary_path, "check", &temp_tree, &["--lang", "noir"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("File not found")); + + fs::remove_file(temp_tree).ok(); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_fails_when_missing_test_function() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("missing_test.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Missing test function") + || stderr.contains("is missing") + ); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_fails_when_missing_helper() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("missing_helper.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Missing helper function") + || stderr.contains("is missing") + ); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_passes_with_skip_helpers() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("no_helpers.tree"); + + let output = + cmd(&binary_path, "check", &tree_path, &["--lang", "noir", "-m"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("All checks completed successfully")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_noir_fails_when_unexpected_should_fail() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_noir"); + let tree_path = tests_path.join("unexpected_should_fail.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "noir"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("has #[test(should_fail)] but shouldn't")); +} diff --git a/crates/bulloak/tests/check_noir/basic.tree b/crates/bulloak/tests/check_noir/basic.tree new file mode 100644 index 0000000..4d25919 --- /dev/null +++ b/crates/bulloak/tests/check_noir/basic.tree @@ -0,0 +1,5 @@ +hash_pair +├── When first arg is smaller than second arg +│ └── It should match the result of hash(a, b). +└── When first arg is bigger than second arg + └── It should match the result of hash(b, a). diff --git a/crates/bulloak/tests/check_noir/basic_test.nr b/crates/bulloak/tests/check_noir/basic_test.nr new file mode 100644 index 0000000..f98c2c5 --- /dev/null +++ b/crates/bulloak/tests/check_noir/basic_test.nr @@ -0,0 +1,21 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of hash(a, b). +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of hash(b, a). +} diff --git a/crates/bulloak/tests/check_noir/missing_helper.tree b/crates/bulloak/tests/check_noir/missing_helper.tree new file mode 100644 index 0000000..73a5eda --- /dev/null +++ b/crates/bulloak/tests/check_noir/missing_helper.tree @@ -0,0 +1,3 @@ +test_func +└── When condition is met + └── It should work. diff --git a/crates/bulloak/tests/check_noir/missing_helper_test.nr b/crates/bulloak/tests/check_noir/missing_helper_test.nr new file mode 100644 index 0000000..2e2364a --- /dev/null +++ b/crates/bulloak/tests/check_noir/missing_helper_test.nr @@ -0,0 +1,9 @@ +// Generated by bulloak + +// Missing: fn condition_is_met() {} + +#[test] +unconstrained fn test_when_condition_is_met() { + condition_is_met(); + // It should work. +} diff --git a/crates/bulloak/tests/check_noir/missing_test.tree b/crates/bulloak/tests/check_noir/missing_test.tree new file mode 100644 index 0000000..3c5314b --- /dev/null +++ b/crates/bulloak/tests/check_noir/missing_test.tree @@ -0,0 +1,3 @@ +test_func +├── It should work. +└── It should also work differently. diff --git a/crates/bulloak/tests/check_noir/missing_test_test.nr b/crates/bulloak/tests/check_noir/missing_test_test.nr new file mode 100644 index 0000000..e2097c9 --- /dev/null +++ b/crates/bulloak/tests/check_noir/missing_test_test.nr @@ -0,0 +1,7 @@ +// Generated by bulloak + +#[test] +unconstrained fn test_should_work() { + // It should work. +} +// Missing: test_should_also_work_differently diff --git a/crates/bulloak/tests/check_noir/no_helpers.tree b/crates/bulloak/tests/check_noir/no_helpers.tree new file mode 100644 index 0000000..8f47c03 --- /dev/null +++ b/crates/bulloak/tests/check_noir/no_helpers.tree @@ -0,0 +1,3 @@ +simple_function +├── It should return true for valid input. +└── It should return false for invalid input. diff --git a/crates/bulloak/tests/check_noir/no_helpers_test.nr b/crates/bulloak/tests/check_noir/no_helpers_test.nr new file mode 100644 index 0000000..c2643dd --- /dev/null +++ b/crates/bulloak/tests/check_noir/no_helpers_test.nr @@ -0,0 +1,11 @@ +// Generated by bulloak + +#[test] +unconstrained fn test_should_return_true_for_valid_input() { + // It should return true for valid input. +} + +#[test] +unconstrained fn test_should_return_false_for_invalid_input() { + // It should return false for invalid input. +} diff --git a/crates/bulloak/tests/check_noir/unexpected_should_fail.tree b/crates/bulloak/tests/check_noir/unexpected_should_fail.tree new file mode 100644 index 0000000..880b5e7 --- /dev/null +++ b/crates/bulloak/tests/check_noir/unexpected_should_fail.tree @@ -0,0 +1,2 @@ +test_func +└── It should work normally. diff --git a/crates/bulloak/tests/check_noir/unexpected_should_fail_test.nr b/crates/bulloak/tests/check_noir/unexpected_should_fail_test.nr new file mode 100644 index 0000000..9d7210b --- /dev/null +++ b/crates/bulloak/tests/check_noir/unexpected_should_fail_test.nr @@ -0,0 +1,6 @@ +// Generated by bulloak + +#[test(should_fail)] +unconstrained fn test_should_work_normally() { + // It should work normally. +} diff --git a/crates/bulloak/tests/check_rust.rs b/crates/bulloak/tests/check_rust.rs new file mode 100644 index 0000000..b7eeb2e --- /dev/null +++ b/crates/bulloak/tests/check_rust.rs @@ -0,0 +1,106 @@ +#![allow(missing_docs)] +use std::{env, fs}; + +use common::{cmd, get_binary_path}; + +mod common; + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_passes_when_correct() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("basic.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("All checks completed successfully")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_fails_when_missing_file() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + + let temp_tree = tests_path.join("temp_missing.tree"); + fs::write(&temp_tree, "test_func\n└── It should work.").unwrap(); + + let output = cmd(&binary_path, "check", &temp_tree, &["--lang", "rust"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Rust test file is missing")); + + fs::remove_file(temp_tree).ok(); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_fails_when_missing_test_function() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("missing_test.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Test function") && stderr.contains("is missing")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_fails_when_missing_helper() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("missing_helper.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Helper function") && stderr.contains("is missing") + ); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_passes_with_skip_helpers() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("no_helpers.tree"); + + let output = + cmd(&binary_path, "check", &tree_path, &["--lang", "rust", "-m"]); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("All checks completed successfully")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn check_rust_fails_when_unexpected_should_panic() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let tests_path = cwd.join("tests").join("check_rust"); + let tree_path = tests_path.join("unexpected_should_panic.tree"); + + let output = cmd(&binary_path, "check", &tree_path, &["--lang", "rust"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("expected none, found #[should_panic]") + || stderr.contains("has incorrect attributes") + ); +} diff --git a/crates/bulloak/tests/check_rust/basic.tree b/crates/bulloak/tests/check_rust/basic.tree new file mode 100644 index 0000000..4d25919 --- /dev/null +++ b/crates/bulloak/tests/check_rust/basic.tree @@ -0,0 +1,5 @@ +hash_pair +├── When first arg is smaller than second arg +│ └── It should match the result of hash(a, b). +└── When first arg is bigger than second arg + └── It should match the result of hash(b, a). diff --git a/crates/bulloak/tests/check_rust/basic_test.rs b/crates/bulloak/tests/check_rust/basic_test.rs new file mode 100644 index 0000000..c755f5f --- /dev/null +++ b/crates/bulloak/tests/check_rust/basic_test.rs @@ -0,0 +1,28 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is bigger than second arg +fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of hash(a, b). + } + #[test] + fn test_when_first_arg_is_bigger_than_second_arg() { + let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); + // It should match the result of hash(b, a). + } +} + diff --git a/crates/bulloak/tests/check_rust/missing_helper.tree b/crates/bulloak/tests/check_rust/missing_helper.tree new file mode 100644 index 0000000..73a5eda --- /dev/null +++ b/crates/bulloak/tests/check_rust/missing_helper.tree @@ -0,0 +1,3 @@ +test_func +└── When condition is met + └── It should work. diff --git a/crates/bulloak/tests/check_rust/missing_helper_test.rs b/crates/bulloak/tests/check_rust/missing_helper_test.rs new file mode 100644 index 0000000..45188e7 --- /dev/null +++ b/crates/bulloak/tests/check_rust/missing_helper_test.rs @@ -0,0 +1,16 @@ +// Generated by bulloak + +struct TestContext {} + +// Missing: fn condition_is_met(mut ctx: TestContext) -> TestContext {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_when_condition_is_met() { + let _ctx = condition_is_met(TestContext::default()); + // It should work. + } +} diff --git a/crates/bulloak/tests/check_rust/missing_test.tree b/crates/bulloak/tests/check_rust/missing_test.tree new file mode 100644 index 0000000..3c5314b --- /dev/null +++ b/crates/bulloak/tests/check_rust/missing_test.tree @@ -0,0 +1,3 @@ +test_func +├── It should work. +└── It should also work differently. diff --git a/crates/bulloak/tests/check_rust/missing_test_test.rs b/crates/bulloak/tests/check_rust/missing_test_test.rs new file mode 100644 index 0000000..7659fd1 --- /dev/null +++ b/crates/bulloak/tests/check_rust/missing_test_test.rs @@ -0,0 +1,14 @@ +// Generated by bulloak + +struct TestContext {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_work() { + // It should work. + } + // Missing: test_should_also_work_differently +} diff --git a/crates/bulloak/tests/check_rust/no_helpers.tree b/crates/bulloak/tests/check_rust/no_helpers.tree new file mode 100644 index 0000000..8f47c03 --- /dev/null +++ b/crates/bulloak/tests/check_rust/no_helpers.tree @@ -0,0 +1,3 @@ +simple_function +├── It should return true for valid input. +└── It should return false for invalid input. diff --git a/crates/bulloak/tests/check_rust/no_helpers_test.rs b/crates/bulloak/tests/check_rust/no_helpers_test.rs new file mode 100644 index 0000000..ee63dcd --- /dev/null +++ b/crates/bulloak/tests/check_rust/no_helpers_test.rs @@ -0,0 +1,18 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_should_return_true_for_valid_input() { + // It should return true for valid input. + } + #[test] + fn test_should_return_false_for_invalid_input() { + // It should return false for invalid input. + } +} + diff --git a/crates/bulloak/tests/check_rust/unexpected_should_panic.tree b/crates/bulloak/tests/check_rust/unexpected_should_panic.tree new file mode 100644 index 0000000..880b5e7 --- /dev/null +++ b/crates/bulloak/tests/check_rust/unexpected_should_panic.tree @@ -0,0 +1,2 @@ +test_func +└── It should work normally. diff --git a/crates/bulloak/tests/check_rust/unexpected_should_panic_test.rs b/crates/bulloak/tests/check_rust/unexpected_should_panic_test.rs new file mode 100644 index 0000000..a12675b --- /dev/null +++ b/crates/bulloak/tests/check_rust/unexpected_should_panic_test.rs @@ -0,0 +1,14 @@ +// Generated by bulloak + +struct TestContext {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic] + fn test_should_work_normally() { + // It should work normally. + } +} diff --git a/crates/bulloak/tests/scaffold_noir.rs b/crates/bulloak/tests/scaffold_noir.rs new file mode 100644 index 0000000..0975f61 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir.rs @@ -0,0 +1,101 @@ +#![allow(missing_docs)] +use std::{env, fs}; + +use common::{cmd, get_binary_path}; +use pretty_assertions::assert_eq; + +mod common; + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_noir_trees() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let outputs_path = cwd.join("tests").join("scaffold_noir"); + let trees = [ + "basic.tree", + "complex.tree", + "disambiguation.tree", + "duplicated_condition.tree", + "duplicated_top_action.tree", + "empty.tree", + "format_descriptions.tree", + "hash_pair.tree", + "removes_invalid_title_chars.tree", + "revert_when.tree", + "skip_modifiers.tree", + "spurious_comments.tree", + ]; + + for tree_name in trees { + let tree_path = trees_path.join(tree_name); + let output = + cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + let output_file = + outputs_path.join(tree_name.replace(".tree", "_test.nr")); + + let expected = fs::read_to_string(&output_file).unwrap_or_else(|_| { + panic!( + "Failed to read expected output file: {}", + output_file.display() + ) + }); + + // We trim here because we don't care about ending newlines. + assert_eq!( + expected.trim(), + actual.trim(), + "Mismatch for {}", + tree_name + ); + } +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_noir_trees_skip_helpers() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let tree_path = trees_path.join("basic.tree"); + + let output = + cmd(&binary_path, "scaffold", &tree_path, &["--lang", "noir", "-m"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + // Should not contain helper functions + assert!(!actual.contains("fn first_arg_is_smaller_than_second_arg")); + assert!(!actual.contains("fn first_arg_is_bigger_than_second_arg")); + + // Should still contain test functions + assert!(actual.contains("#[test]")); + assert!(actual.contains("unconstrained fn")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_noir_trees_format_descriptions() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let tree_path = trees_path.join("basic.tree"); + + let output = cmd( + &binary_path, + "scaffold", + &tree_path, + &["--lang", "noir", "--format-descriptions"], + ); + let actual = String::from_utf8(output.stdout).unwrap(); + + // Comments should be capitalized and have periods + assert!(actual.contains( + "// It should match the result of `keccak256(abi.encodePacked(a,b))`." + )); + assert!(actual.contains( + "// It should match the result of `keccak256(abi.encodePacked(b,a))`." + )); +} diff --git a/crates/bulloak/tests/scaffold_noir/basic_test.nr b/crates/bulloak/tests/scaffold_noir/basic_test.nr new file mode 100644 index 0000000..8fb806b --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/basic_test.nr @@ -0,0 +1,39 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_zero() { +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_zero() { + first_arg_is_smaller_than_second_arg(); + first_arg_is_zero(); + // It should do something. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/complex_test.nr b/crates/bulloak/tests/scaffold_noir/complex_test.nr new file mode 100644 index 0000000..093f61a --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/complex_test.nr @@ -0,0 +1,422 @@ +// Generated by bulloak + +/// Helper function for condition +fn delegate_called() { +} + +/// Helper function for condition +fn not_delegate_called() { +} + +/// Helper function for condition +fn the_caller_is_a_former_recipient() { +} + +/// Helper function for condition +fn the_caller_is_a_malicious_third_party() { +} + +/// Helper function for condition +fn the_caller_is_an_approved_third_party() { +} + +/// Helper function for condition +fn the_caller_is_authorized() { +} + +/// Helper function for condition +fn the_caller_is_the_recipient() { +} + +/// Helper function for condition +fn the_caller_is_the_sender() { +} + +/// Helper function for condition +fn the_caller_is_unauthorized() { +} + +/// Helper function for condition +fn the_id_does_not_reference_a_null_stream() { +} + +/// Helper function for condition +fn the_id_references_a_null_stream() { +} + +/// Helper function for condition +fn the_recipient_does_not_implement_the_hook() { +} + +/// Helper function for condition +fn the_recipient_does_not_revert() { +} + +/// Helper function for condition +fn the_recipient_implements_the_hook() { +} + +/// Helper function for condition +fn the_recipient_is_a_contract() { +} + +/// Helper function for condition +fn the_recipient_is_not_a_contract() { +} + +/// Helper function for condition +fn the_recipient_reverts() { +} + +/// Helper function for condition +fn the_sender_does_not_implement_the_hook() { +} + +/// Helper function for condition +fn the_sender_does_not_revert() { +} + +/// Helper function for condition +fn the_sender_implements_the_hook() { +} + +/// Helper function for condition +fn the_sender_is_a_contract() { +} + +/// Helper function for condition +fn the_sender_is_not_a_contract() { +} + +/// Helper function for condition +fn the_sender_reverts() { +} + +/// Helper function for condition +fn the_stream_is_cancelable() { +} + +/// Helper function for condition +fn the_stream_is_cold() { +} + +/// Helper function for condition +fn the_stream_is_not_cancelable() { +} + +/// Helper function for condition +fn the_stream_is_warm() { +} + +/// Helper function for condition +fn the_streams_status_is_canceled() { +} + +/// Helper function for condition +fn the_streams_status_is_depleted() { +} + +/// Helper function for condition +fn the_streams_status_is_pending() { +} + +/// Helper function for condition +fn the_streams_status_is_settled() { +} + +/// Helper function for condition +fn the_streams_status_is_streaming() { +} + +/// Helper function for condition +fn there_is_no_reentrancy_1() { +} + +/// Helper function for condition +fn there_is_no_reentrancy_2() { +} + +/// Helper function for condition +fn there_is_reentrancy_1() { +} + +/// Helper function for condition +fn there_is_reentrancy_2() { +} + +#[test(should_fail)] +unconstrained fn test_when_delegate_called() { + delegate_called(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_id_references_a_null_stream() { + not_delegate_called(); + the_id_references_a_null_stream(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_streams_status_is_depleted() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_cold(); + the_streams_status_is_depleted(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_streams_status_is_canceled() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_cold(); + the_streams_status_is_canceled(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_streams_status_is_settled() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_cold(); + the_streams_status_is_settled(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_caller_is_a_malicious_third_party() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_unauthorized(); + the_caller_is_a_malicious_third_party(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_caller_is_an_approved_third_party() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_unauthorized(); + the_caller_is_an_approved_third_party(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_caller_is_a_former_recipient() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_unauthorized(); + the_caller_is_a_former_recipient(); + // it should revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_stream_is_not_cancelable() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_not_cancelable(); + // it should revert +} + +#[test] +unconstrained fn test_when_the_streams_status_is_pending() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_pending(); + // it should cancel the stream + // it should mark the stream as depleted + // it should make the stream not cancelable +} + +#[test] +unconstrained fn test_when_the_recipient_is_not_a_contract() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_not_a_contract(); + // it should cancel the stream + // it should mark the stream as canceled +} + +#[test(should_fail)] +unconstrained fn test_when_the_recipient_does_not_implement_the_hook() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_a_contract(); + the_recipient_does_not_implement_the_hook(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_recipient_reverts() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_a_contract(); + the_recipient_implements_the_hook(); + the_recipient_reverts(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert +} + +#[test(should_fail)] +unconstrained fn test_when_there_is_reentrancy_1() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_a_contract(); + the_recipient_implements_the_hook(); + the_recipient_does_not_revert(); + there_is_reentrancy_1(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert +} + +#[test] +unconstrained fn test_when_there_is_no_reentrancy_1() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_sender(); + the_recipient_is_a_contract(); + the_recipient_implements_the_hook(); + the_recipient_does_not_revert(); + there_is_no_reentrancy_1(); + // it should cancel the stream + // it should mark the stream as canceled + // it should make the stream not cancelable + // it should update the refunded amount + // it should refund the sender + // it should call the recipient hook + // it should emit a {CancelLockupStream} event + // it should emit a {MetadataUpdate} event +} + +#[test] +unconstrained fn test_when_the_sender_is_not_a_contract() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_not_a_contract(); + // it should cancel the stream + // it should mark the stream as canceled +} + +#[test(should_fail)] +unconstrained fn test_when_the_sender_does_not_implement_the_hook() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_a_contract(); + the_sender_does_not_implement_the_hook(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert +} + +#[test(should_fail)] +unconstrained fn test_when_the_sender_reverts() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_a_contract(); + the_sender_implements_the_hook(); + the_sender_reverts(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert +} + +#[test(should_fail)] +unconstrained fn test_when_there_is_reentrancy_2() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_a_contract(); + the_sender_implements_the_hook(); + the_sender_does_not_revert(); + there_is_reentrancy_2(); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert +} + +#[test] +unconstrained fn test_when_there_is_no_reentrancy_2() { + not_delegate_called(); + the_id_does_not_reference_a_null_stream(); + the_stream_is_warm(); + the_caller_is_authorized(); + the_stream_is_cancelable(); + the_streams_status_is_streaming(); + the_caller_is_the_recipient(); + the_sender_is_a_contract(); + the_sender_implements_the_hook(); + the_sender_does_not_revert(); + there_is_no_reentrancy_2(); + // it should cancel the stream + // it should mark the stream as canceled + // it should make the stream not cancelable + // it should update the refunded amount + // it should refund the sender + // it should call the sender hook + // it should emit a {MetadataUpdate} event + // it should emit a {CancelLockupStream} event +} + + diff --git a/crates/bulloak/tests/scaffold_noir/disambiguation_test.nr b/crates/bulloak/tests/scaffold_noir/disambiguation_test.nr new file mode 100644 index 0000000..b777c18 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/disambiguation_test.nr @@ -0,0 +1,47 @@ +// Generated by bulloak + +/// Helper function for condition +fn a_is_even() { +} + +/// Helper function for condition +fn b_is_even() { +} + +/// Helper function for condition +fn not_zero() { +} + +/// Helper function for condition +fn zero() { +} + +#[test(should_fail)] +unconstrained fn test_when_zero() { + a_is_even(); + zero(); + // it should revert +} + +#[test] +unconstrained fn test_when_not_zero() { + a_is_even(); + not_zero(); + // it should work +} + +#[test(should_fail)] +unconstrained fn test_when_zero() { + b_is_even(); + zero(); + // it should revert +} + +#[test] +unconstrained fn test_when_not_zero() { + b_is_even(); + not_zero(); + // it should work +} + + diff --git a/crates/bulloak/tests/scaffold_noir/duplicated_condition_test.nr b/crates/bulloak/tests/scaffold_noir/duplicated_condition_test.nr new file mode 100644 index 0000000..408f92c --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/duplicated_condition_test.nr @@ -0,0 +1,25 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/duplicated_top_action_test.nr b/crates/bulloak/tests/scaffold_noir/duplicated_top_action_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_noir/empty_test.nr b/crates/bulloak/tests/scaffold_noir/empty_test.nr new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_noir/format_descriptions_test.nr b/crates/bulloak/tests/scaffold_noir/format_descriptions_test.nr new file mode 100644 index 0000000..031001a --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/format_descriptions_test.nr @@ -0,0 +1,14 @@ +// Generated by bulloak + +/// Helper function for condition +fn formatting_toggled() { +} + +#[test] +unconstrained fn test_when_formatting_toggled() { + formatting_toggled(); + // it should reformat comment + // it should handle question? +} + + diff --git a/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr b/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr new file mode 100644 index 0000000..fc7e0c6 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/hash_pair_test.nr @@ -0,0 +1,60 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the value of `a`. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the value of `b`. +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the value of `b`. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the value of `a`. +} diff --git a/crates/bulloak/tests/scaffold_noir/removes_invalid_title_chars_test.nr b/crates/bulloak/tests/scaffold_noir/removes_invalid_title_chars_test.nr new file mode 100644 index 0000000..fc8eab0 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/removes_invalid_title_chars_test.nr @@ -0,0 +1,8 @@ +// Generated by bulloak + +#[test] +unconstrained fn test_cant_do_x() { + // It can’t do, X. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/revert_when_test.nr b/crates/bulloak/tests/scaffold_noir/revert_when_test.nr new file mode 100644 index 0000000..14cbf75 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/revert_when_test.nr @@ -0,0 +1,18 @@ +// Generated by bulloak + +/// Helper function for condition +fn a_condition_is_met() { +} + +/// Helper function for condition +fn stuff_is_called() { +} + +#[test(should_fail)] +unconstrained fn test_when_a_condition_is_met() { + stuff_is_called(); + a_condition_is_met(); + // It should revert. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/skip_modifiers_test.nr b/crates/bulloak/tests/scaffold_noir/skip_modifiers_test.nr new file mode 100644 index 0000000..8fb806b --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/skip_modifiers_test.nr @@ -0,0 +1,39 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_zero() { +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_zero() { + first_arg_is_smaller_than_second_arg(); + first_arg_is_zero(); + // It should do something. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + + diff --git a/crates/bulloak/tests/scaffold_noir/spurious_comments_test.nr b/crates/bulloak/tests/scaffold_noir/spurious_comments_test.nr new file mode 100644 index 0000000..2a568e8 --- /dev/null +++ b/crates/bulloak/tests/scaffold_noir/spurious_comments_test.nr @@ -0,0 +1,28 @@ +// Generated by bulloak + +/// Helper function for condition +fn first_arg_is_bigger_than_second_arg() { +} + +/// Helper function for condition +fn first_arg_is_smaller_than_second_arg() { +} + +#[test(should_fail)] +unconstrained fn test_should_never_revert() { + // It should never revert. +} + +#[test] +unconstrained fn test_when_first_arg_is_smaller_than_second_arg() { + first_arg_is_smaller_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. +} + +#[test] +unconstrained fn test_when_first_arg_is_bigger_than_second_arg() { + first_arg_is_bigger_than_second_arg(); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. +} + + diff --git a/crates/bulloak/tests/scaffold_rust.rs b/crates/bulloak/tests/scaffold_rust.rs new file mode 100644 index 0000000..4b08441 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust.rs @@ -0,0 +1,102 @@ +#![allow(missing_docs)] +use std::{env, fs}; + +use common::{cmd, get_binary_path}; +use pretty_assertions::assert_eq; + +mod common; + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_rust_trees() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let outputs_path = cwd.join("tests").join("scaffold_rust"); + let trees = [ + "basic.tree", + "complex.tree", + "disambiguation.tree", + "duplicated_condition.tree", + "duplicated_top_action.tree", + "empty.tree", + "format_descriptions.tree", + "hash_pair.tree", + "removes_invalid_title_chars.tree", + "revert_when.tree", + "skip_modifiers.tree", + "spurious_comments.tree", + ]; + + for tree_name in trees { + let tree_path = trees_path.join(tree_name); + let output = + cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + let output_file = + outputs_path.join(tree_name.replace(".tree", "_test.rs")); + + let expected = fs::read_to_string(&output_file).unwrap_or_else(|_| { + panic!( + "Failed to read expected output file: {}", + output_file.display() + ) + }); + + // We trim here because we don't care about ending newlines. + assert_eq!( + expected.trim(), + actual.trim(), + "Mismatch for {}", + tree_name + ); + } +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_rust_trees_skip_helpers() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let tree_path = trees_path.join("basic.tree"); + + let output = + cmd(&binary_path, "scaffold", &tree_path, &["--lang", "rust", "-m"]); + let actual = String::from_utf8(output.stdout).unwrap(); + + // Should not contain helper functions + assert!(!actual.contains("fn first_arg_is_smaller_than_second_arg")); + assert!(!actual.contains("fn first_arg_is_bigger_than_second_arg")); + + // Should still contain test module and context + assert!(actual.contains("#[cfg(test)]")); + assert!(actual.contains("mod tests")); + assert!(actual.contains("struct TestContext")); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn scaffolds_rust_trees_format_descriptions() { + let cwd = env::current_dir().unwrap(); + let binary_path = get_binary_path(); + let trees_path = cwd.join("tests").join("scaffold"); + let tree_path = trees_path.join("basic.tree"); + + let output = cmd( + &binary_path, + "scaffold", + &tree_path, + &["--lang", "rust", "--format-descriptions"], + ); + let actual = String::from_utf8(output.stdout).unwrap(); + + // Comments should be capitalized and have periods + assert!(actual.contains( + "// It should match the result of `keccak256(abi.encodePacked(a,b))`." + )); + assert!(actual.contains( + "// It should match the result of `keccak256(abi.encodePacked(b,a))`." + )); +} diff --git a/crates/bulloak/tests/scaffold_rust/basic_test.rs b/crates/bulloak/tests/scaffold_rust/basic_test.rs new file mode 100644 index 0000000..254c750 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/basic_test.rs @@ -0,0 +1,44 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is zero +fn first_arg_is_zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is bigger than second arg +fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_should_never_revert() { + // It should never revert. + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. + } + #[test] + fn test_when_first_arg_is_zero() { + let _ctx = first_arg_is_zero( + first_arg_is_smaller_than_second_arg(TestContext::default()), + ); + // It should do something. + } + #[test] + fn test_when_first_arg_is_bigger_than_second_arg() { + let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/complex_test.rs b/crates/bulloak/tests/scaffold_rust/complex_test.rs new file mode 100644 index 0000000..beae80c --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/complex_test.rs @@ -0,0 +1,739 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when delegate called +fn delegate_called(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when not delegate called +fn not_delegate_called(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the id references a null stream +fn the_id_references_a_null_stream(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the id does not reference a null stream +fn the_id_does_not_reference_a_null_stream(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the stream is cold +fn the_stream_is_cold(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is DEPLETED +fn the_streams_status_is_d_e_p_l_e_t_e_d(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is CANCELED +fn the_streams_status_is_c_a_n_c_e_l_e_d(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is SETTLED +fn the_streams_status_is_s_e_t_t_l_e_d(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the stream is warm +fn the_stream_is_warm(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is unauthorized +fn the_caller_is_unauthorized(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is a malicious third party +fn the_caller_is_a_malicious_third_party(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is an approved third party +fn the_caller_is_an_approved_third_party(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is a former recipient +fn the_caller_is_a_former_recipient(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is authorized +fn the_caller_is_authorized(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the stream is not cancelable +fn the_stream_is_not_cancelable(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the stream is cancelable +fn the_stream_is_cancelable(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is PENDING +fn the_streams_status_is_p_e_n_d_i_n_g(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the streams status is STREAMING +fn the_streams_status_is_s_t_r_e_a_m_i_n_g(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is the sender +fn the_caller_is_the_sender(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the recipient is not a contract +fn the_recipient_is_not_a_contract(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the recipient is a contract +fn the_recipient_is_a_contract(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the recipient does not implement the hook +fn the_recipient_does_not_implement_the_hook(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the recipient implements the hook +fn the_recipient_implements_the_hook(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the recipient reverts +fn the_recipient_reverts(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the recipient does not revert +fn the_recipient_does_not_revert(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when there is reentrancy 1 +fn there_is_reentrancy_1(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when there is no reentrancy 1 +fn there_is_no_reentrancy_1(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the caller is the recipient +fn the_caller_is_the_recipient(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the sender is not a contract +fn the_sender_is_not_a_contract(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the sender is a contract +fn the_sender_is_a_contract(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the sender does not implement the hook +fn the_sender_does_not_implement_the_hook(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given the sender implements the hook +fn the_sender_implements_the_hook(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the sender reverts +fn the_sender_reverts(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when the sender does not revert +fn the_sender_does_not_revert(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when there is reentrancy 2 +fn there_is_reentrancy_2(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when there is no reentrancy 2 +fn there_is_no_reentrancy_2(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_when_delegate_called() { + let _ctx = delegate_called(TestContext::default()); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_id_references_a_null_stream() { + let _ctx = the_id_references_a_null_stream( + not_delegate_called(TestContext::default()), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_streams_status_is_d_e_p_l_e_t_e_d() { + let _ctx = the_streams_status_is_d_e_p_l_e_t_e_d( + the_stream_is_cold( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_streams_status_is_c_a_n_c_e_l_e_d() { + let _ctx = the_streams_status_is_c_a_n_c_e_l_e_d( + the_stream_is_cold( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_streams_status_is_s_e_t_t_l_e_d() { + let _ctx = the_streams_status_is_s_e_t_t_l_e_d( + the_stream_is_cold( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_caller_is_a_malicious_third_party() { + let _ctx = the_caller_is_a_malicious_third_party( + the_caller_is_unauthorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_caller_is_an_approved_third_party() { + let _ctx = the_caller_is_an_approved_third_party( + the_caller_is_unauthorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_caller_is_a_former_recipient() { + let _ctx = the_caller_is_a_former_recipient( + the_caller_is_unauthorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ); + // it should revert + } + #[test] + #[should_panic] + fn test_when_the_stream_is_not_cancelable() { + let _ctx = the_stream_is_not_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ); + // it should revert + } + #[test] + fn test_when_the_streams_status_is_p_e_n_d_i_n_g() { + let _ctx = the_streams_status_is_p_e_n_d_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as depleted + // it should make the stream not cancelable + } + #[test] + fn test_when_the_streams_status_is_p_e_n_d_i_n_g() { + let _ctx = the_streams_status_is_p_e_n_d_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ); + } + #[test] + fn test_when_the_streams_status_is_p_e_n_d_i_n_g() { + let _ctx = the_streams_status_is_p_e_n_d_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ); + } + #[test] + fn test_when_the_recipient_is_not_a_contract() { + let _ctx = the_recipient_is_not_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + } + #[test] + fn test_when_the_recipient_is_not_a_contract() { + let _ctx = the_recipient_is_not_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_the_recipient_does_not_implement_the_hook() { + let _ctx = the_recipient_does_not_implement_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert + } + #[test] + fn test_when_the_recipient_reverts() { + let _ctx = the_recipient_reverts( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert + } + #[test] + fn test_when_the_recipient_reverts() { + let _ctx = the_recipient_reverts( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + fn test_when_the_recipient_reverts() { + let _ctx = the_recipient_reverts( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_the_recipient_reverts() { + let _ctx = the_recipient_reverts( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_there_is_reentrancy_1() { + let _ctx = there_is_reentrancy_1( + the_recipient_does_not_revert( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the recipient hook + // it should ignore the revert + } + #[test] + fn test_when_there_is_no_reentrancy_1() { + let _ctx = there_is_no_reentrancy_1( + the_recipient_does_not_revert( + the_recipient_implements_the_hook( + the_recipient_is_a_contract( + the_caller_is_the_sender( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should make the stream not cancelable + // it should update the refunded amount + // it should refund the sender + // it should call the recipient hook + // it should emit a {CancelLockupStream} event + // it should emit a {MetadataUpdate} event + } + #[test] + fn test_when_the_sender_is_not_a_contract() { + let _ctx = the_sender_is_not_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + } + #[test] + fn test_when_the_sender_is_not_a_contract() { + let _ctx = the_sender_is_not_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_the_sender_does_not_implement_the_hook() { + let _ctx = the_sender_does_not_implement_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert + } + #[test] + fn test_when_the_sender_reverts() { + let _ctx = the_sender_reverts( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert + } + #[test] + fn test_when_the_sender_reverts() { + let _ctx = the_sender_reverts( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + fn test_when_the_sender_reverts() { + let _ctx = the_sender_reverts( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_the_sender_reverts() { + let _ctx = the_sender_reverts( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + #[test] + #[should_panic] + fn test_when_there_is_reentrancy_2() { + let _ctx = there_is_reentrancy_2( + the_sender_does_not_revert( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should call the sender hook + // it should ignore the revert + } + #[test] + fn test_when_there_is_no_reentrancy_2() { + let _ctx = there_is_no_reentrancy_2( + the_sender_does_not_revert( + the_sender_implements_the_hook( + the_sender_is_a_contract( + the_caller_is_the_recipient( + the_streams_status_is_s_t_r_e_a_m_i_n_g( + the_stream_is_cancelable( + the_caller_is_authorized( + the_stream_is_warm( + the_id_does_not_reference_a_null_stream( + not_delegate_called(TestContext::default()), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + // it should cancel the stream + // it should mark the stream as canceled + // it should make the stream not cancelable + // it should update the refunded amount + // it should refund the sender + // it should call the sender hook + // it should emit a {MetadataUpdate} event + // it should emit a {CancelLockupStream} event + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/disambiguation_test.rs b/crates/bulloak/tests/scaffold_rust/disambiguation_test.rs new file mode 100644 index 0000000..cd2f4d5 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/disambiguation_test.rs @@ -0,0 +1,46 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when a is even +fn a_is_even(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given zero +fn zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: given not zero +fn not_zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: when b is even +fn b_is_even(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_when_zero() { + let _ctx = zero(a_is_even(TestContext::default())); + // it should revert + } + #[test] + fn test_when_not_zero() { + let _ctx = not_zero(a_is_even(TestContext::default())); + // it should work + } + #[test] + #[should_panic] + fn test_when_zero() { + let _ctx = zero(b_is_even(TestContext::default())); + } + #[test] + fn test_when_not_zero() { + let _ctx = not_zero(b_is_even(TestContext::default())); + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/duplicated_condition_test.rs b/crates/bulloak/tests/scaffold_rust/duplicated_condition_test.rs new file mode 100644 index 0000000..4859115 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/duplicated_condition_test.rs @@ -0,0 +1,27 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/duplicated_top_action_test.rs b/crates/bulloak/tests/scaffold_rust/duplicated_top_action_test.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_rust/empty_test.rs b/crates/bulloak/tests/scaffold_rust/empty_test.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_rust/format_descriptions_test.rs b/crates/bulloak/tests/scaffold_rust/format_descriptions_test.rs new file mode 100644 index 0000000..8fcd3d5 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/format_descriptions_test.rs @@ -0,0 +1,20 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: when formatting toggled +fn formatting_toggled(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_when_formatting_toggled() { + let _ctx = formatting_toggled(TestContext::default()); + // it should reformat comment + // it should handle question? + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/hash_pair_test.rs b/crates/bulloak/tests/scaffold_rust/hash_pair_test.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/bulloak/tests/scaffold_rust/removes_invalid_title_chars_test.rs b/crates/bulloak/tests/scaffold_rust/removes_invalid_title_chars_test.rs new file mode 100644 index 0000000..245fdb8 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/removes_invalid_title_chars_test.rs @@ -0,0 +1,14 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_cant_dox() { + // It can’t do, X. + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/revert_when_test.rs b/crates/bulloak/tests/scaffold_rust/revert_when_test.rs new file mode 100644 index 0000000..a1f40d1 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/revert_when_test.rs @@ -0,0 +1,24 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When stuff is called +fn stuff_is_called(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When a condition is met +fn a_condition_is_met(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_when_a_condition_is_met() { + let _ctx = a_condition_is_met(stuff_is_called(TestContext::default())); + // It should revert. + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/skip_modifiers_test.rs b/crates/bulloak/tests/scaffold_rust/skip_modifiers_test.rs new file mode 100644 index 0000000..254c750 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/skip_modifiers_test.rs @@ -0,0 +1,44 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is zero +fn first_arg_is_zero(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is bigger than second arg +fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_should_never_revert() { + // It should never revert. + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. + } + #[test] + fn test_when_first_arg_is_zero() { + let _ctx = first_arg_is_zero( + first_arg_is_smaller_than_second_arg(TestContext::default()), + ); + // It should do something. + } + #[test] + fn test_when_first_arg_is_bigger_than_second_arg() { + let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. + } +} + diff --git a/crates/bulloak/tests/scaffold_rust/spurious_comments_test.rs b/crates/bulloak/tests/scaffold_rust/spurious_comments_test.rs new file mode 100644 index 0000000..e0e03e7 --- /dev/null +++ b/crates/bulloak/tests/scaffold_rust/spurious_comments_test.rs @@ -0,0 +1,33 @@ +// Generated by bulloak + +/// Context for test conditions +#[derive(Default)] +struct TestContext {} +/// Helper: When first arg is smaller than second arg +fn first_arg_is_smaller_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +/// Helper: When first arg is bigger than second arg +fn first_arg_is_bigger_than_second_arg(mut ctx: TestContext) -> TestContext { + ctx +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[should_panic] + fn test_should_never_revert() { + // It should never revert. + } + #[test] + fn test_when_first_arg_is_smaller_than_second_arg() { + let _ctx = first_arg_is_smaller_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(a,b))`. + } + #[test] + fn test_when_first_arg_is_bigger_than_second_arg() { + let _ctx = first_arg_is_bigger_than_second_arg(TestContext::default()); + // It should match the result of `keccak256(abi.encodePacked(b,a))`. + } +} + diff --git a/crates/noir/Cargo.toml b/crates/noir/Cargo.toml new file mode 100644 index 0000000..24320cd --- /dev/null +++ b/crates/noir/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bulloak-noir" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +readme = "./README.md" +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +description.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +bulloak-syntax.workspace = true + +anyhow.workspace = true +thiserror.workspace = true +tree-sitter = "0.20" +tree-sitter-noir = "0.0.1" + +[dev-dependencies] +pretty_assertions.workspace = true +indoc = "2.0.5" +tempfile = "3.8" + +[lints] +workspace = true diff --git a/crates/noir/src/check/mod.rs b/crates/noir/src/check/mod.rs new file mode 100644 index 0000000..fdfba64 --- /dev/null +++ b/crates/noir/src/check/mod.rs @@ -0,0 +1,20 @@ +//! Validation rules for Noir tests. + +pub mod rules; +pub mod violation; + +use std::path::Path; + +use anyhow::Result; +pub use violation::Violation; + +use crate::Config; + +/// Check that a Noir test file matches its tree specification. +/// +/// # Errors +/// +/// Returns an error if checking fails. +pub fn check(tree_path: &Path, cfg: &Config) -> Result> { + rules::structural_match::check(tree_path, cfg) +} diff --git a/crates/noir/src/check/rules/mod.rs b/crates/noir/src/check/rules/mod.rs new file mode 100644 index 0000000..f8c8f7c --- /dev/null +++ b/crates/noir/src/check/rules/mod.rs @@ -0,0 +1,3 @@ +//! Validation rules for Noir tests. + +pub mod structural_match; diff --git a/crates/noir/src/check/rules/structural_match.rs b/crates/noir/src/check/rules/structural_match.rs new file mode 100644 index 0000000..376a9be --- /dev/null +++ b/crates/noir/src/check/rules/structural_match.rs @@ -0,0 +1,313 @@ +//! Structural matching rule for Noir tests. + +use std::{collections::HashSet, fs, path::Path}; + +use anyhow::Result; +use bulloak_syntax::Ast; + +use crate::{ + check::violation::{Violation, ViolationKind}, + noir::ParsedNoirFile, + utils::to_snake_case, + Config, +}; + +/// Expected test structure extracted from AST. +struct ExpectedTests { + helpers: HashSet, + test_functions: Vec, +} + +struct TestInfo { + name: String, + should_fail: bool, +} + +/// Check that a Noir test file matches its tree specification. +/// +/// # Errors +/// +/// Returns an error if checking fails. +pub fn check(tree_path: &Path, cfg: &Config) -> Result> { + let mut violations = Vec::new(); + + // Read the tree file + let tree_text = fs::read_to_string(tree_path)?; + let ast = bulloak_syntax::parse_one(&tree_text)?; + + // Find corresponding Noir test file + let file_stem = + tree_path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| { + anyhow::anyhow!("Invalid tree file name: {}", tree_path.display()) + })?; + + let test_file = tree_path.with_file_name(format!("{file_stem}_test.nr")); + + if !test_file.exists() { + violations.push(Violation::new( + ViolationKind::NoirFileInvalid(format!( + "File not found: {}", + test_file.display() + )), + test_file.display().to_string(), + )); + return Ok(violations); + } + + let noir_source = fs::read_to_string(&test_file)?; + + // Parse the Noir file + let parsed = match ParsedNoirFile::parse(&noir_source) { + Ok(p) => p, + Err(e) => { + violations.push(Violation::new( + ViolationKind::NoirFileInvalid(e.to_string()), + test_file.display().to_string(), + )); + return Ok(violations); + } + }; + + // Extract expected structure from AST + let expected = extract_expected_structure(&ast, cfg)?; + + // Check helpers (if not skipped) + if !cfg.skip_helpers { + let found_helpers = parsed.find_helper_functions(); + let found_helper_set: HashSet = + found_helpers.into_iter().collect(); + + for expected_helper in &expected.helpers { + if !found_helper_set.contains(expected_helper) { + violations.push(Violation::new( + ViolationKind::HelperFunctionMissing( + expected_helper.clone(), + ), + test_file.display().to_string(), + )); + } + } + } + + // Check test functions + let found_tests = parsed.find_test_functions(); + let found_test_map: std::collections::HashMap = found_tests + .iter() + .map(|t| (t.name.clone(), t.has_should_fail)) + .collect(); + + for expected_test in &expected.test_functions { + if let Some(&has_should_fail) = found_test_map.get(&expected_test.name) + { + // Test exists - check attributes + if expected_test.should_fail && !has_should_fail { + violations.push(Violation::new( + ViolationKind::ShouldFailMissing( + expected_test.name.clone(), + ), + test_file.display().to_string(), + )); + } else if !expected_test.should_fail && has_should_fail { + violations.push(Violation::new( + ViolationKind::ShouldFailUnexpected( + expected_test.name.clone(), + ), + test_file.display().to_string(), + )); + } + } else { + // Test is missing + violations.push(Violation::new( + ViolationKind::TestFunctionMissing(expected_test.name.clone()), + test_file.display().to_string(), + )); + } + } + + Ok(violations) +} + +/// Extract expected test structure from AST. +fn extract_expected_structure( + ast: &Ast, + cfg: &Config, +) -> Result { + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + let mut helpers = HashSet::new(); + let mut test_functions = Vec::new(); + + if !cfg.skip_helpers { + collect_helpers_recursive(&ast_root.children, &mut helpers); + } + + collect_tests(&ast_root.children, &[], &mut test_functions, cfg); + + Ok(ExpectedTests { helpers, test_functions }) +} + +/// Recursively collect helper names from conditions. +fn collect_helpers_recursive(children: &[Ast], helpers: &mut HashSet) { + for child in children { + if let Ast::Condition(condition) = child { + helpers.insert(to_snake_case(&condition.title)); + collect_helpers_recursive(&condition.children, helpers); + } + } +} + +/// Collect expected test functions. +fn collect_tests( + children: &[Ast], + parent_helpers: &[String], + tests: &mut Vec, + cfg: &Config, +) { + for child in children { + match child { + Ast::Condition(condition) => { + let mut helpers = parent_helpers.to_vec(); + if !cfg.skip_helpers { + helpers.push(to_snake_case(&condition.title)); + } + + // Collect all direct Action children + let actions: Vec<_> = condition + .children + .iter() + .filter_map(|c| match c { + Ast::Action(a) => Some(a), + _ => None, + }) + .collect(); + + // One test function for all actions under this condition + if !actions.is_empty() { + let test_name = if helpers.is_empty() { + // Root level action (shouldn't really happen with a + // Condition parent, but handle + // it just in case) + format!("test_{}", to_snake_case(&actions[0].title)) + } else { + // Under conditions: use the last helper name, NOT the + // action name + format!("test_when_{}", helpers.last().unwrap()) + }; + + let should_fail = + actions.iter().any(|a| has_panic_keyword(&a.title)); + + tests.push(TestInfo { name: test_name, should_fail }); + } + + // Recursively process only nested Condition children (not + // actions!) + for child in &condition.children { + if matches!(child, Ast::Condition(_)) { + collect_tests( + std::slice::from_ref(child), + &helpers, + tests, + cfg, + ); + } + } + } + Ast::Action(action) => { + // Root-level action + let test_name = + format!("test_{}", to_snake_case(&action.title)); + let should_fail = has_panic_keyword(&action.title); + tests.push(TestInfo { name: test_name, should_fail }); + } + _ => {} + } + } +} + +/// Check if a title contains panic keywords. +fn has_panic_keyword(title: &str) -> bool { + let lower = title.to_lowercase(); + crate::constants::PANIC_KEYWORDS + .iter() + .any(|keyword| lower.contains(keyword)) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use indoc::indoc; + use tempfile::NamedTempFile; + + use super::*; + + #[test] + fn test_check_passes_when_correct() { + let tree_content = indoc! {r#" + hash_pair + └── It should work. + "#}; + + let noir_content = indoc! {r#" + // Generated by bulloak + + #[test] + unconstrained fn test_should_work() { + // It should work. + } + "#}; + + let mut tree_file = NamedTempFile::new().unwrap(); + tree_file.write_all(tree_content.as_bytes()).unwrap(); + tree_file.flush().unwrap(); + + let test_path = tree_file.path().with_file_name(format!( + "{}_test.nr", + tree_file.path().file_stem().unwrap().to_str().unwrap() + )); + fs::write(&test_path, noir_content).unwrap(); + + let cfg = Config::default(); + let violations = check(tree_file.path(), &cfg).unwrap(); + + assert_eq!(violations.len(), 0); + + // Cleanup + let _ = fs::remove_file(test_path); + } + + #[test] + fn test_check_fails_when_missing_test() { + let tree_content = indoc! {r#" + test_root + └── It should work. + "#}; + + let noir_content = "// Generated by bulloak\n\n"; + + let mut tree_file = NamedTempFile::new().unwrap(); + tree_file.write_all(tree_content.as_bytes()).unwrap(); + tree_file.flush().unwrap(); + + let test_path = tree_file.path().with_file_name(format!( + "{}_test.nr", + tree_file.path().file_stem().unwrap().to_str().unwrap() + )); + fs::write(&test_path, noir_content).unwrap(); + + let cfg = Config::default(); + let violations = check(tree_file.path(), &cfg).unwrap(); + + assert!(violations.len() > 0); + assert!(violations + .iter() + .any(|v| matches!(v.kind, ViolationKind::TestFunctionMissing(_)))); + + // Cleanup + let _ = fs::remove_file(test_path); + } +} diff --git a/crates/noir/src/check/violation.rs b/crates/noir/src/check/violation.rs new file mode 100644 index 0000000..acf589d --- /dev/null +++ b/crates/noir/src/check/violation.rs @@ -0,0 +1,65 @@ +//! Violation types for Noir test checking. + +use std::fmt; + +/// A violation found when checking a Noir test file. +#[derive(Debug, Clone)] +pub struct Violation { + /// The kind of violation. + pub kind: ViolationKind, + /// The file where the violation occurred. + pub file: String, +} + +/// The kind of violation. +#[derive(Debug, Clone)] +pub enum ViolationKind { + /// The Noir file could not be parsed. + NoirFileInvalid(String), + /// A test function is missing. + TestFunctionMissing(String), + /// A helper function is missing. + HelperFunctionMissing(String), + /// A test should have `#[test(should_fail)]` but doesn't. + ShouldFailMissing(String), + /// A test has `#[test(should_fail)]` but shouldn't. + ShouldFailUnexpected(String), +} + +impl Violation { + /// Create a new violation. + #[must_use] + pub fn new(kind: ViolationKind, file: String) -> Self { + Self { kind, file } + } +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match &self.kind { + ViolationKind::NoirFileInvalid(err) => { + write!(f, "Failed to parse Noir file {}: {}", self.file, err) + } + ViolationKind::TestFunctionMissing(name) => { + write!(f, "Missing test function '{}' in {}", name, self.file) + } + ViolationKind::HelperFunctionMissing(name) => { + write!(f, "Missing helper function '{}' in {}", name, self.file) + } + ViolationKind::ShouldFailMissing(name) => { + write!( + f, + "Test '{}' should have #[test(should_fail)] in {}", + name, self.file + ) + } + ViolationKind::ShouldFailUnexpected(name) => { + write!( + f, + "Test '{}' has #[test(should_fail)] but shouldn't in {}", + name, self.file + ) + } + } + } +} diff --git a/crates/noir/src/config.rs b/crates/noir/src/config.rs new file mode 100644 index 0000000..3279b93 --- /dev/null +++ b/crates/noir/src/config.rs @@ -0,0 +1,12 @@ +//! Configuration for Noir backend. + +/// Configuration for Noir test generation and checking. +#[derive(Debug, Clone, Default)] +pub struct Config { + /// List of files being processed. + pub files: Vec, + /// Skip generation of helper functions for conditions. + pub skip_helpers: bool, + /// Format action descriptions (capitalize, etc). + pub format_descriptions: bool, +} diff --git a/crates/noir/src/constants.rs b/crates/noir/src/constants.rs new file mode 100644 index 0000000..07ad777 --- /dev/null +++ b/crates/noir/src/constants.rs @@ -0,0 +1,9 @@ +//! Constants for Noir code generation. + +/// Keywords that indicate a test should have the `#[test(should_fail)]` +/// attribute. +pub(crate) const PANIC_KEYWORDS: &[&str] = + &["panic", "revert", "error", "fail", "assert_fail"]; + +/// Prefix for test functions. +pub(crate) const TEST_PREFIX: &str = "test"; diff --git a/crates/noir/src/lib.rs b/crates/noir/src/lib.rs new file mode 100644 index 0000000..8fed8b6 --- /dev/null +++ b/crates/noir/src/lib.rs @@ -0,0 +1,29 @@ +//! Noir backend for bulloak. +//! +//! This crate provides Noir test generation and validation for bulloak, +//! converting `.tree` specifications into Noir test files with `#[test]` +//! attributes. + +#![warn(missing_docs)] +#![warn(unreachable_pub)] + +pub mod check; +pub mod config; +pub mod noir; +pub mod scaffold; + +mod constants; +mod utils; + +use anyhow::Result; +use bulloak_syntax::Ast; +pub use config::Config; + +/// Generate Noir test code from an AST. +/// +/// # Errors +/// +/// Returns an error if code generation fails. +pub fn scaffold(forest: &Vec, cfg: &Config) -> Result { + scaffold::generate(forest, cfg) +} diff --git a/crates/noir/src/noir/mod.rs b/crates/noir/src/noir/mod.rs new file mode 100644 index 0000000..a724eb4 --- /dev/null +++ b/crates/noir/src/noir/mod.rs @@ -0,0 +1,5 @@ +//! Noir code parsing using tree-sitter. + +mod parser; + +pub use parser::ParsedNoirFile; diff --git a/crates/noir/src/noir/parser.rs b/crates/noir/src/noir/parser.rs new file mode 100644 index 0000000..4887449 --- /dev/null +++ b/crates/noir/src/noir/parser.rs @@ -0,0 +1,274 @@ +//! Noir code parser using tree-sitter. + +use anyhow::{Context, Result}; +use tree_sitter::{Node, Parser}; + +/// Parsed Noir test file. +pub struct ParsedNoirFile { + /// The source code. + source: String, + /// The parsed syntax tree. + tree: tree_sitter::Tree, +} + +/// Information about a test function. +#[derive(Debug, Clone)] +pub struct TestFunction { + /// The function name. + pub name: String, + /// Whether the function has `#[test(should_fail)]` attribute. + pub has_should_fail: bool, +} + +impl ParsedNoirFile { + /// Parse a Noir file from source code. + /// + /// # Errors + /// + /// Returns an error if parsing fails. + pub fn parse(source: &str) -> Result { + let mut parser = Parser::new(); + parser + .set_language(tree_sitter_noir::language()) + .context("Failed to load Noir grammar")?; + + let tree = + parser.parse(source, None).context("Failed to parse Noir file")?; + + Ok(Self { source: source.to_string(), tree }) + } + + /// Find all test functions in the file. + #[must_use] + pub fn find_test_functions(&self) -> Vec { + let mut functions = Vec::new(); + let root_node = self.tree.root_node(); + + self.find_test_functions_recursive(root_node, &mut functions); + functions + } + + /// Recursively find test functions in a node and its children. + fn find_test_functions_recursive<'a>( + &self, + node: Node<'a>, + functions: &mut Vec, + ) { + // Check if this node is a function with #[test] attribute + if node.kind() == "function_definition" { + if let Some(test_fn) = self.extract_test_function(node) { + functions.push(test_fn); + } + } + + // Recursively check children + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.find_test_functions_recursive(child, functions); + } + } + + /// Extract test function information from a function node. + fn extract_test_function<'a>( + &self, + node: Node<'a>, + ) -> Option { + // Look for #[test] attribute + let has_test_attr = self.has_test_attribute(node); + if !has_test_attr { + return None; + } + + // Extract function name + let name = self.get_function_name(node)?; + + // Check for should_fail + let has_should_fail = self.has_should_fail_attribute(node); + + Some(TestFunction { name, has_should_fail }) + } + + /// Check if a function has #[test] attribute. + fn has_test_attribute<'a>(&self, node: Node<'a>) -> bool { + self.find_attribute(node, "test").is_some() + } + + /// Check if a function has #[test(should_fail)] attribute. + fn has_should_fail_attribute<'a>(&self, node: Node<'a>) -> bool { + if let Some(attr_node) = self.find_attribute(node, "test") { + // Check if the attribute contains "should_fail" + let attr_text = self.node_text(attr_node); + return attr_text.contains("should_fail"); + } + false + } + + /// Find a macro/attribute node by name (Noir uses "macro" for attributes). + fn find_attribute<'a>( + &self, + node: Node<'a>, + attr_name: &str, + ) -> Option> { + // Look for macro nodes before the function + let mut sibling = node.prev_sibling(); + while let Some(s) = sibling { + if s.kind() == "macro" { + let text = self.node_text(s); + if text.contains(attr_name) { + return Some(s); + } + } else if s.kind() == "identifier" { + // Skip "unconstrained" or other modifiers + let text = self.node_text(s); + if text == "unconstrained" || text == "pub" { + sibling = s.prev_sibling(); + continue; + } + // Stop if we hit an identifier that's not a known modifier + break; + } else if s.kind() != "comment" && s.kind() != "line_comment" { + // Stop if we hit something that's not a macro, comment, or + // known modifier + break; + } + sibling = s.prev_sibling(); + } + + None + } + + /// Extract function name from a function node. + fn get_function_name<'a>(&self, node: Node<'a>) -> Option { + let mut cursor = node.walk(); + // Find the identifier after "fn" keyword + let mut found_fn = false; + for child in node.children(&mut cursor) { + if child.kind() == "fn" { + found_fn = true; + } else if found_fn && child.kind() == "identifier" { + return Some(self.node_text(child)); + } + } + None + } + + /// Find all helper functions (functions without #[test] attribute). + #[must_use] + pub fn find_helper_functions(&self) -> Vec { + let mut functions = Vec::new(); + let root_node = self.tree.root_node(); + + self.find_helper_functions_recursive(root_node, &mut functions); + functions + } + + /// Recursively find helper functions in a node and its children. + fn find_helper_functions_recursive<'a>( + &self, + node: Node<'a>, + functions: &mut Vec, + ) { + if node.kind() == "function_definition" { + // Check if it has #[test] attribute + if !self.has_test_attribute(node) { + if let Some(name) = self.get_function_name(node) { + functions.push(name); + } + } + } + + // Recursively check children + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.find_helper_functions_recursive(child, functions); + } + } + + /// Get text content of a node. + fn node_text<'a>(&self, node: Node<'a>) -> String { + node.utf8_text(self.source.as_bytes()).unwrap_or("").to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_test() { + let source = r#" + #[test] + fn test_something() { + assert(true); + } + "#; + + let parsed = ParsedNoirFile::parse(source).unwrap(); + let test_fns = parsed.find_test_functions(); + + assert_eq!(test_fns.len(), 1); + assert_eq!(test_fns[0].name, "test_something"); + assert!(!test_fns[0].has_should_fail); + } + + #[test] + fn test_parse_should_fail() { + let source = r#" + #[test(should_fail)] + fn test_panics() { + assert(false); + } + "#; + + let parsed = ParsedNoirFile::parse(source).unwrap(); + let test_fns = parsed.find_test_functions(); + + assert_eq!(test_fns.len(), 1); + assert_eq!(test_fns[0].name, "test_panics"); + assert!(test_fns[0].has_should_fail); + } + + #[test] + fn test_parse_unconstrained() { + let source = r#" + #[test] + unconstrained fn test_something() { + assert(true); + } + + #[test(should_fail)] + unconstrained fn test_panics() { + assert(false); + } + "#; + + let parsed = ParsedNoirFile::parse(source).unwrap(); + let test_fns = parsed.find_test_functions(); + + assert_eq!(test_fns.len(), 2); + assert_eq!(test_fns[0].name, "test_something"); + assert!(!test_fns[0].has_should_fail); + assert_eq!(test_fns[1].name, "test_panics"); + assert!(test_fns[1].has_should_fail); + } + + #[test] + fn test_find_helper_functions() { + let source = r#" + fn helper_function() { + // helper + } + + #[test] + fn test_something() { + helper_function(); + } + "#; + + let parsed = ParsedNoirFile::parse(source).unwrap(); + let helpers = parsed.find_helper_functions(); + + assert!(!helpers.is_empty()); + assert!(helpers.contains(&"helper_function".to_string())); + } +} diff --git a/crates/noir/src/scaffold/generator.rs b/crates/noir/src/scaffold/generator.rs new file mode 100644 index 0000000..0a6173c --- /dev/null +++ b/crates/noir/src/scaffold/generator.rs @@ -0,0 +1,261 @@ +//! Noir test code generation. + +use std::collections::HashSet; + +use anyhow::Result; +use bulloak_syntax::{Action, Ast}; + +use crate::{ + config::Config, + constants::{PANIC_KEYWORDS, TEST_PREFIX}, + utils::to_snake_case, +}; + +/// Generate Noir test code from an AST. +/// +/// # Errors +/// +/// Returns an error if code generation fails. +pub(super) fn generate(forest: &Vec, cfg: &Config) -> Result { + let mut output = String::from("// Generated by bulloak\n\n"); + for ast in forest { + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + // Generate helper functions (if not skipped) + if !cfg.skip_helpers { + let helpers = collect_helpers(&ast_root.children); + for helper in helpers { + output.push_str(&generate_helper_function(&helper)); + output.push('\n'); + } + } + + // Generate test functions + let tests = generate_tests(&ast_root.children, &[], cfg); + for test in tests { + output.push_str(&test); + } + } + + Ok(output) +} + +/// Collect all unique helper names from conditions. +fn collect_helpers(children: &[Ast]) -> Vec { + let mut helpers = HashSet::new(); + collect_helpers_recursive(children, &mut helpers); + let mut sorted: Vec = helpers.into_iter().collect(); + sorted.sort(); // Sort alphabetically for deterministic output + sorted +} + +/// Recursively collect helper names. +fn collect_helpers_recursive(children: &[Ast], helpers: &mut HashSet) { + for child in children { + if let Ast::Condition(condition) = child { + helpers.insert(to_snake_case(&condition.title)); + collect_helpers_recursive(&condition.children, helpers); + } + } +} + +/// Generate a helper function. +fn generate_helper_function(name: &str) -> String { + format!( + "/// Helper function for condition\n\ + fn {name}() {{\n\ + }}\n" + ) +} + +/// Generate test functions from AST. +fn generate_tests( + children: &[Ast], + parent_helpers: &[String], + cfg: &Config, +) -> Vec { + let mut tests = Vec::new(); + + for child in children { + match child { + Ast::Condition(condition) => { + let mut helpers = parent_helpers.to_vec(); + if !cfg.skip_helpers { + helpers.push(to_snake_case(&condition.title)); + } + + // Collect all direct Action children + let actions: Vec<&Action> = condition + .children + .iter() + .filter_map(|c| match c { + Ast::Action(a) => Some(a), + _ => None, + }) + .collect(); + + // Generate ONE test function for all actions under this + // condition + if !actions.is_empty() { + tests.push(generate_test_function(&actions, &helpers, cfg)); + } + + // Process only nested Condition children (not actions!) + // recursively We need to collect into a Vec + // first, then pass a slice + let nested_conditions: Vec<_> = condition + .children + .iter() + .filter(|c| matches!(c, Ast::Condition(_))) + .collect(); + + for nested_cond in nested_conditions { + tests.extend(generate_tests( + std::slice::from_ref(nested_cond), + &helpers, + cfg, + )); + } + } + Ast::Action(action) => { + // Root-level action + tests.push(generate_test_function( + &[action], + parent_helpers, + cfg, + )); + } + _ => {} + } + } + + tests +} + +/// Generate a single test function for one or more actions. +fn generate_test_function( + actions: &[&Action], + helpers: &[String], + cfg: &Config, +) -> String { + // Determine test name + let test_name = if helpers.is_empty() { + // Root level: test_{action_name} + format!("{}_{}", TEST_PREFIX, to_snake_case(&actions[0].title)) + } else { + // Under condition: test_when_{last_helper} + format!("{}_when_{}", TEST_PREFIX, helpers.last().unwrap()) + }; + + // Check if any action contains panic keywords + let has_panic = + actions.iter().any(|action| has_panic_keyword(&action.title)); + + // Generate attribute + let attr = if has_panic { "#[test(should_fail)]\n" } else { "#[test]\n" }; + + // Generate function body + let mut body = String::new(); + + // Call helpers in order + if !cfg.skip_helpers { + for helper in helpers { + use std::fmt::Write; + let _ = writeln!(body, " {helper}();"); + } + } + + // Add action comments + for action in actions { + let comment = + format_action_comment(&action.title, cfg.format_descriptions); + use std::fmt::Write; + let _ = writeln!(body, " // {comment}"); + } + + format!("{attr}unconstrained fn {test_name}() {{\n{body}}}\n\n") +} + +/// Format an action comment. +fn format_action_comment(title: &str, format_descriptions: bool) -> String { + if format_descriptions { + // Capitalize first letter + let mut chars = title.chars(); + match chars.next() { + Some(f) => f.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + } else { + title.to_string() + } +} + +/// Check if a title contains panic keywords. +fn has_panic_keyword(title: &str) -> bool { + let lower = title.to_lowercase(); + PANIC_KEYWORDS.iter().any(|keyword| lower.contains(keyword)) +} + +#[cfg(test)] +mod tests { + use bulloak_syntax::parse; + + use super::*; + + #[test] + fn test_generate_basic() { + let tree = r" +hash_pair +├── It should always work. +└── When first arg is smaller + └── It should match result. +"; + + let ast = parse(tree).unwrap(); + let cfg = Config::default(); + let output = generate(&ast, &cfg).unwrap(); + + assert!(output.contains("// Generated by bulloak")); + assert!(output.contains("fn first_arg_is_smaller()")); + assert!(output + .contains("#[test]\nunconstrained fn test_should_always_work()")); + assert!(output.contains( + "#[test]\nunconstrained fn test_when_first_arg_is_smaller()" + )); + } + + #[test] + fn test_generate_with_panic() { + let tree = r" +divide +└── When divisor is zero + └── It should panic with division by zero. +"; + + let ast = parse(tree).unwrap(); + let cfg = Config::default(); + let output = generate(&ast, &cfg).unwrap(); + + assert!(output.contains("#[test(should_fail)]")); + assert!(output.contains("unconstrained fn test_when_divisor_is_zero()")); + } + + #[test] + fn test_skip_helpers() { + let tree = r" +test_root +└── When condition + └── It should work. +"; + + let ast = parse(tree).unwrap(); + let cfg = Config { skip_helpers: true, ..Default::default() }; + let output = generate(&ast, &cfg).unwrap(); + + assert!(!output.contains("fn condition()")); + assert!(output.contains("#[test]\nunconstrained fn test_should_work()")); + } +} diff --git a/crates/noir/src/scaffold/mod.rs b/crates/noir/src/scaffold/mod.rs new file mode 100644 index 0000000..2d8b6e9 --- /dev/null +++ b/crates/noir/src/scaffold/mod.rs @@ -0,0 +1,17 @@ +//! Noir test scaffolding. + +mod generator; + +use anyhow::Result; +use bulloak_syntax::Ast; + +use crate::Config; + +/// Generate Noir test code from an AST. +/// +/// # Errors +/// +/// Returns an error if code generation fails. +pub fn generate(forest: &Vec, cfg: &Config) -> Result { + generator::generate(forest, cfg) +} diff --git a/crates/noir/src/utils.rs b/crates/noir/src/utils.rs new file mode 100644 index 0000000..74c59a7 --- /dev/null +++ b/crates/noir/src/utils.rs @@ -0,0 +1,68 @@ +//! Utility functions for Noir code generation. + +/// Convert a title to snake_case, stripping BDD prefixes. +/// +/// # Examples +/// +/// ```ignore +/// assert_eq!(to_snake_case("When user is logged in"), "user_is_logged_in"); +/// assert_eq!(to_snake_case("It should return true"), "should_return_true"); +/// ``` +pub(crate) fn to_snake_case(title: &str) -> String { + // Strip BDD prefixes (case-insensitive) + let title = title.trim(); + let stripped = title + .strip_prefix("when ") + .or_else(|| title.strip_prefix("When ")) + .or_else(|| title.strip_prefix("given ")) + .or_else(|| title.strip_prefix("Given ")) + .or_else(|| title.strip_prefix("it ")) + .or_else(|| title.strip_prefix("It ")) + .unwrap_or(title); + + // Convert to snake_case + stripped + .chars() + .filter_map(|c| { + if c.is_alphanumeric() { + Some(c.to_ascii_lowercase()) + } else if c.is_whitespace() { + Some('_') + } else { + None + } + }) + .collect::() + .split('_') + .filter(|s| !s.is_empty()) + .collect::>() + .join("_") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_snake_case() { + assert_eq!( + to_snake_case("When user is logged in"), + "user_is_logged_in" + ); + assert_eq!( + to_snake_case("It should return true"), + "should_return_true" + ); + assert_eq!(to_snake_case("given amount is zero"), "amount_is_zero"); + assert_eq!( + to_snake_case("When first arg is bigger than second arg"), + "first_arg_is_bigger_than_second_arg" + ); + } + + #[test] + fn test_to_snake_case_with_special_chars() { + assert_eq!(to_snake_case("It's working!"), "its_working"); + assert_eq!(to_snake_case("value > 100"), "value_100"); + } +} diff --git a/crates/rust/Cargo.toml b/crates/rust/Cargo.toml new file mode 100644 index 0000000..753e29e --- /dev/null +++ b/crates/rust/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bulloak-rust" +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true +readme = "./README.md" +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +description.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +bulloak-syntax.workspace = true + +anyhow.workspace = true +thiserror.workspace = true +syn = { version = "2.0", features = ["full", "parsing", "visit"] } +quote = "1.0" +proc-macro2 = "1.0" +prettyplease = "0.2" + +[dev-dependencies] +pretty_assertions.workspace = true +indoc = "2.0.5" + +[lints] +workspace = true diff --git a/crates/rust/README.md b/crates/rust/README.md new file mode 100644 index 0000000..c25cd24 --- /dev/null +++ b/crates/rust/README.md @@ -0,0 +1,7 @@ +# bulloak-rust + +A backend for `bulloak` that generates Rust test files. + +This crate provides an implementation of turning a `bulloak-syntax` AST into a `_test.rs` file containing scaffolded Rust tests based on the Branching Tree Technique. + +It also includes validation functionality to check that Rust test files correspond to their `.tree` specifications. diff --git a/crates/rust/src/check/mod.rs b/crates/rust/src/check/mod.rs new file mode 100644 index 0000000..b3284fe --- /dev/null +++ b/crates/rust/src/check/mod.rs @@ -0,0 +1,56 @@ +//! Check module for validating Rust test files against specs. + +pub mod rules; +pub mod violation; + +use std::path::Path; + +use anyhow::{Context, Result}; +pub use violation::{Violation, ViolationKind}; + +use crate::config::Config; + +/// Check that a Rust test file matches its tree specification. +/// +/// # Errors +/// +/// Returns an error if checking fails. +pub fn check(tree_path: &Path, cfg: &Config) -> Result> { + // Read tree file + let tree_source = + std::fs::read_to_string(tree_path).with_context(|| { + format!("Failed to read tree file: {}", tree_path.display()) + })?; + + // Parse tree + let ast = bulloak_syntax::parse_one(&tree_source)?; + + // Determine Rust file path (replace .tree with _test.rs) + let file_stem = tree_path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("Invalid file name"))?; + let rust_path = tree_path.with_file_name(format!("{}_test.rs", file_stem)); + + // Check if Rust file exists + if !rust_path.exists() { + return Ok(vec![Violation::new( + ViolationKind::RustFileMissing, + rust_path.display().to_string(), + )]); + } + + // Read Rust file + let rust_source = + std::fs::read_to_string(&rust_path).with_context(|| { + format!("Failed to read Rust file: {}", rust_path.display()) + })?; + + // Run structural match rule + rules::check_structural_match( + &ast, + &rust_source, + &rust_path.display().to_string(), + cfg, + ) +} diff --git a/crates/rust/src/check/rules/mod.rs b/crates/rust/src/check/rules/mod.rs new file mode 100644 index 0000000..aa1c72d --- /dev/null +++ b/crates/rust/src/check/rules/mod.rs @@ -0,0 +1,5 @@ +//! Validation rules for checking Rust test files. + +pub mod structural_match; + +pub use structural_match::check_structural_match; diff --git a/crates/rust/src/check/rules/structural_match.rs b/crates/rust/src/check/rules/structural_match.rs new file mode 100644 index 0000000..2edc558 --- /dev/null +++ b/crates/rust/src/check/rules/structural_match.rs @@ -0,0 +1,258 @@ +//! Structural matching rule that checks if Rust code matches the spec. + +use std::collections::HashSet; + +use anyhow::Result; +use bulloak_syntax::Ast; + +use crate::{ + check::violation::{Violation, ViolationKind}, + config::Config, + rust::ParsedRustFile, + utils::to_snake_case, +}; + +/// Expected test structure extracted from AST. +struct ExpectedTests { + helpers: HashSet, + test_functions: Vec, +} + +struct TestInfo { + name: String, + should_panic: bool, +} + +/// Check that the Rust file structurally matches the spec. +/// +/// # Errors +/// +/// Returns an error if checking fails. +pub fn check_structural_match( + ast: &Ast, + rust_source: &str, + file_path: &str, + cfg: &Config, +) -> Result> { + let mut violations = Vec::new(); + + // Parse the Rust file + let parsed = match ParsedRustFile::parse(rust_source) { + Ok(p) => p, + Err(e) => { + violations.push(Violation::new( + ViolationKind::RustFileInvalid(e.to_string()), + file_path.to_string(), + )); + return Ok(violations); + } + }; + + // Check test module exists + if parsed.find_test_module().is_none() { + violations.push(Violation::new( + ViolationKind::TestModuleMissing, + file_path.to_string(), + )); + return Ok(violations); + } + + // Extract expected structure from AST + let expected = extract_expected_structure(ast, cfg)?; + + // Check helpers (if not skipped) + if !cfg.skip_helpers { + let found_helpers: HashSet = parsed + .find_helper_functions() + .iter() + .map(|f| f.sig.ident.to_string()) + .collect(); + + for expected_helper in &expected.helpers { + if !found_helpers.contains(expected_helper) { + violations.push(Violation::new( + ViolationKind::HelperFunctionMissing( + expected_helper.clone(), + ), + file_path.to_string(), + )); + } + } + } + + // Check test functions + let found_tests = parsed.find_test_functions(); + let found_test_names: HashSet = + found_tests.iter().map(|f| f.sig.ident.to_string()).collect(); + + for expected_test in &expected.test_functions { + if !found_test_names.contains(&expected_test.name) { + violations.push(Violation::new( + ViolationKind::TestFunctionMissing(expected_test.name.clone()), + file_path.to_string(), + )); + } else { + // Check attributes + let found_fn = found_tests + .iter() + .find(|f| f.sig.ident.to_string() == expected_test.name) + .unwrap(); + + let has_should_panic = ParsedRustFile::has_should_panic(found_fn); + + if expected_test.should_panic && !has_should_panic { + violations.push(Violation::new( + ViolationKind::TestAttributeIncorrect { + function: expected_test.name.clone(), + expected: "#[should_panic]".to_string(), + found: "none".to_string(), + }, + file_path.to_string(), + )); + } else if !expected_test.should_panic && has_should_panic { + violations.push(Violation::new( + ViolationKind::TestAttributeIncorrect { + function: expected_test.name.clone(), + expected: "none".to_string(), + found: "#[should_panic]".to_string(), + }, + file_path.to_string(), + )); + } + } + } + + Ok(violations) +} + +/// Extract expected test structure from AST. +fn extract_expected_structure( + ast: &Ast, + cfg: &Config, +) -> Result { + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + let mut helpers = HashSet::new(); + let mut test_functions = Vec::new(); + + // Collect helpers + if !cfg.skip_helpers { + collect_helpers_recursive(&ast_root.children, &mut helpers); + } + + // Collect test functions + collect_tests_recursive(&ast_root.children, &[], &mut test_functions); + + Ok(ExpectedTests { helpers, test_functions }) +} + +/// Recursively collect helper function names. +fn collect_helpers_recursive(children: &[Ast], helpers: &mut HashSet) { + for child in children { + if let Ast::Condition(condition) = child { + let name = to_snake_case(&condition.title); + helpers.insert(name); + collect_helpers_recursive(&condition.children, helpers); + } + } +} + +/// Recursively collect test function info. +fn collect_tests_recursive( + children: &[Ast], + parent_helpers: &[String], + tests: &mut Vec, +) { + for child in children { + match child { + Ast::Condition(condition) => { + let helper_name = to_snake_case(&condition.title); + let mut new_helpers = parent_helpers.to_vec(); + new_helpers.push(helper_name); + + // Collect all direct action children of this condition + let actions: Vec<&bulloak_syntax::Action> = condition + .children + .iter() + .filter_map(|c| { + if let Ast::Action(a) = c { + Some(a) + } else { + None + } + }) + .collect(); + + if !actions.is_empty() { + // Generate a single test for all actions under this + // condition + let test_name = if new_helpers.is_empty() { + let action_part = to_snake_case(&actions[0].title); + format!("test_{}", action_part) + } else { + let last_helper = &new_helpers[new_helpers.len() - 1]; + format!("test_when_{}", last_helper) + }; + + // Check if any action should panic + let should_panic = actions.iter().any(|action| { + action.title.to_lowercase().split_whitespace().any( + |w| { + matches!( + w, + "panic" + | "panics" + | "revert" + | "reverts" + | "error" + | "errors" + | "fail" + | "fails" + ) + }, + ) + }); + + tests.push(TestInfo { name: test_name, should_panic }); + } + + // Process nested conditions + collect_tests_recursive( + &condition.children, + &new_helpers, + tests, + ); + } + Ast::Action(action) => { + // Root-level action (no condition) + if parent_helpers.is_empty() { + let action_part = to_snake_case(&action.title); + let test_name = format!("test_{}", action_part); + + let should_panic = + action.title.to_lowercase().split_whitespace().any( + |w| { + matches!( + w, + "panic" + | "panics" + | "revert" + | "reverts" + | "error" + | "errors" + | "fail" + | "fails" + ) + }, + ); + + tests.push(TestInfo { name: test_name, should_panic }); + } + } + _ => {} + } + } +} diff --git a/crates/rust/src/check/violation.rs b/crates/rust/src/check/violation.rs new file mode 100644 index 0000000..bac1e44 --- /dev/null +++ b/crates/rust/src/check/violation.rs @@ -0,0 +1,92 @@ +//! Violation types for check command. + +use std::fmt; + +/// A violation found during checking. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + /// The kind of violation. + pub kind: ViolationKind, + /// The file path where the violation occurred. + pub file_path: String, + /// Optional line number. + pub line: Option, +} + +impl Violation { + /// Create a new violation. + #[must_use] + pub fn new(kind: ViolationKind, file_path: String) -> Self { + Self { kind, file_path, line: None } + } + + /// Create a new violation with a line number. + #[must_use] + pub fn with_line( + kind: ViolationKind, + file_path: String, + line: usize, + ) -> Self { + Self { kind, file_path, line: Some(line) } + } +} + +/// The kind of violation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ViolationKind { + /// The Rust file is missing. + RustFileMissing, + /// The Rust file could not be parsed. + RustFileInvalid(String), + /// The test module is missing. + TestModuleMissing, + /// A test function is missing. + TestFunctionMissing(String), + /// A helper function is missing. + HelperFunctionMissing(String), + /// A test function has incorrect attributes. + TestAttributeIncorrect { + /// The function name. + function: String, + /// The expected attribute. + expected: String, + /// The found attribute. + found: String, + }, + /// Test function order does not match spec. + TestOrderIncorrect, +} + +impl fmt::Display for ViolationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RustFileMissing => write!(f, "Rust test file is missing"), + Self::RustFileInvalid(err) => write!(f, "Rust file could not be parsed: {}", err), + Self::TestModuleMissing => write!(f, "Test module (#[cfg(test)] mod tests) is missing"), + Self::TestFunctionMissing(name) => write!(f, "Test function '{}' is missing", name), + Self::HelperFunctionMissing(name) => write!(f, "Helper function '{}' is missing", name), + Self::TestAttributeIncorrect { + function, + expected, + found, + } => write!( + f, + "Test function '{}' has incorrect attributes: expected {}, found {}", + function, expected, found + ), + Self::TestOrderIncorrect => { + write!(f, "Test function order does not match spec order") + } + } + } +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(line) = self.line { + write!(f, "{}:{}: {}", self.file_path, line, self.kind) + } else { + write!(f, "{}: {}", self.file_path, self.kind) + } + } +} diff --git a/crates/rust/src/config.rs b/crates/rust/src/config.rs new file mode 100644 index 0000000..43c4145 --- /dev/null +++ b/crates/rust/src/config.rs @@ -0,0 +1,20 @@ +//! Configuration for the Rust backend. + +/// Configuration for the Rust backend. +#[derive(Debug, Clone, Default)] +pub struct Config { + /// List of files to process. + pub files: Vec, + /// Whether to skip emitting helper functions. + pub skip_helpers: bool, + /// Whether to format/capitalize branch descriptions. + pub format_descriptions: bool, +} + +impl Config { + /// Create a new configuration with default values. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} diff --git a/crates/rust/src/constants.rs b/crates/rust/src/constants.rs new file mode 100644 index 0000000..ff1c8e0 --- /dev/null +++ b/crates/rust/src/constants.rs @@ -0,0 +1,9 @@ +//! Constants used in the Rust backend. + +/// Keywords that indicate a test should panic. +pub(crate) const PANIC_KEYWORDS: &[&str] = &[ + "panic", "panics", "revert", "reverts", "error", "errors", "fail", "fails", +]; + +/// Name of the test context struct. +pub(crate) const CONTEXT_STRUCT_NAME: &str = "TestContext"; diff --git a/crates/rust/src/lib.rs b/crates/rust/src/lib.rs new file mode 100644 index 0000000..77a9d5a --- /dev/null +++ b/crates/rust/src/lib.rs @@ -0,0 +1,19 @@ +//! A `bulloak` backend for Rust tests. +//! +//! `bulloak-rust` provides an implementation of turning a `bulloak-syntax` +//! AST into a `_test.rs` file containing scaffolded Rust tests based on the +//! Branching Tree Technique. +//! +//! It also includes validation functionality to check that Rust test files +//! correspond to their `.tree` specifications. + +pub mod check; +pub mod config; +pub mod constants; +pub mod rust; +pub mod scaffold; +mod utils; + +pub use check::{Violation, ViolationKind}; +pub use config::Config; +pub use scaffold::scaffold; diff --git a/crates/rust/src/rust/mod.rs b/crates/rust/src/rust/mod.rs new file mode 100644 index 0000000..73b6120 --- /dev/null +++ b/crates/rust/src/rust/mod.rs @@ -0,0 +1,5 @@ +//! Rust code parsing and analysis. + +pub mod parser; + +pub use parser::ParsedRustFile; diff --git a/crates/rust/src/rust/parser.rs b/crates/rust/src/rust/parser.rs new file mode 100644 index 0000000..bcbe2fa --- /dev/null +++ b/crates/rust/src/rust/parser.rs @@ -0,0 +1,177 @@ +//! Rust code parser using syn. + +use anyhow::{Context, Result}; +use syn::{File, Item, ItemFn, ItemMod, ItemStruct}; + +/// Parsed Rust test file. +pub struct ParsedRustFile { + /// The parsed syntax tree. + pub syntax: File, +} + +impl ParsedRustFile { + /// Parse a Rust file from source code. + /// + /// # Errors + /// + /// Returns an error if parsing fails. + pub fn parse(source: &str) -> Result { + let syntax = + syn::parse_file(source).context("Failed to parse Rust file")?; + Ok(Self { syntax }) + } + + /// Find the test module in the file. + #[must_use] + pub fn find_test_module(&self) -> Option<&ItemMod> { + for item in &self.syntax.items { + if let Item::Mod(module) = item { + // Check if it has #[cfg(test)] attribute + if Self::has_cfg_test(&module.attrs) { + return Some(module); + } + } + } + None + } + + /// Find all test functions in the file. + #[must_use] + pub fn find_test_functions(&self) -> Vec<&ItemFn> { + let mut functions = Vec::new(); + + // Check in test module + if let Some(test_module) = self.find_test_module() { + if let Some((_, items)) = &test_module.content { + for item in items { + if let Item::Fn(func) = item { + if Self::has_test_attr(&func.attrs) { + functions.push(func); + } + } + } + } + } + + functions + } + + /// Find all helper functions (non-test functions at module level). + #[must_use] + pub fn find_helper_functions(&self) -> Vec<&ItemFn> { + let mut functions = Vec::new(); + + for item in &self.syntax.items { + if let Item::Fn(func) = item { + // Not a test function + if !Self::has_test_attr(&func.attrs) { + functions.push(func); + } + } + } + + functions + } + + /// Find the context struct. + #[must_use] + pub fn find_context_struct(&self) -> Option<&ItemStruct> { + for item in &self.syntax.items { + if let Item::Struct(s) = item { + // Look for a struct with "Context" in the name + if s.ident.to_string().contains("Context") { + return Some(s); + } + } + } + None + } + + /// Check if a function has #[test] attribute. + fn has_test_attr(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| attr.path().is_ident("test")) + } + + /// Check if an item has #[cfg(test)] attribute. + fn has_cfg_test(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + if attr.path().is_ident("cfg") { + if let Ok(meta_list) = attr.meta.require_list() { + return meta_list.tokens.to_string().contains("test"); + } + } + false + }) + } + + /// Check if a function has #[should_panic] attribute. + #[must_use] + pub fn has_should_panic(func: &ItemFn) -> bool { + func.attrs.iter().any(|attr| attr.path().is_ident("should_panic")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_file() { + let source = r#" + #[cfg(test)] + mod tests { + #[test] + fn test_something() {} + } + "#; + + let parsed = ParsedRustFile::parse(source).unwrap(); + assert!(parsed.find_test_module().is_some()); + + let test_fns = parsed.find_test_functions(); + assert_eq!(test_fns.len(), 1); + assert_eq!(test_fns[0].sig.ident.to_string(), "test_something"); + } + + #[test] + fn test_find_helper_functions() { + let source = r#" + fn helper_function() {} + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_something() {} + } + "#; + + let parsed = ParsedRustFile::parse(source).unwrap(); + let helpers = parsed.find_helper_functions(); + assert_eq!(helpers.len(), 1); + assert_eq!(helpers[0].sig.ident.to_string(), "helper_function"); + } + + #[test] + fn test_has_should_panic() { + let source = r#" + #[cfg(test)] + mod tests { + #[test] + #[should_panic] + fn test_panics() {} + + #[test] + fn test_normal() {} + } + "#; + + let parsed = ParsedRustFile::parse(source).unwrap(); + let test_fns = parsed.find_test_functions(); + + assert_eq!(test_fns.len(), 2); + assert!(ParsedRustFile::has_should_panic(test_fns[0])); + assert!(!ParsedRustFile::has_should_panic(test_fns[1])); + } +} diff --git a/crates/rust/src/scaffold/comment.rs b/crates/rust/src/scaffold/comment.rs new file mode 100644 index 0000000..ffd87a1 --- /dev/null +++ b/crates/rust/src/scaffold/comment.rs @@ -0,0 +1,38 @@ +//! Comment formatting utilities. + +/// Format a comment by capitalizing the first letter and ensuring it ends with +/// a period. +pub(crate) fn format_comment(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let mut chars = trimmed.chars(); + let first = chars.next().unwrap(); + let rest: String = chars.collect(); + + let capitalized = format!("{}{}", first.to_uppercase(), rest); + + if capitalized.ends_with('.') + || capitalized.ends_with('!') + || capitalized.ends_with('?') + { + capitalized + } else { + format!("{}.", capitalized) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_comment() { + assert_eq!(format_comment("should return sum"), "Should return sum."); + assert_eq!(format_comment("Should return sum."), "Should return sum."); + assert_eq!(format_comment("should panic!"), "Should panic!"); + assert_eq!(format_comment(""), ""); + } +} diff --git a/crates/rust/src/scaffold/generator.rs b/crates/rust/src/scaffold/generator.rs new file mode 100644 index 0000000..bec2ef2 --- /dev/null +++ b/crates/rust/src/scaffold/generator.rs @@ -0,0 +1,583 @@ +//! Direct code generation using quote! macro. + +use std::collections::HashSet; + +use bulloak_syntax::{Action, Ast}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use crate::{ + config::Config, + constants::{CONTEXT_STRUCT_NAME, PANIC_KEYWORDS}, + scaffold::comment, + utils::to_snake_case, +}; + +/// Generate Rust test code from an AST using quote! macro. +pub struct Generator { + /// Whether to format descriptions. + format_descriptions: bool, + /// Whether to skip helper functions. + skip_helpers: bool, +} + +impl Generator { + /// Create a new generator. + #[must_use] + pub fn new(cfg: &Config) -> Self { + Self { + format_descriptions: cfg.format_descriptions, + skip_helpers: cfg.skip_helpers, + } + } + + /// Generate complete Rust test file from AST. + pub fn generate(&self, ast: &Ast) -> anyhow::Result { + let ast_root = match ast { + Ast::Root(r) => r, + _ => anyhow::bail!("Expected Root node"), + }; + + // Generate all parts + let context_struct = self.generate_context_struct(); + let helpers = if self.skip_helpers { + TokenStream::new() + } else { + self.generate_helpers(&ast_root.children) + }; + let test_module = self.generate_test_module(&ast_root.children)?; + + // Combine everything + let tokens = quote! { + #context_struct + + #helpers + + #test_module + }; + + // Format the output using prettyplease + let syntax_tree = syn::parse2(tokens)?; + let mut formatted = prettyplease::unparse(&syntax_tree); + + // Post-process: add header comment + formatted = format!("// Generated by bulloak\n\n{}", formatted); + + // Post-process: fix doc comment formatting (add space after ///) + formatted = formatted.replace("///Helper:", "/// Helper:"); + + // Post-process: add action comments to test function bodies + formatted = self.add_test_body_comments(formatted, &ast_root.children); + + Ok(formatted) + } + + /// Add comments to test function bodies based on action titles. + fn add_test_body_comments( + &self, + formatted: String, + children: &[Ast], + ) -> String { + let mut test_comments = Vec::new(); + self.collect_test_comments(children, &[], &mut test_comments); + + let mut result = formatted; + for (test_name, comments) in test_comments { + self.insert_comments_for_test(&mut result, &test_name, &comments); + } + + result + } + + /// Insert comments into a specific test function body. + fn insert_comments_for_test( + &self, + result: &mut String, + test_name: &str, + comments: &[String], + ) { + let pattern = format!("fn {}() {{", test_name); + let Some(pos) = result.find(&pattern) else { + return; + }; + + let closing_brace_pos = pos + pattern.len(); + let Some(next_brace) = result[closing_brace_pos..].find('}') else { + return; + }; + + let body = &result[closing_brace_pos..closing_brace_pos + next_brace]; + let all_comments = comments.join("\n "); + + if body.trim().is_empty() { + // Empty body - just add comments with proper indentation + let comment_str = format!("\n {}\n ", all_comments); + let insertion_pos = closing_brace_pos + next_brace; + result.insert_str(insertion_pos, &comment_str); + } else if !body.contains("//") && body.contains("let _ctx") { + // Has helper call - add comments after it + let trimmed_body = body.trim_end(); + let chars_to_remove = body.len() - trimmed_body.len(); + result.replace_range( + closing_brace_pos + next_brace - chars_to_remove + ..closing_brace_pos + next_brace, + &format!("\n {}\n ", all_comments), + ); + } + } + + /// Collect test function names and their comments (grouped by test + /// function). + fn collect_test_comments( + &self, + children: &[Ast], + parent_helpers: &[String], + comments: &mut Vec<(String, Vec)>, + ) { + for child in children { + match child { + Ast::Condition(condition) => { + let helper_name = to_snake_case(&condition.title); + let mut new_helpers = parent_helpers.to_vec(); + new_helpers.push(helper_name); + + // Collect all action comments under this condition + let action_comments: Vec = + Self::collect_actions(&condition.children) + .iter() + .map(|action| { + format!("// {}", self.format_comment(&action.title)) + }) + .collect(); + + if !action_comments.is_empty() { + let test_name = + Self::generate_test_name(&condition.title, &new_helpers); + comments.push((test_name, action_comments)); + } + + // Process nested conditions + self.collect_test_comments( + &condition.children, + &new_helpers, + comments, + ); + } + Ast::Action(action) => { + // Root-level action (no condition) + if parent_helpers.is_empty() { + let test_name = + Self::generate_test_name(&action.title, parent_helpers); + let comment = format!( + "// {}", + self.format_comment(&action.title) + ); + comments.push((test_name, vec![comment])); + } + } + _ => {} + } + } + } + + /// Generate the TestContext struct. + fn generate_context_struct(&self) -> TokenStream { + let context_name = format_ident!("{}", CONTEXT_STRUCT_NAME); + quote! { + /// Context for test conditions + #[derive(Default)] + struct #context_name { + // Add fields as needed + } + } + } + + /// Generate all helper functions from conditions. + fn generate_helpers(&self, children: &[Ast]) -> TokenStream { + let mut helpers = Vec::new(); + let mut seen = HashSet::new(); + + self.collect_helpers_recursive(children, &mut helpers, &mut seen); + + let helper_fns: Vec<_> = helpers + .iter() + .map(|(name, doc)| self.generate_helper(name, doc)) + .collect(); + + quote! { + #(#helper_fns)* + } + } + + /// Recursively collect unique helper functions. + fn collect_helpers_recursive( + &self, + children: &[Ast], + helpers: &mut Vec<(String, String)>, + seen: &mut HashSet, + ) { + for child in children { + if let Ast::Condition(condition) = child { + let name = to_snake_case(&condition.title); + if seen.insert(name.clone()) { + // insert returns true if the value was newly inserted + helpers.push((name, condition.title.clone())); + } + self.collect_helpers_recursive( + &condition.children, + helpers, + seen, + ); + } + } + } + + /// Generate a single helper function. + fn generate_helper(&self, name: &str, doc: &str) -> TokenStream { + let fn_name = format_ident!("{}", name); + let context_ty = format_ident!("{}", CONTEXT_STRUCT_NAME); + let doc_comment = format!("Helper: {}", doc); + + quote! { + #[doc = #doc_comment] + fn #fn_name(mut ctx: #context_ty) -> #context_ty { + // TODO: Set up condition + ctx + } + } + } + + /// Generate the test module. + fn generate_test_module( + &self, + children: &[Ast], + ) -> anyhow::Result { + let test_fns = self.process_children(children, &[])?; + + Ok(quote! { + #[cfg(test)] + mod tests { + use super::*; + + #(#test_fns)* + } + }) + } + + /// Process AST children to generate test functions. + fn process_children( + &self, + children: &[Ast], + parent_helpers: &[String], + ) -> anyhow::Result> { + let mut test_fns = Vec::new(); + + for child in children { + match child { + Ast::Condition(condition) => { + let helper_name = to_snake_case(&condition.title); + let mut new_helpers = parent_helpers.to_vec(); + new_helpers.push(helper_name); + + // Collect all direct action children of this condition + let actions = Self::collect_actions(&condition.children); + + if !actions.is_empty() { + // Generate a single test function for all actions under + // this condition + test_fns.push( + self.generate_test_function(&actions, &new_helpers)?, + ); + } + + // Process only nested conditions (not actions, as they were + // already processed above) + let nested_conditions: Vec<&Ast> = condition + .children + .iter() + .filter(|c| !matches!(c, Ast::Action(_))) + .collect(); + + for nested_child in nested_conditions { + let Ast::Condition(nested_cond) = nested_child else { + continue; + }; + + let nested_helper_name = to_snake_case(&nested_cond.title); + let mut nested_helpers = new_helpers.clone(); + nested_helpers.push(nested_helper_name); + test_fns.extend(self.process_children( + &nested_cond.children, + &nested_helpers, + )?); + } + } + Ast::Action(action) => { + // Action at root level (no condition) + test_fns.push( + self.generate_test_function(&[action], parent_helpers)?, + ); + } + _ => {} + } + } + + Ok(test_fns) + } + + /// Generate a test function from one or more actions. + fn generate_test_function( + &self, + actions: &[&Action], + helpers: &[String], + ) -> anyhow::Result { + if actions.is_empty() { + anyhow::bail!("Cannot generate test function with no actions"); + } + + // Use the last helper (condition) for the test name if helpers exist + let test_name = Self::generate_test_name(&actions[0].title, helpers); + let test_fn_name = format_ident!("{}", test_name); + + // Check if any action should panic + let should_panic = actions.iter().any(|a| self.should_panic(&a.title)); + + // Collect comments from all actions + let mut comment_lines = Vec::new(); + for action in actions { + comment_lines + .push(format!("// {}", self.format_comment(&action.title))); + for desc_ast in &action.children { + if let Ast::ActionDescription(desc) = desc_ast { + comment_lines.push(format!( + "// {}", + self.format_comment(&desc.text) + )); + } + } + } + let body_comments = comment_lines.join("\n "); + + // Generate helper calls + let helper_calls = Self::build_helper_chain(helpers); + + // Build complete function body as a string + let body_str = if helper_calls.is_empty() { + body_comments + } else { + format!("{}\n {}", helper_calls, body_comments) + }; + + // Parse the body as tokens + let body_tokens: TokenStream = body_str.parse().unwrap_or_else(|_| { + // Fallback to just comments if parsing fails + quote! {} + }); + + // Build test function + let test_fn = if should_panic { + quote! { + #[test] + #[should_panic] + fn #test_fn_name() { + #body_tokens + } + } + } else { + quote! { + #[test] + fn #test_fn_name() { + #body_tokens + } + } + }; + + Ok(test_fn) + } + + /// Check if action should panic. + fn should_panic(&self, title: &str) -> bool { + let title_lower = title.to_lowercase(); + PANIC_KEYWORDS.iter().any(|keyword| title_lower.contains(keyword)) + } + + /// Format a comment string. + fn format_comment(&self, text: &str) -> String { + if self.format_descriptions { + comment::format_comment(text) + } else { + text.to_string() + } + } + + /// Generate test function name from action title and helpers. + fn generate_test_name(action_title: &str, helpers: &[String]) -> String { + if helpers.is_empty() { + format!("test_{}", to_snake_case(action_title)) + } else { + format!("test_when_{}", helpers.last().unwrap()) + } + } + + /// Collect all direct action children from AST nodes. + fn collect_actions(children: &[Ast]) -> Vec<&Action> { + children + .iter() + .filter_map(|c| { + if let Ast::Action(a) = c { + Some(a) + } else { + None + } + }) + .collect() + } + + /// Build helper function call chain. + fn build_helper_chain(helpers: &[String]) -> String { + if helpers.is_empty() { + return String::new(); + } + + if helpers.len() == 1 { + format!( + "let _ctx = {}({}::default());", + &helpers[0], CONTEXT_STRUCT_NAME + ) + } else { + let mut chain = format!("{}::default()", CONTEXT_STRUCT_NAME); + for helper in helpers { + chain = format!("{}({})", helper, chain); + } + format!("let _ctx = {};", chain) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bulloak_syntax::{Action, Condition}; + + #[test] + fn test_should_panic() { + let cfg = Config::default(); + let gen = Generator::new(&cfg); + + assert!(gen.should_panic("It should panic")); + assert!(gen.should_panic("It should revert")); + assert!(!gen.should_panic("It should return a value")); + } + + #[test] + fn test_generate_test_name_without_helpers() { + let result = Generator::generate_test_name("It should work", &[]); + assert_eq!(result, "test_should_work"); + } + + #[test] + fn test_generate_test_name_with_one_helper() { + let helpers = vec!["user_is_logged_in".to_string()]; + let result = Generator::generate_test_name("It should succeed", &helpers); + assert_eq!(result, "test_when_user_is_logged_in"); + } + + #[test] + fn test_generate_test_name_with_multiple_helpers() { + let helpers = vec![ + "user_is_logged_in".to_string(), + "balance_is_zero".to_string(), + ]; + let result = Generator::generate_test_name("It should fail", &helpers); + assert_eq!(result, "test_when_balance_is_zero"); + } + + #[test] + fn test_collect_actions_empty() { + let children: Vec = vec![]; + let actions = Generator::collect_actions(&children); + assert!(actions.is_empty()); + } + + #[test] + fn test_collect_actions_mixed() { + let children = vec![ + Ast::Action(Action { + title: "action1".to_string(), + children: vec![], + span: Default::default(), + }), + Ast::Condition(Condition { + title: "condition1".to_string(), + children: vec![], + span: Default::default(), + }), + Ast::Action(Action { + title: "action2".to_string(), + children: vec![], + span: Default::default(), + }), + ]; + + let actions = Generator::collect_actions(&children); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].title, "action1"); + assert_eq!(actions[1].title, "action2"); + } + + #[test] + fn test_collect_actions_only_conditions() { + let children = vec![ + Ast::Condition(Condition { + title: "condition1".to_string(), + children: vec![], + span: Default::default(), + }), + Ast::Condition(Condition { + title: "condition2".to_string(), + children: vec![], + span: Default::default(), + }), + ]; + + let actions = Generator::collect_actions(&children); + assert!(actions.is_empty()); + } + + #[test] + fn test_build_helper_chain_empty() { + let result = Generator::build_helper_chain(&[]); + assert_eq!(result, ""); + } + + #[test] + fn test_build_helper_chain_single() { + let helpers = vec!["helper1".to_string()]; + let result = Generator::build_helper_chain(&helpers); + assert_eq!(result, "let _ctx = helper1(TestContext::default());"); + } + + #[test] + fn test_build_helper_chain_multiple() { + let helpers = vec!["helper1".to_string(), "helper2".to_string()]; + let result = Generator::build_helper_chain(&helpers); + assert_eq!( + result, + "let _ctx = helper2(helper1(TestContext::default()));" + ); + } + + #[test] + fn test_build_helper_chain_three() { + let helpers = vec![ + "helper1".to_string(), + "helper2".to_string(), + "helper3".to_string(), + ]; + let result = Generator::build_helper_chain(&helpers); + assert_eq!( + result, + "let _ctx = helper3(helper2(helper1(TestContext::default())));" + ); + } +} diff --git a/crates/rust/src/scaffold/mod.rs b/crates/rust/src/scaffold/mod.rs new file mode 100644 index 0000000..26cf4a0 --- /dev/null +++ b/crates/rust/src/scaffold/mod.rs @@ -0,0 +1,20 @@ +//! Scaffold module for generating Rust test code. + +pub mod comment; +pub mod generator; + +use anyhow::Result; +use bulloak_syntax::Ast; +pub use generator::Generator; + +use crate::config::Config; + +/// Scaffold Rust test code from an AST. +/// +/// # Errors +/// +/// Returns an error if scaffolding fails. +pub fn scaffold(ast: &Ast, cfg: &Config) -> Result { + let generator = Generator::new(cfg); + generator.generate(ast) +} diff --git a/crates/rust/src/utils.rs b/crates/rust/src/utils.rs new file mode 100644 index 0000000..06abf4e --- /dev/null +++ b/crates/rust/src/utils.rs @@ -0,0 +1,59 @@ +//! Utility functions for the Rust backend. + +/// Convert string to snake_case. +/// +/// Strips common BDD prefixes (when, given, it) and converts to snake_case. +pub(crate) fn to_snake_case(s: &str) -> String { + let s = s.trim(); + let s = s + .strip_prefix("when ") + .or_else(|| s.strip_prefix("When ")) + .or_else(|| s.strip_prefix("WHEN ")) + .or_else(|| s.strip_prefix("given ")) + .or_else(|| s.strip_prefix("Given ")) + .or_else(|| s.strip_prefix("GIVEN ")) + .or_else(|| s.strip_prefix("it ")) + .or_else(|| s.strip_prefix("It ")) + .or_else(|| s.strip_prefix("IT ")) + .unwrap_or(s); + + let mut result = String::new(); + let mut prev_is_alphanumeric = false; + + for c in s.chars() { + if c.is_alphanumeric() { + if c.is_uppercase() && prev_is_alphanumeric && !result.is_empty() { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + prev_is_alphanumeric = true; + } else if c.is_whitespace() || c == '-' { + if prev_is_alphanumeric { + result.push('_'); + prev_is_alphanumeric = false; + } + } else { + prev_is_alphanumeric = false; + } + } + + result.trim_end_matches('_').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_snake_case() { + assert_eq!( + to_snake_case("when first arg is smaller"), + "first_arg_is_smaller" + ); + assert_eq!( + to_snake_case("It should return the sum"), + "should_return_the_sum" + ); + assert_eq!(to_snake_case("given a valid input"), "a_valid_input"); + } +}