diff --git a/AGENTS.md b/AGENTS.md index 4646a738..9864cadf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,10 @@ project: - Use `rstest` fixtures for shared setup. - Replace duplicated tests with `#[rstest(...)]` parameterised cases. - Prefer `mockall` for mocks/stubs. +- Mock non-deterministic dependencies (e.g., environment variables and the + system clock) using dependency injection with the `mockable` crate (traits + like `Env` and `Clock`) where appropriate. See + `docs/reliable-testing-in-rust-via-dependency-injection.md` for guidance. - Prefer `.expect()` over `.unwrap()`. - Use `concat!()` to combine long string literals rather than escaping newlines with a backslash. diff --git a/Cargo.lock b/Cargo.lock index 26605112..d7f8b449 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,7 +92,7 @@ dependencies = [ "bstr", "doc-comment", "libc", - "predicates", + "predicates 3.1.3", "predicates-core", "predicates-tree", "wait-timeout", @@ -106,7 +106,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -206,7 +206,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -298,7 +298,7 @@ dependencies = [ "globwalk", "humantime", "inventory", - "itertools", + "itertools 0.12.1", "lazy-regex", "linked-hash-map", "once_cell", @@ -316,11 +316,11 @@ checksum = "01091e28d1f566c8b31b67948399d2efd6c0a8f6228a9785519ed7b73f7f0aef" dependencies = [ "cucumber-expressions", "inflections", - "itertools", + "itertools 0.12.1", "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.104", "synthez", ] @@ -346,7 +346,7 @@ checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -371,6 +371,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "drain_filter_polyfill" version = "0.1.3" @@ -411,6 +417,21 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "futures" version = "0.3.31" @@ -467,7 +488,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -539,7 +560,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn", + "syn 2.0.104", "textwrap", "thiserror", "typed-builder", @@ -675,6 +696,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -710,7 +740,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.104", ] [[package]] @@ -804,6 +834,43 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mockable" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce05993e13a317aef6ba5be77bb84b7e5b6bf67a7ba9a5168754c6a7aa3921da" +dependencies = [ + "mockall", + "tracing", +] + +[[package]] +name = "mockall" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates 2.1.5", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "netsuke" version = "0.1.0" @@ -813,9 +880,10 @@ dependencies = [ "clap", "cucumber", "insta", - "itertools", + "itertools 0.12.1", "itoa", "minijinja", + "mockable", "rstest", "semver", "serde", @@ -852,6 +920,12 @@ dependencies = [ "nom", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -862,6 +936,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.7" @@ -956,7 +1039,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -971,6 +1054,20 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "predicates" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" +dependencies = [ + "difflib", + "float-cmp", + "itertools 0.10.5", + "normalize-line-endings", + "predicates-core", + "regex", +] + [[package]] name = "predicates" version = "3.1.3" @@ -1097,7 +1194,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.104", "unicode-ident", ] @@ -1186,7 +1283,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1215,7 +1312,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1278,7 +1375,7 @@ checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1327,7 +1424,7 @@ checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1342,6 +1439,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.104" @@ -1359,7 +1467,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" dependencies = [ - "syn", + "syn 2.0.104", "synthez-codegen", "synthez-core", ] @@ -1370,7 +1478,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" dependencies = [ - "syn", + "syn 2.0.104", "synthez-core", ] @@ -1383,7 +1491,7 @@ dependencies = [ "proc-macro2", "quote", "sealed", - "syn", + "syn 2.0.104", ] [[package]] @@ -1443,7 +1551,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1478,7 +1586,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1500,7 +1608,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] @@ -1555,7 +1663,7 @@ checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.104", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a5a97648..048b393d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,8 +65,9 @@ rstest = "0.18.0" 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" +mockable = { version = "0.3", features = ["mock"] } +serial_test = "3" [[test]] name = "cucumber" diff --git a/src/runner.rs b/src/runner.rs index ff170246..197c4968 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -21,6 +21,34 @@ pub const NINJA_PROGRAM: &str = "ninja"; /// Environment variable override for the Ninja executable. pub const NINJA_ENV: &str = "NETSUKE_NINJA"; +// Public helpers for doctests only. This exposes internal helpers as a stable +// testing surface without exporting them in release builds. +#[doc(hidden)] +pub mod doc { + #[allow(unused_imports, reason = "doctest-only wrapper module")] + use super::*; + + // Public wrappers to expose crate-private helpers to doctests. + #[must_use] + pub fn contains_sensitive_keyword(arg: &CommandArg) -> bool { + super::contains_sensitive_keyword(arg) + } + #[must_use] + pub fn is_sensitive_arg(arg: &CommandArg) -> bool { + super::is_sensitive_arg(arg) + } + #[must_use] + pub fn redact_argument(arg: &CommandArg) -> CommandArg { + super::redact_argument(arg) + } + #[must_use] + pub fn redact_sensitive_args(args: &[CommandArg]) -> Vec { + super::redact_sensitive_args(args) + } + + pub use super::CommandArg; +} + #[derive(Debug, Clone)] pub struct NinjaContent(String); impl NinjaContent { @@ -163,6 +191,12 @@ fn create_temp_ninja_file(content: &NinjaContent) -> Result { /// write_ninja_file(Path::new("out.ninja"), &content).unwrap(); /// ``` fn write_ninja_file(path: &Path, content: &NinjaContent) -> Result<()> { + // Ensure the parent directory exists; guard against empty components so we + // do not attempt to create the current directory on some platforms. + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create parent directory {}", parent.display()))?; + } fs::write(path, content.as_str()) .with_context(|| format!("failed to write Ninja file to {}", path.display()))?; info!("Generated Ninja file at {}", path.display()); @@ -225,10 +259,11 @@ fn resolve_ninja_program() -> PathBuf { /// /// # Examples /// ``` +/// # use netsuke::runner::doc::{CommandArg, contains_sensitive_keyword}; /// assert!(contains_sensitive_keyword(&CommandArg::new("token=abc".into()))); /// assert!(!contains_sensitive_keyword(&CommandArg::new("path=/tmp".into()))); /// ``` -fn contains_sensitive_keyword(arg: &CommandArg) -> bool { +pub(crate) fn contains_sensitive_keyword(arg: &CommandArg) -> bool { let lower = arg.as_str().to_lowercase(); lower.contains("password") || lower.contains("token") || lower.contains("secret") } @@ -237,10 +272,11 @@ fn contains_sensitive_keyword(arg: &CommandArg) -> bool { /// /// # Examples /// ``` +/// # use netsuke::runner::doc::{CommandArg, is_sensitive_arg}; /// assert!(is_sensitive_arg(&CommandArg::new("password=123".into()))); /// assert!(!is_sensitive_arg(&CommandArg::new("file=readme".into()))); /// ``` -fn is_sensitive_arg(arg: &CommandArg) -> bool { +pub(crate) fn is_sensitive_arg(arg: &CommandArg) -> bool { contains_sensitive_keyword(arg) } @@ -250,12 +286,13 @@ fn is_sensitive_arg(arg: &CommandArg) -> bool { /// /// # Examples /// ``` +/// # use netsuke::runner::doc::{CommandArg, redact_argument}; /// 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: &CommandArg) -> CommandArg { +pub(crate) fn redact_argument(arg: &CommandArg) -> CommandArg { if is_sensitive_arg(arg) { let redacted = arg.as_str().split_once('=').map_or_else( || "***REDACTED***".to_string(), @@ -271,6 +308,7 @@ fn redact_argument(arg: &CommandArg) -> CommandArg { /// /// # Examples /// ``` +/// # use netsuke::runner::doc::{CommandArg, redact_sensitive_args}; /// let args = vec![ /// CommandArg::new("ninja".into()), /// CommandArg::new("token=abc".into()), @@ -278,7 +316,7 @@ fn redact_argument(arg: &CommandArg) -> CommandArg { /// let redacted = redact_sensitive_args(&args); /// assert_eq!(redacted[1].as_str(), "token=***REDACTED***"); /// ``` -fn redact_sensitive_args(args: &[CommandArg]) -> Vec { +pub(crate) fn redact_sensitive_args(args: &[CommandArg]) -> Vec { args.iter().map(redact_argument).collect() } diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index 5d4ace19..b2a5b69b 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -1,11 +1,84 @@ use netsuke::cli::{BuildArgs, Cli, Commands}; use netsuke::runner::{BuildTargets, NINJA_ENV, run, run_ninja}; -use rstest::rstest; +use rstest::{fixture, rstest}; use serial_test::serial; +use std::ffi::OsString; use std::path::{Path, PathBuf}; mod support; +/// Guard that restores PATH to its original value when dropped. +/// +/// Using a simple guard avoids heap allocation and guarantees teardown on +/// early returns or panics. +struct PathGuard { + original: OsString, +} + +impl PathGuard { + fn new(original: OsString) -> Self { + Self { original } + } +} + +impl Drop for PathGuard { + fn drop(&mut self) { + // Nightly marks set_var unsafe. + unsafe { std::env::set_var("PATH", &self.original) }; + } +} + +/// Fixture: Put a fake `ninja` (that checks for a build file) on PATH. +/// +/// Returns: (tempdir holding ninja, `ninja_path`, PATH guard) +#[fixture] +fn ninja_in_path() -> (tempfile::TempDir, PathBuf, PathGuard) { + let (ninja_dir, ninja_path) = support::fake_ninja_check_build_file(); + + // Save PATH and prepend our fake ninja directory. + 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()); + let new_path = std::env::join_paths(paths).expect("join paths"); + // Nightly marks set_var unsafe. + unsafe { std::env::set_var("PATH", &new_path) }; + + let guard = PathGuard::new(original_path); + (ninja_dir, ninja_path, guard) +} + +/// Fixture: Put a fake `ninja` with a specific exit code on PATH. +/// +/// The default exit code is 0, but can be customised via `#[with(...)]`. +/// +/// Returns: (tempdir holding ninja, `ninja_path`, PATH guard) +#[fixture] +fn ninja_with_exit_code(#[default(0)] exit_code: i32) -> (tempfile::TempDir, PathBuf, PathGuard) { + let (ninja_dir, ninja_path) = support::fake_ninja(exit_code); + + // Save PATH and prepend our fake ninja directory. + 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()); + let new_path = std::env::join_paths(paths).expect("join paths"); + // Nightly marks set_var unsafe. + unsafe { std::env::set_var("PATH", &new_path) }; + + let guard = PathGuard::new(original_path); + (ninja_dir, ninja_path, guard) +} + +/// Fixture: Create a temporary project with a Netsukefile from minimal.yml. +/// +/// Returns: (tempdir for project, path to Netsukefile) +#[fixture] +fn test_manifest() -> (tempfile::TempDir, PathBuf) { + 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"); + (temp, manifest_path) +} + #[test] fn run_exits_with_manifest_error_on_invalid_version() { let temp = tempfile::tempdir().expect("temp dir"); @@ -54,18 +127,8 @@ fn run_ninja_not_found() { #[rstest] #[serial] fn run_executes_ninja_without_persisting_file() { - let (ninja_dir, ninja_path) = support::fake_ninja_check_build_file(); - 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()); - 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 (_ninja_dir, ninja_path, _guard) = ninja_in_path(); + let (temp, manifest_path) = test_manifest(); let cli = Cli { file: manifest_path.clone(), directory: Some(temp.path().to_path_buf()), @@ -83,28 +146,16 @@ fn run_executes_ninja_without_persisting_file() { // Ensure no ninja file remains in project directory assert!(!temp.path().join("build.ninja").exists()); - unsafe { - std::env::set_var("PATH", original_path); - } // Nightly marks set_var unsafe. + // Drop the fake ninja artifacts. PATH is restored by guard drop. drop(ninja_path); } #[cfg(unix)] -#[test] #[serial] +#[rstest] fn run_build_with_emit_keeps_file() { - let (ninja_dir, ninja_path) = support::fake_ninja_check_build_file(); - 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()); - let new_path = std::env::join_paths(paths).expect("join paths"); - unsafe { - std::env::set_var("PATH", &new_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 (_ninja_dir, ninja_path, _guard) = ninja_in_path(); + let (temp, manifest_path) = test_manifest(); let emit_path = temp.path().join("emitted.ninja"); let cli = Cli { file: manifest_path.clone(), @@ -126,20 +177,41 @@ fn run_build_with_emit_keeps_file() { assert!(emitted.contains("build ")); assert!(!temp.path().join("build.ninja").exists()); - unsafe { - std::env::set_var("PATH", original_path); - } + // Drop the fake ninja artifacts. PATH is restored by guard drop. drop(ninja_path); } -#[test] +#[cfg(unix)] #[serial] -fn run_manifest_subcommand_writes_file() { - let original_path = std::env::var_os("PATH").unwrap_or_default(); - unsafe { - std::env::set_var("PATH", ""); - } +#[rstest] +fn run_build_with_emit_creates_parent_dirs() { + let (_ninja_dir, ninja_path, _guard) = ninja_with_exit_code(0); + let (temp, manifest_path) = test_manifest(); + let nested_dir = temp.path().join("nested").join("dir"); + let emit_path = nested_dir.join("emitted.ninja"); + assert!(!nested_dir.exists()); + let cli = Cli { + file: manifest_path.clone(), + directory: Some(temp.path().to_path_buf()), + jobs: None, + verbose: false, + command: Some(Commands::Build(BuildArgs { + emit: Some(emit_path.clone()), + targets: vec![], + })), + }; + let result = run(&cli); + assert!(result.is_ok()); + assert!(emit_path.exists()); + assert!(nested_dir.exists()); + + // Drop the fake ninja artifacts. PATH is restored by guard drop. + drop(ninja_path); +} + +#[test] +fn run_manifest_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"); @@ -158,10 +230,6 @@ fn run_manifest_subcommand_writes_file() { assert!(result.is_ok()); assert!(output_path.exists()); assert!(!temp.path().join("build.ninja").exists()); - - unsafe { - std::env::set_var("PATH", original_path); - } } #[test] diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 443d7221..8a7ed244 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -3,9 +3,10 @@ //! This module provides helpers for creating fake executables and //! generating minimal manifests used in behavioural tests. +use mockable::MockEnv; use std::fs::{self, File}; use std::io::{self, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use tempfile::TempDir; use tracing::Level; @@ -34,6 +35,48 @@ pub fn fake_ninja(exit_code: i32) -> (TempDir, PathBuf) { (dir, path) } +/// Set up `env` so `PATH` resolves only to `dir`. +/// +/// # Examples +/// ```ignore +/// let (dir, _) = fake_ninja(0); +/// let mut env = MockEnv::new(); +/// mock_path_to(&mut env, dir.path()); +/// ``` +#[allow( + unfulfilled_lint_expectations, + reason = "used only in some test crates" +)] +#[expect(dead_code, reason = "used in PATH tests")] +/// Build a valid `PATH` string that contains exactly one entry pointing to +/// `dir` and configure the mock to return it. This avoids lossy conversions +/// and makes the UTF-8 requirement explicit to callers. +/// +/// Note: `MockEnv::raw` returns a `String`, so callers must accept UTF-8. This +/// helper returns an error if the constructed `PATH` cannot be represented as +/// UTF-8. +pub fn mock_path_to(env: &mut MockEnv, dir: &Path) -> std::io::Result<()> { + // Join using the platform-appropriate separator while ensuring exactly one + // element is present in the PATH value. + let joined = std::env::join_paths([dir.as_os_str()]) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + + // `MockEnv::raw` expects a `String`. Propagate if the single-entry PATH is + // not valid UTF-8 to keep the contract explicit. + let path = joined.into_string().map_err(|os| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("non-UTF-8 PATH entry: {}", os.to_string_lossy()), + ) + })?; + + env.expect_raw() + .withf(|key| key == "PATH") + .returning(move |_| Ok(path.clone())); + + Ok(()) +} + /// 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