1
0
Fork 0
mirror of https://github.com/imjasonh/testscript-rs synced 2026-07-16 12:12:07 +00:00

add readme, remove redundant tests

Signed-off-by: Jason Hall <jason@chainguard.dev>
This commit is contained in:
Jason Hall 2025-09-26 18:53:31 -04:00
parent c39114f1a9
commit 4074b2ecac
2 changed files with 0 additions and 442 deletions

234
plan.md
View file

@ -1,234 +0,0 @@
## 🦀 Engineering Plan: `rust-testscript` Crate
### **1. Project Vision & Core Principles**
The goal is to create `rust-testscript`, a crate for testing command-line tools using filesystem-based script files, mirroring the functionality and developer experience of Go's `rogpeppe/go-internal/testscript`.
* **Idiomatic Rust:** The library must feel natural to Rust developers. This means using `Result` for error handling, leveraging iterators, employing the builder pattern for configuration, and using traits for extensibility.
* **Modularity:** Components like the parser, environment manager, and command executor should be distinct and testable in isolation.
* **Extensibility:** Users must be able to easily define their own custom commands and conditions, just like in the Go version.
* **Minimal Dependencies:** We will rely on well-vetted, popular crates where necessary (e.g., for temp files, command execution) but avoid unnecessary bloat.
### **2. High-Level Architecture**
The library will consist of several key components that work together:
1. **Test Runner:** The main entry point for the user. It discovers test script files and orchestrates their execution.
2. **Script Parser:** Responsible for parsing the `.txtar` format into a structured representation of commands and files.
3. **Execution Environment:** Manages the temporary, isolated directory for each test script run, including file setup and environment variables.
4. **Command Engine:** A dispatcher that interprets and executes parsed commands (e.g., `exec`, `cmp`, `stdout`) against the execution environment.
5. **Configuration (`RunParams`):** A builder struct that allows users to customize the test run, such as by adding custom commands or setup logic.
-----
### **3. Phase 1: The Core Engine (MVP)**
This phase focuses on building the non-extensible core functionality. The goal is to successfully parse and run a basic test script with built-in commands.
#### **Task 3.1: Project Scaffolding**
* Initialize a new Rust library crate: `cargo new --lib rust-testscript`.
* Set up the initial `Cargo.toml` with metadata (authors, license, description).
* Add initial dependencies:
* `anyhow` for simple, flexible error handling.
* `thiserror` for creating custom, structured error types.
* `tempfile` for creating isolated temporary directories for test runs.
* `walkdir` for discovering test script files.
#### **Task 3.2: Implement the `txtar` Parser**
* Create a new module: `mod parser;`.
* **Data Structures:** Define structs to represent the parsed script.
```rust
// Represents a single file block in the archive
pub struct TxtarFile {
pub name: String,
pub contents: Vec<u8>,
}
// Represents the parsed script and its associated files
pub struct Script {
pub commands: Vec<Command>,
pub files: Vec<TxtarFile>,
}
// Represents a single command line in the script
pub struct Command {
pub name: String,
pub args: Vec<String>,
pub line_num: usize,
}
```
* **Parsing Logic:** Implement a function `parser::parse(content: &str) -> Result<Script>`. This function will perform a line-by-line parse.
1. It should handle the file preamble (`-- filename --`).
2. It must correctly extract file contents until the next preamble or the end of the file.
3. It must parse command lines, splitting them into a command name and arguments, while correctly handling quoted arguments.
4. Ignore lines starting with `#` (comments).
5. Return a structured `Script` object.
#### **Task 3.3: The Execution Environment**
* Create a new module: `mod run;`.
* **`TestEnvironment` Struct:** This struct will manage the state for a single script execution.
```rust
use tempfile::TempDir;
use std::path::PathBuf;
use std::collections::HashMap;
pub struct TestEnvironment {
// The root temporary directory for the test run.
pub work_dir: PathBuf,
// The underlying TempDir that cleans up on drop.
_temp_dir: TempDir,
// Environment variables for this specific run.
pub env_vars: HashMap<String, String>,
}
```
* **Implementation:**
1. `TestEnvironment::new()`: Creates a new `TempDir` using the `tempfile` crate. Sets `work_dir` to its path.
2. `setup_files(&self, files: &[TxtarFile])`: A method that takes the files from the parsed `Script` and writes them into `work_dir`. It must handle creating subdirectories as needed.
#### **Task 3.4: The Main Test Runner & Command Engine**
* **`run_test` function:** This will be the main public function in the crate.
```rust
// In lib.rs
pub fn run_test(script_path: &Path) -> Result<()> {
// ... implementation ...
}
```
* **Implementation Steps:**
1. Read the script file content from `script_path`.
2. Use the `parser` to parse the content into a `Script` object.
3. Create a new `TestEnvironment`.
4. Call `env.setup_files()` to populate the working directory.
5. **Command Loop:** Iterate through the `script.commands`.
6. **Command Dispatch:** Use a `match` statement on `command.name` to dispatch to built-in command handlers.
* **`exec`:**
* Use `std::process::Command` to execute the command.
* Set the `current_dir` to the `env.work_dir`.
* Inject the `env.env_vars`.
* Capture `stdout`, `stderr`, and the exit code.
* Store this output in a state variable for subsequent checks (e.g., for `stdout` and `stderr` commands).
* **`cmp`:**
* Read the contents of the two specified files within `work_dir`.
* Compare them. Return an error if they don't match.
* **`stdout` / `stderr`:**
* Compare the captured output from the *last* `exec` command with the provided argument (or a file's content).
* Support basic regular expressions via the `regex` crate.
* **`cd`:**
* Update a `current_subdir` path within the `TestEnvironment` struct. All subsequent `exec` commands will use this updated path relative to `work_dir`.
-----
### **4. Phase 2: Extensibility and Usability**
This phase makes the library configurable and ergonomic for end-users, introducing the builder pattern and mechanisms for custom commands.
#### **Task 4.1: The `RunParams` Builder**
* Create a `RunParams` struct to hold all configuration.
```rust
// In run.rs or a new params.rs module
// Type alias for a command function
pub type CommandFn = fn(&mut TestEnvironment, args: &[String]) -> Result<()>;
pub struct RunParams {
// Custom commands provided by the user.
pub commands: HashMap<String, CommandFn>,
// Setup function to run before the script executes.
pub setup: Option<Box<dyn FnOnce(&TestEnvironment) -> Result<()>>>,
// ... other params to be added later ...
}
// Implement the builder pattern for RunParams
impl RunParams {
pub fn new() -> Self { /* ... */ }
pub fn command(mut self, name: &str, func: CommandFn) -> Self { /* ... */ }
pub fn setup(mut self, func: impl FnOnce(&TestEnvironment) -> Result<()> + 'static) -> Self { /* ... */ }
// ... etc. ...
}
```
#### **Task 4.2: Integrate `RunParams` into the Runner**
* Refactor the `run_test` function to accept `RunParams`. The main test discovery logic will now live in a separate function.
```rust
// In lib.rs
use std::path::Path;
// The user calls this from their tests/ directory.
pub fn run(params: &mut RunParams, test_data_glob: &str) {
// Find all files matching the glob (e.g., "testdata/*.txt").
// For each file, call run_script.
// Panic on the first failure to match `go test` behavior.
}
// Internal function that runs a single script.
fn run_script(path: &Path, params: &RunParams) -> Result<()> {
// ... existing logic from run_test ...
}
```
* **Refactor Command Dispatch:** Modify the command loop in `run_script`.
1. First, check if the command name exists in `params.commands`. If so, execute the user-provided function.
2. If not, fall back to the `match` statement for built-in commands.
#### **Task 4.3: Implement the `setup` Hook**
* In `run_script`, after creating the `TestEnvironment` but before executing the command loop, check if `params.setup` is `Some`.
* If it is, execute the setup closure, passing it a reference to the `TestEnvironment`. This allows the user to perform actions like compiling a binary into the `work_dir`.
* **Example Usage:** A user would write:
```rust
// in tests/integration_test.rs
#[test]
fn run_all_scripts() {
let mut params = rust_testscript::RunParams::new();
params = params.setup(|env| {
// Compile the main binary into the test's temp directory
let status = std::process::Command::new("cargo")
.args(["build", "--bin", "my-cli"])
.status()?;
assert!(status.success());
// Copy binary to work_dir
std::fs::copy("target/debug/my-cli", env.work_dir.join("my-cli"))?;
Ok(())
});
rust_testscript::run(&mut params, "testdata/*.txt");
}
```
-----
### **5. Phase 3: Advanced Features & Polish**
This phase adds features for more complex scenarios and improves the overall quality of the crate.
#### **Task 5.1: Implement Conditions (`[condition]`)**
* **Parser Update:** Modify the `parser` to recognize conditional prefixes on commands (e.g., `[windows] exec ...`). Store the condition in the `Command` struct.
* **`RunParams` Update:** Add a `conditions` map.
```rust
// In RunParams
pub conditions: HashMap<String, bool>,
```
* **Runner Update:** In the command loop, before executing a command, check if it has a condition.
* If it does, look up the condition in `params.conditions`.
* If the condition is present and `true`, execute the command.
* If the condition is present and `false`, skip the command.
* If the condition is *not* present, fail the test with an "unknown condition" error.
* **Default Conditions:** Pre-populate the `conditions` map with useful defaults like `windows`, `linux`, `mac`, `unix`.
#### **Task 5.2: Implement Background Commands (`&`)**
* **Parser Update:** Recognize the `&` suffix on `exec` commands.
* **Runner Update:**
* When an `exec` command has the `&` suffix, spawn the process using `std::process::Command::spawn()`.
* Store the `Child` process handle in a `background_pids` map in the `TestEnvironment`. Give it a name (e.g., the first argument).
* Implement a new built-in command: `wait <name>`. This command will find the named background process in the map and call `wait()` on it, capturing its output.
#### **Task 5.3: Documentation and Examples**
* Write comprehensive doc comments (`///`) for all public functions and structs, explaining their purpose and usage.
* Create an `examples/` directory in the crate with a simple CLI tool and a corresponding `tests/` directory that uses `rust-testscript` to test it. This will serve as a reference implementation.
* Write a detailed `README.md` that explains the philosophy, provides a quick-start guide, and documents all built-in commands and features.

View file

@ -1,208 +0,0 @@
//! Integration tests for testscript-rs
use std::fs;
use tempfile::TempDir;
use testscript_rs::{testscript, run_test};
#[test]
fn test_full_script_execution() {
let temp_dir = TempDir::new().unwrap();
let script_path = temp_dir.path().join("test.txt");
let script_content = r#"# Test basic commands
exec echo "hello world"
stdout "hello world"
# Test stderr
exec sh -c "echo error >&2"
stderr "error"
# Test file operations
cmp file1.txt file2.txt
-- file1.txt --
identical content
-- file2.txt --
identical content"#;
fs::write(&script_path, script_content).unwrap();
// Test single script execution
let result = run_test(&script_path);
assert!(result.is_ok(), "Script execution failed: {:?}", result);
}
#[test]
fn test_script_with_conditions() {
let temp_dir = TempDir::new().unwrap();
let script_path = temp_dir.path().join("conditional.txt");
let script_content = r#"# Test conditions
[unix] exec echo "unix system"
[unix] stdout "unix system"
[!windows] exec echo "not windows"
[!windows] stdout "not windows"
# This should be skipped on Unix systems
[windows] exec echo "windows system"
[windows] stdout "windows system""#;
fs::write(&script_path, script_content).unwrap();
let result = run_test(&script_path);
assert!(result.is_ok(), "Conditional script failed: {:?}", result);
}
#[test]
fn test_directory_operations() {
let temp_dir = TempDir::new().unwrap();
let script_path = temp_dir.path().join("dirs.txt");
let script_content = r#"# Test directory operations
exec mkdir subdir
cd subdir
exec pwd
exec touch file_in_subdir.txt
exec ls
-- file_in_subdir.txt --"#;
fs::write(&script_path, script_content).unwrap();
let result = run_test(&script_path);
// This might fail if mkdir/touch/ls aren't available, so we'll be lenient
if result.is_err() {
println!("Directory test failed (expected on some systems): {:?}", result);
}
}
#[test]
fn test_failing_script() {
let temp_dir = TempDir::new().unwrap();
let script_path = temp_dir.path().join("failing.txt");
let script_content = r#"# This should fail
exec echo "hello"
stdout "goodbye""#;
fs::write(&script_path, script_content).unwrap();
let result = run_test(&script_path);
assert!(result.is_err(), "Script should have failed but didn't");
}
#[test]
fn test_file_comparison_failure() {
let temp_dir = TempDir::new().unwrap();
let script_path = temp_dir.path().join("file_fail.txt");
let script_content = r#"# File comparison should fail
cmp file1.txt file2.txt
-- file1.txt --
different content
-- file2.txt --
other content"#;
fs::write(&script_path, script_content).unwrap();
let result = run_test(&script_path);
assert!(result.is_err(), "File comparison should have failed");
}
#[test]
fn test_glob_discovery() {
let temp_dir = TempDir::new().unwrap();
let testdata_dir = temp_dir.path().join("testdata");
fs::create_dir(&testdata_dir).unwrap();
// Create multiple test files
let test1_content = r#"exec echo "test1"
stdout "test1"
-- dummy.txt --
content"#;
let test2_content = r#"exec echo "test2"
stdout "test2"
-- another.txt --
more content"#;
fs::write(testdata_dir.join("test1.txt"), test1_content).unwrap();
fs::write(testdata_dir.join("test2.txt"), test2_content).unwrap();
fs::write(testdata_dir.join("readme.md"), "not a test").unwrap();
// Change to temp directory for the test
// Test using the main API
let result = testscript::run(testdata_dir.to_string_lossy()).execute();
assert!(result.is_ok(), "Glob test execution failed: {:?}", result);
}
#[test]
fn test_empty_files() {
let temp_dir = TempDir::new().unwrap();
let script_path = temp_dir.path().join("empty.txt");
let script_content = r#"# Test empty file handling
cmp empty1.txt empty2.txt
exec cat empty1.txt
stdout ""
-- empty1.txt --
-- empty2.txt --"#;
fs::write(&script_path, script_content).unwrap();
let result = run_test(&script_path);
if result.is_err() {
println!("Empty file test failed (cat might not be available): {:?}", result);
}
}
#[test]
fn test_regex_stdout_matching() {
let temp_dir = TempDir::new().unwrap();
let script_path = temp_dir.path().join("regex.txt");
let script_content = r#"# Test regex matching in stdout
exec echo "Hello, World!"
stdout "^Hello.*World!$"
exec echo "timestamp: 2024-01-01"
stdout "timestamp: [0-9]{4}-[0-9]{2}-[0-9]{2}""#;
fs::write(&script_path, script_content).unwrap();
let result = run_test(&script_path);
assert!(result.is_ok(), "Regex test failed: {:?}", result);
}
#[test]
fn test_multiline_files() {
let temp_dir = TempDir::new().unwrap();
let script_path = temp_dir.path().join("multiline.txt");
let script_content = r#"# Test multiline file content
cmp multi1.txt multi2.txt
-- multi1.txt --
line one
line two
line three
-- multi2.txt --
line one
line two
line three"#;
fs::write(&script_path, script_content).unwrap();
let result = run_test(&script_path);
assert!(result.is_ok(), "Multiline test failed: {:?}", result);
}