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, update: bool, keep: bool, clean_env: bool, path_dirs: Vec, timeout: String, verbose: bool, port_from: u16, filter: Option, 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 Prepend to PATH (repeatable)"); eprintln!(" --timeout Per-command timeout (default: 10s)"); eprintln!(" -v, --verbose Print each command as it runs"); eprintln!(" --port-from Auto-assign $PORT starting from (default: 5400)"); eprintln!(" -t, --filter Only run files matching (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 = 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) { 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) -> Vec { if files.is_empty() { return files; } let input: String = files.iter().map(|f| f.to_string_lossy().to_string()).collect::>().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::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 { 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 = 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 = vec![]; let mut teardown_commands: Vec = 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 = 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| { 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> = if opts.verbose { Some(Box::new(|cmd: &parse::Command| { let _ = write!(stderr(), "{DIM} $ {}{RESET}\n", cmd.command); })) } else { None }; let on_result: Box = 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 = 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 = 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> = 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 }); }