mirror of
https://github.com/imjasonh/testscript-rs
synced 2026-07-11 10:29:28 +00:00
Refactor run module into modular structure (#18)
## Summary Refactors the monolithic `run.rs` file into a clean modular structure to improve maintainability and code organization. ### Changes - **Split `run.rs` into focused modules:** - `run/environment.rs` - TestEnvironment and file operations - `run/params.rs` - RunParams configuration and conditions - `run/commands.rs` - Built-in command implementations - `run/execution.rs` - Command dispatch and execution logic - `run/mod.rs` - Public API coordination ### Benefits - **Better maintainability** - Each file has clear responsibility - **Easier navigation** - Find specific functionality quickly - **Separation of concerns** - Commands, config, environment isolated - **Prepares for future features** - Clean foundation for WorkdirRoot and other enhancements ### Test Plan - [x] All 37 tests still pass - [x] No functionality changes - [x] Clean compilation with no warnings - [x] Pre-commit hooks pass (formatting, linting, compilation) This is a pure refactoring with no behavior changes - just improved code organization. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d9f9d4f0e6
commit
0c4315ece8
8 changed files with 1173 additions and 1076 deletions
377
src/run/execution.rs
Normal file
377
src/run/execution.rs
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
//! Command execution logic
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::parser::Command;
|
||||
use crate::run::{environment::TestEnvironment, params::RunParams};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Run a single script with the given parameters - main implementation
|
||||
pub fn run_script_impl(script_path: &Path, params: &RunParams) -> Result<()> {
|
||||
// Read and parse the script
|
||||
let content = fs::read_to_string(script_path)?;
|
||||
let script = crate::parser::parse(&content).map_err(|e| {
|
||||
// Enhance parse errors with script context
|
||||
match e {
|
||||
Error::Parse { line, message } => Error::script_error(
|
||||
script_path.to_string_lossy().to_string(),
|
||||
line,
|
||||
&content,
|
||||
Error::Parse { line, message },
|
||||
),
|
||||
other => other,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Create script context for better error reporting
|
||||
let script_file = script_path.to_string_lossy().to_string();
|
||||
|
||||
// Create test environment
|
||||
let mut env = TestEnvironment::new()?;
|
||||
|
||||
// Set up files from the script
|
||||
env.setup_files(&script.files)?;
|
||||
|
||||
// Set the $WORK environment variable to the working directory
|
||||
let work_dir_str = env.work_dir.to_string_lossy().to_string();
|
||||
env.set_env_var("WORK", &work_dir_str);
|
||||
|
||||
// Run setup hook if provided
|
||||
if let Some(setup) = ¶ms.setup {
|
||||
setup(&env)?;
|
||||
}
|
||||
|
||||
// Execute commands
|
||||
for command in &script.commands {
|
||||
if let Err(e) = execute_command(&mut env, command, params) {
|
||||
// Wrap error with script context
|
||||
return Err(Error::script_error(
|
||||
&script_file,
|
||||
command.line_num,
|
||||
&content,
|
||||
e,
|
||||
));
|
||||
}
|
||||
|
||||
// Check for early termination
|
||||
if env.should_skip {
|
||||
return Err(Error::Generic("Test skipped".to_string()));
|
||||
}
|
||||
if env.should_stop {
|
||||
break; // Stop early but don't fail
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for any remaining background processes
|
||||
let background_names: Vec<String> = env.background_processes.keys().cloned().collect();
|
||||
for name in background_names {
|
||||
env.wait_for_background(&name)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a single command
|
||||
fn execute_command(env: &mut TestEnvironment, command: &Command, params: &RunParams) -> Result<()> {
|
||||
// For negated commands, we expect them to fail
|
||||
let result = execute_command_inner(env, command, params);
|
||||
|
||||
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
|
||||
}
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner command execution logic
|
||||
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 = if let Some(value) = params.conditions.get(condition) {
|
||||
*value
|
||||
} else if let Some(base_condition) = condition.strip_prefix('!') {
|
||||
// Handle negated conditions
|
||||
if let Some(value) = params.conditions.get(base_condition) {
|
||||
!value
|
||||
} else {
|
||||
return Err(Error::UnknownCondition {
|
||||
condition: base_condition.to_string(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return Err(Error::UnknownCondition {
|
||||
condition: condition.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
if !condition_met {
|
||||
return Ok(()); // Skip this command
|
||||
}
|
||||
}
|
||||
|
||||
// Check for custom commands first
|
||||
if let Some(custom_fn) = params.commands.get(&command.name) {
|
||||
return custom_fn(env, &command.args);
|
||||
}
|
||||
|
||||
// Handle built-in commands
|
||||
match command.name.as_str() {
|
||||
"exec" => {
|
||||
if command.args.is_empty() {
|
||||
return Err(Error::command_error("exec", "No command specified"));
|
||||
}
|
||||
|
||||
let cmd = &command.args[0];
|
||||
let args = &command.args[1..];
|
||||
|
||||
if command.background {
|
||||
// Use the command name as the background process name
|
||||
let process_name = cmd.to_string();
|
||||
env.execute_background_command(&process_name, cmd, args)?;
|
||||
} else {
|
||||
let output = env.execute_command(cmd, args)?;
|
||||
|
||||
// Check exit status
|
||||
if !output.status.success() {
|
||||
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()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
"cmp" => {
|
||||
if command.args.len() != 2 {
|
||||
return Err(Error::command_error("cmp", "Expected exactly 2 arguments"));
|
||||
}
|
||||
env.compare_files(&command.args[0], &command.args[1])?;
|
||||
}
|
||||
"cmpenv" => {
|
||||
if command.args.len() != 2 {
|
||||
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",
|
||||
));
|
||||
}
|
||||
|
||||
let expected = &command.args[0];
|
||||
|
||||
// Check if argument is a filename or literal text
|
||||
let expected_content = if expected == "-" {
|
||||
// Empty string
|
||||
"".to_string()
|
||||
} else if let Ok(file_content) = fs::read_to_string(env.work_dir.join(expected)) {
|
||||
// It's a file - use its contents
|
||||
file_content.trim_end().to_string()
|
||||
} else {
|
||||
// It's literal text
|
||||
expected.clone()
|
||||
};
|
||||
|
||||
env.compare_output(&command.name, &expected_content)?;
|
||||
}
|
||||
"cd" => {
|
||||
if command.args.len() != 1 {
|
||||
return Err(Error::command_error("cd", "Expected exactly 1 argument"));
|
||||
}
|
||||
env.change_directory(&command.args[0])?;
|
||||
}
|
||||
"wait" => {
|
||||
if command.args.len() != 1 {
|
||||
return Err(Error::command_error("wait", "Expected exactly 1 argument"));
|
||||
}
|
||||
env.wait_for_background(&command.args[0])?;
|
||||
}
|
||||
"exists" => {
|
||||
if command.args.is_empty() {
|
||||
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",
|
||||
));
|
||||
}
|
||||
(true, &command.args[1..])
|
||||
} else {
|
||||
(false, &command.args[..])
|
||||
};
|
||||
|
||||
for path in files {
|
||||
if !env.file_exists(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),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
"mkdir" => {
|
||||
if command.args.is_empty() {
|
||||
return Err(Error::command_error(
|
||||
"mkdir",
|
||||
"Expected at least 1 argument",
|
||||
));
|
||||
}
|
||||
env.create_directories(&command.args)?;
|
||||
}
|
||||
"cp" => {
|
||||
if command.args.len() < 2 {
|
||||
return Err(Error::command_error("cp", "Expected at least 2 arguments"));
|
||||
}
|
||||
env.copy_files(&command.args)?;
|
||||
}
|
||||
"rm" => {
|
||||
if command.args.is_empty() {
|
||||
return Err(Error::command_error("rm", "Expected at least 1 argument"));
|
||||
}
|
||||
env.remove_files(&command.args)?;
|
||||
}
|
||||
"mv" => {
|
||||
if command.args.len() != 2 {
|
||||
return Err(Error::command_error("mv", "Expected exactly 2 arguments"));
|
||||
}
|
||||
env.move_file(&command.args[0], &command.args[1])?;
|
||||
}
|
||||
"env" => {
|
||||
if command.args.is_empty() {
|
||||
// Print current environment for debugging
|
||||
for (key, value) in &env.env_vars {
|
||||
println!("{}={}", key, value);
|
||||
}
|
||||
} else {
|
||||
// Set environment variables
|
||||
for arg in &command.args {
|
||||
if let Some(eq_pos) = arg.find('=') {
|
||||
let key = &arg[..eq_pos];
|
||||
let value = &arg[eq_pos + 1..];
|
||||
env.set_env_var(key, value);
|
||||
} else {
|
||||
return Err(Error::command_error(
|
||||
"env",
|
||||
format!("Invalid env format: {}", arg),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"stdin" => {
|
||||
if command.args.len() != 1 {
|
||||
return Err(Error::command_error("stdin", "Expected exactly 1 argument"));
|
||||
}
|
||||
env.set_stdin_from_file(&command.args[0])?;
|
||||
}
|
||||
"skip" => {
|
||||
env.should_skip = true;
|
||||
let message = if command.args.is_empty() {
|
||||
"Test skipped".to_string()
|
||||
} else {
|
||||
command.args.join(" ")
|
||||
};
|
||||
return Err(Error::Generic(format!("SKIP: {}", message)));
|
||||
}
|
||||
"stop" => {
|
||||
env.should_stop = true;
|
||||
let _message = if command.args.is_empty() {
|
||||
"Test stopped early".to_string()
|
||||
} else {
|
||||
command.args.join(" ")
|
||||
};
|
||||
// For stop, we don't return an error - the test passes but stops
|
||||
return Ok(());
|
||||
}
|
||||
"kill" => {
|
||||
let (signal, target) = if command.args.len() == 2 && command.args[0].starts_with('-') {
|
||||
(Some(command.args[0].as_str()), &command.args[1])
|
||||
} else if command.args.len() == 1 {
|
||||
(None, &command.args[0])
|
||||
} else {
|
||||
return Err(Error::command_error("kill", "Expected 1 or 2 arguments"));
|
||||
};
|
||||
env.kill_background_process(target, signal)?;
|
||||
}
|
||||
"chmod" => {
|
||||
if command.args.len() != 2 {
|
||||
return Err(Error::command_error(
|
||||
"chmod",
|
||||
"Expected exactly 2 arguments",
|
||||
));
|
||||
}
|
||||
env.change_permissions(&command.args[0], &command.args[1])?;
|
||||
}
|
||||
"symlink" => {
|
||||
if command.args.len() != 2 {
|
||||
return Err(Error::command_error(
|
||||
"symlink",
|
||||
"Expected exactly 2 arguments: target link_name",
|
||||
));
|
||||
}
|
||||
env.create_symlink(&command.args[0], &command.args[1])?;
|
||||
}
|
||||
"unquote" => {
|
||||
if command.args.len() != 1 {
|
||||
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",
|
||||
));
|
||||
}
|
||||
let pattern = &command.args[0];
|
||||
let files = &command.args[1..];
|
||||
env.grep_files(pattern, files)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::UnknownCommand {
|
||||
command: command.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue