1
0
Fork 0
mirror of https://github.com/imjasonh/testscript-rs synced 2026-07-08 17:16:38 +00:00
testscript-rs/src/lib.rs
copilot-swe-agent[bot] cce8ff1802 Implement support for running specific test files
Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
2025-09-30 07:21:26 +00:00

455 lines
15 KiB
Rust

//! # testscript-rs
//!
//! A Rust crate for testing command-line tools using filesystem-based script files,
//! mirroring the functionality of Go's `rogpeppe/go-internal/testscript`.
//!
//! This crate provides a framework for writing integration tests for CLI tools
//! using `.txtar` format files that contain both test scripts and file contents.
pub mod error;
pub mod parser;
pub mod run;
pub use error::{Error, Result};
pub use parser::{Command, Script, TxtarFile};
pub use run::{CommandFn, RunParams, SetupFn, TestEnvironment};
// Re-export for advanced users who need direct access
pub use run::run_test;
// Internal function used by the Builder - not part of public API
fn run(params: &mut RunParams, test_data_glob: &str) -> Result<()> {
use walkdir::WalkDir;
let mut test_files = Vec::new();
// If specific files are provided, use them directly
if let Some(ref files) = params.files {
// Parse the base directory from the glob pattern
let base_dir = if let Some(slash_pos) = test_data_glob.rfind('/') {
&test_data_glob[..slash_pos]
} else {
"."
};
for file in files {
let file_path = if file.starts_with('/') {
// Absolute path - use as-is
std::path::PathBuf::from(file)
} else if file.contains('/') {
// Relative path - resolve relative to base directory
std::path::PathBuf::from(base_dir).join(file)
} else {
// Just a filename - look in the base directory
std::path::PathBuf::from(base_dir).join(file)
};
// Validate that the file exists
if !file_path.exists() {
return Err(Error::Generic(format!(
"Test file not found: {}",
file_path.display()
)));
}
if !file_path.is_file() {
return Err(Error::Generic(format!(
"Path is not a file: {}",
file_path.display()
)));
}
test_files.push(file_path);
}
} else {
// Use the original glob-based discovery
let (base_dir, pattern) = if let Some(slash_pos) = test_data_glob.rfind('/') {
let base_dir = &test_data_glob[..slash_pos];
let pattern = &test_data_glob[slash_pos + 1..];
(base_dir, pattern)
} else {
(".", test_data_glob)
};
// Convert glob pattern to a simple matcher
let pattern_regex = pattern.replace("*", ".*");
let regex = regex::Regex::new(&format!("^{}$", pattern_regex))?;
// Walk the directory and collect matching files
for entry in WalkDir::new(base_dir).min_depth(1).max_depth(1) {
let entry = entry?;
if entry.file_type().is_file() {
if let Some(file_name) = entry.file_name().to_str() {
if regex.is_match(file_name) {
test_files.push(entry.path().to_path_buf());
}
}
}
}
}
// Sort test files for consistent execution order
test_files.sort();
if test_files.is_empty() {
if params.files.is_some() {
return Err(Error::Generic("No test files specified".to_string()));
} else {
return Err(Error::Generic(format!(
"No test files found matching pattern: {}",
test_data_glob
)));
}
}
// Run each test file
for test_file in test_files {
run::run_script(&test_file, params)
.map_err(|e| Error::Generic(format!("Test '{}' failed: {}", test_file.display(), e)))?;
}
Ok(())
}
/// Builder for configuring and running testscript tests
///
/// This provides a fluent interface for setting up and executing test scripts.
///
/// ## Automatic Condition Detection
///
/// The following conditions are automatically available without any setup:
///
/// - **Platform conditions**: `[unix]`, `[windows]`, `[linux]`, `[darwin]`, `[macos]`
/// - **Network condition**: `[net]` - Tests network connectivity by pinging reliable hosts
/// - **Build conditions**: `[debug]`, `[release]` - Based on compilation flags
/// - **Program conditions**: `[exec:program]` - Checks if a program is available in PATH
/// - **Environment conditions**: `[env:VAR]` - Dynamic checking of environment variables
/// - **Program existence**: `[exec:program]` - Checks if a program is available in PATH
/// - **Negation**: Use `!` to negate any condition, e.g. `[!windows]`, `[!env:CI]`, `[!exec:git]`
///
/// ## Examples
///
/// ### Basic Usage
/// ```no_run
/// use testscript_rs::testscript;
///
/// // Simple usage - all conditions detected automatically
/// testscript::run("testdata").execute().unwrap();
/// ```
///
/// ### Running Specific Test Files
/// ```no_run
/// use testscript_rs::testscript;
///
/// // Run only specific test files instead of all .txt files
/// testscript::run("testdata")
/// .files(["hello.txt", "exists.txt"])
/// .execute()
/// .unwrap();
/// ```
///
/// ### With Custom Setup and Work Directory Preservation
/// ```no_run
/// use testscript_rs::testscript;
///
/// testscript::run("testdata")
/// .setup(|env| {
/// // Compile your CLI tool
/// std::process::Command::new("cargo")
/// .args(["build", "--bin", "my-cli"])
/// .status()
/// .expect("Failed to build");
/// Ok(())
/// })
/// .command("my-cmd", |_env, _args| {
/// // Custom command implementation
/// Ok(())
/// })
/// .condition("custom", my_custom_check())
/// .preserve_work_on_failure(true) // Preserve work directory on test failure
/// .execute()
/// .unwrap();
///
/// fn my_custom_check() -> bool {
/// // Your custom condition logic
/// true
/// }
/// ```
///
/// ### With Custom Work Directory Root
/// ```no_run
/// use testscript_rs::testscript;
///
/// // Use a custom location for test working directories
/// testscript::run("testdata")
/// .workdir_root("/tmp/my-app-tests")
/// .preserve_work_on_failure(true) // Combine features
/// .execute()
/// .unwrap();
/// ```
pub struct Builder {
dir: String,
params: RunParams,
}
impl Builder {
/// Create a new builder for the given test directory
fn new(dir: impl Into<String>) -> Self {
Self {
dir: dir.into(),
params: RunParams::new(),
}
}
/// Add a setup function that runs before each test script
///
/// The setup function receives a reference to the test environment and can
/// perform actions like compiling binaries or setting up test data.
pub fn setup<F>(mut self, func: F) -> Self
where
F: Fn(&TestEnvironment) -> Result<()> + 'static,
{
self.params = self.params.setup(func);
self
}
/// Add a custom command that can be used in test scripts
///
/// # Arguments
/// * `name` - The command name as it will appear in test scripts
/// * `func` - The function to execute when the command is called
pub fn command(mut self, name: &str, func: CommandFn) -> Self {
self.params = self.params.command(name, func);
self
}
/// Set a condition value for conditional command execution
///
/// Use this to add custom conditions beyond the built-in ones.
/// Many common conditions are automatically detected (see Builder docs for details).
///
/// # Arguments
/// * `name` - The condition name (use in scripts as `[name]`)
/// * `value` - Whether the condition is met
///
/// # Built-in Conditions (automatically available)
/// - `net` - Network connectivity
/// - `unix`, `windows`, `linux`, `darwin` - Platform detection
/// - `debug`, `release` - Build type
/// - `exec:program` - Program availability (35+ programs)
/// - `env:VAR` - Environment variables (dynamic)
///
/// # Examples
/// ```no_run
/// use testscript_rs::testscript;
///
/// testscript::run("testdata")
/// .condition("feature_enabled", cfg!(feature = "advanced"))
/// .condition("has_gpu", check_gpu_available())
/// .execute()
/// .unwrap();
///
/// fn check_gpu_available() -> bool {
/// // Your GPU detection logic
/// false
/// }
/// ```
pub fn condition(mut self, name: &str, value: bool) -> Self {
self.params = self.params.condition(name, value);
self
}
/// Enable or disable updating test scripts with actual output
///
/// When enabled, instead of failing on output mismatches, the test files
/// will be updated with the actual command output.
pub fn update_scripts(mut self, update: bool) -> Self {
self.params = self.params.update_scripts(update);
self
}
/// Enable or disable preserving working directories when tests fail
///
/// When enabled, if a test fails, the working directory will be preserved
/// and its path will be printed to stderr for debugging purposes.
/// This matches the behavior of Go's testscript TestWork functionality.
///
/// # Examples
/// ```no_run
/// use testscript_rs::testscript;
///
/// testscript::run("testdata")
/// .preserve_work_on_failure(true)
/// .execute()
/// .unwrap();
/// ```
///
/// When a test fails, you'll see output like:
/// ```text
/// Test failed. Work directory preserved at: /tmp/testscript-work-abc123
/// You can inspect the test environment:
/// cd /tmp/testscript-work-abc123
/// ls -la
/// ```
pub fn preserve_work_on_failure(mut self, preserve: bool) -> Self {
self.params = self.params.preserve_work_on_failure(preserve);
self
}
/// Set the root directory for test working directories
///
/// When specified, test directories will be created inside this root directory
/// instead of the system default temporary directory. This is useful for:
/// - **Debugging**: Use a known location for test directories
/// - **Performance**: Use faster storage (e.g., tmpfs on Linux)
/// - **CI environments**: Use specific temp locations
/// - **Security**: Isolate test directories to specific paths
///
/// # Arguments
/// * `root` - The directory path where test working directories should be created
///
/// # Examples
/// ```no_run
/// use testscript_rs::testscript;
///
/// // Use custom location for test directories
/// testscript::run("testdata")
/// .workdir_root("/tmp/my-app-tests")
/// .preserve_work_on_failure(true) // Combine with other features
/// .execute()
/// .unwrap();
/// ```
///
/// This creates test directories like `/tmp/my-app-tests/testscript-abc123/`.
///
/// # Notes
/// - The root directory must exist and be writable
/// - If not specified, uses the system default temporary directory
/// - Each test still gets its own isolated subdirectory
pub fn workdir_root<P: Into<std::path::PathBuf>>(mut self, root: P) -> Self {
self.params = self.params.workdir_root(root);
self
}
/// Run only specific test files instead of discovering all .txt files
///
/// When specified, only these files will be executed instead of discovering
/// all .txt files in the directory. Files can be specified as:
/// - Relative paths (relative to the test directory): `"hello.txt"`
/// - Absolute paths: `"/path/to/test.txt"`
/// - Just filenames: `"test.txt"` (looked up in the test directory)
///
/// # Arguments
/// * `files` - An iterator of file paths to run
///
/// # Examples
/// ```no_run
/// use testscript_rs::testscript;
///
/// // Run specific test files
/// testscript::run("testdata")
/// .files(["hello.txt", "exists.txt"])
/// .execute()
/// .unwrap();
///
/// // Using Vec<String>
/// let test_files = vec!["hello.txt".to_string(), "exists.txt".to_string()];
/// testscript::run("testdata")
/// .files(test_files)
/// .execute()
/// .unwrap();
/// ```
///
/// # Benefits
/// - **Selective testing**: Run only specific test scenarios
/// - **Faster development**: Skip unrelated tests during development
/// - **CI optimization**: Run subsets of tests in different jobs
/// - **Go compatibility**: Match Go testscript functionality
pub fn files<I, S>(mut self, files: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.params = self.params.files(files);
self
}
/// Execute all test scripts in the configured directory
///
/// This will discover all `.txt` files in the directory and run them as test scripts.
/// Each test runs in isolation with its own temporary directory.
///
/// # Returns
/// `Ok(())` if all tests pass, or the first error encountered.
pub fn execute(mut self) -> Result<()> {
let pattern = format!("{}/*.txt", self.dir);
run(&mut self.params, &pattern)
}
}
/// Create a new testscript builder for the given directory
///
/// This is the main entry point for running testscript tests.
///
/// # Examples
///
/// ```no_run
/// use testscript_rs::testscript;
///
/// // Run all tests in testdata directory
/// testscript::run("testdata").execute().unwrap();
///
/// // Run only specific test files
/// testscript::run("testdata")
/// .files(["hello.txt", "exists.txt"])
/// .execute()
/// .unwrap();
/// ```
pub mod testscript {
use super::*;
/// Create a new testscript builder for the given directory
pub fn run(dir: impl Into<String>) -> Builder {
Builder::new(dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_integration_test() {
// Test the parser directly with a simple script
let script_content = r#"exec echo hello
stdout hello
-- file.txt --
content"#;
let script = crate::parser::parse(script_content).unwrap();
assert_eq!(script.commands.len(), 2);
assert_eq!(script.files.len(), 1);
}
#[test]
fn test_example() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let testdata_dir = temp_dir.path().join("testdata");
fs::create_dir(&testdata_dir).unwrap();
let test_content = r#"exec echo "API works!"
stdout "API works!"
-- test.txt --
content"#;
fs::write(testdata_dir.join("api_test.txt"), test_content).unwrap();
let result = testscript::run(testdata_dir.to_string_lossy()).execute();
assert!(result.is_ok(), "API example failed: {:?}", result);
}
}