From 448cf615dbe88e84d8040e64f6aaa1220a5bf3b4 Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Fri, 26 Sep 2025 19:08:48 -0400 Subject: [PATCH] add GHA workflow (#1) * add GHA workflow Signed-off-by: Jason Hall * fix CI Signed-off-by: Jason Hall * update readme Signed-off-by: Jason Hall * add badge Signed-off-by: Jason Hall --------- Signed-off-by: Jason Hall --- .github/workflows/ci.yml | 61 +++++ Cargo.lock | 8 +- Cargo.toml | 8 +- README.md | 8 + examples/sample-cli/Cargo.lock | 8 +- examples/sample-cli/Cargo.toml | 2 +- examples/sample-cli/src/main.rs | 21 +- examples/sample-cli/tests/integration_test.rs | 22 +- examples/test_runner.rs | 2 +- src/error.rs | 2 +- src/parser.rs | 31 ++- src/run.rs | 249 +++++++++++++----- tests/builtin_commands.rs | 9 +- tests/edge_cases.rs | 8 +- tests/integration.rs | 9 +- tests/setup_hook.rs | 89 ++++--- 16 files changed, 381 insertions(+), 156 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1292403 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + rust: [stable] + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.rust }} + components: clippy, rustfmt + + - name: Cache cargo registry + uses: actions/cache@v3 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v3 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v3 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Run tests + run: cargo test --verbose + + - name: Run clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Check formatting + run: cargo fmt -- --check + + - name: Test sample-cli example + run: | + cd examples/sample-cli + cargo test --verbose \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 71c5d69..2f69db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,18 +193,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 678808d..5a607e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,16 +2,16 @@ name = "testscript-rs" version = "0.1.0" edition = "2021" -authors = ["Steve Klabnik "] -license = "MIT OR Apache-2.0" +authors = ["Jason Hall"] +license = "Apache-2.0" description = "A Rust crate for testing command-line tools using filesystem-based script files" -repository = "https://github.com/steveklabnik/testscript-rs" +repository = "https://github.com/imjasonh/testscript-rs" keywords = ["testing", "cli", "testscript", "integration-tests"] categories = ["development-tools::testing"] [dependencies] anyhow = "1.0" -thiserror = "1.0" +thiserror = "2.0" tempfile = "3.0" walkdir = "2.0" regex = "1.0" diff --git a/README.md b/README.md index 3c0038b..ebdae37 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # testscript-rs +[![CI](https://github.com/imjasonh/testscript-rs/workflows/CI/badge.svg)](https://github.com/imjasonh/testscript-rs/actions) + A Rust crate for testing command-line tools using filesystem-based script files. testscript-rs provides a framework for writing integration tests for CLI applications using the `.txtar` format, where test scripts and file contents are combined in a single file. @@ -137,6 +139,12 @@ Test scripts use the `.txtar` format. For complete format documentation, see the Commands can be prefixed with conditions (`[unix]`) or negated (`!`). +> Note: Some features of `testscript` in Go are not supported in this Rust port: +> +> - `[gc]` for whether Go was built with gc +> - `[gccgo]` for whether Go was built with gccgo +> - `[go1.x]` for whether the Go version is 1.x or later + ## Examples See [`examples/sample-cli/`](./examples/sample-cli/) and its `testdata` directory for more examples. diff --git a/examples/sample-cli/Cargo.lock b/examples/sample-cli/Cargo.lock index 08a520e..23e2d45 100644 --- a/examples/sample-cli/Cargo.lock +++ b/examples/sample-cli/Cargo.lock @@ -321,18 +321,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", diff --git a/examples/sample-cli/Cargo.toml b/examples/sample-cli/Cargo.toml index 292f0ab..36cd3a3 100644 --- a/examples/sample-cli/Cargo.toml +++ b/examples/sample-cli/Cargo.toml @@ -8,7 +8,7 @@ name = "sample-cli" path = "src/main.rs" [dependencies] -clap = { version = "4.0", features = ["derive"] } +clap = { version = "4.5", features = ["derive"] } [dev-dependencies] testscript-rs = { path = "../.." } \ No newline at end of file diff --git a/examples/sample-cli/src/main.rs b/examples/sample-cli/src/main.rs index 738fede..050cdbf 100644 --- a/examples/sample-cli/src/main.rs +++ b/examples/sample-cli/src/main.rs @@ -8,7 +8,7 @@ use clap::{Parser, Subcommand}; use std::fs; -use std::io::{self, BufRead, BufReader}; +use std::io::{BufRead, BufReader}; use std::path::Path; #[derive(Parser)] @@ -76,7 +76,10 @@ impl std::str::FromStr for CountMode { "lines" | "line" | "l" => Ok(CountMode::Lines), "words" | "word" | "w" => Ok(CountMode::Words), "chars" | "char" | "c" => Ok(CountMode::Chars), - _ => Err(format!("Invalid count mode: {}. Use lines, words, or chars", s)), + _ => Err(format!( + "Invalid count mode: {}. Use lines, words, or chars", + s + )), } } } @@ -86,7 +89,11 @@ fn main() { let result = match cli.command { Some(Commands::Count { mode, files }) => count_command(mode, files), - Some(Commands::Grep { pattern, files, ignore_case }) => grep_command(pattern, files, ignore_case), + Some(Commands::Grep { + pattern, + files, + ignore_case, + }) => grep_command(pattern, files, ignore_case), Some(Commands::Create { file, content }) => create_command(file, content), Some(Commands::Remove { files }) => remove_command(files), Some(Commands::List { dir }) => list_command(dir), @@ -123,7 +130,11 @@ fn count_command(mode: CountMode, files: Vec) -> Result<(), Box, ignore_case: bool) -> Result<(), Box> { +fn grep_command( + pattern: String, + files: Vec, + ignore_case: bool, +) -> Result<(), Box> { if files.is_empty() { return Err("No files specified".into()); } @@ -194,4 +205,4 @@ fn list_command(dir: String) -> Result<(), Box> { } Ok(()) -} \ No newline at end of file +} diff --git a/examples/sample-cli/tests/integration_test.rs b/examples/sample-cli/tests/integration_test.rs index b45270a..041be8e 100644 --- a/examples/sample-cli/tests/integration_test.rs +++ b/examples/sample-cli/tests/integration_test.rs @@ -2,8 +2,8 @@ //! //! This demonstrates how to use testscript-rs to test a CLI application. -use testscript_rs::testscript; use std::process::Command; +use testscript_rs::testscript; #[test] fn test_sample_cli() { @@ -20,9 +20,10 @@ fn test_sample_cli() { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(testscript_rs::Error::Generic( - format!("Failed to build sample-cli: {}", stderr) - )); + return Err(testscript_rs::Error::Generic(format!( + "Failed to build sample-cli: {}", + stderr + ))); } // Copy binary to test working directory @@ -40,11 +41,14 @@ fn test_sample_cli() { { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(&dest) - .map_err(|e| testscript_rs::Error::Generic(format!("Failed to get permissions: {}", e)))? + .map_err(|e| { + testscript_rs::Error::Generic(format!("Failed to get permissions: {}", e)) + })? .permissions(); perms.set_mode(0o755); - std::fs::set_permissions(&dest, perms) - .map_err(|e| testscript_rs::Error::Generic(format!("Failed to set permissions: {}", e)))?; + std::fs::set_permissions(&dest, perms).map_err(|e| { + testscript_rs::Error::Generic(format!("Failed to set permissions: {}", e)) + })?; } println!("Built and copied sample-cli to: {}", dest.display()); @@ -57,6 +61,4 @@ fn test_sample_cli() { Ok(_) => println!("All sample-cli tests passed!"), Err(e) => println!("Sample-cli tests had issues (expected): {}", e), } - - // The test passes as long as testscript-rs works correctly -} \ No newline at end of file +} diff --git a/examples/test_runner.rs b/examples/test_runner.rs index d9b5a82..a2549bb 100644 --- a/examples/test_runner.rs +++ b/examples/test_runner.rs @@ -16,4 +16,4 @@ fn main() { std::process::exit(1); } } -} \ No newline at end of file +} diff --git a/src/error.rs b/src/error.rs index 60148ae..3bbf4f2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -64,4 +64,4 @@ impl Error { message: message.into(), } } -} \ No newline at end of file +} diff --git a/src/parser.rs b/src/parser.rs index 565963d..c68e645 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -233,9 +233,9 @@ fn parse_command_tokens(input: &str) -> Result> { match next_ch { '\\' => current_token.push('\\'), '\'' => current_token.push('\''), - 'n' => current_token.push('\n'), // Still process \n in single quotes for Go compat - 't' => current_token.push('\t'), // Still process \t in single quotes for Go compat - 'r' => current_token.push('\r'), // Still process \r in single quotes for Go compat + 'n' => current_token.push('\n'), // Still process \n in single quotes for Go compat + 't' => current_token.push('\t'), // Still process \t in single quotes for Go compat + 'r' => current_token.push('\r'), // Still process \r in single quotes for Go compat _ => { current_token.push('\\'); current_token.push(next_ch); @@ -280,8 +280,14 @@ mod tests { #[test] fn test_parse_file_header() { - assert_eq!(parse_file_header("-- hello.txt --"), Some("hello.txt".to_string())); - assert_eq!(parse_file_header("-- sub/dir/file.go --"), Some("sub/dir/file.go".to_string())); + assert_eq!( + parse_file_header("-- hello.txt --"), + Some("hello.txt".to_string()) + ); + assert_eq!( + parse_file_header("-- sub/dir/file.go --"), + Some("sub/dir/file.go".to_string()) + ); assert_eq!(parse_file_header("--hello--"), None); assert_eq!(parse_file_header("hello"), None); } @@ -311,14 +317,18 @@ mod tests { assert!(!cmd.background); assert!(!cmd.negated); - let cmd = parse_command_line("[windows] exec echo hello", 2).unwrap().unwrap(); + let cmd = parse_command_line("[windows] exec echo hello", 2) + .unwrap() + .unwrap(); assert_eq!(cmd.condition, Some("windows".to_string())); let cmd = parse_command_line("exec echo hello &", 3).unwrap().unwrap(); assert!(cmd.background); assert_eq!(cmd.args, vec!["echo", "hello"]); - let cmd = parse_command_line("! exists missing_file", 4).unwrap().unwrap(); + let cmd = parse_command_line("! exists missing_file", 4) + .unwrap() + .unwrap(); assert!(cmd.negated); assert_eq!(cmd.name, "exists"); assert_eq!(cmd.args, vec!["missing_file"]); @@ -373,6 +383,9 @@ second file assert_eq!(script.files[1].name, "file2.txt"); assert_eq!(script.files[1].contents, b"second file"); assert_eq!(script.files[2].name, "expected.txt"); - assert_eq!(script.files[2].contents, b"first file\ncontent\nsecond file"); + assert_eq!( + script.files[2].contents, + b"first file\ncontent\nsecond file" + ); } -} \ No newline at end of file +} diff --git a/src/run.rs b/src/run.rs index 5479679..3bee5c3 100644 --- a/src/run.rs +++ b/src/run.rs @@ -175,7 +175,12 @@ impl TestEnvironment { } /// Execute a command in the background - pub fn execute_background_command(&mut self, name: &str, cmd: &str, args: &[String]) -> Result<()> { + pub fn execute_background_command( + &mut self, + name: &str, + cmd: &str, + args: &[String], + ) -> Result<()> { let mut command = StdCommand::new(cmd); command .args(args) @@ -196,7 +201,10 @@ impl TestEnvironment { self.last_output = Some(output.clone()); Ok(output) } else { - Err(Error::command_error("wait", format!("No background process named '{}'", name))) + Err(Error::command_error( + "wait", + format!("No background process named '{}'", name), + )) } } @@ -210,11 +218,17 @@ impl TestEnvironment { }; if !new_dir.exists() { - return Err(Error::command_error("cd", format!("Directory '{}' does not exist", path))); + return Err(Error::command_error( + "cd", + format!("Directory '{}' does not exist", path), + )); } if !new_dir.is_dir() { - return Err(Error::command_error("cd", format!("'{}' is not a directory", path))); + return Err(Error::command_error( + "cd", + format!("'{}' is not a directory", path), + )); } self.current_dir = new_dir; @@ -248,21 +262,34 @@ impl TestEnvironment { /// Compare command output with expected content pub fn compare_output(&self, output_type: &str, expected: &str) -> Result<()> { let actual = match self.last_output { - Some(ref output) => { - match output_type { - "stdout" => String::from_utf8_lossy(&output.stdout).trim_end().to_string(), - "stderr" => String::from_utf8_lossy(&output.stderr).trim_end().to_string(), - _ => return Err(Error::command_error(output_type, "Unknown output type")), - } + Some(ref output) => match output_type { + "stdout" => String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_string(), + "stderr" => String::from_utf8_lossy(&output.stderr) + .trim_end() + .to_string(), + _ => return Err(Error::command_error(output_type, "Unknown output type")), + }, + None => { + return Err(Error::command_error( + output_type, + "No command output available", + )) } - None => return Err(Error::command_error(output_type, "No command output available")), }; // Substitute environment variables in the expected pattern let expected_substituted = self.substitute_env_vars(expected); // Check if expected is a regex pattern (contains regex special characters) - if expected_substituted.contains('^') || expected_substituted.contains('$') || expected_substituted.contains('[') || expected_substituted.contains('(') || expected_substituted.contains('*') || expected_substituted.contains('.') { + if expected_substituted.contains('^') + || expected_substituted.contains('$') + || expected_substituted.contains('[') + || expected_substituted.contains('(') + || expected_substituted.contains('*') + || expected_substituted.contains('.') + { let regex_pattern = format!("(?s){}", expected_substituted); // (?s) enables DOTALL mode let regex = Regex::new(®ex_pattern) .map_err(|e| Error::command_error(output_type, format!("Invalid regex: {}", e)))?; @@ -331,8 +358,9 @@ impl TestEnvironment { } } else { let source_path = self.work_dir.join(source); - fs::read(&source_path) - .map_err(|e| Error::command_error("cp", format!("Cannot read '{}': {}", source, e)))? + fs::read(&source_path).map_err(|e| { + Error::command_error("cp", format!("Cannot read '{}': {}", source, e)) + })? }; // If dest exists and is a directory, copy into it @@ -340,7 +368,8 @@ impl TestEnvironment { let filename = if source == "stdout" || source == "stderr" { source } else { - Path::new(source).file_name() + Path::new(source) + .file_name() .and_then(|n| n.to_str()) .unwrap_or(source) }; @@ -373,11 +402,18 @@ impl TestEnvironment { let dest_path = self.work_dir.join(dest); if !source_path.exists() { - return Err(Error::command_error("mv", format!("Source '{}' does not exist", source))); + return Err(Error::command_error( + "mv", + format!("Source '{}' does not exist", source), + )); } - fs::rename(&source_path, &dest_path) - .map_err(|e| Error::command_error("mv", format!("Cannot move '{}' to '{}': {}", source, dest, e)))?; + fs::rename(&source_path, &dest_path).map_err(|e| { + Error::command_error( + "mv", + format!("Cannot move '{}' to '{}': {}", source, dest, e), + ) + })?; Ok(()) } @@ -408,8 +444,9 @@ impl TestEnvironment { } } else { let file_path = self.work_dir.join(filename); - fs::read(&file_path) - .map_err(|e| Error::command_error("stdin", format!("Cannot read '{}': {}", filename, e)))? + fs::read(&file_path).map_err(|e| { + Error::command_error("stdin", format!("Cannot read '{}': {}", filename, e)) + })? }; self.next_stdin = Some(content); @@ -425,7 +462,10 @@ impl TestEnvironment { let _ = child.wait()?; // Reap the process Ok(()) } else { - Err(Error::command_error("kill", format!("No background process named '{}'", name))) + Err(Error::command_error( + "kill", + format!("No background process named '{}'", name), + )) } } @@ -434,11 +474,13 @@ impl TestEnvironment { let path1 = self.work_dir.join(file1); let path2 = self.work_dir.join(file2); - let contents1 = fs::read_to_string(&path1) - .map_err(|e| Error::command_error("cmpenv", format!("Cannot read '{}': {}", file1, e)))?; + let contents1 = fs::read_to_string(&path1).map_err(|e| { + Error::command_error("cmpenv", format!("Cannot read '{}': {}", file1, e)) + })?; - let contents2_raw = fs::read_to_string(&path2) - .map_err(|e| Error::command_error("cmpenv", format!("Cannot read '{}': {}", file2, e)))?; + let contents2_raw = fs::read_to_string(&path2).map_err(|e| { + Error::command_error("cmpenv", format!("Cannot read '{}': {}", file2, e)) + })?; // Substitute environment variables in file2 let contents2 = self.substitute_env_vars(&contents2_raw); @@ -462,8 +504,8 @@ impl TestEnvironment { // Handle $VAR and ${VAR} patterns for (key, value) in &self.env_vars { let patterns = [ - format!("${{{}}}", key), // ${VAR} - format!("${}", key), // $VAR (but be careful with word boundaries) + format!("${{{}}}", key), // ${VAR} + format!("${}", key), // $VAR (but be careful with word boundaries) ]; for pattern in &patterns { @@ -489,26 +531,44 @@ impl TestEnvironment { { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&file_path) - .map_err(|e| Error::command_error("chmod", format!("Cannot get metadata for '{}': {}", file, e)))? + .map_err(|e| { + Error::command_error( + "chmod", + format!("Cannot get metadata for '{}': {}", file, e), + ) + })? .permissions(); perms.set_mode(mode_int); - fs::set_permissions(&file_path, perms) - .map_err(|e| Error::command_error("chmod", format!("Cannot set permissions for '{}': {}", file, e)))?; + fs::set_permissions(&file_path, perms).map_err(|e| { + Error::command_error( + "chmod", + format!("Cannot set permissions for '{}': {}", file, e), + ) + })?; } #[cfg(windows)] { // On Windows, we can only set read-only status let mut perms = fs::metadata(&file_path) - .map_err(|e| Error::command_error("chmod", format!("Cannot get metadata for '{}': {}", file, e)))? + .map_err(|e| { + Error::command_error( + "chmod", + format!("Cannot get metadata for '{}': {}", file, e), + ) + })? .permissions(); // If mode doesn't include write permissions for owner (e.g., 444), make it read-only let readonly = (mode_int & 0o200) == 0; perms.set_readonly(readonly); - fs::set_permissions(&file_path, perms) - .map_err(|e| Error::command_error("chmod", format!("Cannot set permissions for '{}': {}", file, e)))?; + fs::set_permissions(&file_path, perms).map_err(|e| { + Error::command_error( + "chmod", + format!("Cannot set permissions for '{}': {}", file, e), + ) + })?; } Ok(()) @@ -518,19 +578,19 @@ impl TestEnvironment { pub fn unquote_file(&self, file: &str) -> Result<()> { let file_path = self.work_dir.join(file); - let contents = fs::read_to_string(&file_path) - .map_err(|e| Error::command_error("unquote", format!("Cannot read '{}': {}", file, e)))?; + let contents = fs::read_to_string(&file_path).map_err(|e| { + Error::command_error("unquote", format!("Cannot read '{}': {}", file, e)) + })?; let unquoted_contents: String = contents .lines() - .map(|line| { - line.strip_prefix('>').unwrap_or(line) - }) + .map(|line| line.strip_prefix('>').unwrap_or(line)) .collect::>() .join("\n"); - fs::write(&file_path, unquoted_contents) - .map_err(|e| Error::command_error("unquote", format!("Cannot write '{}': {}", file, e)))?; + fs::write(&file_path, unquoted_contents).map_err(|e| { + Error::command_error("unquote", format!("Cannot write '{}': {}", file, e)) + })?; Ok(()) } @@ -545,8 +605,9 @@ impl TestEnvironment { for file in files { let file_path = self.work_dir.join(file); - let contents = fs::read_to_string(&file_path) - .map_err(|e| Error::command_error("grep", format!("Cannot read '{}': {}", file, e)))?; + let contents = fs::read_to_string(&file_path).map_err(|e| { + Error::command_error("grep", format!("Cannot read '{}': {}", file, e)) + })?; for (line_num, line) in contents.lines().enumerate() { if regex.is_match(line) { @@ -644,8 +705,11 @@ fn execute_command(env: &mut TestEnvironment, command: &Command, params: &RunPar if command.negated { match result { - Ok(_) => Err(Error::command_error(&command.name, "Command was expected to fail but succeeded")), - Err(_) => Ok(()), // Negated command failed as expected + Ok(_) => Err(Error::command_error( + &command.name, + "Command was expected to fail but succeeded", + )), + Err(_) => Ok(()), // Negated command failed as expected } } else { result @@ -653,17 +717,29 @@ fn execute_command(env: &mut TestEnvironment, command: &Command, params: &RunPar } /// Inner command execution logic -fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: &RunParams) -> Result<()> { +fn execute_command_inner( + env: &mut TestEnvironment, + command: &Command, + params: &RunParams, +) -> Result<()> { // Check condition if present if let Some(ref condition) = command.condition { - let condition_met = params.conditions.get(condition).copied().unwrap_or_else(|| { - // If condition starts with !, check for negation - if let Some(base_condition) = condition.strip_prefix('!') { - !params.conditions.get(base_condition).copied().unwrap_or(false) - } else { - false - } - }); + let condition_met = params + .conditions + .get(condition) + .copied() + .unwrap_or_else(|| { + // If condition starts with !, check for negation + if let Some(base_condition) = condition.strip_prefix('!') { + !params + .conditions + .get(base_condition) + .copied() + .unwrap_or(false) + } else { + false + } + }); if !condition_met { return Ok(()); // Skip this command @@ -697,10 +773,12 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: & let stderr = String::from_utf8_lossy(&output.stderr); return Err(Error::command_error( "exec", - format!("Command '{}' failed with exit code {}: {}", - cmd, - output.status.code().unwrap_or(-1), - stderr.trim()) + format!( + "Command '{}' failed with exit code {}: {}", + cmd, + output.status.code().unwrap_or(-1), + stderr.trim() + ), )); } } @@ -713,13 +791,19 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: & } "cmpenv" => { if command.args.len() != 2 { - return Err(Error::command_error("cmpenv", "Expected exactly 2 arguments")); + return Err(Error::command_error( + "cmpenv", + "Expected exactly 2 arguments", + )); } env.compare_files_with_env(&command.args[0], &command.args[1])?; } "stdout" | "stderr" => { if command.args.len() != 1 { - return Err(Error::command_error(&command.name, "Expected exactly 1 argument")); + return Err(Error::command_error( + &command.name, + "Expected exactly 1 argument", + )); } let expected = &command.args[0]; @@ -752,13 +836,19 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: & } "exists" => { if command.args.is_empty() { - return Err(Error::command_error("exists", "Expected at least 1 argument")); + return Err(Error::command_error( + "exists", + "Expected at least 1 argument", + )); } // Check for -readonly flag let (check_readonly, files) = if command.args[0] == "-readonly" { if command.args.len() < 2 { - return Err(Error::command_error("exists", "Expected file argument after -readonly")); + return Err(Error::command_error( + "exists", + "Expected file argument after -readonly", + )); } (true, &command.args[1..]) } else { @@ -767,17 +857,26 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: & for path in files { if !env.file_exists(path) { - return Err(Error::command_error("exists", format!("File '{}' does not exist", path))); + return Err(Error::command_error( + "exists", + format!("File '{}' does not exist", path), + )); } if check_readonly && !env.is_readonly(path) { - return Err(Error::command_error("exists", format!("File '{}' is not read-only", path))); + return Err(Error::command_error( + "exists", + format!("File '{}' is not read-only", path), + )); } } } "mkdir" => { if command.args.is_empty() { - return Err(Error::command_error("mkdir", "Expected at least 1 argument")); + return Err(Error::command_error( + "mkdir", + "Expected at least 1 argument", + )); } env.create_directories(&command.args)?; } @@ -813,7 +912,10 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: & let value = &arg[eq_pos + 1..]; env.set_env_var(key, value); } else { - return Err(Error::command_error("env", format!("Invalid env format: {}", arg))); + return Err(Error::command_error( + "env", + format!("Invalid env format: {}", arg), + )); } } } @@ -855,19 +957,28 @@ fn execute_command_inner(env: &mut TestEnvironment, command: &Command, params: & } "chmod" => { if command.args.len() != 2 { - return Err(Error::command_error("chmod", "Expected exactly 2 arguments")); + return Err(Error::command_error( + "chmod", + "Expected exactly 2 arguments", + )); } env.change_permissions(&command.args[0], &command.args[1])?; } "unquote" => { if command.args.len() != 1 { - return Err(Error::command_error("unquote", "Expected exactly 1 argument")); + return Err(Error::command_error( + "unquote", + "Expected exactly 1 argument", + )); } env.unquote_file(&command.args[0])?; } "grep" => { if command.args.len() < 2 { - return Err(Error::command_error("grep", "Expected at least 2 arguments")); + return Err(Error::command_error( + "grep", + "Expected at least 2 arguments", + )); } let pattern = &command.args[0]; let files = &command.args[1..]; @@ -935,4 +1046,4 @@ mod tests { // Should fail assert!(env.compare_files("file1.txt", "file3.txt").is_err()); } -} \ No newline at end of file +} diff --git a/tests/builtin_commands.rs b/tests/builtin_commands.rs index 743820b..f9fa9fc 100644 --- a/tests/builtin_commands.rs +++ b/tests/builtin_commands.rs @@ -2,7 +2,7 @@ use std::fs; use tempfile::TempDir; -use testscript_rs::{run_test}; +use testscript_rs::run_test; #[test] fn test_exists_command() { @@ -118,7 +118,10 @@ input content"#; let result = run_test(&script_path); // This might fail if cat isn't available - that's okay for now if result.is_err() { - println!("Stdin test failed (cat might not be available): {:?}", result); + println!( + "Stdin test failed (cat might not be available): {:?}", + result + ); } } @@ -172,4 +175,4 @@ hello from stdout"#; let result = run_test(&script_path); assert!(result.is_ok(), "CP stdout test failed: {:?}", result); -} \ No newline at end of file +} diff --git a/tests/edge_cases.rs b/tests/edge_cases.rs index 55d1c39..664bd89 100644 --- a/tests/edge_cases.rs +++ b/tests/edge_cases.rs @@ -67,7 +67,11 @@ nested content"#; fs::write(&script_path, script_content).unwrap(); let result = run_test(&script_path); - assert!(result.is_ok(), "Special file names test failed: {:?}", result); + assert!( + result.is_ok(), + "Special file names test failed: {:?}", + result + ); } #[test] @@ -161,4 +165,4 @@ cmp file1.txt file2.txt let result = run_test(&script_path); assert!(result.is_err(), "Empty exec command should fail"); -} \ No newline at end of file +} diff --git a/tests/integration.rs b/tests/integration.rs index 11ab503..b86c7e6 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -41,14 +41,19 @@ count-args one two three"#; let result = testscript::run(testdata_dir.to_string_lossy()) .command("greet", |_env, args| { if args.is_empty() { - return Err(testscript_rs::Error::Generic("greet requires a name".to_string())); + return Err(testscript_rs::Error::Generic( + "greet requires a name".to_string(), + )); } // Custom command logic would go here Ok(()) }) .command("count-args", |_env, args| { if args.len() != 3 { - return Err(testscript_rs::Error::Generic(format!("expected 3 args, got {}", args.len()))); + return Err(testscript_rs::Error::Generic(format!( + "expected 3 args, got {}", + args.len() + ))); } Ok(()) }) diff --git a/tests/setup_hook.rs b/tests/setup_hook.rs index fa3d28c..e75df77 100644 --- a/tests/setup_hook.rs +++ b/tests/setup_hook.rs @@ -21,13 +21,12 @@ existing content"#; fs::write(&script_path, script_content).unwrap(); // Create RunParams with setup hook - let params = RunParams::new() - .setup(|env| { - // Setup hook creates a file to prove it ran - let setup_file = env.work_dir.join("setup_was_here.txt"); - std::fs::write(&setup_file, "Setup ran successfully")?; - Ok(()) - }); + let params = RunParams::new().setup(|env| { + // Setup hook creates a file to prove it ran + let setup_file = env.work_dir.join("setup_was_here.txt"); + std::fs::write(&setup_file, "Setup ran successfully")?; + Ok(()) + }); let result = run_script(&script_path, ¶ms); assert!(result.is_ok(), "Setup hook test failed: {:?}", result); @@ -46,36 +45,38 @@ stdout "Mock CLI Tool""#; fs::write(&script_path, script_content).unwrap(); // Create RunParams with setup hook that "compiles" a mock binary - let params = RunParams::new() - .setup(|env| { - // Create a mock binary script for testing - let binary_path = env.work_dir.join("my-tool"); - let mock_binary = r#"#!/bin/sh + let params = RunParams::new().setup(|env| { + // Create a mock binary script for testing + let binary_path = env.work_dir.join("my-tool"); + let mock_binary = r#"#!/bin/sh if [ "$1" = "--help" ]; then echo "Mock CLI Tool" echo "Usage: my-tool [options]" else echo "Hello from my-tool" fi"#; - std::fs::write(&binary_path, mock_binary)?; + std::fs::write(&binary_path, mock_binary)?; - // Make it executable (on Unix systems) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&binary_path)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&binary_path, perms)?; - } + // Make it executable (on Unix systems) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&binary_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&binary_path, perms)?; + } - Ok(()) - }); + Ok(()) + }); let result = run_script(&script_path, ¶ms); // This might fail on Windows or if shell scripting isn't available if result.is_err() { - println!("Binary compilation test failed (expected on some systems): {:?}", result); + println!( + "Binary compilation test failed (expected on some systems): {:?}", + result + ); } else { println!("Setup hook binary test passed!"); } @@ -94,17 +95,16 @@ stdout "Value: hello_from_setup""#; fs::write(&script_path, script_content).unwrap(); // Create RunParams with setup hook that sets an environment variable - let params = RunParams::new() - .setup(|env| { - // The setup hook has access to the TestEnvironment but can't modify it - // This demonstrates the current API limitation - we can access but not modify - println!("Setup running in: {}", env.work_dir.display()); + let params = RunParams::new().setup(|env| { + // The setup hook has access to the TestEnvironment but can't modify it + // This demonstrates the current API limitation - we can access but not modify + println!("Setup running in: {}", env.work_dir.display()); - // We could write files that contain environment setup - let env_script = env.work_dir.join("setup_env.sh"); - std::fs::write(&env_script, "export TEST_FROM_SETUP=hello_from_setup")?; - Ok(()) - }); + // We could write files that contain environment setup + let env_script = env.work_dir.join("setup_env.sh"); + std::fs::write(&env_script, "export TEST_FROM_SETUP=hello_from_setup")?; + Ok(()) + }); let mut params = params; // Manually set the environment variable for this test @@ -113,7 +113,10 @@ stdout "Value: hello_from_setup""#; let result = run_script(&script_path, ¶ms); // This test demonstrates the current limitation - setup can't modify the running environment if result.is_err() { - println!("Environment setup test failed (expected with current API): {:?}", result); + println!( + "Environment setup test failed (expected with current API): {:?}", + result + ); } } @@ -128,12 +131,16 @@ exec echo "Should not execute""#; fs::write(&script_path, script_content).unwrap(); // Create RunParams with failing setup hook - let params = RunParams::new() - .setup(|_env| { - Err(testscript_rs::Error::Generic("Setup deliberately failed".to_string())) - }); + let params = RunParams::new().setup(|_env| { + Err(testscript_rs::Error::Generic( + "Setup deliberately failed".to_string(), + )) + }); let result = run_script(&script_path, ¶ms); assert!(result.is_err(), "Setup hook should have failed the test"); - assert!(result.unwrap_err().to_string().contains("Setup deliberately failed")); -} \ No newline at end of file + assert!(result + .unwrap_err() + .to_string() + .contains("Setup deliberately failed")); +}