diff --git a/Cargo.toml b/Cargo.toml index c07b5f5c..713bf2bf 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.8.0" [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..083b22ca 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1348,13 +1348,8 @@ entire CLI specification. Rust ```rust -// In src/main.rs +use clap::{Parser, Subcommand}; -#[rustfmt::skip] -use clap::Parser; -#[rustfmt::skip] -use clap::Subcommand; -#[rustfmt::skip] use std::path::PathBuf; #[derive(Parser)] @@ -1380,11 +1375,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, - /// Remove build artefacts and intermediate files. Clean {}, /// Display - the build dependency graph in DOT format for visualisation. Graph {}, } + /// A list of specific targets to build. targets: Vec, }, + + /// Remove build artefacts and intermediate files. Clean {}, + + /// 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 +1401,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 +1422,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..bc2ab12f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -6,42 +6,95 @@ 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}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::{Command, Stdio}; 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 /// -/// 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 } => { - 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 = 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)?; + } else { + let tmp = Builder::new() + .prefix("netsuke.") + .suffix(".ninja") + .tempfile() + .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 = generate_ninja(cli)?; + write_and_log(&file, &ninja)?; + Ok(()) } Commands::Clean => { println!("Clean requested"); @@ -54,15 +107,66 @@ pub fn run(cli: &Cli) -> io::Result<()> { } } +/// Write `content` to `path` and log the file's location. +/// +/// # Errors +/// +/// 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) -> 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(()) +} + +/// Generate the Ninja manifest string from the Netsuke manifest referenced by `cli`. +/// +/// # Errors +/// +/// Returns an 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.as_str().contains("rule")); +/// ``` +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) + .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).context("building 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") } @@ -70,10 +174,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) } @@ -83,17 +187,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() } } @@ -101,18 +208,22 @@ 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. /// -/// 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 +233,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: &BuildTargets, +) -> 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,17 +251,25 @@ 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.args(targets); + // 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.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/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/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 d0fd90d1..0a15d9f3 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -1,11 +1,8 @@ use netsuke::cli::{Cli, Commands}; -use netsuke::runner::{run, run_ninja}; +use netsuke::runner::{BuildTargets, run, run_ninja}; use rstest::rstest; -use std::error::Error; -use std::fs::{self, File}; -use std::io::Write; +use serial_test::serial; use std::path::{Path, PathBuf}; -use tempfile::TempDir; mod support; @@ -19,41 +16,45 @@ 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); 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"))); } -#[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 targets = BuildTargets::new(vec![]); + let err = run_ninja( + Path::new("does-not-exist"), + &cli, + Path::new("build.ninja"), + &targets, ) - .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 +71,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 +89,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 +100,66 @@ 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] +#[serial] +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..72f5db18 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)); } +#[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))); +} +#[expect( + 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..be67517a 100644 --- a/tests/steps/process_steps.rs +++ b/tests/steps/process_steps.rs @@ -2,8 +2,9 @@ use crate::{CliWorld, support}; use cucumber::{given, then, when}; -use netsuke::runner; -use std::path::PathBuf; +use netsuke::runner::{self, BuildTargets}; +use std::fs; +use std::path::{Path, PathBuf}; use tempfile::TempDir; /// Installs a test-specific ninja binary and updates the `PATH`. @@ -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 @@ -58,11 +81,12 @@ fn no_ninja(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") }; - match runner::run_ninja(program, cli, &[]) { + 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; diff --git a/tests/support/mod.rs b/tests/support/mod.rs index d7a4b817..34e1a617 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -26,7 +26,40 @@ pub fn fake_ninja(exit_code: i32) -> (TempDir, PathBuf) { (dir, path) } -#[allow(dead_code, reason = "compiled as its own crate during linting")] +/// 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(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"); + 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(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>>, @@ -51,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(), @@ -74,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");