diff --git a/.github/workflows/netsukefile-test.yml b/.github/workflows/netsukefile-test.yml index 03e3cbc0..ea950751 100644 --- a/.github/workflows/netsukefile-test.yml +++ b/.github/workflows/netsukefile-test.yml @@ -37,6 +37,6 @@ jobs: command: "touch generated.txt" MANIFEST - name: Run Netsuke - run: ./target/debug/netsuke build generated.txt + run: ./target/debug/netsuke --verbose build generated.txt - name: Assert artefact exists run: scripts/assert-file-exists.sh generated.txt diff --git a/Cargo.lock b/Cargo.lock index b34da936..ccb32098 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,6 +678,12 @@ dependencies = [ "syn", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.174" @@ -757,11 +763,14 @@ dependencies = [ "rstest", "semver", "serde", + "serde_json", "serde_yml", "sha2", "tempfile", "thiserror", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -785,6 +794,16 @@ dependencies = [ "nom", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "object" version = "0.36.7" @@ -806,6 +825,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "peg" version = "0.6.3" @@ -1087,6 +1112,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "similar" version = "2.7.0" @@ -1099,6 +1133,12 @@ version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "smart-default" version = "0.7.1" @@ -1220,6 +1260,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tokio" version = "1.46.1" @@ -1246,6 +1295,63 @@ dependencies = [ "syn", ] +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + [[package]] name = "typed-builder" version = "0.15.2" @@ -1296,6 +1402,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -1327,6 +1439,22 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.9" @@ -1336,6 +1464,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index b9c2d888..560281b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ thiserror = "1" sha2 = "0.10" itoa = "1" itertools = "0.12" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt"] } +serde_json = "1" [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index b24641c6..4cf9b7a4 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ You can also pass: - `--file` to use an alternate manifest - `--directory` to run in a different working dir - `-j N` to control parallelism (passed through to Ninja) +- `-v`, `--verbose` to enable verbose logging ## 🚧 Status diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 07e45549..c1780d59 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1369,6 +1369,10 @@ struct Cli { /// Path to the Netsuke manifest file to use. #[arg(short, long, value_name = "N")] jobs: Option, + /// Enable verbose logging output. + #[arg(short, long)] + verbose: bool, + #[command(subcommand)] command: Option, } @@ -1513,6 +1517,7 @@ selected for this project and the rationale for their inclusion. | Templating | minijinja | High compatibility with Jinja2, minimal dependencies, and supports runtime template loading. | | Shell Quoting | shell-quote | A critical security component; provides robust, shell-specific escaping for command arguments. | | Error Handling | anyhow + thiserror | An idiomatic and powerful combination for creating rich, contextual, and user-friendly error reports. | +| Logging | tracing | Structured, levelled diagnostic output for debugging and insight. | | Versioning | semver | The standard library for parsing and evaluating Semantic Versioning strings, essential for the `netsuke_version` field. | ### 9.3 Future Enhancements diff --git a/src/ast.rs b/src/ast.rs index 5d03fbbf..cf4a49ea 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -18,7 +18,7 @@ //! ``` use semver::Version; -use serde::{Deserialize, de::Deserializer}; +use serde::{Deserialize, Serialize, de::Deserializer}; fn deserialize_actions<'de, D>(deserializer: D) -> Result, D::Error> where @@ -53,7 +53,7 @@ use std::collections::HashMap; /// assert_eq!(manifest.targets.len(), 1); /// # Ok(()) } /// ``` -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct NetsukeManifest { /// Semantic version of the manifest format. @@ -86,7 +86,7 @@ pub struct NetsukeManifest { /// targets. It may define a command line, a script block, or delegate to another /// named rule. Dependencies may be specified as either a single string or a /// list of strings. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Rule { /// Unique identifier used by targets to reference this rule. @@ -164,7 +164,7 @@ impl<'de> Deserialize<'de> for Recipe { /// Targets describe the files produced by a rule and their dependencies. /// `phony` targets are always considered out of date, while `always` targets are /// regenerated even if their inputs are unchanged. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Target { /// Output file or files. @@ -212,7 +212,7 @@ pub struct Target { /// - hello /// - world /// ``` -#[derive(Debug, Deserialize, Default, Clone, PartialEq)] +#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)] #[serde(untagged)] pub enum StringOrList { /// No value provided. diff --git a/src/cli.rs b/src/cli.rs index 19dd3a24..5360707f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,6 +36,10 @@ pub struct Cli { #[arg(short, long, value_name = "N", value_parser = parse_jobs)] pub jobs: Option, + /// Enable verbose logging output. + #[arg(short, long)] + pub verbose: bool, + #[command(subcommand)] pub command: Option, } diff --git a/src/main.rs b/src/main.rs index 61d6c077..a20c0bb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,14 @@ use netsuke::{cli::Cli, runner}; use std::process::ExitCode; +use tracing::Level; +use tracing_subscriber::fmt; fn main() -> ExitCode { let cli = Cli::parse_with_default(); + if cli.verbose { + fmt().with_max_level(Level::DEBUG).init(); + } match runner::run(&cli) { Ok(()) => ExitCode::SUCCESS, Err(err) => { diff --git a/src/runner.rs b/src/runner.rs index a90b92d7..5d6eb26d 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -5,10 +5,14 @@ //! subprocess, streaming its output back to the user. use crate::cli::{Cli, Commands}; +use crate::{ir::BuildGraph, manifest, ninja_gen}; +use serde_json; +use std::fs; use std::io::{self, BufRead, BufReader, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; +use tracing::{debug, info}; /// Execute the parsed [`Cli`] commands. /// @@ -21,7 +25,24 @@ pub fn run(cli: &Cli) -> io::Result<()> { targets: Vec::new(), }); match command { - Commands::Build { targets } => run_ninja(Path::new("ninja"), cli, &targets), + 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::Clean => { println!("Clean requested"); Ok(()) @@ -33,6 +54,61 @@ pub fn run(cli: &Cli) -> io::Result<()> { } } +/// Check if `arg` contains a sensitive keyword. +/// +/// # Examples +/// ``` +/// assert!(contains_sensitive_keyword("token=abc")); +/// assert!(!contains_sensitive_keyword("path=/tmp")); +/// ``` +fn contains_sensitive_keyword(arg: &str) -> bool { + let lower = arg.to_lowercase(); + lower.contains("password") || lower.contains("token") || lower.contains("secret") +} + +/// Determine whether the argument should be redacted. +/// +/// # Examples +/// ``` +/// assert!(is_sensitive_arg("password=123")); +/// assert!(!is_sensitive_arg("file=readme")); +/// ``` +fn is_sensitive_arg(arg: &str) -> bool { + contains_sensitive_keyword(arg) +} + +/// Redact sensitive information in a single argument. +/// +/// Sensitive values are replaced with `***REDACTED***`, preserving keys. +/// +/// # Examples +/// ``` +/// assert_eq!(redact_argument("token=abc"), "token=***REDACTED***"); +/// assert_eq!(redact_argument("path=/tmp"), "path=/tmp"); +/// ``` +fn redact_argument(arg: &str) -> String { + if is_sensitive_arg(arg) { + arg.split_once('=').map_or_else( + || "***REDACTED***".to_string(), + |(key, _)| format!("{key}=***REDACTED***"), + ) + } else { + arg.to_string() + } +} + +/// Redact sensitive information from all `args`. +/// +/// # Examples +/// ``` +/// let args = vec!["ninja".into(), "token=abc".into()]; +/// let redacted = redact_sensitive_args(&args); +/// assert_eq!(redacted[1], "token=***REDACTED***"); +/// ``` +fn redact_sensitive_args(args: &[String]) -> Vec { + args.iter().map(|arg| redact_argument(arg)).collect() +} + /// Invoke the Ninja executable with the provided CLI settings. /// /// The function forwards the job count and working directory to Ninja and @@ -58,6 +134,14 @@ pub fn run_ninja(program: &Path, cli: &Cli, targets: &[String]) -> io::Result<() cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); + let program = cmd.get_program().to_string_lossy().into_owned(); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let redacted_args = redact_sensitive_args(&args); + info!("Running command: {} {}", program, redacted_args.join(" ")); + let mut child = cmd.spawn()?; let stdout = child.stdout.take().expect("child stdout"); let stderr = child.stderr.take().expect("child stderr"); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 78943cd9..dd8d1d7f 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -9,25 +9,29 @@ use rstest::rstest; use std::path::PathBuf; #[rstest] -#[case(vec!["netsuke"], PathBuf::from("Netsukefile"), None, None, Commands::Build { targets: Vec::new() })] +#[case(vec!["netsuke"], PathBuf::from("Netsukefile"), None, None, false, Commands::Build { 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()] }, )] +#[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, #[case] directory: Option, #[case] jobs: Option, + #[case] verbose: bool, #[case] expected_cmd: Commands, ) { let cli = Cli::parse_from_with_default(argv.clone()); assert_eq!(cli.file, file); assert_eq!(cli.directory, directory); assert_eq!(cli.jobs, jobs); + assert_eq!(cli.verbose, verbose); assert_eq!(cli.command.expect("command should be set"), expected_cmd); } diff --git a/tests/runner_tests.rs b/tests/runner_tests.rs index 79cb9a82..d60ce894 100644 --- a/tests/runner_tests.rs +++ b/tests/runner_tests.rs @@ -4,6 +4,7 @@ use netsuke::cli::{Cli, Commands}; use netsuke::runner; use rstest::rstest; use std::path::{Path, PathBuf}; +use tracing::Level; /// Creates a default CLI configuration for testing Ninja invocation. fn test_cli() -> Cli { @@ -11,6 +12,7 @@ fn test_cli() -> Cli { file: PathBuf::from("Netsukefile"), directory: None, jobs: None, + verbose: false, command: Some(Commands::Build { targets: Vec::new(), }), @@ -36,3 +38,86 @@ fn run_ninja_not_found() { runner::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() { + 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()); + 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 cli = Cli { + file: manifest_path, + directory: Some(temp.path().to_path_buf()), + jobs: None, + verbose: false, + command: Some(Commands::Build { + targets: Vec::new(), + }), + }; + + runner::run(&cli).expect("run"); + assert!(temp.path().join("build.ninja").exists()); + + unsafe { + std::env::set_var("PATH", original_path); + } // Nightly marks set_var unsafe. + drop(ninja_path); +} + +#[rstest] +fn run_ninja_logs_command() { + let (_dir, path) = support::fake_ninja(0); + let mut cli = test_cli(); + cli.verbose = true; + let logs = support::capture_logs(Level::INFO, || { + runner::run_ninja(&path, &cli, &["--password=123".to_string()]).expect("run"); + }); + assert!(logs.contains("Running command:")); + assert!(logs.contains("password=***REDACTED***")); + assert!(!logs.contains("123")); +} + +#[rstest] +fn run_with_verbose_mode_emits_logs() { + 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()); + 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 cli = Cli { + file: manifest_path, + directory: Some(temp.path().to_path_buf()), + jobs: None, + verbose: true, + command: Some(Commands::Build { + targets: Vec::new(), + }), + }; + + let logs = support::capture_logs(Level::DEBUG, || { + runner::run(&cli).expect("run"); + }); + assert!(logs.contains("AST:")); + assert!(logs.contains("Generated Ninja file at")); + assert!(logs.contains("Running command:")); + + unsafe { + std::env::set_var("PATH", original_path); + } // Nightly marks set_var unsafe. + drop(ninja_path); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 722f3cae..054267ec 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,9 +1,12 @@ -//! Test utilities for process management. +//! Test utilities for process management and log capture. use std::fs::{self, File}; -use std::io::Write; +use std::io::{self, Write}; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use tempfile::TempDir; +use tracing::Level; +use tracing_subscriber::fmt; /// Create a fake Ninja executable that exits with `exit_code`. /// @@ -22,3 +25,47 @@ pub fn fake_ninja(exit_code: i32) -> (TempDir, PathBuf) { } (dir, path) } + +#[allow(dead_code, reason = "compiled as its own crate during linting")] +#[derive(Clone)] +struct BufferWriter { + buf: Arc>>, +} + +impl Write for BufferWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + self.buf.lock().expect("lock").write(data) + } + + fn flush(&mut self) -> io::Result<()> { + self.buf.lock().expect("lock").flush() + } +} + +/// Capture logs emitted within the provided closure. +/// +/// # Examples +/// +/// ```ignore +/// use tracing::Level; +/// let output = capture_logs(Level::INFO, || tracing::info!("hello")); +/// assert!(output.contains("hello")); +/// ``` +#[allow(dead_code, reason = "compiled as its own crate during linting")] +pub fn capture_logs(level: Level, f: F) -> String +where + F: FnOnce(), +{ + let buf = Arc::new(Mutex::new(Vec::new())); + let writer = BufferWriter { + buf: Arc::clone(&buf), + }; + let subscriber = fmt() + .with_max_level(level) + .without_time() + .with_writer(move || writer.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, f); + let locked = buf.lock().expect("lock"); + String::from_utf8(locked.clone()).expect("utf8") +}