//! CLI argument validation regression tests. //! //! Nyx's surface is a `clap` parser plus a handful of downstream validators //! (`SeverityFilter::parse`, `Severity::from_str`, `Confidence::from_str`, //! `apply_profile`). These tests lock in the user-visible contract that //! bad input exits non-zero with a message that names the offending flag , //! a scanner that silently accepts a typo'd severity and returns zero //! findings is a footgun in CI. //! //! The scanner binary reads its configuration from a platform-dependent //! project directory (macOS: `$HOME/Library/Application Support/nyx`; //! Linux: `$XDG_CONFIG_HOME/nyx`). Each test redirects both env vars to a //! tempdir so the developer's real config is never touched and runs are //! reproducible. use assert_cmd::Command; use predicates::prelude::*; use std::path::PathBuf; /// Build a scan command with a fresh config dir and a writable tempdir as /// the scan target. The caller layers extra args on top. fn scan_cmd(tmp_home: &std::path::Path, scan_target: &std::path::Path) -> (Command, PathBuf) { let mut cmd = Command::cargo_bin("nyx").expect("nyx binary must exist"); cmd.env("HOME", tmp_home) .env("XDG_CONFIG_HOME", tmp_home.join(".config")) .env("XDG_DATA_HOME", tmp_home.join(".local/share")) // Avoid the welcome banner / animation from interfering with exit codes. .env("NO_COLOR", "1"); cmd.arg("scan").arg(scan_target); (cmd, scan_target.to_path_buf()) } /// Prepare a scan tempdir with a single clean file so the scanner has a /// valid target and only the flag being tested should produce an error. fn prepare_scan_target() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("ok.js"), b"var x = 1;\n").unwrap(); dir } /// Nonexistent scan path: `Path::new(path).canonicalize()?` in `scan::handle` /// returns an io::Error, which NyxError wraps and the process exits non-zero. #[test] fn scan_with_nonexistent_path_exits_nonzero() { let home = tempfile::tempdir().unwrap(); let fake = home.path().join("does/not/exist/anywhere"); let (mut cmd, _) = scan_cmd(home.path(), &fake); cmd.assert().failure().stderr( predicate::str::contains(fake.to_string_lossy().as_ref()).or( // On some platforms the error wraps the path inside an IO error // message; accept either direct mention or a canonicalize-shaped // error so the assertion isn't brittle to errno text. Windows // reports ERROR_PATH_NOT_FOUND as "cannot find the path specified". predicate::str::contains("canonicalize") .or(predicate::str::contains("No such file")) .or(predicate::str::contains("not found")) .or(predicate::str::contains("cannot find")), ), ); } /// Clap enforces `ValueEnum` for `--format`; an unknown value fails at parse /// time with a usage message that lists the valid enum values. #[test] fn scan_with_unknown_format_exits_nonzero() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--format").arg("unknown-format-xyz"); cmd.assert().failure().stderr( predicate::str::contains("format").and( predicate::str::contains("unknown-format-xyz") .or(predicate::str::contains("possible values") .or(predicate::str::contains("invalid value"))), ), ); } /// Clap enforces `ValueEnum` for `--mode`; an unknown value fails at parse /// time. #[test] fn scan_with_unknown_mode_exits_nonzero() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--mode").arg("bogus-mode-xyz"); cmd.assert() .failure() .stderr( predicate::str::contains("mode").and( predicate::str::contains("bogus-mode-xyz") .or(predicate::str::contains("invalid value")), ), ); } /// `--severity BOGUS` fails at `SeverityFilter::parse` with a message naming /// the flag. #[test] fn scan_with_invalid_severity_exits_nonzero() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--severity").arg("BOGUSSEV"); cmd.assert() .failure() .stderr(predicate::str::contains("severity")); } /// `--fail-on BOGUS` fails at `Severity::from_str`. #[test] fn scan_with_invalid_fail_on_exits_nonzero() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--fail-on").arg("BOGUSSEV"); cmd.assert() .failure() .stderr(predicate::str::contains("fail-on").or(predicate::str::contains("severity"))); } /// `--min-confidence bogus` fails at `Confidence::from_str`. #[test] fn scan_with_invalid_min_confidence_exits_nonzero() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--min-confidence").arg("ultra-extreme"); cmd.assert().failure().stderr( predicate::str::contains("min-confidence").or(predicate::str::contains("confidence")), ); } /// `--profile nonexistent-profile` fails at `config.apply_profile` which /// errors with "unknown profile". #[test] fn scan_with_unknown_profile_exits_nonzero() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--profile").arg("not-a-real-profile-xyz"); cmd.assert() .failure() .stderr(predicate::str::contains("profile")); } /// Sanity check: the scan command with no flags on a valid target succeeds. /// Guards against a regression where the redirected `HOME` / `XDG_CONFIG_HOME` /// setup breaks scans (which would invalidate every negative test above). #[test] fn scan_with_no_extra_flags_on_clean_target_succeeds() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--format").arg("json"); cmd.assert().success(); } /// `--explain-engine` short-circuits the scan path and prints the resolved /// engine configuration to stdout. Exit code 0, non-empty stdout, and the /// "Effective engine configuration" header present. #[test] fn scan_with_explain_engine_prints_config_and_exits_zero() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--explain-engine"); cmd.assert() .success() .stdout(predicate::str::contains("Effective engine configuration")) .stdout(predicate::str::contains("Abstract interpretation")) .stdout(predicate::str::contains("Parse timeout")); } /// `--engine-profile` is a `ValueEnum`; valid values parse, invalid values /// fail at the clap layer. #[test] fn scan_with_valid_engine_profile_succeeds() { for prof in &["fast", "balanced", "deep"] { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--engine-profile").arg(prof); cmd.arg("--explain-engine"); cmd.assert() .success() .stdout(predicate::str::contains(*prof)); } } #[test] fn scan_with_unknown_engine_profile_exits_nonzero() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--engine-profile").arg("bogus-profile-xyz"); cmd.assert() .failure() .stderr( predicate::str::contains("engine-profile").and( predicate::str::contains("possible values") .or(predicate::str::contains("invalid value")), ), ); } /// Engine-profile + individual flag layering: `--engine-profile fast` turns /// backwards analysis off, but a later `--backwards-analysis` flag wins. #[test] fn scan_engine_profile_is_overridden_by_individual_flag() { let home = tempfile::tempdir().unwrap(); let target = prepare_scan_target(); let (mut cmd, _) = scan_cmd(home.path(), target.path()); cmd.arg("--engine-profile").arg("fast"); cmd.arg("--backwards-analysis"); cmd.arg("--explain-engine"); cmd.assert() .success() .stdout(predicate::str::contains("Backwards taint: on")); } /// Scanning a directory that contains a C file emits the Preview-tier /// banner on stderr. Banner text is asserted loosely to tolerate future /// wording changes without going brittle on the exact letter-for-letter /// string. #[test] fn scan_c_file_emits_preview_tier_banner() { let home = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("hello.c"), b"#include \nint main(void) { puts(\"hi\"); return 0; }\n", ) .unwrap(); let (mut cmd, _) = scan_cmd(home.path(), dir.path()); cmd.assert() .success() .stderr(predicate::str::contains("Preview for C/C++").and( predicate::str::contains("Pointer aliasing").or(predicate::str::contains("clang-tidy")), )); } /// `--quiet` must suppress the Preview-tier banner along with the rest of /// the status output. Separate test so a regression in quiet-handling /// surfaces clearly. #[test] fn scan_quiet_suppresses_preview_banner() { let home = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("hello.c"), b"int main(void){return 0;}\n").unwrap(); let (mut cmd, _) = scan_cmd(home.path(), dir.path()); cmd.arg("--quiet"); cmd.assert() .success() .stderr(predicate::str::contains("Preview for C/C++").not()); } /// JSON output format must not print the Preview banner either, machine- /// readable output has to stay clean on both stdout and stderr. #[test] fn scan_json_format_suppresses_preview_banner() { let home = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("hello.c"), b"int main(void){return 0;}\n").unwrap(); let (mut cmd, _) = scan_cmd(home.path(), dir.path()); cmd.arg("--format").arg("json"); cmd.assert() .success() .stderr(predicate::str::contains("Preview for C/C++").not()); }