From 98047f0c0074a26d7525e70d3816aa2d6ca1010b Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 6 Aug 2025 06:40:39 +0100 Subject: [PATCH 1/2] Rename emit command to manifest Switch the CLI subcommand to `manifest` and cover it with `assert_cmd` integration tests. Also test the `--emit` build option via `assert_cmd` and update documentation and fixtures. --- Cargo.lock | 72 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 3 ++ docs/netsuke-design.md | 6 ++-- src/cli.rs | 4 +-- src/runner.rs | 2 +- tests/assert_cmd_tests.rs | 49 ++++++++++++++++++++++++++ tests/cli_tests.rs | 4 +-- tests/features/cli.feature | 8 ++--- tests/runner_tests.rs | 10 +++--- tests/steps/cli_steps.rs | 14 ++++---- 11 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 tests/assert_cmd_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 90b18f6f..bfdbdacf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,22 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[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 = "async-trait" version = "0.1.88" @@ -142,6 +158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -332,6 +349,12 @@ dependencies = [ "syn", ] +[[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" @@ -342,6 +365,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "drain_filter_polyfill" version = "0.1.3" @@ -771,6 +800,7 @@ name = "netsuke" version = "0.1.0" dependencies = [ "anyhow", + "assert_cmd", "clap", "cucumber", "insta", @@ -930,6 +960,33 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[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 = "proc-macro2" version = "1.0.95" @@ -1324,6 +1381,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "textwrap" version = "0.16.2" @@ -1509,6 +1572,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 713bf2bf..61aafb78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ cucumber = "0.20.0" tokio = { version = "1", features = ["macros", "rt-multi-thread"], default-features = false } insta = { version = "1", features = ["yaml"] } serial_test = "3" +assert_cmd = "2.0.17" [[test]] name = "cucumber" diff --git a/README.md b/README.md index 4cf9b7a4..939472d1 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,14 @@ command: "echo {{ dangerous_value | raw }}" # Unsafe (your problem now) netsuke [build] [target1 target2 ...] netsuke clean netsuke graph + netsuke manifest FILE ``` - `netsuke` alone builds the `defaults:` targets from your manifest - `netsuke graph` emits a Graphviz `.dot` of the build DAG - `netsuke clean` runs `ninja -t clean` +- `netsuke manifest FILE` writes the Ninja manifest to `FILE` without invoking + Ninja You can also pass: diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 083b22ca..020277d2 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1387,8 +1387,8 @@ and retain it. /// Display the build dependency graph in DOT format for visualisation. Graph {}, - /// Emit the Ninja manifest to `FILE` without invoking Ninja. Emit { /// - Output path for the generated Ninja file. + /// Write the Ninja manifest to `FILE` without invoking Ninja. Manifest { + /// Output path for the generated Ninja file. #[arg(value_name = "FILE")] file: PathBuf, }, } ``` @@ -1422,7 +1422,7 @@ The behaviour of each subcommand is clearly defined: Dagre.js viewer. Visualizing the graph is invaluable for understanding and debugging complex projects. -- `Netsuke emit FILE`: This command performs the pipeline up to Ninja +- `Netsuke manifest FILE`: This command performs the pipeline up to Ninja synthesis and writes the resulting Ninja file to `FILE` without invoking Ninja. diff --git a/src/cli.rs b/src/cli.rs index c9408b31..aac87248 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -99,8 +99,8 @@ pub enum Commands { /// Display the build dependency graph in DOT format for visualization. Graph, - /// Emit the Ninja manifest to the specified file without running Ninja. - Emit { + /// Write the Ninja manifest to the specified file without invoking Ninja. + Manifest { /// Output path for the generated Ninja file. #[arg(value_name = "FILE")] file: PathBuf, diff --git a/src/runner.rs b/src/runner.rs index bc2ab12f..5ef6786d 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -91,7 +91,7 @@ pub fn run(cli: &Cli) -> Result<()> { } Ok(()) } - Commands::Emit { file } => { + Commands::Manifest { file } => { let ninja = generate_ninja(cli)?; write_and_log(&file, &ninja)?; Ok(()) diff --git a/tests/assert_cmd_tests.rs b/tests/assert_cmd_tests.rs new file mode 100644 index 00000000..b55ac924 --- /dev/null +++ b/tests/assert_cmd_tests.rs @@ -0,0 +1,49 @@ +//! Integration tests for CLI execution using `assert_cmd`. +//! +//! These tests exercise end-to-end command handling by invoking the compiled +//! binary and verifying file outputs for the `manifest` subcommand and the +//! `--emit` build option. + +use assert_cmd::Command; +use std::fs; +use tempfile::tempdir; + +mod support; + +#[test] +fn manifest_subcommand_writes_file() { + let temp = tempdir().expect("temp dir"); + fs::copy("tests/data/minimal.yml", temp.path().join("Netsukefile")).expect("copy manifest"); + let output = temp.path().join("standalone.ninja"); + let mut cmd = Command::cargo_bin("netsuke").expect("binary"); + cmd.current_dir(temp.path()) + .env("PATH", "") + .arg("manifest") + .arg(&output) + .assert() + .success(); + assert!(output.exists()); +} + +#[test] +fn build_with_emit_writes_file() { + let (ninja_dir, _ninja_path) = support::fake_ninja(0); + let temp = tempdir().expect("temp dir"); + fs::copy("tests/data/minimal.yml", temp.path().join("Netsukefile")).expect("copy manifest"); + let output = temp.path().join("emitted.ninja"); + let original = std::env::var_os("PATH").unwrap_or_default(); + let path = std::env::join_paths( + std::iter::once(ninja_dir.path().to_path_buf()) + .chain(std::env::split_paths(&original)), + ) + .expect("join path"); + let mut cmd = Command::cargo_bin("netsuke").expect("binary"); + cmd.current_dir(temp.path()) + .env("PATH", path) + .arg("build") + .arg("--emit") + .arg(&output) + .assert() + .success(); + assert!(output.exists()); +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 85fd4433..3db59362 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -28,12 +28,12 @@ use std::path::PathBuf; Commands::Build { emit: Some(PathBuf::from("out.ninja")), targets: vec!["a".into()] }, )] #[case( - vec!["netsuke", "emit", "out.ninja"], + vec!["netsuke", "manifest", "out.ninja"], PathBuf::from("Netsukefile"), None, None, false, - Commands::Emit { file: PathBuf::from("out.ninja") }, + Commands::Manifest { file: PathBuf::from("out.ninja") }, )] fn parse_cli( #[case] argv: Vec<&str>, diff --git a/tests/features/cli.feature b/tests/features/cli.feature index e9fca342..2a3a8dab 100644 --- a/tests/features/cli.feature +++ b/tests/features/cli.feature @@ -30,11 +30,11 @@ Feature: CLI parsing And the emit path is "out.ninja" And the first target is "target" - Scenario: Emit subcommand writes Ninja file - When the CLI is parsed with "emit out.ninja" + Scenario: Manifest subcommand writes Ninja file + When the CLI is parsed with "manifest out.ninja" Then parsing succeeds - And the command is emit - And the emit command path is "out.ninja" + And the command is manifest + And the manifest command path is "out.ninja" Scenario: Unknown command fails When the CLI is parsed with invalid arguments "unknown" diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index 0a15d9f3..623776b9 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -134,7 +134,7 @@ fn run_build_with_emit_keeps_file() { #[test] #[serial] -fn run_emit_subcommand_writes_file() { +fn run_manifest_subcommand_writes_file() { let original_path = std::env::var_os("PATH").unwrap_or_default(); unsafe { std::env::set_var("PATH", ""); @@ -143,20 +143,20 @@ fn run_emit_subcommand_writes_file() { let temp = tempfile::tempdir().expect("temp dir"); let manifest_path = temp.path().join("Netsukefile"); std::fs::copy("tests/data/minimal.yml", &manifest_path).expect("copy manifest"); - let emit_path = temp.path().join("standalone.ninja"); + let output_path = temp.path().join("standalone.ninja"); let cli = Cli { file: manifest_path.clone(), directory: Some(temp.path().to_path_buf()), jobs: None, verbose: false, - command: Some(Commands::Emit { - file: emit_path.clone(), + command: Some(Commands::Manifest { + file: output_path.clone(), }), }; let result = run(&cli); assert!(result.is_ok()); - assert!(emit_path.exists()); + assert!(output_path.exists()); assert!(!temp.path().join("build.ninja").exists()); unsafe { diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index 72f5db18..91544fc7 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -87,12 +87,12 @@ fn command_is_graph(world: &mut CliWorld) { )); } -#[then("the command is emit")] -fn command_is_emit(world: &mut CliWorld) { +#[then("the command is manifest")] +fn command_is_manifest(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); assert!(matches!( cli.command.as_ref().expect("command"), - Commands::Emit { .. } + Commands::Manifest { .. } )); } @@ -145,12 +145,12 @@ fn emit_path(world: &mut CliWorld, path: String) { clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] -#[then(expr = "the emit command path is {string}")] -fn emit_command_path(world: &mut CliWorld, path: String) { +#[then(expr = "the manifest command path is {string}")] +fn manifest_command_path(world: &mut CliWorld, path: String) { let cli = world.cli.as_ref().expect("cli"); match cli.command.as_ref().expect("command") { - Commands::Emit { file } => assert_eq!(file, &PathBuf::from(&path)), - _ => panic!("command should be emit"), + Commands::Manifest { file } => assert_eq!(file, &PathBuf::from(&path)), + _ => panic!("command should be manifest"), } } From d105f2069cc0ff89805b019e344400a04a083718 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 6 Aug 2025 07:34:21 +0100 Subject: [PATCH 2/2] Add missing-file test for manifest command --- Makefile | 2 +- tests/assert_cmd_tests.rs | 3 +-- tests/cli_tests.rs | 1 + tests/features/cli.feature | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 0a748679..bdc7ea1b 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ markdownlint: ## Lint Markdown files find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT) nixie: ## Validate Mermaid diagrams - find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(NIXIE) + find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -n 1 -- $(NIXIE) help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ diff --git a/tests/assert_cmd_tests.rs b/tests/assert_cmd_tests.rs index b55ac924..22ba6591 100644 --- a/tests/assert_cmd_tests.rs +++ b/tests/assert_cmd_tests.rs @@ -33,8 +33,7 @@ fn build_with_emit_writes_file() { let output = temp.path().join("emitted.ninja"); let original = std::env::var_os("PATH").unwrap_or_default(); let path = std::env::join_paths( - std::iter::once(ninja_dir.path().to_path_buf()) - .chain(std::env::split_paths(&original)), + std::iter::once(ninja_dir.path().to_path_buf()).chain(std::env::split_paths(&original)), ) .expect("join path"); let mut cmd = Command::cargo_bin("netsuke").expect("binary"); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 3db59362..b6999f8b 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -56,6 +56,7 @@ fn parse_cli( #[case(vec!["netsuke", "--file"], ErrorKind::InvalidValue)] #[case(vec!["netsuke", "-j", "notanumber"], ErrorKind::ValueValidation)] #[case(vec!["netsuke", "--file", "alt.yml", "-C"], ErrorKind::InvalidValue)] +#[case(vec!["netsuke", "manifest"], ErrorKind::MissingRequiredArgument)] fn parse_cli_errors(#[case] argv: Vec<&str>, #[case] expected_error: ErrorKind) { let err = Cli::try_parse_from(argv).expect_err("unexpected success"); assert_eq!(err.kind(), expected_error); diff --git a/tests/features/cli.feature b/tests/features/cli.feature index 2a3a8dab..a51d22c7 100644 --- a/tests/features/cli.feature +++ b/tests/features/cli.feature @@ -36,6 +36,11 @@ Feature: CLI parsing And the command is manifest And the manifest command path is "out.ninja" + Scenario: Manifest subcommand requires a path + When the CLI is parsed with invalid arguments "manifest" + Then an error should be returned + And the error message should contain "" + Scenario: Unknown command fails When the CLI is parsed with invalid arguments "unknown" Then an error should be returned