shout/src/main.rs

651 lines
20 KiB
Rust

mod duration;
mod format;
mod matching;
mod parse;
mod run;
mod update;
use std::fs;
use std::io::{Write, stderr, stdout};
use std::path::{Path, PathBuf};
use std::process;
use std::time::Instant;
use format::{TestResult, evaluate_file, format_failure, format_summary, print_dot};
use parse::{Command, Directive, ExitCode, ShoutFile};
use run::{CommandResult, RunOptions, cleanup_tmp_dir, command_passes, run_file};
use update::rewrite_file;
const VERSION: &str = env!("CARGO_PKG_VERSION");
// ANSI
const RED: &str = "\x1b[31m";
const YELLOW: &str = "\x1b[33m";
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";
struct TestOpts {
files: Vec<String>,
update: bool,
keep: bool,
clean_env: bool,
path_dirs: Vec<String>,
timeout: String,
verbose: bool,
port_from: u16,
filter: Option<String>,
parallel: bool,
}
fn print_usage() {
eprintln!("Usage: shout [options] [command]");
eprintln!();
eprintln!("$ shell output tester");
eprintln!();
eprintln!("Options:");
eprintln!(" -V, --version output the version number");
eprintln!(" -h, --help display help for command");
eprintln!();
eprintln!("Commands:");
eprintln!(" test [options] [files...] Run .shout test files");
eprintln!(" version Print the version");
eprintln!(" example Print an example .shout file");
eprintln!(" help [command] display help for command");
}
fn print_test_help() {
eprintln!("Usage: shout test [options] [files...]");
eprintln!();
eprintln!("Run .shout test files");
eprintln!();
eprintln!("Arguments:");
eprintln!(" files Files or directories to test");
eprintln!();
eprintln!("Options:");
eprintln!(" -u, --update Rewrite expected output in-place with actual output");
eprintln!(" -k, --keep Keep temp directories after run");
eprintln!(" --clean-env Start with empty environment");
eprintln!(" --path <path> Prepend <path> to PATH (repeatable)");
eprintln!(" --timeout <dur> Per-command timeout (default: 10s)");
eprintln!(" -v, --verbose Print each command as it runs");
eprintln!(" --port-from <n> Auto-assign $PORT starting from <n> (default: 5400)");
eprintln!(" -t, --filter <pattern> Only run files matching <pattern> (substring match)");
eprintln!(" --parallel Run files in parallel");
eprintln!(" -h, --help display help for command");
}
fn parse_args() -> Option<(&'static str, TestOpts)> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
print_usage();
process::exit(if args.is_empty() { 1 } else { 0 });
}
if args[0] == "help" {
if args.len() < 2 {
print_usage();
} else {
match args[1].as_str() {
"test" => print_test_help(),
"example" => print_example_help(),
"version" => print_version_help(),
"help" => print_help_help(),
other => {
eprintln!("Unknown command: {other}");
eprintln!("Run 'shout --help' for usage");
process::exit(1);
}
}
}
process::exit(0);
}
let subcommand = args[0].as_str();
match subcommand {
"-V" | "--version" | "version" => {
println!("{VERSION}");
process::exit(0);
}
"example" => {
print_example();
process::exit(0);
}
"test" => {}
other => {
eprintln!("Unknown command: {other}");
eprintln!("Run 'shout --help' for usage");
process::exit(1);
}
}
let mut opts = TestOpts {
files: vec![],
update: false,
keep: false,
clean_env: false,
path_dirs: vec![],
timeout: "10s".to_string(),
verbose: false,
port_from: 5400,
filter: None,
parallel: false,
};
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
print_test_help();
process::exit(0);
}
"-u" | "--update" => opts.update = true,
"-k" | "--keep" => opts.keep = true,
"--clean-env" => opts.clean_env = true,
"-v" | "--verbose" => opts.verbose = true,
"--parallel" => opts.parallel = true,
"--path" => {
i += 1;
if i >= args.len() {
eprintln!("--path requires a value");
process::exit(1);
}
opts.path_dirs.push(args[i].clone());
}
"--timeout" => {
i += 1;
if i >= args.len() {
eprintln!("--timeout requires a value");
process::exit(1);
}
opts.timeout = args[i].clone();
}
"--port-from" => {
i += 1;
if i >= args.len() {
eprintln!("--port-from requires a value");
process::exit(1);
}
opts.port_from = match args[i].parse() {
Ok(n) => n,
Err(_) => {
eprintln!("--port-from must be an integer");
process::exit(1);
}
};
}
"-t" | "--filter" => {
i += 1;
if i >= args.len() {
eprintln!("--filter requires a value");
process::exit(1);
}
opts.filter = Some(args[i].clone());
}
arg if arg.starts_with('-') => {
eprintln!("Unknown option: {arg}");
process::exit(1);
}
_ => opts.files.push(args[i].clone()),
}
i += 1;
}
Some(("test", opts))
}
fn find_shout_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
find_shout_files_recursive(&path, out);
} else if path.extension().is_some_and(|ext| ext == "shout") {
out.push(path);
}
}
}
}
fn filter_gitignored(files: Vec<PathBuf>) -> Vec<PathBuf> {
if files.is_empty() {
return files;
}
let input: String = files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>().join("\n");
let result = process::Command::new("git")
.args(["check-ignore", "--stdin"])
.stdin(process::Stdio::piped())
.stdout(process::Stdio::piped())
.stderr(process::Stdio::null())
.spawn();
let Ok(mut child) = result else {
return files;
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(input.as_bytes());
}
let output = child.wait_with_output();
let Ok(output) = output else {
return files;
};
let ignored: std::collections::HashSet<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
files
.into_iter()
.filter(|f| !ignored.contains(&f.to_string_lossy().to_string()))
.collect()
}
fn find_shout_files(paths: &[String]) -> Vec<PathBuf> {
let mut explicit = Vec::new();
let mut discovered = Vec::new();
for p in paths {
let abs = fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p));
if abs.is_file() && abs.extension().is_some_and(|e| e == "shout") {
explicit.push(abs);
} else if abs.is_dir() {
find_shout_files_recursive(&abs, &mut discovered);
} else if abs.extension().is_some_and(|e| e == "shout") {
explicit.push(abs);
}
}
let filtered = filter_gitignored(discovered);
let mut all: Vec<PathBuf> = explicit.into_iter().chain(filtered).collect();
all.sort();
all
}
fn run_one(
file_path: &Path,
port: u16,
opts: &TestOpts,
timeout_ms: u64,
cwd: &Path,
) -> TestResult {
let content = match fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
return evaluate_file(
&rel_path(cwd, file_path),
&[],
Some(&format!("Failed to read file: {e}")),
);
}
};
let rel = rel_path(cwd, file_path);
let parsed = match parse::parse(&rel, &content) {
Ok(p) => p,
Err(e) => {
return evaluate_file(&rel, &[], Some(&e.0));
}
};
// Resolve directives
let mut setup_env: Vec<(String, String)> = vec![];
let mut user_env: Vec<(String, String)> = vec![];
let mut setup_commands: Vec<Command> = vec![];
let mut teardown_commands: Vec<Command> = parsed.teardown_commands.clone();
let mut setup_defs: Vec<(String, String)> = vec![];
let mut user_defs: Vec<(String, String)> = vec![];
for d in &parsed.directives {
match d {
Directive::Setup { path: setup_path, .. } => {
let full_path = file_path.parent().unwrap().join(setup_path);
let setup_content = match fs::read_to_string(&full_path) {
Ok(c) => c,
Err(e) => {
return evaluate_file(
&rel,
&[],
Some(&format!("Failed to read setup file {setup_path}: {e}")),
);
}
};
let setup_rel = rel_path(cwd, &full_path);
let setup_parsed = match parse::parse_setup(&setup_rel, &setup_content) {
Ok(p) => p,
Err(e) => {
return evaluate_file(&rel, &[], Some(&e.0));
}
};
for sd in &setup_parsed.directives {
match sd {
Directive::Env { key, value, .. } => {
setup_env.push((key.clone(), value.clone()));
}
Directive::Def { name, body, .. } => {
setup_defs.push((name.clone(), body.clone()));
}
_ => {}
}
}
setup_commands.extend(setup_parsed.commands);
teardown_commands.extend(setup_parsed.teardown_commands);
}
Directive::Env { key, value, .. } => {
user_env.push((key.clone(), value.clone()));
}
Directive::Def { name, body, .. } => {
user_defs.push((name.clone(), body.clone()));
}
}
}
// Merge defs: setup first, then user overrides
let mut defs: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for (name, body) in &setup_defs {
defs.insert(name.clone(), body.clone());
}
for (name, body) in &user_defs {
defs.insert(name.clone(), body.clone());
}
// Merge env: setup first, then user overrides
let mut env_vars: Vec<(String, String)> = vec![];
env_vars.extend(setup_env.clone());
// Remove setup keys that user overrides
let user_keys: std::collections::HashSet<&str> = user_env.iter().map(|(k, _)| k.as_str()).collect();
env_vars.retain(|(k, _)| !user_keys.contains(k.as_str()));
env_vars.extend(user_env.clone());
// Assign PORT if not set
let has_port = env_vars.iter().any(|(k, _)| k == "PORT");
if !has_port {
env_vars.push(("PORT".to_string(), port.to_string()));
}
// Apply macro substitutions
let apply_defs = |commands: &mut Vec<Command>| {
for cmd in commands.iter_mut() {
if let Some(body) = defs.get(&cmd.command) {
cmd.command = body.clone();
}
}
};
let mut user_commands = parsed.commands.clone();
apply_defs(&mut setup_commands);
apply_defs(&mut user_commands);
// Merge commands: setup + user + teardown
let mut merged_commands = Vec::new();
merged_commands.extend(setup_commands.clone());
merged_commands.extend(user_commands.clone());
merged_commands.extend(teardown_commands.clone());
let merged = ShoutFile {
path: parsed.path.clone(),
commands: merged_commands,
directives: vec![],
teardown_commands: vec![],
};
let setup_len = setup_commands.len();
let user_len = user_commands.len();
let run_opts = RunOptions {
clean_env: opts.clean_env,
path_dirs: opts.path_dirs.clone(),
env_vars,
source_dir: file_path.parent().map(|p| p.to_string_lossy().to_string()),
project_dir: Some(cwd.to_string_lossy().to_string()),
timeout_ms,
verbose: opts.verbose,
};
let on_cmd: Option<Box<dyn Fn(&parse::Command)>> = if opts.verbose {
Some(Box::new(|cmd: &parse::Command| {
let _ = write!(stderr(), "{DIM} $ {}{RESET}\n", cmd.command);
}))
} else {
None
};
let on_result: Box<dyn Fn(usize, &CommandResult)> = Box::new(move |index: usize, result: &CommandResult| {
if index >= setup_len && index < setup_len + user_len {
print_dot(command_passes(result));
}
});
let file_result = run_file(
&merged,
&run_opts,
on_cmd.as_deref(),
Some(&*on_result),
);
// Check setup commands for failures
for i in 0..setup_commands.len() {
if let Some(r) = file_result.results.get(i) {
let expected = &setup_commands[i].exit_code;
let ok = match expected {
ExitCode::Default => r.exit_code == 0,
ExitCode::Any => r.exit_code != 0,
ExitCode::Code(c) => r.exit_code == *c,
};
if !ok {
if opts.keep {
let _ = writeln!(stderr(), "{}", file_result.tmp_dir);
} else {
cleanup_tmp_dir(&file_result.tmp_dir);
}
return evaluate_file(
&parsed.path,
&[],
Some(&format!(
"setup command failed (exit {}): $ {}",
r.exit_code, setup_commands[i].command
)),
);
}
}
}
// Extract user command results
let file_own_results: Vec<CommandResult> = file_result
.results
.iter()
.skip(setup_len)
.take(user_len)
.cloned()
.collect();
// Warn on teardown failures
let teardown_results: Vec<&CommandResult> = file_result
.results
.iter()
.skip(setup_len + user_len)
.collect();
for (i, r) in teardown_results.iter().enumerate() {
if r.exit_code != 0 {
if let Some(td) = teardown_commands.get(i) {
let _ = write!(
stderr(),
"{YELLOW}warning: teardown command failed (exit {}): $ {}{RESET}\n",
r.exit_code, td.command
);
}
}
}
let test_result = evaluate_file(&parsed.path, &file_own_results, file_result.error.as_deref());
// Update mode
if opts.update && !file_own_results.is_empty() {
let updated = rewrite_file(&parsed, &file_own_results, &content);
if updated != content {
let _ = fs::write(file_path, &updated);
}
}
if opts.keep {
let _ = writeln!(stderr(), "{}", file_result.tmp_dir);
} else {
cleanup_tmp_dir(&file_result.tmp_dir);
}
test_result
}
fn rel_path(base: &Path, path: &Path) -> String {
pathdiff(path, base)
}
/// Simple relative path calculation.
fn pathdiff(path: &Path, base: &Path) -> String {
if let Ok(stripped) = path.strip_prefix(base) {
stripped.to_string_lossy().to_string()
} else {
path.to_string_lossy().to_string()
}
}
fn print_example_help() {
eprintln!("Usage: shout example");
eprintln!();
eprintln!("Print an example .shout file");
}
fn print_version_help() {
eprintln!("Usage: shout version");
eprintln!();
eprintln!("Print the version");
}
fn print_help_help() {
eprintln!("Usage: shout help [command]");
eprintln!();
eprintln!("Display help for command");
}
fn print_example() {
println!(
r#"# Example .shout file
$ echo hello
hello
$ echo "one"; echo "two"; echo "three"
one
...
three
$ cat nonexistent
cat: nonexistent: ...
[1]
$ true
[0]"#
);
}
fn main() {
let (_, opts) = parse_args().unwrap();
let timeout_ms = match duration::parse_duration(&opts.timeout) {
Ok(ms) => ms,
Err(e) => {
eprintln!("{e}");
process::exit(1);
}
};
let paths = if opts.files.is_empty() {
vec![".".to_string()]
} else {
opts.files.clone()
};
let cwd = std::env::current_dir().unwrap();
let mut files = find_shout_files(&paths);
if let Some(ref pattern) = opts.filter {
files.retain(|f| rel_path(&cwd, f).contains(pattern));
}
if files.is_empty() {
if opts.filter.is_some() {
eprintln!("No .shout files matching \"{}\"", opts.filter.as_ref().unwrap());
} else {
eprintln!("No .shout files found");
}
process::exit(1);
}
let start = Instant::now();
let mut results: Vec<TestResult> = Vec::new();
let mut next_port = opts.port_from;
let print_error_dot = |r: &TestResult| {
if r.error.is_some() {
let _ = stdout().write_all(format!("{RED}F{RESET}").as_bytes());
let _ = stdout().flush();
}
};
if opts.parallel {
// Pre-assign ports
let file_ports: Vec<(PathBuf, u16)> = files
.iter()
.enumerate()
.map(|(i, f)| (f.clone(), opts.port_from + i as u16))
.collect();
let mut par_results: Vec<Option<TestResult>> = file_ports.iter().map(|_| None).collect();
std::thread::scope(|s| {
let handles: Vec<_> = file_ports
.iter()
.map(|(f, port)| s.spawn(|| run_one(f, *port, &opts, timeout_ms, &cwd)))
.collect();
for (slot, handle) in par_results.iter_mut().zip(handles) {
let r = handle.join().expect("thread panicked");
print_error_dot(&r);
*slot = Some(r);
}
});
results.extend(par_results.into_iter().map(|r| r.unwrap()));
let _ = writeln!(stdout());
} else {
for file_path in &files {
let r = run_one(file_path, next_port, &opts, timeout_ms, &cwd);
print_error_dot(&r);
results.push(r);
next_port += 1;
}
let _ = writeln!(stdout());
}
// Print failures
let failures: Vec<&TestResult> = results.iter().filter(|r| !r.passed).collect();
if !failures.is_empty() {
println!();
for f in &failures {
println!("{}", format_failure(f));
println!();
}
}
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
let single_file = if files.len() == 1 {
Some(rel_path(&cwd, &files[0]))
} else {
None
};
println!("{}", format_summary(&results, elapsed, single_file.as_deref()));
process::exit(if failures.is_empty() { 0 } else { 1 });
}