1
0
Fork 0
mirror of https://github.com/imjasonh/testscript-rs synced 2026-07-09 01:26:40 +00:00

add GHA workflow (#1)

* add GHA workflow

Signed-off-by: Jason Hall <jason@chainguard.dev>

* fix CI

Signed-off-by: Jason Hall <jason@chainguard.dev>

* update readme

Signed-off-by: Jason Hall <jason@chainguard.dev>

* add badge

Signed-off-by: Jason Hall <jason@chainguard.dev>

---------

Signed-off-by: Jason Hall <jason@chainguard.dev>
This commit is contained in:
Jason Hall 2025-09-26 19:08:48 -04:00 committed by GitHub
parent fa546ce09f
commit 448cf615db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 381 additions and 156 deletions

61
.github/workflows/ci.yml vendored Normal file
View file

@ -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

8
Cargo.lock generated
View file

@ -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",

View file

@ -2,16 +2,16 @@
name = "testscript-rs"
version = "0.1.0"
edition = "2021"
authors = ["Steve Klabnik <steve@steveklabnik.com>"]
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"

View file

@ -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.

View file

@ -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",

View file

@ -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 = "../.." }

View file

@ -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<String>) -> Result<(), Box<dyn std:
Ok(())
}
fn grep_command(pattern: String, files: Vec<String>, ignore_case: bool) -> Result<(), Box<dyn std::error::Error>> {
fn grep_command(
pattern: String,
files: Vec<String>,
ignore_case: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if files.is_empty() {
return Err("No files specified".into());
}
@ -194,4 +205,4 @@ fn list_command(dir: String) -> Result<(), Box<dyn std::error::Error>> {
}
Ok(())
}
}

View file

@ -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
}
}

View file

@ -16,4 +16,4 @@ fn main() {
std::process::exit(1);
}
}
}
}

View file

@ -64,4 +64,4 @@ impl Error {
message: message.into(),
}
}
}
}

View file

@ -233,9 +233,9 @@ fn parse_command_tokens(input: &str) -> Result<Vec<String>> {
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"
);
}
}
}

View file

@ -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(&regex_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::<Vec<&str>>()
.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());
}
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}

View file

@ -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(())
})

View file

@ -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, &params);
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, &params);
// 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, &params);
// 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, &params);
assert!(result.is_err(), "Setup hook should have failed the test");
assert!(result.unwrap_err().to_string().contains("Setup deliberately failed"));
}
assert!(result
.unwrap_err()
.to_string()
.contains("Setup deliberately failed"));
}