From e0b544fbdef3904729a56f60872f39b2d18dc518 Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 5 Aug 2025 20:11:06 +0100 Subject: [PATCH 1/4] Add emit command and secure temp manifest --- Cargo.toml | 2 +- docs/netsuke-design.md | 46 +++++++----- src/cli.rs | 12 ++++ src/runner.rs | 92 ++++++++++++++++++------ tests/cli_tests.rs | 22 +++++- tests/features/cli.feature | 13 ++++ tests/runner_tests.rs | 136 +++++++++++++++++++++-------------- tests/steps/cli_steps.rs | 38 +++++++++- tests/steps/process_steps.rs | 4 +- 9 files changed, 262 insertions(+), 103 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c07b5f5c..911786e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ itertools = "0.12" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt"] } serde_json = "1" +tempfile = "3" [lints.clippy] pedantic = { level = "warn", priority = -1 } @@ -62,7 +63,6 @@ rstest = "0.18.0" cucumber = "0.20.0" tokio = { version = "1", features = ["macros", "rt-multi-thread"], default-features = false } insta = { version = "1", features = ["yaml"] } -tempfile = "3" serial_test = "3" [[test]] diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 49636b1d..c338634b 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1348,14 +1348,7 @@ entire CLI specification. Rust ```rust -// In src/main.rs - -#[rustfmt::skip] -use clap::Parser; -#[rustfmt::skip] -use clap::Subcommand; -#[rustfmt::skip] -use std::path::PathBuf; +use clap::{Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser)] #[command(author, version, about, long_about = None)] @@ -1380,11 +1373,22 @@ struct Cli { /// Path to the Netsuke manifest file to use. #[derive(Subcommand)] enum Commands { /// Build specified targets (or default targets if none are -given) [default]. Build { /// A list of specific targets to build. targets: -Vec, }, +given) [default]. Build { /// Write the generated Ninja manifest to this path +and retain it. + #[arg(long, value_name = "FILE")] + emit: Option, + + /// A list of specific targets to build. targets: Vec, }, + + /// Remove build artefacts and intermediate files. Clean {}, - /// Remove build artefacts and intermediate files. Clean {}, /// Display - the build dependency graph in DOT format for visualisation. Graph {}, } + /// 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. + #[arg(value_name = "FILE")] + file: PathBuf, }, } ``` *Note: The* `Build` *command is wrapped in an* `Option` *and will be @@ -1395,12 +1399,14 @@ treated as the default subcommand if none is provided, allowing for the common* The behaviour of each subcommand is clearly defined: -- `Netsuke build [targets...]`: This is the primary and default command. It - executes the full five-stage pipeline: ingestion, Jinja rendering, YAML - parsing, IR generation, and Ninja synthesis. It then invokes `ninja` to build - the list of specified `targets`. If no targets are provided on the command - line, it will build the targets listed in the `defaults` section of the - manifest. +- `Netsuke build [--emit FILE] [targets...]`: This is the primary and default + command. It executes the full five-stage pipeline: ingestion, Jinja + rendering, YAML parsing, IR generation, and Ninja synthesis. By default the + generated Ninja file is written to a securely created temporary location and + removed after the build completes. Supplying `--emit FILE` writes the Ninja + file to `FILE` and retains it. If no targets are provided on the command + line, the targets listed in the `defaults` section of the manifest are + built. - `Netsuke clean`: This command provides a convenient way to clean the build directory. It will invoke the Ninja backend with the appropriate flags, such @@ -1414,6 +1420,10 @@ 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 + synthesis and writes the resulting Ninja file to `FILE` without invoking + Ninja. + ### 8.4 Design Decisions The CLI is implemented using clap's derive API in `src/cli.rs`. Clap's diff --git a/src/cli.rs b/src/cli.rs index 5360707f..c9408b31 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -72,6 +72,7 @@ impl Cli { fn with_default_command(mut self) -> Self { if self.command.is_none() { self.command = Some(Commands::Build { + emit: None, targets: Vec::new(), }); } @@ -84,6 +85,10 @@ impl Cli { pub enum Commands { /// Build specified targets (or default targets if none are given) [default]. Build { + /// Write the generated Ninja manifest to this path and retain it. + #[arg(long, value_name = "FILE")] + emit: Option, + /// A list of specific targets to build. targets: Vec, }, @@ -93,4 +98,11 @@ 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 { + /// Output path for the generated Ninja file. + #[arg(value_name = "FILE")] + file: PathBuf, + }, } diff --git a/src/runner.rs b/src/runner.rs index b6174f28..4e492da2 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -9,9 +9,10 @@ use crate::{ir::BuildGraph, manifest, ninja_gen}; use serde_json; use std::fs; use std::io::{self, BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::{Command, Stdio}; use std::thread; +use tempfile::Builder; use tracing::{debug, info}; /// Execute the parsed [`Cli`] commands. @@ -22,26 +23,36 @@ use tracing::{debug, info}; /// non-zero status code. pub fn run(cli: &Cli) -> io::Result<()> { let command = cli.command.clone().unwrap_or(Commands::Build { + emit: None, targets: Vec::new(), }); match command { - Commands::Build { targets } => { - let manifest_path = cli - .directory - .as_ref() - .map_or_else(|| cli.file.clone(), |dir| dir.join(&cli.file)); - let manifest = manifest::from_path(&manifest_path).map_err(io::Error::other)?; - let ast_json = serde_json::to_string_pretty(&manifest).map_err(io::Error::other)?; - debug!("AST:\n{ast_json}"); - let graph = BuildGraph::from_manifest(&manifest).map_err(io::Error::other)?; - let ninja_content = ninja_gen::generate(&graph); - let ninja_path = cli.directory.as_ref().map_or_else( - || PathBuf::from("build.ninja"), - |dir| dir.join("build.ninja"), - ); - fs::write(&ninja_path, ninja_content).map_err(io::Error::other)?; - info!("Generated Ninja file at {}", ninja_path.display()); - run_ninja(Path::new("ninja"), cli, &targets) + Commands::Build { targets, emit } => { + let ninja_content = generate_ninja(cli)?; + if let Some(path) = emit { + fs::write(&path, ninja_content.as_bytes()).map_err(io::Error::other)?; + info!("Generated Ninja file at {}", path.display()); + run_ninja(Path::new("ninja"), cli, &path, &targets) + } else { + let mut tmp = Builder::new() + .prefix("netsuke.") + .suffix(".ninja") + .tempfile() + .map_err(io::Error::other)?; + tmp.write_all(ninja_content.as_bytes()) + .map_err(io::Error::other)?; + let path = tmp.into_temp_path(); + info!("Generated Ninja file at {}", path.display()); + let result = run_ninja(Path::new("ninja"), cli, path.as_ref(), &targets); + drop(path); + result + } + } + Commands::Emit { file } => { + let ninja_content = generate_ninja(cli)?; + fs::write(&file, ninja_content.as_bytes()).map_err(io::Error::other)?; + info!("Generated Ninja file at {}", file.display()); + Ok(()) } Commands::Clean => { println!("Clean requested"); @@ -54,6 +65,38 @@ pub fn run(cli: &Cli) -> io::Result<()> { } } +/// Generate the Ninja manifest string from the Netsuke manifest referenced by `cli`. +/// +/// # Errors +/// +/// Returns an [`io::Error`] if the manifest cannot be loaded or translated. +/// +/// # Examples +/// ```ignore +/// use netsuke::cli::{Cli, Commands}; +/// use netsuke::runner::generate_ninja; +/// let cli = Cli { +/// file: "Netsukefile".into(), +/// directory: None, +/// jobs: None, +/// verbose: false, +/// command: Some(Commands::Build { emit: None, targets: vec![] }), +/// }; +/// let ninja = generate_ninja(&cli).expect("generate"); +/// assert!(ninja.contains("rule")); +/// ``` +fn generate_ninja(cli: &Cli) -> io::Result { + let manifest_path = cli + .directory + .as_ref() + .map_or_else(|| cli.file.clone(), |dir| dir.join(&cli.file)); + let manifest = manifest::from_path(&manifest_path).map_err(io::Error::other)?; + let ast_json = serde_json::to_string_pretty(&manifest).map_err(io::Error::other)?; + debug!("AST:\n{ast_json}"); + let graph = BuildGraph::from_manifest(&manifest).map_err(io::Error::other)?; + Ok(ninja_gen::generate(&graph)) +} + /// Check if `arg` contains a sensitive keyword. /// /// # Examples @@ -111,8 +154,9 @@ fn redact_sensitive_args(args: &[String]) -> Vec { /// Invoke the Ninja executable with the provided CLI settings. /// -/// The function forwards the job count and working directory to Ninja and -/// streams its standard output and error back to the user. +/// The function forwards the job count and working directory to Ninja, +/// specifies the temporary build file, and streams its standard output and +/// error back to the user. /// /// # Errors /// @@ -122,7 +166,12 @@ fn redact_sensitive_args(args: &[String]) -> Vec { /// # Panics /// /// Panics if the child's output streams cannot be captured. -pub fn run_ninja(program: &Path, cli: &Cli, targets: &[String]) -> io::Result<()> { +pub fn run_ninja( + program: &Path, + cli: &Cli, + build_file: &Path, + targets: &[String], +) -> io::Result<()> { let mut cmd = Command::new(program); if let Some(dir) = &cli.directory { // Resolve and canonicalise the directory so Ninja receives a stable @@ -135,6 +184,7 @@ pub fn run_ninja(program: &Path, cli: &Cli, targets: &[String]) -> io::Result<() if let Some(jobs) = cli.jobs { cmd.arg("-j").arg(jobs.to_string()); } + cmd.arg("-f").arg(build_file); cmd.args(targets); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index dd8d1d7f..85fd4433 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -9,16 +9,32 @@ use rstest::rstest; use std::path::PathBuf; #[rstest] -#[case(vec!["netsuke"], PathBuf::from("Netsukefile"), None, None, false, Commands::Build { targets: Vec::new() })] +#[case(vec!["netsuke"], PathBuf::from("Netsukefile"), None, None, false, Commands::Build { emit: None, targets: Vec::new() })] #[case( vec!["netsuke", "--file", "alt.yml", "-C", "work", "-j", "4", "build", "a", "b"], PathBuf::from("alt.yml"), Some(PathBuf::from("work")), Some(4), false, - Commands::Build { targets: vec!["a".into(), "b".into()] }, + Commands::Build { emit: None, targets: vec!["a".into(), "b".into()] }, +)] +#[case(vec!["netsuke", "--verbose"], PathBuf::from("Netsukefile"), None, None, true, Commands::Build { emit: None, targets: Vec::new() })] +#[case( + vec!["netsuke", "build", "--emit", "out.ninja", "a"], + PathBuf::from("Netsukefile"), + None, + None, + false, + Commands::Build { emit: Some(PathBuf::from("out.ninja")), targets: vec!["a".into()] }, +)] +#[case( + vec!["netsuke", "emit", "out.ninja"], + PathBuf::from("Netsukefile"), + None, + None, + false, + Commands::Emit { file: PathBuf::from("out.ninja") }, )] -#[case(vec!["netsuke", "--verbose"], PathBuf::from("Netsukefile"), None, None, true, Commands::Build { targets: Vec::new() })] fn parse_cli( #[case] argv: Vec<&str>, #[case] file: PathBuf, diff --git a/tests/features/cli.feature b/tests/features/cli.feature index 34ebb363..e9fca342 100644 --- a/tests/features/cli.feature +++ b/tests/features/cli.feature @@ -23,6 +23,19 @@ Feature: CLI parsing And the manifest path is "alt.yml" And the first target is "target" + Scenario: Build command writes Ninja file + When the CLI is parsed with "build --emit out.ninja target" + Then parsing succeeds + And the command is build + 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" + Then parsing succeeds + And the command is emit + And the emit command path is "out.ninja" + Scenario: Unknown command fails When the CLI is parsed with invalid arguments "unknown" Then an error should be returned diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index d0fd90d1..a78c06f1 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -1,11 +1,9 @@ use netsuke::cli::{Cli, Commands}; use netsuke::runner::{run, run_ninja}; use rstest::rstest; +use serial_test::serial; use std::error::Error; -use std::fs::{self, File}; -use std::io::Write; use std::path::{Path, PathBuf}; -use tempfile::TempDir; mod support; @@ -19,7 +17,10 @@ fn run_exits_with_manifest_error_on_invalid_version() { directory: None, jobs: None, verbose: false, - command: Some(Commands::Build { targets: vec![] }), + command: Some(Commands::Build { + emit: None, + targets: vec![], + }), }; let result = run(&cli); @@ -33,27 +34,32 @@ fn run_exits_with_manifest_error_on_invalid_version() { ); } -#[cfg(unix)] -fn fake_ninja_pwd() -> (TempDir, PathBuf) { - use std::os::unix::fs::PermissionsExt; - let dir = TempDir::new().expect("temp dir"); - let path = dir.path().join("ninja"); - let mut file = File::create(&path).expect("script"); - writeln!( - file, - "#!/bin/sh\nif [ -n \"$1\" ]; then pwd > \"$1\"; else pwd; fi" +#[rstest] +fn run_ninja_not_found() { + let cli = Cli { + file: PathBuf::from("/dev/null"), + directory: None, + jobs: None, + verbose: false, + command: Some(Commands::Build { + emit: None, + targets: vec![], + }), + }; + let err = run_ninja( + Path::new("does-not-exist"), + &cli, + Path::new("build.ninja"), + &[], ) - .expect("write script"); - let mut perms = fs::metadata(&path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&path, perms).expect("perms"); - (dir, path) + .expect_err("process should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); } -#[cfg(unix)] -#[test] -fn run_executes_ninja_and_captures_logs() { - let (ninja_dir, ninja_path) = fake_ninja_pwd(); +#[rstest] +#[serial] +fn run_executes_ninja_without_persisting_file() { + let (ninja_dir, ninja_path) = support::fake_ninja(0); let original_path = std::env::var_os("PATH").unwrap_or_default(); let mut paths: Vec<_> = std::env::split_paths(&original_path).collect(); paths.insert(0, ninja_dir.path().to_path_buf()); @@ -70,19 +76,17 @@ fn run_executes_ninja_and_captures_logs() { directory: Some(temp.path().to_path_buf()), jobs: None, verbose: false, - command: Some(Commands::Build { targets: vec![] }), + command: Some(Commands::Build { + emit: None, + targets: vec![], + }), }; let result = run(&cli); assert!(result.is_ok()); - // Verify the ninja file was written and contains some content - let ninja_file_path = temp.path().join("build.ninja"); - assert!(ninja_file_path.exists()); - let ninja_content = std::fs::read_to_string(&ninja_file_path).expect("read ninja file"); - assert!(!ninja_content.is_empty()); - assert!(ninja_content.contains("build ")); - assert!(ninja_content.contains("rule ")); + // Ensure no ninja file remains in project directory + assert!(!temp.path().join("build.ninja").exists()); unsafe { std::env::set_var("PATH", original_path); @@ -90,21 +94,10 @@ fn run_executes_ninja_and_captures_logs() { drop(ninja_path); } -#[rstest] -fn run_ninja_not_found() { - let cli = Cli { - file: PathBuf::from("/dev/null"), - directory: None, - jobs: None, - verbose: false, - command: Some(Commands::Build { targets: vec![] }), - }; - let err = run_ninja(Path::new("does-not-exist"), &cli, &[]).expect_err("process should fail"); - assert_eq!(err.kind(), std::io::ErrorKind::NotFound); -} - -#[rstest] -fn run_writes_ninja_file() { +#[cfg(unix)] +#[test] +#[serial] +fn run_build_with_emit_keeps_file() { let (ninja_dir, ninja_path) = support::fake_ninja(0); let original_path = std::env::var_os("PATH").unwrap_or_default(); let mut paths: Vec<_> = std::env::split_paths(&original_path).collect(); @@ -112,32 +105,65 @@ fn run_writes_ninja_file() { let new_path = std::env::join_paths(paths).expect("join paths"); unsafe { std::env::set_var("PATH", &new_path); - } // Nightly marks set_var unsafe. + } 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("emitted.ninja"); let cli = Cli { file: manifest_path.clone(), directory: Some(temp.path().to_path_buf()), jobs: None, verbose: false, - command: Some(Commands::Build { targets: vec![] }), + command: Some(Commands::Build { + emit: Some(emit_path.clone()), + targets: vec![], + }), }; let result = run(&cli); assert!(result.is_ok()); - // Verify the ninja file was written and contains some content - let ninja_file_path = temp.path().join("build.ninja"); - assert!(ninja_file_path.exists()); - let ninja_content = std::fs::read_to_string(&ninja_file_path).expect("read ninja file"); - assert!(!ninja_content.is_empty()); - assert!(ninja_content.contains("build ")); - assert!(ninja_content.contains("rule ")); + assert!(emit_path.exists()); + let emitted = std::fs::read_to_string(&emit_path).expect("read emitted"); + assert!(emitted.contains("rule ")); + assert!(emitted.contains("build ")); + assert!(!temp.path().join("build.ninja").exists()); unsafe { std::env::set_var("PATH", original_path); - } // Nightly marks set_var unsafe. + } drop(ninja_path); } + +#[test] +fn run_emit_subcommand_writes_file() { + let original_path = std::env::var_os("PATH").unwrap_or_default(); + unsafe { + std::env::set_var("PATH", ""); + } + + 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 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(), + }), + }; + + let result = run(&cli); + assert!(result.is_ok()); + assert!(emit_path.exists()); + assert!(!temp.path().join("build.ninja").exists()); + + unsafe { + std::env::set_var("PATH", original_path); + } +} diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index 984e5b82..79e74d31 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -17,6 +17,7 @@ fn apply_cli(world: &mut CliWorld, args: &str) { Ok(mut cli) => { if cli.command.is_none() { cli.command = Some(Commands::Build { + emit: None, targets: Vec::new(), }); } @@ -30,10 +31,10 @@ fn apply_cli(world: &mut CliWorld, args: &str) { } } -fn extract_build(world: &CliWorld) -> Option<&Vec> { +fn extract_build(world: &CliWorld) -> Option<(&Vec, &Option)> { let cli = world.cli.as_ref()?; match cli.command.as_ref()? { - Commands::Build { targets } => Some(targets), + Commands::Build { targets, emit } => Some((targets, emit)), _ => None, } } @@ -86,6 +87,15 @@ fn command_is_graph(world: &mut CliWorld) { )); } +#[then("the command is emit")] +fn command_is_emit(world: &mut CliWorld) { + let cli = world.cli.as_ref().expect("cli"); + assert!(matches!( + cli.command.as_ref().expect("command"), + Commands::Emit { .. } + )); +} + #[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" @@ -102,7 +112,7 @@ fn manifest_path(world: &mut CliWorld, path: String) { )] #[then(expr = "the first target is {string}")] fn first_target(world: &mut CliWorld, target: String) { - let targets = extract_build(world).expect("command should be build"); + let (targets, _) = extract_build(world).expect("command should be build"); assert_eq!(targets.first(), Some(&target)); } @@ -122,6 +132,28 @@ fn job_count(world: &mut CliWorld, jobs: usize) { assert_eq!(cli.jobs, Some(jobs)); } +#[allow( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[then(expr = "the emit path is {string}")] +fn emit_path(world: &mut CliWorld, path: String) { + let (_, emit) = extract_build(world).expect("command should be build"); + assert_eq!(emit.as_ref(), Some(&PathBuf::from(path))); +} +#[allow( + 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) { + 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"), + } +} + #[then("an error should be returned")] fn error_should_be_returned(world: &mut CliWorld) { assert!( diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 6a34153a..261ffc6c 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -3,7 +3,7 @@ use crate::{CliWorld, support}; use cucumber::{given, then, when}; use netsuke::runner; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tempfile::TempDir; /// Installs a test-specific ninja binary and updates the `PATH`. @@ -62,7 +62,7 @@ fn run(world: &mut CliWorld) { } else { std::path::Path::new("ninja") }; - match runner::run_ninja(program, cli, &[]) { + match runner::run_ninja(program, cli, Path::new("build.ninja"), &[]) { Ok(()) => { world.run_status = Some(true); world.run_error = None; From 1883ec144388ad7823d0cb348f11e2717248eeaa Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 5 Aug 2025 21:54:08 +0100 Subject: [PATCH 2/4] Improve Ninja generation errors and tests --- docs/netsuke-design.md | 4 +- src/runner.rs | 67 +++++++++++++++++----------- tests/features/ninja_process.feature | 15 +++++++ tests/runner_tests.rs | 8 +--- tests/steps/process_steps.rs | 23 ++++++++++ tests/support/mod.rs | 31 +++++++++++++ 6 files changed, 115 insertions(+), 33 deletions(-) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index c338634b..083b22ca 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1348,7 +1348,9 @@ entire CLI specification. Rust ```rust -use clap::{Parser, Subcommand}; use std::path::PathBuf; +use clap::{Parser, Subcommand}; + +use std::path::PathBuf; #[derive(Parser)] #[command(author, version, about, long_about = None)] diff --git a/src/runner.rs b/src/runner.rs index 4e492da2..fac552de 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -6,6 +6,7 @@ use crate::cli::{Cli, Commands}; use crate::{ir::BuildGraph, manifest, ninja_gen}; +use anyhow::{Context, Result}; use serde_json; use std::fs; use std::io::{self, BufRead, BufReader, Write}; @@ -19,39 +20,32 @@ use tracing::{debug, info}; /// /// # Errors /// -/// Returns an [`io::Error`] if the Ninja process fails to spawn or exits with a -/// non-zero status code. -pub fn run(cli: &Cli) -> io::Result<()> { +/// Returns an error if manifest generation or the Ninja process fails. +pub fn run(cli: &Cli) -> Result<()> { let command = cli.command.clone().unwrap_or(Commands::Build { emit: None, targets: Vec::new(), }); match command { Commands::Build { targets, emit } => { - let ninja_content = generate_ninja(cli)?; + let ninja = generate_ninja(cli)?; if let Some(path) = emit { - fs::write(&path, ninja_content.as_bytes()).map_err(io::Error::other)?; - info!("Generated Ninja file at {}", path.display()); - run_ninja(Path::new("ninja"), cli, &path, &targets) + write_and_log(&path, &ninja)?; + run_ninja(Path::new("ninja"), cli, &path, &targets)?; } else { - let mut tmp = Builder::new() + let tmp = Builder::new() .prefix("netsuke.") .suffix(".ninja") .tempfile() - .map_err(io::Error::other)?; - tmp.write_all(ninja_content.as_bytes()) - .map_err(io::Error::other)?; - let path = tmp.into_temp_path(); - info!("Generated Ninja file at {}", path.display()); - let result = run_ninja(Path::new("ninja"), cli, path.as_ref(), &targets); - drop(path); - result + .context("create temp file")?; + write_and_log(tmp.path(), &ninja)?; + run_ninja(Path::new("ninja"), cli, tmp.path(), &targets)?; } + Ok(()) } Commands::Emit { file } => { - let ninja_content = generate_ninja(cli)?; - fs::write(&file, ninja_content.as_bytes()).map_err(io::Error::other)?; - info!("Generated Ninja file at {}", file.display()); + let ninja = generate_ninja(cli)?; + write_and_log(&file, &ninja)?; Ok(()) } Commands::Clean => { @@ -65,11 +59,27 @@ pub fn run(cli: &Cli) -> io::Result<()> { } } +/// Write `content` to `path` and log the file's location. +/// +/// # Errors +/// +/// Returns an [`io::Error`] if the file cannot be written. +/// +/// # Examples +/// ```ignore +/// write_and_log(Path::new("out.ninja"), "rule cc\n").unwrap(); +/// ``` +fn write_and_log(path: &Path, content: &str) -> io::Result<()> { + fs::write(path, content)?; + info!("Generated Ninja file at {}", path.display()); + Ok(()) +} + /// Generate the Ninja manifest string from the Netsuke manifest referenced by `cli`. /// /// # Errors /// -/// Returns an [`io::Error`] if the manifest cannot be loaded or translated. +/// Returns an error if the manifest cannot be loaded or translated. /// /// # Examples /// ```ignore @@ -85,15 +95,16 @@ pub fn run(cli: &Cli) -> io::Result<()> { /// let ninja = generate_ninja(&cli).expect("generate"); /// assert!(ninja.contains("rule")); /// ``` -fn generate_ninja(cli: &Cli) -> io::Result { +fn generate_ninja(cli: &Cli) -> Result { let manifest_path = cli .directory .as_ref() .map_or_else(|| cli.file.clone(), |dir| dir.join(&cli.file)); - let manifest = manifest::from_path(&manifest_path).map_err(io::Error::other)?; - let ast_json = serde_json::to_string_pretty(&manifest).map_err(io::Error::other)?; + let manifest = manifest::from_path(&manifest_path) + .with_context(|| format!("loading manifest at {}", manifest_path.display()))?; + let ast_json = serde_json::to_string_pretty(&manifest).context("serialising manifest")?; debug!("AST:\n{ast_json}"); - let graph = BuildGraph::from_manifest(&manifest).map_err(io::Error::other)?; + let graph = BuildGraph::from_manifest(&manifest).context("building graph")?; Ok(ninja_gen::generate(&graph)) } @@ -184,7 +195,13 @@ pub fn run_ninja( if let Some(jobs) = cli.jobs { cmd.arg("-j").arg(jobs.to_string()); } - cmd.arg("-f").arg(build_file); + // Canonicalise the build file path so Ninja resolves it correctly from the + // working directory. Fall back to the original on failure so Ninja can + // surface a meaningful error. + let build_file_path = build_file + .canonicalize() + .unwrap_or_else(|_| build_file.to_path_buf()); + cmd.arg("-f").arg(&build_file_path); cmd.args(targets); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); diff --git a/tests/features/ninja_process.feature b/tests/features/ninja_process.feature index c8502804..4d4785f6 100644 --- a/tests/features/ninja_process.feature +++ b/tests/features/ninja_process.feature @@ -17,3 +17,18 @@ Feature: Ninja process execution And the CLI is parsed with "" When the ninja process is run Then the command should fail with error "No such file or directory" + + Scenario: Build file missing + Given a fake ninja executable that checks for the build file + And the CLI is parsed with "" + And the CLI uses the temporary directory + When the ninja process is run + Then the command should fail with error "ninja exited with exit status: 1" + + Scenario: Build file is not a regular file + Given a fake ninja executable that checks for the build file + And the CLI is parsed with "" + And the CLI uses the temporary directory + And a directory named build.ninja exists + When the ninja process is run + Then the command should fail with error "ninja exited with exit status: 1" diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index a78c06f1..bfb1e7ef 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -2,7 +2,6 @@ use netsuke::cli::{Cli, Commands}; use netsuke::runner::{run, run_ninja}; use rstest::rstest; use serial_test::serial; -use std::error::Error; use std::path::{Path, PathBuf}; mod support; @@ -26,12 +25,7 @@ fn run_exits_with_manifest_error_on_invalid_version() { let result = run(&cli); assert!(result.is_err()); let err = result.expect_err("should have error"); - assert!( - err.source() - .expect("should have source") - .to_string() - .contains("version") - ); + assert!(err.chain().any(|e| e.to_string().contains("version"))); } #[rstest] diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 261ffc6c..4d438708 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -3,6 +3,7 @@ use crate::{CliWorld, support}; use cucumber::{given, then, when}; use netsuke::runner; +use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; @@ -33,6 +34,13 @@ fn fake_ninja(world: &mut CliWorld, code: i32) { install_test_ninja(world, dir, path); } +/// Creates a fake ninja executable that validates the build file path. +#[given("a fake ninja executable that checks for the build file")] +fn fake_ninja_check(world: &mut CliWorld) { + let (dir, path) = support::fake_ninja_check_build_file(); + install_test_ninja(world, dir, path); +} + /// Sets up a scenario where no ninja executable is available. /// /// This step creates a temporary directory and records the path to a @@ -45,6 +53,21 @@ fn no_ninja(world: &mut CliWorld) { install_test_ninja(world, dir, path); } +/// Updates the CLI to use the temporary directory created for the fake ninja. +#[given("the CLI uses the temporary directory")] +fn cli_uses_temp_dir(world: &mut CliWorld) { + let temp = world.temp.as_ref().expect("temp dir"); + let cli = world.cli.as_mut().expect("cli"); + cli.directory = Some(temp.path().to_path_buf()); +} + +/// Creates a directory named `build.ninja` in the temporary working directory. +#[given("a directory named build.ninja exists")] +fn build_dir_exists(world: &mut CliWorld) { + let temp = world.temp.as_ref().expect("temp dir"); + fs::create_dir(temp.path().join("build.ninja")).expect("create dir"); +} + /// Executes the ninja process and captures the result in the test world. /// /// This step runs the `ninja` executable using the CLI configuration stored in diff --git a/tests/support/mod.rs b/tests/support/mod.rs index d7a4b817..7099e56d 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -26,6 +26,37 @@ pub fn fake_ninja(exit_code: i32) -> (TempDir, PathBuf) { (dir, path) } +/// Create a fake Ninja that validates the build file path provided via `-f`. +/// +/// The script exits with status `1` if the file is missing or not a regular +/// file, otherwise `0`. +#[allow(dead_code, reason = "used in build file validation tests")] +pub fn fake_ninja_check_build_file() -> (TempDir, PathBuf) { + let dir = TempDir::new().expect("temp dir"); + let path = dir.path().join("ninja"); + let mut file = File::create(&path).expect("script"); + writeln!( + file, + concat!( + "#!/bin/sh\n", + "if [ \"$1\" = \"-f\" ] && [ ! -f \"$2\" ]; then\n", + " echo 'missing build file: $2' >&2\n", + " exit 1\n", + "fi\n", + "exit 0" + ) + ) + .expect("write script"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).expect("perms"); + } + (dir, path) +} + #[allow(dead_code, reason = "compiled as its own crate during linting")] #[derive(Clone)] struct BufferWriter { From 37d24556e7fc3eb9d17398b478d368480529db0c Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 5 Aug 2025 22:56:57 +0100 Subject: [PATCH 3/4] Wrap runner args in domain types --- src/runner.rs | 112 ++++++++++++++++++++++++++--------- tests/runner_tests.rs | 5 +- tests/steps/process_steps.rs | 5 +- 3 files changed, 90 insertions(+), 32 deletions(-) diff --git a/src/runner.rs b/src/runner.rs index fac552de..520deb9b 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -16,6 +16,53 @@ use std::thread; use tempfile::Builder; use tracing::{debug, info}; +#[derive(Debug, Clone)] +pub struct NinjaContent(String); +impl NinjaContent { + #[must_use] + pub fn new(content: String) -> Self { + Self(content) + } + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + #[must_use] + pub fn into_string(self) -> String { + self.0 + } +} + +#[derive(Debug, Clone)] +pub struct CommandArg(String); +impl CommandArg { + #[must_use] + pub fn new(arg: String) -> Self { + Self(arg) + } + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct BuildTargets(Vec); +impl BuildTargets { + #[must_use] + pub fn new(targets: Vec) -> Self { + Self(targets) + } + #[must_use] + pub fn as_slice(&self) -> &[String] { + &self.0 + } + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + /// Execute the parsed [`Cli`] commands. /// /// # Errors @@ -29,6 +76,7 @@ pub fn run(cli: &Cli) -> Result<()> { match command { Commands::Build { targets, emit } => { let ninja = generate_ninja(cli)?; + let targets = BuildTargets::new(targets); if let Some(path) = emit { write_and_log(&path, &ninja)?; run_ninja(Path::new("ninja"), cli, &path, &targets)?; @@ -67,10 +115,11 @@ pub fn run(cli: &Cli) -> Result<()> { /// /// # Examples /// ```ignore -/// write_and_log(Path::new("out.ninja"), "rule cc\n").unwrap(); +/// let content = NinjaContent::new("rule cc\n".to_string()); +/// write_and_log(Path::new("out.ninja"), &content).unwrap(); /// ``` -fn write_and_log(path: &Path, content: &str) -> io::Result<()> { - fs::write(path, content)?; +fn write_and_log(path: &Path, content: &NinjaContent) -> io::Result<()> { + fs::write(path, content.as_str())?; info!("Generated Ninja file at {}", path.display()); Ok(()) } @@ -93,9 +142,9 @@ fn write_and_log(path: &Path, content: &str) -> io::Result<()> { /// command: Some(Commands::Build { emit: None, targets: vec![] }), /// }; /// let ninja = generate_ninja(&cli).expect("generate"); -/// assert!(ninja.contains("rule")); +/// assert!(ninja.as_str().contains("rule")); /// ``` -fn generate_ninja(cli: &Cli) -> Result { +fn generate_ninja(cli: &Cli) -> Result { let manifest_path = cli .directory .as_ref() @@ -105,18 +154,18 @@ fn generate_ninja(cli: &Cli) -> Result { let ast_json = serde_json::to_string_pretty(&manifest).context("serialising manifest")?; debug!("AST:\n{ast_json}"); let graph = BuildGraph::from_manifest(&manifest).context("building graph")?; - Ok(ninja_gen::generate(&graph)) + Ok(NinjaContent::new(ninja_gen::generate(&graph))) } /// Check if `arg` contains a sensitive keyword. /// /// # Examples /// ``` -/// assert!(contains_sensitive_keyword("token=abc")); -/// assert!(!contains_sensitive_keyword("path=/tmp")); +/// assert!(contains_sensitive_keyword(&CommandArg::new("token=abc".into()))); +/// assert!(!contains_sensitive_keyword(&CommandArg::new("path=/tmp".into()))); /// ``` -fn contains_sensitive_keyword(arg: &str) -> bool { - let lower = arg.to_lowercase(); +fn contains_sensitive_keyword(arg: &CommandArg) -> bool { + let lower = arg.as_str().to_lowercase(); lower.contains("password") || lower.contains("token") || lower.contains("secret") } @@ -124,10 +173,10 @@ fn contains_sensitive_keyword(arg: &str) -> bool { /// /// # Examples /// ``` -/// assert!(is_sensitive_arg("password=123")); -/// assert!(!is_sensitive_arg("file=readme")); +/// assert!(is_sensitive_arg(&CommandArg::new("password=123".into()))); +/// assert!(!is_sensitive_arg(&CommandArg::new("file=readme".into()))); /// ``` -fn is_sensitive_arg(arg: &str) -> bool { +fn is_sensitive_arg(arg: &CommandArg) -> bool { contains_sensitive_keyword(arg) } @@ -137,17 +186,20 @@ fn is_sensitive_arg(arg: &str) -> bool { /// /// # Examples /// ``` -/// assert_eq!(redact_argument("token=abc"), "token=***REDACTED***"); -/// assert_eq!(redact_argument("path=/tmp"), "path=/tmp"); +/// let arg = CommandArg::new("token=abc".into()); +/// assert_eq!(redact_argument(&arg).as_str(), "token=***REDACTED***"); +/// let arg = CommandArg::new("path=/tmp".into()); +/// assert_eq!(redact_argument(&arg).as_str(), "path=/tmp"); /// ``` -fn redact_argument(arg: &str) -> String { +fn redact_argument(arg: &CommandArg) -> CommandArg { if is_sensitive_arg(arg) { - arg.split_once('=').map_or_else( + let redacted = arg.as_str().split_once('=').map_or_else( || "***REDACTED***".to_string(), |(key, _)| format!("{key}=***REDACTED***"), - ) + ); + CommandArg::new(redacted) } else { - arg.to_string() + arg.clone() } } @@ -155,12 +207,15 @@ fn redact_argument(arg: &str) -> String { /// /// # Examples /// ``` -/// let args = vec!["ninja".into(), "token=abc".into()]; +/// let args = vec![ +/// CommandArg::new("ninja".into()), +/// CommandArg::new("token=abc".into()), +/// ]; /// let redacted = redact_sensitive_args(&args); -/// assert_eq!(redacted[1], "token=***REDACTED***"); +/// assert_eq!(redacted[1].as_str(), "token=***REDACTED***"); /// ``` -fn redact_sensitive_args(args: &[String]) -> Vec { - args.iter().map(|arg| redact_argument(arg)).collect() +fn redact_sensitive_args(args: &[CommandArg]) -> Vec { + args.iter().map(redact_argument).collect() } /// Invoke the Ninja executable with the provided CLI settings. @@ -181,7 +236,7 @@ pub fn run_ninja( program: &Path, cli: &Cli, build_file: &Path, - targets: &[String], + targets: &BuildTargets, ) -> io::Result<()> { let mut cmd = Command::new(program); if let Some(dir) = &cli.directory { @@ -202,17 +257,18 @@ pub fn run_ninja( .canonicalize() .unwrap_or_else(|_| build_file.to_path_buf()); cmd.arg("-f").arg(&build_file_path); - cmd.args(targets); + cmd.args(targets.as_slice()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); let program = cmd.get_program().to_string_lossy().into_owned(); - let args: Vec = cmd + let args: Vec = cmd .get_args() - .map(|a| a.to_string_lossy().into_owned()) + .map(|a| CommandArg::new(a.to_string_lossy().into_owned())) .collect(); let redacted_args = redact_sensitive_args(&args); - info!("Running command: {} {}", program, redacted_args.join(" ")); + let arg_strings: Vec<&str> = redacted_args.iter().map(CommandArg::as_str).collect(); + info!("Running command: {} {}", program, arg_strings.join(" ")); let mut child = cmd.spawn()?; let stdout = child.stdout.take().expect("child stdout"); diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index bfb1e7ef..51d626a2 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -1,5 +1,5 @@ use netsuke::cli::{Cli, Commands}; -use netsuke::runner::{run, run_ninja}; +use netsuke::runner::{BuildTargets, run, run_ninja}; use rstest::rstest; use serial_test::serial; use std::path::{Path, PathBuf}; @@ -40,11 +40,12 @@ fn run_ninja_not_found() { targets: vec![], }), }; + let targets = BuildTargets::new(vec![]); let err = run_ninja( Path::new("does-not-exist"), &cli, Path::new("build.ninja"), - &[], + &targets, ) .expect_err("process should fail"); assert_eq!(err.kind(), std::io::ErrorKind::NotFound); diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 4d438708..b9d34529 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -2,7 +2,7 @@ use crate::{CliWorld, support}; use cucumber::{given, then, when}; -use netsuke::runner; +use netsuke::runner::{self, BuildTargets}; use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; @@ -85,7 +85,8 @@ fn run(world: &mut CliWorld) { } else { std::path::Path::new("ninja") }; - match runner::run_ninja(program, cli, Path::new("build.ninja"), &[]) { + let targets = BuildTargets::new(vec![]); + match runner::run_ninja(program, cli, Path::new("build.ninja"), &targets) { Ok(()) => { world.run_status = Some(true); world.run_error = None; From 360972ab2a67f4ce8aa30121735ad7b5bfc7baa7 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 6 Aug 2025 00:32:28 +0100 Subject: [PATCH 4/4] Refine runner helpers and test scaffolding --- Cargo.toml | 2 +- src/runner.rs | 7 ++++--- tests/runner_tests.rs | 1 + tests/steps/cli_steps.rs | 8 ++++---- tests/steps/process_steps.rs | 4 ++-- tests/support/mod.rs | 10 +++++++--- 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 911786e3..713bf2bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ itertools = "0.12" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt"] } serde_json = "1" -tempfile = "3" +tempfile = "3.8.0" [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/src/runner.rs b/src/runner.rs index 520deb9b..bc2ab12f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -111,15 +111,16 @@ pub fn run(cli: &Cli) -> Result<()> { /// /// # Errors /// -/// Returns an [`io::Error`] if the file cannot be written. +/// Returns an error if the file cannot be written. /// /// # Examples /// ```ignore /// let content = NinjaContent::new("rule cc\n".to_string()); /// write_and_log(Path::new("out.ninja"), &content).unwrap(); /// ``` -fn write_and_log(path: &Path, content: &NinjaContent) -> io::Result<()> { - fs::write(path, content.as_str())?; +fn write_and_log(path: &Path, content: &NinjaContent) -> Result<()> { + fs::write(path, content.as_str()) + .with_context(|| format!("failed to write Ninja file to {}", path.display()))?; info!("Generated Ninja file at {}", path.display()); Ok(()) } diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index 51d626a2..0a15d9f3 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -133,6 +133,7 @@ fn run_build_with_emit_keeps_file() { } #[test] +#[serial] fn run_emit_subcommand_writes_file() { let original_path = std::env::var_os("PATH").unwrap_or_default(); unsafe { diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index 79e74d31..72f5db18 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -132,16 +132,16 @@ fn job_count(world: &mut CliWorld, jobs: usize) { assert_eq!(cli.jobs, Some(jobs)); } -#[allow( +#[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] #[then(expr = "the emit path is {string}")] fn emit_path(world: &mut CliWorld, path: String) { let (_, emit) = extract_build(world).expect("command should be build"); - assert_eq!(emit.as_ref(), Some(&PathBuf::from(path))); + assert_eq!(emit.as_ref(), Some(&PathBuf::from(&path))); } -#[allow( +#[expect( clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] @@ -149,7 +149,7 @@ fn emit_path(world: &mut CliWorld, path: String) { fn emit_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)), + Commands::Emit { file } => assert_eq!(file, &PathBuf::from(&path)), _ => panic!("command should be emit"), } } diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index b9d34529..be67517a 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -81,9 +81,9 @@ fn build_dir_exists(world: &mut CliWorld) { fn run(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); let program = if let Some(ninja) = &world.ninja { - std::path::Path::new(ninja) + Path::new(ninja) } else { - std::path::Path::new("ninja") + Path::new("ninja") }; let targets = BuildTargets::new(vec![]); match runner::run_ninja(program, cli, Path::new("build.ninja"), &targets) { diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 7099e56d..34e1a617 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -30,7 +30,8 @@ pub fn fake_ninja(exit_code: i32) -> (TempDir, PathBuf) { /// /// The script exits with status `1` if the file is missing or not a regular /// file, otherwise `0`. -#[allow(dead_code, reason = "used in build file validation tests")] +#[allow(unfulfilled_lint_expectations, reason = "used only in some test crates")] +#[expect(dead_code, reason = "used in build file validation tests")] pub fn fake_ninja_check_build_file() -> (TempDir, PathBuf) { let dir = TempDir::new().expect("temp dir"); let path = dir.path().join("ninja"); @@ -57,7 +58,8 @@ pub fn fake_ninja_check_build_file() -> (TempDir, PathBuf) { (dir, path) } -#[allow(dead_code, reason = "compiled as its own crate during linting")] +#[allow(unfulfilled_lint_expectations, reason = "compiled only for logging tests")] +#[expect(dead_code, reason = "compiled as its own crate during linting")] #[derive(Clone)] struct BufferWriter { buf: Arc>>, @@ -82,7 +84,8 @@ impl Write for BufferWriter { /// let output = capture_logs(Level::INFO, || tracing::info!("hello")); /// assert!(output.contains("hello")); /// ``` -#[allow(dead_code, reason = "compiled as its own crate during linting")] +#[allow(unfulfilled_lint_expectations, reason = "compiled only for logging tests")] +#[expect(dead_code, reason = "compiled as its own crate during linting")] pub fn capture_logs(level: Level, f: F) -> String where F: FnOnce(), @@ -105,6 +108,7 @@ where /// specified as the first argument. /// /// Returns the temporary directory and the path to the executable. +#[allow(unfulfilled_lint_expectations, reason = "used only in directory tests")] #[expect(dead_code, reason = "used only in directory tests")] pub fn fake_ninja_pwd() -> (TempDir, PathBuf) { let dir = TempDir::new().expect("temp dir");