From 6beb3a04d6a8f6ba04e3d61ac61759fa2ba99c1c Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 2 Aug 2025 20:34:45 +0100 Subject: [PATCH 01/14] Invoke ninja subprocess --- docs/netsuke-design.md | 6 +++ docs/roadmap.md | 4 +- src/ir.rs | 15 +++--- src/main.rs | 15 +++++- src/runner.rs | 56 +++++++++++++++++++--- tests/cucumber.rs | 14 ++++++ tests/features/ninja_process.feature | 19 ++++++++ tests/runner_tests.rs | 64 +++++++++++++++++++++++++ tests/steps/mod.rs | 1 + tests/steps/process_steps.rs | 72 ++++++++++++++++++++++++++++ 10 files changed, 246 insertions(+), 20 deletions(-) create mode 100644 tests/features/ninja_process.feature create mode 100644 tests/runner_tests.rs create mode 100644 tests/steps/process_steps.rs diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 7237cce3..6df4ae66 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1155,6 +1155,12 @@ The command construction will follow this pattern: streamed to the user's console, potentially with additional formatting or status updates from Netsuke itself. +In the initial implementation a small helper wraps `Command::new` to forward +the `-j` and `-C` flags and any explicit build targets. Standard output and +error are piped and written back to Netsuke's own streams so users see Ninja's +messages in order. A non-zero exit status or failure to spawn the process is +reported as an `io::Error` for the CLI to surface. + ### 6.2 The Criticality of Shell Escaping A primary security responsibility for Netsuke is the prevention of command diff --git a/docs/roadmap.md b/docs/roadmap.md index dd08fcde..22038c39 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -61,8 +61,8 @@ compilation pipeline from parsing to execution. - [x] Write logic to generate Ninja rule statements from ir::Action structs and build statements from ir::BuildEdge structs. *(done)* - - [ ] Implement the process management logic in `main.rs` to invoke the ninja - executable as a subprocess using `std::process::Command`. + - [x] Implement the process management logic in `main.rs` to invoke the ninja + executable as a subprocess using `std::process::Command`. *(done)* - **Success Criterion:** diff --git a/src/ir.rs b/src/ir.rs index 9fb84c35..cf5fbbf6 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -323,8 +323,7 @@ fn find_cycle(targets: &HashMap) -> Option> { stack.push(node.clone()); if let Some(edge) = targets.get(node) { - let deps_result = visit_dependencies(targets, &edge.inputs, stack, states); - if let Some(cycle) = deps_result { + if let Some(cycle) = visit_dependencies(targets, &edge.inputs, stack, states) { return Some(cycle); } } @@ -342,8 +341,7 @@ fn find_cycle(targets: &HashMap) -> Option> { ) -> Option> { for dep in deps { if targets.contains_key(dep) { - let visit_result = visit(targets, dep, stack, states); - if let Some(cycle) = visit_result { + if let Some(cycle) = visit(targets, dep, stack, states) { return Some(cycle); } } @@ -355,11 +353,10 @@ fn find_cycle(targets: &HashMap) -> Option> { let mut stack = Vec::new(); for node in targets.keys() { - if states.contains_key(node) { - continue; - } - if let Some(cycle) = visit(targets, node, &mut stack, &mut states) { - return Some(cycle); + if !states.contains_key(node) { + if let Some(cycle) = visit(targets, node, &mut stack, &mut states) { + return Some(cycle); + } } } None diff --git a/src/main.rs b/src/main.rs index f72c2a2e..61d6c077 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,17 @@ +//! Application entry point. +//! +//! Parses command-line arguments and delegates execution to [`runner::run`]. + use netsuke::{cli::Cli, runner}; +use std::process::ExitCode; -fn main() { +fn main() -> ExitCode { let cli = Cli::parse_with_default(); - runner::run(cli); + match runner::run(&cli) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{err}"); + ExitCode::FAILURE + } + } } diff --git a/src/runner.rs b/src/runner.rs index 1abdf777..9d7ba53b 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,23 +1,65 @@ //! CLI execution and command dispatch logic. //! //! This module keeps [`main`] minimal by providing a single entry point that -//! handles command execution. It currently prints which command was invoked. +//! handles command execution. It now delegates build requests to the Ninja +//! subprocess, streaming its output back to the user. use crate::cli::{Cli, Commands}; +use std::io::{self, Write}; +use std::path::Path; +use std::process::Command; /// Execute the parsed [`Cli`] commands. -pub fn run(cli: Cli) { - match cli.command.unwrap_or(Commands::Build { +/// +/// # 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<()> { + let command = cli.command.clone().unwrap_or(Commands::Build { targets: Vec::new(), - }) { - Commands::Build { targets } => { - println!("Building targets: {targets:?}"); - } + }); + match command { + Commands::Build { targets } => run_ninja(Path::new("ninja"), cli, &targets), Commands::Clean => { println!("Clean requested"); + Ok(()) } Commands::Graph => { println!("Graph requested"); + Ok(()) } } } + +/// 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. +/// +/// # Errors +/// +/// Returns an [`io::Error`] if the Ninja process fails to spawn or reports a +/// non-zero exit status. +pub fn run_ninja(program: &Path, cli: &Cli, targets: &[String]) -> io::Result<()> { + let mut cmd = Command::new(program); + if let Some(dir) = &cli.directory { + cmd.current_dir(dir).arg("-C").arg(dir); + } + if let Some(jobs) = cli.jobs { + cmd.arg("-j").arg(jobs.to_string()); + } + cmd.args(targets); + + let output = cmd.output()?; + io::stdout().write_all(&output.stdout)?; + io::stderr().write_all(&output.stderr)?; + if output.status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "ninja exited with {}", + output.status + ))) + } +} diff --git a/tests/cucumber.rs b/tests/cucumber.rs index 56c422d8..8530367f 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -8,10 +8,24 @@ pub struct CliWorld { pub manifest_error: Option, pub build_graph: Option, pub ninja: Option, + pub run_status: Option, + pub run_error: Option, + pub temp: Option, + pub original_path: Option, } mod steps; +impl Drop for CliWorld { + fn drop(&mut self) { + if let Some(path) = self.original_path.take() { + unsafe { + std::env::set_var("PATH", path); + } + } + } +} + #[tokio::main] async fn main() { CliWorld::run("tests/features").await; diff --git a/tests/features/ninja_process.feature b/tests/features/ninja_process.feature new file mode 100644 index 00000000..9ef46898 --- /dev/null +++ b/tests/features/ninja_process.feature @@ -0,0 +1,19 @@ +Feature: Ninja process execution + + Scenario: Ninja succeeds + Given a fake ninja executable that exits with 0 + And the CLI is parsed with "" + When the ninja process is run + Then the command should succeed + + Scenario: Ninja fails + Given a fake ninja executable that exits with 1 + And the CLI is parsed with "" + When the ninja process is run + Then the command should fail + + Scenario: Ninja missing + Given no ninja executable is available + And the CLI is parsed with "" + When the ninja process is run + Then the command should fail diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs new file mode 100644 index 00000000..677f4a32 --- /dev/null +++ b/tests/runner_tests.rs @@ -0,0 +1,64 @@ +//! Unit tests for Ninja process invocation. + +use netsuke::cli::{Cli, Commands}; +use netsuke::runner; +use rstest::rstest; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn test_cli() -> Cli { + Cli { + file: PathBuf::from("Netsukefile"), + directory: None, + jobs: None, + command: Some(Commands::Build { + targets: Vec::new(), + }), + } +} + +struct FakeNinja { + _dir: TempDir, + path: PathBuf, +} + +impl FakeNinja { + fn new(exit_code: i32) -> Self { + 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\nexit {exit_code}").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"); + } + Self { _dir: dir, path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +#[rstest] +#[case(0, true)] +#[case(1, false)] +fn run_ninja_status(#[case] code: i32, #[case] succeeds: bool) { + let fake = FakeNinja::new(code); + let cli = test_cli(); + let result = runner::run_ninja(fake.path(), &cli, &[]); + assert_eq!(result.is_ok(), succeeds); +} + +#[rstest] +fn run_ninja_not_found() { + let cli = test_cli(); + let err = + runner::run_ninja(Path::new("does-not-exist"), &cli, &[]).expect_err("process should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index cb30bcdf..3be671dc 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -2,3 +2,4 @@ mod cli_steps; mod ir_steps; mod manifest_steps; mod ninja_steps; +mod process_steps; diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs new file mode 100644 index 00000000..1ceff4c8 --- /dev/null +++ b/tests/steps/process_steps.rs @@ -0,0 +1,72 @@ +//! Step definitions for Ninja process execution. + +use crate::CliWorld; +use cucumber::{given, then, when}; +use netsuke::runner; +use std::fs::{self, File}; +use std::io::Write; + +#[given(expr = "a fake ninja executable that exits with {int}")] +fn fake_ninja(world: &mut CliWorld, code: i32) { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("ninja"); + let mut file = File::create(&path).expect("script"); + writeln!(file, "#!/bin/sh\nexit {code}").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"); + } + if world.original_path.is_none() { + world.original_path = Some(std::env::var_os("PATH").unwrap_or_default()); + } + let dir_path = dir.path().display().to_string(); + let new_path = if let Some(old) = world.original_path.as_ref() { + format!("{dir_path}:{}", old.to_string_lossy()) + } else { + dir_path + }; + unsafe { + std::env::set_var("PATH", new_path); + } + world.temp = Some(dir); +} + +#[given("no ninja executable is available")] +fn no_ninja(world: &mut CliWorld) { + let dir = tempfile::tempdir().expect("temp dir"); + if world.original_path.is_none() { + world.original_path = Some(std::env::var_os("PATH").unwrap_or_default()); + } + unsafe { + std::env::set_var("PATH", dir.path()); + } + world.temp = Some(dir); +} + +#[when("the ninja process is run")] +fn run(world: &mut CliWorld) { + let cli = world.cli.as_ref().expect("cli"); + match runner::run(cli) { + Ok(()) => { + world.run_status = Some(true); + world.run_error = None; + } + Err(e) => { + world.run_status = Some(false); + world.run_error = Some(e.to_string()); + } + } +} + +#[then("the command should succeed")] +fn command_should_succeed(world: &mut CliWorld) { + assert_eq!(world.run_status, Some(true)); +} + +#[then("the command should fail")] +fn command_should_fail(world: &mut CliWorld) { + assert_eq!(world.run_status, Some(false)); +} From 7d861d54545af9ff0b3478f005af0eebdf8e0db8 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 4 Aug 2025 00:33:49 +0100 Subject: [PATCH 02/14] Stream Ninja output without buffering --- src/ir.rs | 19 ++++++----- src/runner.rs | 44 +++++++++++++++++++------ tests/cucumber.rs | 11 ------- tests/features/ninja_process.feature | 4 +-- tests/runner_tests.rs | 1 + tests/steps/process_steps.rs | 48 +++++++++++++++++----------- 6 files changed, 77 insertions(+), 50 deletions(-) diff --git a/src/ir.rs b/src/ir.rs index cf5fbbf6..cdbda617 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -322,10 +322,11 @@ fn find_cycle(targets: &HashMap) -> Option> { stack.push(node.clone()); - if let Some(edge) = targets.get(node) { - if let Some(cycle) = visit_dependencies(targets, &edge.inputs, stack, states) { - return Some(cycle); - } + if let Some(cycle) = targets + .get(node) + .and_then(|edge| visit_dependencies(targets, &edge.inputs, stack, states)) + { + return Some(cycle); } stack.pop(); @@ -340,10 +341,12 @@ fn find_cycle(targets: &HashMap) -> Option> { states: &mut HashMap, ) -> Option> { for dep in deps { - if targets.contains_key(dep) { - if let Some(cycle) = visit(targets, dep, stack, states) { - return Some(cycle); - } + if let Some(cycle) = targets + .contains_key(dep) + .then(|| visit(targets, dep, stack, states)) + .flatten() + { + return Some(cycle); } } None diff --git a/src/runner.rs b/src/runner.rs index 9d7ba53b..a7252e22 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -5,9 +5,10 @@ //! subprocess, streaming its output back to the user. use crate::cli::{Cli, Commands}; -use std::io::{self, Write}; +use std::io::{self, BufRead, BufReader, Write}; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::thread; /// Execute the parsed [`Cli`] commands. /// @@ -41,6 +42,10 @@ pub fn run(cli: &Cli) -> io::Result<()> { /// /// Returns an [`io::Error`] if the Ninja process fails to spawn or reports a /// non-zero exit status. +/// +/// # Panics +/// +/// Panics if the child's output streams cannot be captured. pub fn run_ninja(program: &Path, cli: &Cli, targets: &[String]) -> io::Result<()> { let mut cmd = Command::new(program); if let Some(dir) = &cli.directory { @@ -50,16 +55,35 @@ pub fn run_ninja(program: &Path, cli: &Cli, targets: &[String]) -> io::Result<() cmd.arg("-j").arg(jobs.to_string()); } cmd.args(targets); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + let stdout = child.stdout.take().expect("child stdout"); + let stderr = child.stderr.take().expect("child stderr"); + + let out_handle = thread::spawn(move || { + let reader = BufReader::new(stdout); + let mut handle = io::stdout(); + for line in reader.lines().map_while(Result::ok) { + let _ = writeln!(handle, "{line}"); + } + }); + let err_handle = thread::spawn(move || { + let reader = BufReader::new(stderr); + let mut handle = io::stderr(); + for line in reader.lines().map_while(Result::ok) { + let _ = writeln!(handle, "{line}"); + } + }); + + let status = child.wait()?; + let _ = out_handle.join(); + let _ = err_handle.join(); - let output = cmd.output()?; - io::stdout().write_all(&output.stdout)?; - io::stderr().write_all(&output.stderr)?; - if output.status.success() { + if status.success() { Ok(()) } else { - Err(io::Error::other(format!( - "ninja exited with {}", - output.status - ))) + Err(io::Error::other(format!("ninja exited with {status}"))) } } diff --git a/tests/cucumber.rs b/tests/cucumber.rs index 8530367f..315d4dbd 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -11,21 +11,10 @@ pub struct CliWorld { pub run_status: Option, pub run_error: Option, pub temp: Option, - pub original_path: Option, } mod steps; -impl Drop for CliWorld { - fn drop(&mut self) { - if let Some(path) = self.original_path.take() { - unsafe { - std::env::set_var("PATH", path); - } - } - } -} - #[tokio::main] async fn main() { CliWorld::run("tests/features").await; diff --git a/tests/features/ninja_process.feature b/tests/features/ninja_process.feature index 9ef46898..c8502804 100644 --- a/tests/features/ninja_process.feature +++ b/tests/features/ninja_process.feature @@ -10,10 +10,10 @@ Feature: Ninja process execution Given a fake ninja executable that exits with 1 And the CLI is parsed with "" When the ninja process is run - Then the command should fail + Then the command should fail with error "ninja exited with exit status: 1" Scenario: Ninja missing Given no ninja executable is available And the CLI is parsed with "" When the ninja process is run - Then the command should fail + Then the command should fail with error "No such file or directory" diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index 677f4a32..4f6fb364 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -8,6 +8,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use tempfile::TempDir; +/// Creates a default CLI configuration for testing Ninja invocation. fn test_cli() -> Cli { Cli { file: PathBuf::from("Netsukefile"), diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 1ceff4c8..3958c7af 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -19,37 +19,25 @@ fn fake_ninja(world: &mut CliWorld, code: i32) { perms.set_mode(0o755); fs::set_permissions(&path, perms).expect("perms"); } - if world.original_path.is_none() { - world.original_path = Some(std::env::var_os("PATH").unwrap_or_default()); - } - let dir_path = dir.path().display().to_string(); - let new_path = if let Some(old) = world.original_path.as_ref() { - format!("{dir_path}:{}", old.to_string_lossy()) - } else { - dir_path - }; - unsafe { - std::env::set_var("PATH", new_path); - } + world.ninja = Some(path.to_string_lossy().into_owned()); world.temp = Some(dir); } #[given("no ninja executable is available")] fn no_ninja(world: &mut CliWorld) { let dir = tempfile::tempdir().expect("temp dir"); - if world.original_path.is_none() { - world.original_path = Some(std::env::var_os("PATH").unwrap_or_default()); - } - unsafe { - std::env::set_var("PATH", dir.path()); - } + world.ninja = Some(dir.path().join("ninja").to_string_lossy().into_owned()); world.temp = Some(dir); } #[when("the ninja process is run")] fn run(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); - match runner::run(cli) { + let program = world + .ninja + .as_ref() + .map_or_else(|| std::path::Path::new("ninja"), std::path::Path::new); + match runner::run_ninja(program, cli, &[]) { Ok(()) => { world.run_status = Some(true); world.run_error = None; @@ -69,4 +57,26 @@ fn command_should_succeed(world: &mut CliWorld) { #[then("the command should fail")] fn command_should_fail(world: &mut CliWorld) { assert_eq!(world.run_status, Some(false)); + assert!( + world.run_error.is_some(), + "Expected an error message, but none was found" + ); +} + +/// Asserts that the command failed and the error message matches the expected value. +#[allow( + clippy::needless_pass_by_value, + reason = "cucumber step parameters require owned Strings" +)] +#[then(expr = "the command should fail with error {string}")] +fn command_should_fail_with_error(world: &mut CliWorld, expected: String) { + assert_eq!(world.run_status, Some(false)); + let actual = world + .run_error + .as_ref() + .expect("Expected an error message, but none was found"); + assert!( + actual.contains(&expected), + "Expected error message to contain '{expected}', but got '{actual}'" + ); } From 9bb8cbb9fca0e57aabf59845596e71ef992db2be Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 4 Aug 2025 01:34:47 +0100 Subject: [PATCH 03/14] Skip visited nodes during cycle detection --- src/ir.rs | 10 ++++++---- src/runner.rs | 9 ++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/ir.rs b/src/ir.rs index cdbda617..c38a3019 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -356,10 +356,12 @@ fn find_cycle(targets: &HashMap) -> Option> { let mut stack = Vec::new(); for node in targets.keys() { - if !states.contains_key(node) { - if let Some(cycle) = visit(targets, node, &mut stack, &mut states) { - return Some(cycle); - } + // Skip nodes we've already processed to avoid redundant traversal. + if states.contains_key(node) { + continue; + } + if let Some(cycle) = visit(targets, node, &mut stack, &mut states) { + return Some(cycle); } } None diff --git a/src/runner.rs b/src/runner.rs index a7252e22..3fe27167 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -84,6 +84,13 @@ pub fn run_ninja(program: &Path, cli: &Cli, targets: &[String]) -> io::Result<() if status.success() { Ok(()) } else { - Err(io::Error::other(format!("ninja exited with {status}"))) + #[allow( + clippy::io_other_error, + reason = "use explicit error kind for compatibility with older Rust" + )] + Err(io::Error::new( + io::ErrorKind::Other, + format!("ninja exited with {status}"), + )) } } From f52e184ea5002fbc433367cc22a4c7f6ba89ea53 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 4 Aug 2025 09:09:21 +0100 Subject: [PATCH 04/14] Share fake Ninja helper and document steps --- tests/cucumber.rs | 8 ++++++++ tests/runner_tests.rs | 33 +++------------------------------ tests/steps/process_steps.rs | 33 +++++++++++++++++---------------- tests/support/mod.rs | 24 ++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 tests/support/mod.rs diff --git a/tests/cucumber.rs b/tests/cucumber.rs index 315d4dbd..c7cb2036 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -1,5 +1,8 @@ +//! Cucumber test runner and world state. + use cucumber::World; +/// Shared state for Cucumber scenarios. #[derive(Debug, Default, World)] pub struct CliWorld { pub cli: Option, @@ -8,12 +11,17 @@ pub struct CliWorld { pub manifest_error: Option, pub build_graph: Option, pub ninja: Option, + /// Status of the last process execution (true for success, false for + /// failure). pub run_status: Option, + /// Error message from the last failed process execution. pub run_error: Option, + /// Temporary directory handle for test isolation. pub temp: Option, } mod steps; +mod support; #[tokio::main] async fn main() { diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index 4f6fb364..79cb9a82 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -3,10 +3,7 @@ use netsuke::cli::{Cli, Commands}; use netsuke::runner; use rstest::rstest; -use std::fs::{self, File}; -use std::io::Write; use std::path::{Path, PathBuf}; -use tempfile::TempDir; /// Creates a default CLI configuration for testing Ninja invocation. fn test_cli() -> Cli { @@ -20,39 +17,15 @@ fn test_cli() -> Cli { } } -struct FakeNinja { - _dir: TempDir, - path: PathBuf, -} - -impl FakeNinja { - fn new(exit_code: i32) -> Self { - 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\nexit {exit_code}").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"); - } - Self { _dir: dir, path } - } - - fn path(&self) -> &Path { - &self.path - } -} +mod support; #[rstest] #[case(0, true)] #[case(1, false)] fn run_ninja_status(#[case] code: i32, #[case] succeeds: bool) { - let fake = FakeNinja::new(code); + let (_dir, path) = support::fake_ninja(code); let cli = test_cli(); - let result = runner::run_ninja(fake.path(), &cli, &[]); + let result = runner::run_ninja(&path, &cli, &[]); assert_eq!(result.is_ok(), succeeds); } diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 3958c7af..5a460575 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -1,28 +1,22 @@ //! Step definitions for Ninja process execution. -use crate::CliWorld; +use crate::{CliWorld, support}; use cucumber::{given, then, when}; use netsuke::runner; -use std::fs::{self, File}; -use std::io::Write; +/// Creates a fake ninja executable that exits with the given status code. #[given(expr = "a fake ninja executable that exits with {int}")] fn fake_ninja(world: &mut CliWorld, code: i32) { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("ninja"); - let mut file = File::create(&path).expect("script"); - writeln!(file, "#!/bin/sh\nexit {code}").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"); - } + let (dir, path) = support::fake_ninja(code); world.ninja = Some(path.to_string_lossy().into_owned()); world.temp = Some(dir); } +/// Sets up a scenario where no ninja executable is available. +/// +/// This step creates a temporary directory and sets the ninja path to a +/// non-existent executable within that directory, allowing tests to verify +/// behaviour when ninja is not found on the system. #[given("no ninja executable is available")] fn no_ninja(world: &mut CliWorld) { let dir = tempfile::tempdir().expect("temp dir"); @@ -30,6 +24,11 @@ fn no_ninja(world: &mut CliWorld) { world.temp = Some(dir); } +/// Executes the ninja process and captures the result in the test world. +/// +/// This step runs the ninja executable (either real or fake) using the CLI +/// configuration stored in the world, then updates the world's `run_status` and +/// `run_error` fields based on the execution outcome. #[when("the ninja process is run")] fn run(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); @@ -49,17 +48,19 @@ fn run(world: &mut CliWorld) { } } +/// Asserts that the command succeeds. #[then("the command should succeed")] fn command_should_succeed(world: &mut CliWorld) { assert_eq!(world.run_status, Some(true)); } +/// Asserts that the command fails and records an error message. #[then("the command should fail")] fn command_should_fail(world: &mut CliWorld) { assert_eq!(world.run_status, Some(false)); assert!( world.run_error.is_some(), - "Expected an error message, but none was found" + "Expected an error message, but none was found", ); } @@ -77,6 +78,6 @@ fn command_should_fail_with_error(world: &mut CliWorld, expected: String) { .expect("Expected an error message, but none was found"); assert!( actual.contains(&expected), - "Expected error message to contain '{expected}', but got '{actual}'" + "Expected error message to contain '{expected}', but got '{actual}'", ); } diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 00000000..722f3cae --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,24 @@ +//! Test utilities for process management. + +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; +use tempfile::TempDir; + +/// Create a fake Ninja executable that exits with `exit_code`. +/// +/// Returns the temporary directory and the path to the executable. +pub fn fake_ninja(exit_code: i32) -> (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, "#!/bin/sh\nexit {exit_code}").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) +} From 386a0bf809738a4944bd9b05a2e782a0dd96153f Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 4 Aug 2025 12:44:02 +0100 Subject: [PATCH 05/14] Refine cycle check and tighten lint expectations --- src/ir.rs | 10 ++++------ src/runner.rs | 2 +- tests/steps/process_steps.rs | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/ir.rs b/src/ir.rs index c38a3019..deb4eefd 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -341,12 +341,10 @@ fn find_cycle(targets: &HashMap) -> Option> { states: &mut HashMap, ) -> Option> { for dep in deps { - if let Some(cycle) = targets - .contains_key(dep) - .then(|| visit(targets, dep, stack, states)) - .flatten() - { - return Some(cycle); + if targets.contains_key(dep) { + if let Some(cycle) = visit(targets, dep, stack, states) { + return Some(cycle); + } } } None diff --git a/src/runner.rs b/src/runner.rs index 3fe27167..a90b92d7 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -84,7 +84,7 @@ pub fn run_ninja(program: &Path, cli: &Cli, targets: &[String]) -> io::Result<() if status.success() { Ok(()) } else { - #[allow( + #[expect( clippy::io_other_error, reason = "use explicit error kind for compatibility with older Rust" )] diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 5a460575..55f60731 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -65,7 +65,7 @@ fn command_should_fail(world: &mut CliWorld) { } /// Asserts that the command failed and the error message matches the expected value. -#[allow( +#[expect( clippy::needless_pass_by_value, reason = "cucumber step parameters require owned Strings" )] From bc9047aeaa4885dedb795bc140b62c8cdf6f1c51 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 4 Aug 2025 17:29:28 +0100 Subject: [PATCH 06/14] Document world fields and simplify process steps --- tests/cucumber.rs | 1 + tests/steps/cli_steps.rs | 4 +++- tests/steps/process_steps.rs | 12 ++++++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/cucumber.rs b/tests/cucumber.rs index c7cb2036..80db8a7d 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -10,6 +10,7 @@ pub struct CliWorld { pub manifest: Option, pub manifest_error: Option, pub build_graph: Option, + /// Generated Ninja file content. pub ninja: Option, /// Status of the last process execution (true for success, false for /// failure). diff --git a/tests/steps/cli_steps.rs b/tests/steps/cli_steps.rs index f31643d8..984e5b82 100644 --- a/tests/steps/cli_steps.rs +++ b/tests/steps/cli_steps.rs @@ -5,7 +5,7 @@ use crate::CliWorld; use clap::Parser; -use cucumber::{then, when}; +use cucumber::{given, then, when}; use netsuke::cli::{Cli, Commands}; use std::path::PathBuf; @@ -42,6 +42,7 @@ fn extract_build(world: &CliWorld) -> Option<&Vec> { clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] +#[given(expr = "the CLI is parsed with {string}")] #[when(expr = "the CLI is parsed with {string}")] fn parse_cli(world: &mut CliWorld, args: String) { apply_cli(world, &args); @@ -51,6 +52,7 @@ fn parse_cli(world: &mut CliWorld, args: String) { clippy::needless_pass_by_value, reason = "Cucumber requires owned String arguments" )] +#[given(expr = "the CLI is parsed with invalid arguments {string}")] #[when(expr = "the CLI is parsed with invalid arguments {string}")] fn parse_cli_invalid(world: &mut CliWorld, args: String) { apply_cli(world, &args); diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 55f60731..db1887da 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -14,9 +14,9 @@ fn fake_ninja(world: &mut CliWorld, code: i32) { /// Sets up a scenario where no ninja executable is available. /// -/// This step creates a temporary directory and sets the ninja path to a -/// non-existent executable within that directory, allowing tests to verify -/// behaviour when ninja is not found on the system. +/// This step creates a temporary directory and records the path to a +/// non-existent `ninja` binary within that directory, allowing tests to verify +/// behaviour when the executable is missing. #[given("no ninja executable is available")] fn no_ninja(world: &mut CliWorld) { let dir = tempfile::tempdir().expect("temp dir"); @@ -26,9 +26,9 @@ fn no_ninja(world: &mut CliWorld) { /// Executes the ninja process and captures the result in the test world. /// -/// This step runs the ninja executable (either real or fake) using the CLI -/// configuration stored in the world, then updates the world's `run_status` and -/// `run_error` fields based on the execution outcome. +/// This step runs the `ninja` executable using the CLI configuration stored in +/// the world, then updates the world's `run_status` and `run_error` fields based +/// on the execution outcome. #[when("the ninja process is run")] fn run(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); From 1a4cb9d9e81b0a745c24d3bc78a311f808b9a811 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 4 Aug 2025 18:39:20 +0100 Subject: [PATCH 07/14] Refactor PATH handling in process steps --- tests/cucumber.rs | 13 +++++++++++++ tests/steps/process_steps.rs | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/cucumber.rs b/tests/cucumber.rs index 80db8a7d..a5559003 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -19,6 +19,19 @@ pub struct CliWorld { pub run_error: Option, /// Temporary directory handle for test isolation. pub temp: Option, + /// Original `PATH` value restored after each scenario. + pub original_path: Option, +} + +impl Drop for CliWorld { + fn drop(&mut self) { + if let Some(path) = self.original_path.as_ref() { + // SAFETY: restoring the environment ensures isolation between tests. + unsafe { + std::env::set_var("PATH", path); + } + } + } } mod steps; diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index db1887da..88ec84c8 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -4,10 +4,28 @@ use crate::{CliWorld, support}; use cucumber::{given, then, when}; use netsuke::runner; +/// Saves the original `PATH` and installs a test-specific value. +fn set_test_path(world: &mut CliWorld, new_path: impl AsRef) { + if world.original_path.is_none() { + world.original_path = Some(std::env::var_os("PATH").unwrap_or_default()); + } + // SAFETY: tests require PATH overrides to exercise process lookup. + unsafe { + std::env::set_var("PATH", new_path); + } +} + /// Creates a fake ninja executable that exits with the given status code. #[given(expr = "a fake ninja executable that exits with {int}")] fn fake_ninja(world: &mut CliWorld, code: i32) { let (dir, path) = support::fake_ninja(code); + let dir_path = dir.path().display().to_string(); + let new_path = if let Some(old) = world.original_path.as_ref() { + format!("{dir_path}:{}", old.to_string_lossy()) + } else { + dir_path + }; + set_test_path(world, new_path); world.ninja = Some(path.to_string_lossy().into_owned()); world.temp = Some(dir); } @@ -20,6 +38,13 @@ fn fake_ninja(world: &mut CliWorld, code: i32) { #[given("no ninja executable is available")] fn no_ninja(world: &mut CliWorld) { let dir = tempfile::tempdir().expect("temp dir"); + let dir_path = dir.path().display().to_string(); + let new_path = if let Some(old) = world.original_path.as_ref() { + format!("{dir_path}:{}", old.to_string_lossy()) + } else { + dir_path + }; + set_test_path(world, new_path); world.ninja = Some(dir.path().join("ninja").to_string_lossy().into_owned()); world.temp = Some(dir); } From 32e563ccf10bf98e7d7efc44c411e282d7e5547e Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 4 Aug 2025 19:39:52 +0100 Subject: [PATCH 08/14] Fix manifest example doctest --- src/ast.rs | 2 +- tests/steps/process_steps.rs | 49 ++++++++++++++++++------------------ 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/ast.rs b/src/ast.rs index 42d5e500..68f2ae76 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -10,7 +10,7 @@ //! use netsuke::ast::NetsukeManifest; //! use netsuke::ast::StringOrList; //! -//! let yaml = r#"netsuke_version: \"1.0.0\"\ntargets:\n - name: hello\n recipe:\n kind: command\n command: \"echo hi\""#; +//! let yaml = "netsuke_version: 1.0.0\ntargets:\n - name: hello\n recipe:\n kind: command\n command: \"echo hi\""; //! let manifest: NetsukeManifest = serde_yml::from_str(yaml).expect("parse"); //! if let StringOrList::String(name) = &manifest.targets[0].name { //! assert_eq!(name, "hello"); diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 88ec84c8..f881fda9 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -3,31 +3,37 @@ use crate::{CliWorld, support}; use cucumber::{given, then, when}; use netsuke::runner; +use std::path::PathBuf; +use tempfile::TempDir; + +/// Installs a test-specific ninja binary and updates the `PATH`. +#[expect( + clippy::needless_pass_by_value, + reason = "helper owns path for simplicity" +)] +fn install_test_ninja(world: &mut CliWorld, dir: TempDir, ninja_path: PathBuf) { + let original = world + .original_path + .get_or_insert_with(|| std::env::var_os("PATH").unwrap_or_default()); + + let mut new_path = std::ffi::OsString::from(dir.path()); + new_path.push(":"); + new_path.push(original); -/// Saves the original `PATH` and installs a test-specific value. -fn set_test_path(world: &mut CliWorld, new_path: impl AsRef) { - if world.original_path.is_none() { - world.original_path = Some(std::env::var_os("PATH").unwrap_or_default()); - } // SAFETY: tests require PATH overrides to exercise process lookup. unsafe { - std::env::set_var("PATH", new_path); + std::env::set_var("PATH", &new_path); } + + world.ninja = Some(ninja_path.to_string_lossy().into_owned()); + world.temp = Some(dir); } /// Creates a fake ninja executable that exits with the given status code. #[given(expr = "a fake ninja executable that exits with {int}")] fn fake_ninja(world: &mut CliWorld, code: i32) { let (dir, path) = support::fake_ninja(code); - let dir_path = dir.path().display().to_string(); - let new_path = if let Some(old) = world.original_path.as_ref() { - format!("{dir_path}:{}", old.to_string_lossy()) - } else { - dir_path - }; - set_test_path(world, new_path); - world.ninja = Some(path.to_string_lossy().into_owned()); - world.temp = Some(dir); + install_test_ninja(world, dir, path); } /// Sets up a scenario where no ninja executable is available. @@ -37,16 +43,9 @@ fn fake_ninja(world: &mut CliWorld, code: i32) { /// behaviour when the executable is missing. #[given("no ninja executable is available")] fn no_ninja(world: &mut CliWorld) { - let dir = tempfile::tempdir().expect("temp dir"); - let dir_path = dir.path().display().to_string(); - let new_path = if let Some(old) = world.original_path.as_ref() { - format!("{dir_path}:{}", old.to_string_lossy()) - } else { - dir_path - }; - set_test_path(world, new_path); - world.ninja = Some(dir.path().join("ninja").to_string_lossy().into_owned()); - world.temp = Some(dir); + let dir = TempDir::new().expect("temp dir"); + let path = dir.path().join("ninja"); + install_test_ninja(world, dir, path); } /// Executes the ninja process and captures the result in the test world. From 119db99b9ad0ef965836f729323bcfd3fbac5a54 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 4 Aug 2025 20:12:06 +0100 Subject: [PATCH 09/14] Refine process test path handling --- src/ast.rs | 4 ++-- tests/cucumber.rs | 4 ++-- tests/steps/process_steps.rs | 20 +++++++++++--------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/ast.rs b/src/ast.rs index 68f2ae76..640b428d 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -10,7 +10,7 @@ //! use netsuke::ast::NetsukeManifest; //! use netsuke::ast::StringOrList; //! -//! let yaml = "netsuke_version: 1.0.0\ntargets:\n - name: hello\n recipe:\n kind: command\n command: \"echo hi\""; +//! let yaml = "netsuke_version: \"1.0.0\"\ntargets:\n - name: hello\n recipe:\n kind: command\n command: \"echo hi\""; //! let manifest: NetsukeManifest = serde_yml::from_str(yaml).expect("parse"); //! if let StringOrList::String(name) = &manifest.targets[0].name { //! assert_eq!(name, "hello"); @@ -50,7 +50,7 @@ use std::collections::HashMap; /// ```rust /// use netsuke::ast::NetsukeManifest; /// # fn main() -> Result<(), Box> { -/// let yaml = "netsuke_version: 1.0.0\ntargets:\n - name: hello\n recipe:\n kind: command\n command: echo hi"; +/// let yaml = "netsuke_version: \"1.0.0\"\ntargets:\n - name: hello\n recipe:\n kind: command\n command: echo hi"; /// let manifest: NetsukeManifest = serde_yml::from_str(yaml)?; /// assert_eq!(manifest.targets.len(), 1); /// # Ok(()) } diff --git a/tests/cucumber.rs b/tests/cucumber.rs index a5559003..76e7885d 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -25,8 +25,8 @@ pub struct CliWorld { impl Drop for CliWorld { fn drop(&mut self) { - if let Some(path) = self.original_path.as_ref() { - // SAFETY: restoring the environment ensures isolation between tests. + if let Some(path) = self.original_path.take() { + // SAFETY: nightly marks `set_var` as unsafe; restore path for isolation. unsafe { std::env::set_var("PATH", path); } diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index f881fda9..679b4dd0 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -16,11 +16,8 @@ fn install_test_ninja(world: &mut CliWorld, dir: TempDir, ninja_path: PathBuf) { .original_path .get_or_insert_with(|| std::env::var_os("PATH").unwrap_or_default()); - let mut new_path = std::ffi::OsString::from(dir.path()); - new_path.push(":"); - new_path.push(original); - - // SAFETY: tests require PATH overrides to exercise process lookup. + let new_path = format!("{}:{}", dir.path().display(), original.to_string_lossy()); + // SAFETY: nightly marks `set_var` as unsafe; override path for test isolation. unsafe { std::env::set_var("PATH", &new_path); } @@ -53,13 +50,18 @@ fn no_ninja(world: &mut CliWorld) { /// This step runs the `ninja` executable using the CLI configuration stored in /// the world, then updates the world's `run_status` and `run_error` fields based /// on the execution outcome. +#[expect( + clippy::option_if_let_else, + reason = "explicit conditional is clearer than map_or_else", +)] #[when("the ninja process is run")] fn run(world: &mut CliWorld) { let cli = world.cli.as_ref().expect("cli"); - let program = world - .ninja - .as_ref() - .map_or_else(|| std::path::Path::new("ninja"), std::path::Path::new); + let program = if let Some(ninja) = &world.ninja { + std::path::Path::new(ninja) + } else { + std::path::Path::new("ninja") + }; match runner::run_ninja(program, cli, &[]) { Ok(()) => { world.run_status = Some(true); From 987a5ac1b6b4289df8e737960f06f348cdd3fa33 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 4 Aug 2025 20:21:15 +0100 Subject: [PATCH 10/14] Apply formatting --- tests/steps/process_steps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/steps/process_steps.rs b/tests/steps/process_steps.rs index 679b4dd0..6a34153a 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -52,7 +52,7 @@ fn no_ninja(world: &mut CliWorld) { /// on the execution outcome. #[expect( clippy::option_if_let_else, - reason = "explicit conditional is clearer than map_or_else", + reason = "explicit conditional is clearer than map_or_else" )] #[when("the ninja process is run")] fn run(world: &mut CliWorld) { From baab3810cfcb8b3bdf499b73f206458480e0b991 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 4 Aug 2025 20:25:30 +0100 Subject: [PATCH 11/14] Fix collapsible if statement --- src/ir.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ir.rs b/src/ir.rs index deb4eefd..296a2ef4 100644 --- a/src/ir.rs +++ b/src/ir.rs @@ -341,10 +341,10 @@ fn find_cycle(targets: &HashMap) -> Option> { states: &mut HashMap, ) -> Option> { for dep in deps { - if targets.contains_key(dep) { - if let Some(cycle) = visit(targets, dep, stack, states) { - return Some(cycle); - } + if targets.contains_key(dep) + && let Some(cycle) = visit(targets, dep, stack, states) + { + return Some(cycle); } } None From 17c45deae0017e358bacc8688a2f78859e5fd8a3 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 4 Aug 2025 20:25:54 +0100 Subject: [PATCH 12/14] Enable crush --- .gitignore | 1 + CRUSH.md | 1 + 2 files changed, 2 insertions(+) create mode 120000 CRUSH.md diff --git a/.gitignore b/.gitignore index 324c57f7..221eb3b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ **/*.rs.bk +.crush diff --git a/CRUSH.md b/CRUSH.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CRUSH.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 29f3e1338945a19fc9509ac3e68781bd80a4bbc2 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 4 Aug 2025 20:29:15 +0100 Subject: [PATCH 13/14] Simplify ci workflow --- .github/workflows/ci.yml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbe43610..21ed9fff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,26 +17,24 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Rust - uses: leynos/shared-actions/.github/actions/setup-rust@v1.1.0 + uses: leynos/shared-actions/.github/actions/setup-rust@c6559452842af6a83b83429129dccaf910e34562 - name: Show Ninja version run: ninja --version - name: Format run: make check-fmt - name: Lint run: make lint - - name: Test - run: make test - - name: Install cargo-tarpaulin - run: cargo install cargo-tarpaulin - - name: Run coverage - run: cargo tarpaulin --out lcov + - name: Test and Measure Coverage + uses: leynos/shared-actions/.github/actions/generate-coverage@c6559452842af6a83b83429129dccaf910e34562 + with: + output-path: lcov.info + format: lcov - name: Upload coverage data to CodeScene env: CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }} - if: ${{ env.CS_ACCESS_TOKEN != '' }} - uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@v1.2.1 + if: ${{ env.CS_ACCESS_TOKEN }} + uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@c6559452842af6a83b83429129dccaf910e34562 with: format: lcov access-token: ${{ env.CS_ACCESS_TOKEN }} installer-checksum: ${{ vars.CODESCENE_CLI_SHA256 }} - From 826876c6d03889b1660627050520db955c66493a Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 4 Aug 2025 20:48:12 +0100 Subject: [PATCH 14/14] Run tests in isolation --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ed9fff..8a556e38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: run: make check-fmt - name: Lint run: make lint + - name: Test + run: make test - name: Test and Measure Coverage uses: leynos/shared-actions/.github/actions/generate-coverage@c6559452842af6a83b83429129dccaf910e34562 with: