diff --git a/shout-rs/Cargo.lock b/shout-rs/Cargo.lock new file mode 100644 index 0000000..496cfd3 --- /dev/null +++ b/shout-rs/Cargo.lock @@ -0,0 +1,113 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "shout" +version = "0.0.18" +dependencies = [ + "libc", + "rayon", + "regex", +] diff --git a/shout-rs/Cargo.toml b/shout-rs/Cargo.toml new file mode 100644 index 0000000..aeac032 --- /dev/null +++ b/shout-rs/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "shout" +version = "0.0.18" +edition = "2024" + +[dependencies] +libc = "0.2" +rayon = "1" +regex = "1" diff --git a/shout-rs/src/duration.rs b/shout-rs/src/duration.rs new file mode 100644 index 0000000..85b6022 --- /dev/null +++ b/shout-rs/src/duration.rs @@ -0,0 +1,34 @@ +use std::fmt; + +#[derive(Debug)] +pub struct ParseDurationError(String); + +impl fmt::Display for ParseDurationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Invalid duration: {}", self.0) + } +} + +/// Parse a duration string like "10s", "500ms", "1m" into milliseconds. +pub fn parse_duration(s: &str) -> Result { + let (num, unit) = if let Some(rest) = s.strip_suffix("ms") { + (rest, "ms") + } else if let Some(rest) = s.strip_suffix('s') { + (rest, "s") + } else if let Some(rest) = s.strip_suffix('m') { + (rest, "m") + } else { + return Err(ParseDurationError(s.to_string())); + }; + + let value: f64 = num.parse().map_err(|_| ParseDurationError(s.to_string()))?; + + let ms = match unit { + "ms" => value, + "s" => value * 1000.0, + "m" => value * 60_000.0, + _ => unreachable!(), + }; + + Ok(ms as u64) +} diff --git a/shout-rs/src/format.rs b/shout-rs/src/format.rs new file mode 100644 index 0000000..b07c735 --- /dev/null +++ b/shout-rs/src/format.rs @@ -0,0 +1,188 @@ +use crate::matching::{DiffKind, DiffLine, diff, match_output}; +use crate::parse::ExitCode; +use crate::run::CommandResult; + +// ANSI color codes +const RED: &str = "\x1b[31m"; +const GREEN: &str = "\x1b[32m"; +#[allow(dead_code)] +const YELLOW: &str = "\x1b[33m"; +const DIM: &str = "\x1b[2m"; +const RESET: &str = "\x1b[0m"; + +#[derive(Debug)] +pub struct FailedCommand { + pub result: CommandResult, + pub diff_lines: Vec, + pub exit_code_mismatch: bool, +} + +#[derive(Debug)] +pub struct TestResult { + pub path: String, + pub passed: bool, + pub command_count: usize, + pub failures: Vec, + pub error: Option, +} + +pub fn evaluate_file( + path: &str, + results: &[CommandResult], + error: Option<&str>, +) -> TestResult { + if let Some(err) = error { + return TestResult { + path: path.to_string(), + passed: false, + command_count: results.len(), + failures: vec![], + error: Some(err.to_string()), + }; + } + + let mut failures = Vec::new(); + + for result in results { + let output_matches = match_output(&result.command.expected, &result.actual); + + let exit_code_mismatch = match &result.command.exit_code { + ExitCode::Default => result.exit_code != 0, + ExitCode::Any => result.exit_code == 0, + ExitCode::Code(expected) => result.exit_code != *expected, + }; + + if !output_matches || exit_code_mismatch { + failures.push(FailedCommand { + result: result.clone(), + diff_lines: if output_matches { + vec![] + } else { + diff(&result.command.expected, &result.actual) + }, + exit_code_mismatch, + }); + } + } + + TestResult { + path: path.to_string(), + passed: failures.is_empty(), + command_count: results.len(), + failures, + error: None, + } +} + +pub fn format_failure(test: &TestResult) -> String { + let mut lines = Vec::new(); + + lines.push(format!("{RED}FAIL {}{RESET}", test.path)); + + if let Some(ref err) = test.error { + lines.push(format!(" {RED}{err}{RESET}")); + return lines.join("\n"); + } + + for failure in &test.failures { + lines.push(String::new()); + lines.push(format!(" {DIM}${RESET} {}", failure.result.command.command)); + + if !failure.diff_lines.is_empty() { + let mut expected_lines = Vec::new(); + let mut actual_lines = Vec::new(); + + for dl in &failure.diff_lines { + let text = if dl.kind == DiffKind::Context { + format!("{DIM}{}{RESET}", dl.text) + } else { + dl.text.clone() + }; + + match dl.kind { + DiffKind::Expected | DiffKind::Equal | DiffKind::Context => { + let prefix = if dl.kind == DiffKind::Expected { + format!("{GREEN} > {RESET}") + } else { + " ".to_string() + }; + expected_lines.push(format!("{prefix}{text}")); + } + _ => {} + } + + match dl.kind { + DiffKind::Actual | DiffKind::Equal | DiffKind::Context => { + let prefix = if dl.kind == DiffKind::Actual { + format!("{RED} > {RESET}") + } else { + " ".to_string() + }; + actual_lines.push(format!("{prefix}{text}")); + } + _ => {} + } + } + + lines.push(format!("{GREEN} expected:{RESET}")); + lines.extend(expected_lines); + lines.push(format!("{RED} actual:{RESET}")); + lines.extend(actual_lines); + } + + if failure.exit_code_mismatch { + let expected = match &failure.result.command.exit_code { + ExitCode::Default => "0".to_string(), + ExitCode::Any => "non-zero".to_string(), + ExitCode::Code(c) => c.to_string(), + }; + lines.push(format!("{GREEN} expected exit code: {expected}{RESET}")); + lines.push(format!("{RED} actual exit code: {}{RESET}", failure.result.exit_code)); + } + } + + lines.join("\n") +} + +pub fn format_summary( + results: &[TestResult], + elapsed_ms: f64, + single_file: Option<&str>, +) -> String { + let total_commands: usize = results.iter().map(|r| r.command_count).sum(); + let failed_commands: usize = results.iter().map(|r| r.failures.len()).sum(); + let passed_commands = total_commands - failed_commands; + + let mut parts = Vec::new(); + if passed_commands > 0 { + parts.push(format!("{GREEN}{passed_commands} passed{RESET}")); + } + if failed_commands > 0 { + parts.push(format!("{RED}{failed_commands} failed{RESET}")); + } + + let time = if elapsed_ms < 1000.0 { + format!("{}ms", elapsed_ms.round() as u64) + } else { + format!("{:.1}s", elapsed_ms / 1000.0) + }; + + let label = match single_file { + Some(f) => format!(" in {f}"), + None => String::new(), + }; + + format!("{}{label} {DIM}[{time}]{RESET}", parts.join(", ")) +} + +/// Print a green dot for pass, red F for fail. +pub fn print_dot(passed: bool) { + use std::io::{Write, stdout}; + let s = if passed { + format!("{GREEN}.{RESET}") + } else { + format!("{RED}F{RESET}") + }; + let _ = stdout().write_all(s.as_bytes()); + let _ = stdout().flush(); +} diff --git a/shout-rs/src/main.rs b/shout-rs/src/main.rs new file mode 100644 index 0000000..ea016a4 --- /dev/null +++ b/shout-rs/src/main.rs @@ -0,0 +1,538 @@ +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 parse_args() -> Option<(&'static str, TestOpts)> { + let args: Vec = std::env::args().skip(1).collect(); + + if args.is_empty() { + eprintln!("Usage: shout [options]"); + process::exit(1); + } + + let subcommand = args[0].as_str(); + + match subcommand { + "version" => { + println!("{VERSION}"); + process::exit(0); + } + "example" => { + print_example(); + process::exit(0); + } + "test" => {} + other => { + eprintln!("Unknown command: {other}"); + 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() { + "-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(); + + 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 { + if let Directive::Env { key, value, .. } = sd { + setup_env.push((key.clone(), value.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())); + } + } + } + + // 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())); + } + + // Merge commands: setup + user + teardown + let mut merged_commands = Vec::new(); + merged_commands.extend(setup_commands.clone()); + merged_commands.extend(parsed.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 = parsed.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() { + 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 { + use rayon::prelude::*; + + // 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 par_results: Vec = file_ports + .par_iter() + .map(|(f, port)| { + let r = run_one(f, *port, &opts, timeout_ms, &cwd); + print_error_dot(&r); + r + }) + .collect(); + + results.extend(par_results); + 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 }); +} diff --git a/shout-rs/src/matching.rs b/shout-rs/src/matching.rs new file mode 100644 index 0000000..b7a6ffb --- /dev/null +++ b/shout-rs/src/matching.rs @@ -0,0 +1,123 @@ +use regex::Regex; + +/// Check if a single line matches a pattern that may contain inline `...` wildcards. +pub fn match_line(pattern: &str, actual: &str) -> bool { + if !pattern.contains("...") { + return pattern == actual; + } + + let parts: Vec<&str> = pattern.split("...").collect(); + let escaped: Vec = parts.iter().map(|p| regex::escape(p)).collect(); + let re_str = format!("^{}$", escaped.join(".*")); + match Regex::new(&re_str) { + Ok(re) => re.is_match(actual), + Err(_) => false, + } +} + +/// Match expected output against actual output, supporting multi-line `...` wildcards. +pub fn match_output(expected: &[String], actual: &[String]) -> bool { + do_match(expected, 0, actual, 0) +} + +fn do_match(expected: &[String], ei: usize, actual: &[String], ai: usize) -> bool { + // Both exhausted — match + if ei == expected.len() && ai == actual.len() { + return true; + } + + // Expected exhausted but actual remains — no match + if ei == expected.len() { + return false; + } + + let exp = &expected[ei]; + + // Multi-line wildcard + if exp == "..." { + // Try matching zero or more actual lines + for skip in ai..=actual.len() { + if do_match(expected, ei + 1, actual, skip) { + return true; + } + } + return false; + } + + // Actual exhausted but expected remains — no match + if ai == actual.len() { + return false; + } + + // Line-level match (with possible inline wildcards) + if match_line(exp, &actual[ai]) { + return do_match(expected, ei + 1, actual, ai + 1); + } + + false +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DiffKind { + Equal, + Expected, + Actual, + Context, +} + +#[derive(Debug, Clone)] +pub struct DiffLine { + pub kind: DiffKind, + pub text: String, +} + +pub fn diff(expected: &[String], actual: &[String]) -> Vec { + let mut result = Vec::new(); + let mut ei = 0; + let mut ai = 0; + + while ei < expected.len() || ai < actual.len() { + if ei < expected.len() && expected[ei] == "..." { + // Find where the wildcard ends by looking at next expected line + let next_exp = if ei + 1 < expected.len() { + Some(&expected[ei + 1]) + } else { + None + }; + if next_exp.is_none() { + // ... at end matches everything remaining + result.push(DiffLine { kind: DiffKind::Context, text: "...".to_string() }); + break; + } + // Skip actual lines until we find the next expected match + result.push(DiffLine { kind: DiffKind::Context, text: "...".to_string() }); + ei += 1; + let next = next_exp.unwrap(); + while ai < actual.len() && !match_line(next, &actual[ai]) { + ai += 1; + } + continue; + } + + if ei < expected.len() && ai < actual.len() { + if match_line(&expected[ei], &actual[ai]) { + result.push(DiffLine { kind: DiffKind::Equal, text: actual[ai].clone() }); + ei += 1; + ai += 1; + } else { + result.push(DiffLine { kind: DiffKind::Expected, text: expected[ei].clone() }); + result.push(DiffLine { kind: DiffKind::Actual, text: actual[ai].clone() }); + ei += 1; + ai += 1; + } + } else if ei < expected.len() { + result.push(DiffLine { kind: DiffKind::Expected, text: expected[ei].clone() }); + ei += 1; + } else { + result.push(DiffLine { kind: DiffKind::Actual, text: actual[ai].clone() }); + ai += 1; + } + } + + result +} diff --git a/shout-rs/src/parse.rs b/shout-rs/src/parse.rs new file mode 100644 index 0000000..da53873 --- /dev/null +++ b/shout-rs/src/parse.rs @@ -0,0 +1,260 @@ +use std::fmt; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Command { + pub line: usize, + pub raw: String, + pub command: String, + pub expected: Vec, + pub exit_code: ExitCode, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ExitCode { + /// No explicit exit code — expects 0 + Default, + /// Expect a specific exit code + Code(i32), + /// Expect any non-zero exit code ([*]) + Any, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum Directive { + Setup { path: String, line: usize }, + Env { key: String, value: String, line: usize }, +} + +#[derive(Debug, Clone)] +pub struct ShoutFile { + pub path: String, + pub commands: Vec, + pub directives: Vec, + pub teardown_commands: Vec, +} + +#[derive(Debug)] +pub struct ParseError(pub String); + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +pub fn trim_trailing_empty(lines: &[String]) -> Vec { + let mut end = lines.len(); + while end > 0 && lines[end - 1].is_empty() { + end -= 1; + } + lines[..end].to_vec() +} + +/// Strip trailing `# comment` from a command line, respecting quotes. +fn strip_comment(line: &str) -> &str { + let mut in_single = false; + let mut in_double = false; + for (i, ch) in line.char_indices() { + match ch { + '\'' if !in_double => in_single = !in_single, + '"' if !in_single => in_double = !in_double, + '#' if !in_single && !in_double => return line[..i].trim_end(), + _ => {} + } + } + line +} + +/// A line like `$# ...` or `$ # ...` — a comment, not a real command. +pub fn is_comment_line(line: &str) -> bool { + line.starts_with("$#") + || (line.starts_with("$ ") && strip_comment(&line[2..]).is_empty()) +} + +fn parse_exit_code(lines: &[String]) -> (Vec, ExitCode) { + if lines.is_empty() { + return (vec![], ExitCode::Default); + } + + let last = &lines[lines.len() - 1]; + + // Match [N] or [*] + if last.starts_with('[') && last.ends_with(']') { + let inner = &last[1..last.len() - 1]; + if inner == "*" { + return (lines[..lines.len() - 1].to_vec(), ExitCode::Any); + } + if let Ok(code) = inner.parse::() { + return (lines[..lines.len() - 1].to_vec(), ExitCode::Code(code)); + } + } + + (lines.to_vec(), ExitCode::Default) +} + +fn parse_env_directive(path: &str, line: &str, line_num: usize) -> Result<(String, String), ParseError> { + let rest = line[5..].trim(); + match rest.find('=') { + Some(eq) if eq > 0 => { + let key = rest[..eq].to_string(); + let value = rest[eq + 1..].to_string(); + Ok((key, value)) + } + _ => Err(ParseError(format!( + "{}:{}: malformed @env directive (expected KEY=VALUE): {}", + path, line_num, line + ))), + } +} + +fn finalize_command(cmd: &mut Command) { + let trimmed = trim_trailing_empty(&cmd.expected); + let (expected, exit_code) = parse_exit_code(&trimmed); + cmd.expected = trim_trailing_empty(&expected); + cmd.exit_code = exit_code; +} + +pub fn parse_setup(path: &str, content: &str) -> Result { + let mut raw_lines: Vec<&str> = content.split('\n').collect(); + + // Remove trailing empty line + if raw_lines.last() == Some(&"") { + raw_lines.pop(); + } + + let mut commands = Vec::new(); + let mut teardown_commands = Vec::new(); + let mut directives = Vec::new(); + + for (i, &line) in raw_lines.iter().enumerate() { + let line_num = i + 1; + + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some(rest) = line.strip_prefix("@env ") { + let _ = rest; // parsed below + let (key, value) = parse_env_directive(path, line, line_num)?; + directives.push(Directive::Env { key, value, line: line_num }); + } else if let Some(rest) = line.strip_prefix("@teardown ") { + if rest.trim().is_empty() { + return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num))); + } + teardown_commands.push(Command { + line: line_num, + raw: line.to_string(), + command: strip_comment(rest).to_string(), + expected: vec![], + exit_code: ExitCode::Default, + }); + } else if line.starts_with("@setup ") { + return Err(ParseError(format!("{}:{}: @setup not allowed in setup files", path, line_num))); + } else if line.starts_with('@') { + return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line))); + } else { + commands.push(Command { + line: line_num, + raw: line.to_string(), + command: strip_comment(line).to_string(), + expected: vec![], + exit_code: ExitCode::Default, + }); + } + } + + Ok(ShoutFile { + path: path.to_string(), + commands, + directives, + teardown_commands, + }) +} + +pub fn parse(path: &str, content: &str) -> Result { + let mut raw_lines: Vec<&str> = content.split('\n').collect(); + + // Remove trailing newline + if raw_lines.last() == Some(&"") { + raw_lines.pop(); + } + + let mut commands = Vec::new(); + let mut teardown_commands = Vec::new(); + let mut directives = Vec::new(); + let mut current: Option = None; + let mut seen_command = false; + + for (i, &line) in raw_lines.iter().enumerate() { + let line_num = i + 1; + + // Directives (before first command) + if !seen_command && line.starts_with('@') { + if let Some(rest) = line.strip_prefix("@setup ") { + let setup_path = rest.trim(); + if setup_path.is_empty() { + return Err(ParseError(format!("{}:{}: @setup requires a file path", path, line_num))); + } + directives.push(Directive::Setup { path: setup_path.to_string(), line: line_num }); + } else if let Some(rest) = line.strip_prefix("@teardown ") { + if rest.trim().is_empty() { + return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num))); + } + teardown_commands.push(Command { + line: line_num, + raw: line.to_string(), + command: strip_comment(rest).to_string(), + expected: vec![], + exit_code: ExitCode::Default, + }); + } else if line.starts_with("@env ") { + let (key, value) = parse_env_directive(path, line, line_num)?; + directives.push(Directive::Env { key, value, line: line_num }); + } else { + return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line))); + } + continue; + } + + if is_comment_line(line) { + seen_command = true; + if let Some(ref mut cmd) = current { + finalize_command(cmd); + commands.push(cmd.clone()); + current = None; + } + } else if line.starts_with("\\$ ") && current.is_some() { + // Escaped dollar-space: literal expected output starting with "$ " + current.as_mut().unwrap().expected.push(line[1..].to_string()); + } else if line.starts_with("$ ") { + seen_command = true; + if let Some(ref mut cmd) = current { + finalize_command(cmd); + commands.push(cmd.clone()); + } + current = Some(Command { + line: line_num, + raw: line.to_string(), + command: strip_comment(&line[2..]).to_string(), + expected: vec![], + exit_code: ExitCode::Default, + }); + } else if let Some(ref mut cmd) = current { + cmd.expected.push(line.to_string()); + } + } + + if let Some(ref mut cmd) = current { + finalize_command(cmd); + commands.push(cmd.clone()); + } + + Ok(ShoutFile { + path: path.to_string(), + commands, + directives, + teardown_commands, + }) +} diff --git a/shout-rs/src/run.rs b/shout-rs/src/run.rs new file mode 100644 index 0000000..7e0d0c9 --- /dev/null +++ b/shout-rs/src/run.rs @@ -0,0 +1,436 @@ +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +use regex::Regex; + +use crate::parse::{self, ShoutFile}; + +#[derive(Debug, Clone)] +pub struct CommandResult { + pub command: parse::Command, + pub actual: Vec, + pub exit_code: i32, +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct FileResult { + pub file: ShoutFile, + pub results: Vec, + pub tmp_dir: String, + pub error: Option, +} + +pub struct RunOptions { + pub clean_env: bool, + pub path_dirs: Vec, + pub env_vars: Vec<(String, String)>, + pub source_dir: Option, + pub project_dir: Option, + pub timeout_ms: u64, + pub verbose: bool, +} + +const SENTINEL_PREFIX: &str = "__SHOUT_SENTINEL_"; +const VERBOSE_MARKER: &str = "__SHOUT_CMD_"; + +fn build_script(commands: &[parse::Command], verbose: bool) -> String { + let mut lines = Vec::new(); + + if verbose { + lines.push("exec 3>&2 2>&1 9>&1".to_string()); + } else { + lines.push("exec 2>&1 9>&1".to_string()); + } + + for (i, cmd) in commands.iter().enumerate() { + if verbose { + lines.push(format!("printf '{VERBOSE_MARKER}{i}\\n' >&3")); + } + lines.push("__shout_out=$(mktemp)".to_string()); + lines.push("exec 1>\"$__shout_out\" 2>&1".to_string()); + lines.push(cmd.command.clone()); + lines.push("__shout_ec=$?".to_string()); + lines.push("exec 1>&9 2>&1".to_string()); + lines.push("cat \"$__shout_out\"".to_string()); + lines.push("rm -f \"$__shout_out\"".to_string()); + lines.push(format!( + "printf '\\n{SENTINEL_PREFIX}%s_{i}__\\n' \"$__shout_ec\"" + )); + } + + lines.join("\n") + "\n" +} + +fn strip_ansi(line: &str) -> String { + // Same regex as the TS version + let re = Regex::new(r"[\x1b\x9b][\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]").unwrap(); + re.replace_all(line, "").to_string() +} + +fn parse_sentinel_output(raw: &str, command_count: usize) -> (Vec>, Vec) { + let mut outputs = Vec::new(); + let mut exit_codes = Vec::new(); + + let sentinel_re = Regex::new(&format!(r"{}(\d+)_(\d+)__", regex::escape(SENTINEL_PREFIX))).unwrap(); + + let mut remaining = raw; + + for _i in 0..command_count { + if let Some(m) = sentinel_re.find(remaining) { + let caps = sentinel_re.captures(&remaining[m.start()..]).unwrap(); + let exit_code: i32 = caps[1].parse().unwrap_or(1); + + let before = &remaining[..m.start()]; + let mut lines: Vec = before.split('\n').map(|s| s.to_string()).collect(); + + // Remove leading empty line (from printf \n prefix) + if !lines.is_empty() && lines[0].is_empty() { + lines.remove(0); + } + // Remove trailing empty lines + lines = parse::trim_trailing_empty(&lines); + if lines.len() == 1 && lines[0].is_empty() { + lines.clear(); + } + + outputs.push(lines); + exit_codes.push(exit_code); + + // Skip past sentinel + let after = &remaining[m.end()..]; + remaining = if after.starts_with('\n') { + &after[1..] + } else { + after + }; + } else { + // No sentinel found — rest is output for this command + let mut lines: Vec = remaining.split('\n').map(|s| s.to_string()).collect(); + if !lines.is_empty() && lines[0].is_empty() { + lines.remove(0); + } + lines = parse::trim_trailing_empty(&lines); + outputs.push(lines); + exit_codes.push(1); // assume failure + break; + } + } + + // Fill missing entries + while outputs.len() < command_count { + outputs.push(vec![]); + exit_codes.push(1); + } + + (outputs, exit_codes) +} + +fn make_tmp_dir() -> std::io::Result { + let base = std::env::temp_dir(); + // Create a unique temp directory + loop { + let suffix: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + let dir = base.join(format!("shout-{suffix}")); + if !dir.exists() { + std::fs::create_dir_all(&dir)?; + return Ok(dir.to_string_lossy().to_string()); + } + } +} + +fn kill_tree(pid: u32) { + // Find processes in the same process group + if let Ok(output) = Command::new("ps") + .args(["-eo", "pid,pgid"]) + .output() + { + let text = String::from_utf8_lossy(&output.stdout); + let pgid = pid.to_string(); + for line in text.lines() { + let parts: Vec<&str> = line.trim().split_whitespace().collect(); + if parts.len() >= 2 && parts[1] == pgid { + if let Ok(p) = parts[0].parse::() { + if p as u32 != pid && p > 1 { + unsafe { libc::kill(p, libc::SIGKILL); } + } + } + } + } + } + // Kill the process group + unsafe { libc::kill(-(pid as i32), libc::SIGKILL); } +} + +pub fn run_file( + file: &ShoutFile, + options: &RunOptions, + on_command: Option<&dyn Fn(&parse::Command)>, + on_command_result: Option<&dyn Fn(usize, &CommandResult)>, +) -> FileResult { + let tmp_dir = match make_tmp_dir() { + Ok(d) => d, + Err(e) => { + return FileResult { + file: file.clone(), + results: vec![], + tmp_dir: String::new(), + error: Some(format!("Failed to create temp dir: {e}")), + }; + } + }; + + if file.commands.is_empty() { + return FileResult { + file: file.clone(), + results: vec![], + tmp_dir, + error: None, + }; + } + + let verbose = options.verbose && on_command.is_some(); + let script = build_script(&file.commands, verbose); + + let mut cmd = Command::new("/bin/sh"); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(&tmp_dir); + + // Set up process group (detached) + unsafe { + use std::os::unix::process::CommandExt; + cmd.pre_exec(|| { + libc::setpgid(0, 0); + Ok(()) + }); + } + + // Environment + if options.clean_env { + cmd.env_clear(); + } + + cmd.env("HOME", &tmp_dir); + cmd.env("SHOUT_DIR", &tmp_dir); + + if let Some(ref source_dir) = options.source_dir { + cmd.env("SHOUT_SOURCE_DIR", source_dir); + } + if let Some(ref project_dir) = options.project_dir { + cmd.env("SHOUT_PROJECT_DIR", project_dir); + } + + for (key, value) in &options.env_vars { + cmd.env(key, value); + } + + if !options.path_dirs.is_empty() { + let existing = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{existing}", options.path_dirs.join(":")); + cmd.env("PATH", new_path); + } + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + return FileResult { + file: file.clone(), + results: vec![], + tmp_dir, + error: Some(format!("Failed to spawn shell: {e}")), + }; + } + }; + + let pid = child.id(); + + // Stream verbose markers from stderr in a separate thread + if verbose { + let stderr = child.stderr.take().unwrap(); + let commands = file.commands.clone(); + let _handle = std::thread::spawn(move || { + let mut reader = std::io::BufReader::new(stderr); + let mut buf = String::new(); + let mut byte = [0u8; 1]; + while reader.read(&mut byte).unwrap_or(0) > 0 { + if byte[0] == b'\n' { + if buf.starts_with(VERBOSE_MARKER) { + if let Ok(idx) = buf[VERBOSE_MARKER.len()..].parse::() { + if idx < commands.len() { + let _ = write!( + std::io::stderr(), + "\x1b[2m $ {}\x1b[0m\n", + commands[idx].command + ); + } + } + } + buf.clear(); + } else { + buf.push(byte[0] as char); + } + } + }); + } + + // Write script to stdin + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(script.as_bytes()); + // stdin drops here, closing the pipe + } + + // Read stdout with timeout + let total_timeout_ms = options.timeout_ms * file.commands.len() as u64; + let stdout = child.stdout.take().unwrap(); + let (tx, rx) = mpsc::channel::>(); + + let last_sentinel_suffix = format!("_{}_", file.commands.len() - 1); + let sentinel_prefix = SENTINEL_PREFIX.to_string(); + + let reader_thread = std::thread::spawn(move || { + let mut reader = std::io::BufReader::new(stdout); + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if tx.send(buf[..n].to_vec()).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let mut accumulated = String::new(); + let deadline = std::time::Instant::now() + Duration::from_millis(total_timeout_ms); + let mut timed_out = false; + let mut sentinels_reported: usize = 0; + let mut last_sentinel_end: usize = 0; + + let sentinel_re = Regex::new(&format!(r"{}(\d+)_(\d+)__", regex::escape(SENTINEL_PREFIX))).unwrap(); + + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + timed_out = true; + break; + } + + match rx.recv_timeout(remaining) { + Ok(chunk) => { + accumulated.push_str(&String::from_utf8_lossy(&chunk)); + + // Stream command results as they come in + if let Some(on_result) = on_command_result { + for caps in sentinel_re.captures_iter(&accumulated[last_sentinel_end..]) { + let idx: usize = caps[2].parse().unwrap_or(0); + if idx >= sentinels_reported { + let exit_code: i32 = caps[1].parse().unwrap_or(1); + let sentinel_match = caps.get(0).unwrap(); + let abs_start = last_sentinel_end + sentinel_match.start(); + let abs_end = last_sentinel_end + sentinel_match.end(); + let output_slice = &accumulated[last_sentinel_end..abs_start]; + let mut lines: Vec = output_slice.split('\n').map(|s| s.to_string()).collect(); + if !lines.is_empty() && lines[0].is_empty() { + lines.remove(0); + } + lines = parse::trim_trailing_empty(&lines); + if lines.len() == 1 && lines[0].is_empty() { + lines.clear(); + } + let result = CommandResult { + command: file.commands[idx].clone(), + actual: lines.iter().map(|l| strip_ansi(l)).collect(), + exit_code, + }; + on_result(idx, &result); + sentinels_reported = idx + 1; + last_sentinel_end = abs_end; + if accumulated.as_bytes().get(last_sentinel_end) == Some(&b'\n') { + last_sentinel_end += 1; + } + } + } + } + + // Check if we've seen the last sentinel + if let Some(prefix_idx) = accumulated.rfind(&sentinel_prefix) { + if accumulated[prefix_idx..].contains(&last_sentinel_suffix) { + break; + } + } + } + Err(mpsc::RecvTimeoutError::Timeout) => { + timed_out = true; + break; + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + let _ = reader_thread.join(); + + // Kill the process tree + kill_tree(pid); + let _ = child.wait(); + + if timed_out { + return FileResult { + file: file.clone(), + results: vec![], + tmp_dir, + error: Some("Timeout reading output".to_string()), + }; + } + + let (outputs, exit_codes) = parse_sentinel_output(&accumulated, file.commands.len()); + + let results: Vec = file + .commands + .iter() + .enumerate() + .map(|(i, cmd)| CommandResult { + command: cmd.clone(), + actual: outputs + .get(i) + .unwrap_or(&vec![]) + .iter() + .map(|l| strip_ansi(l)) + .collect(), + exit_code: exit_codes.get(i).copied().unwrap_or(1), + }) + .collect(); + + FileResult { + file: file.clone(), + results, + tmp_dir, + error: None, + } +} + +pub fn cleanup_tmp_dir(dir: &str) { + let _ = std::fs::remove_dir_all(dir); +} + +/// Check if a command result passes. +pub fn command_passes(result: &CommandResult) -> bool { + use crate::matching::match_output; + + let output_matches = match_output(&result.command.expected, &result.actual); + let exit_code_mismatch = match &result.command.exit_code { + parse::ExitCode::Default => result.exit_code != 0, + parse::ExitCode::Any => result.exit_code == 0, + parse::ExitCode::Code(expected) => result.exit_code != *expected, + }; + output_matches && !exit_code_mismatch +} diff --git a/shout-rs/src/update.rs b/shout-rs/src/update.rs new file mode 100644 index 0000000..169f65f --- /dev/null +++ b/shout-rs/src/update.rs @@ -0,0 +1,115 @@ +use crate::matching::match_output; +use crate::parse::{ShoutFile, is_comment_line, trim_trailing_empty}; +use crate::run::CommandResult; + +fn escape_dollar(line: &str) -> String { + if line.starts_with("$ ") { + format!("\\{line}") + } else { + line.to_string() + } +} + +pub fn rewrite_file( + file: &ShoutFile, + results: &[CommandResult], + original_content: &str, +) -> String { + let lines: Vec<&str> = original_content.split('\n').collect(); + let mut output: Vec = Vec::new(); + let mut cmd_idx = 0; + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + + if is_comment_line(line) { + output.push(line.to_string()); + i += 1; + } else if line.starts_with("$ ") && !line.starts_with("\\$ ") { + // Emit the command line as-is + output.push(line.to_string()); + + let cmd = file.commands.get(cmd_idx); + let result = results.get(cmd_idx); + + if cmd.is_none() || result.is_none() { + cmd_idx += 1; + i += 1; + continue; + } + let cmd = cmd.unwrap(); + let result = result.unwrap(); + + // Skip past old expected output lines in the original + let mut j = i + 1; + while j < lines.len() + && !is_comment_line(lines[j]) + && !(lines[j].starts_with("$ ") && !lines[j].starts_with("\\$ ")) + { + j += 1; + } + + // Collect old expected lines + let old_expected_raw: Vec = lines[i + 1..j].iter().map(|s| s.to_string()).collect(); + + // Check if old expected output had an exit code marker + let old_trimmed = trim_trailing_empty(&old_expected_raw); + let old_exit_marker = if let Some(last) = old_trimmed.last() { + if last.starts_with('[') && last.ends_with(']') { + let inner = &last[1..last.len() - 1]; + if inner == "*" || inner.parse::().is_ok() { + Some(last.clone()) + } else { + None + } + } else { + None + } + } else { + None + }; + + // Count trailing blank lines + let mut trailing_blanks = 0; + for k in (0..old_expected_raw.len()).rev() { + if old_expected_raw[k].is_empty() { + trailing_blanks += 1; + } else { + break; + } + } + + // If wildcards match, keep original expected output + if match_output(&cmd.expected, &result.actual) { + for ol in &old_expected_raw { + output.push(ol.clone()); + } + } else { + // Replace with actual output + for al in &result.actual { + output.push(escape_dollar(al)); + } + // Re-add exit code marker if it existed + if let Some(marker) = old_exit_marker { + output.push(marker); + } + // Preserve trailing blank lines as separators + for _ in 0..trailing_blanks { + output.push(String::new()); + } + } + + i = j; + cmd_idx += 1; + } else if cmd_idx == 0 { + // Lines before first command (directives, etc.) + output.push(line.to_string()); + i += 1; + } else { + i += 1; + } + } + + output.join("\n") +} diff --git a/shout-rs/test/basic.shout b/shout-rs/test/basic.shout new file mode 100644 index 0000000..2d759ed --- /dev/null +++ b/shout-rs/test/basic.shout @@ -0,0 +1,9 @@ +$ echo hello +hello + +$ echo one && echo two +one +two + +$ echo "working directory: $(basename $PWD)" +working directory: ... diff --git a/shout-rs/test/comments.shout b/shout-rs/test/comments.shout new file mode 100644 index 0000000..54fe5eb --- /dev/null +++ b/shout-rs/test/comments.shout @@ -0,0 +1,5 @@ +$ echo hello # this is a comment +hello + +$ echo "keep # this" +keep # this diff --git a/shout-rs/test/dollar-sign-output.shout b/shout-rs/test/dollar-sign-output.shout new file mode 100644 index 0000000..e41109c --- /dev/null +++ b/shout-rs/test/dollar-sign-output.shout @@ -0,0 +1,9 @@ +$ echo '$ hello world' +\$ hello world + +$ printf '$ line one\n$ line two\n' +\$ line one +\$ line two + +$ echo 'no dollar here' +no dollar here diff --git a/shout-rs/test/env.shout b/shout-rs/test/env.shout new file mode 100644 index 0000000..245d02c --- /dev/null +++ b/shout-rs/test/env.shout @@ -0,0 +1,5 @@ +@env GREETING=hello +@env TARGET=world + +$ echo "$GREETING $TARGET" +hello world diff --git a/shout-rs/test/features.shout b/shout-rs/test/features.shout new file mode 100644 index 0000000..92eed91 --- /dev/null +++ b/shout-rs/test/features.shout @@ -0,0 +1,28 @@ +$ echo "test exit codes" +test exit codes + +$ false +[1] + +$ sh -c "exit 42" +[42] + +$ sh -c "echo oops && exit 1" +oops +[*] + +$ export MY_VAR=hello +$ echo $MY_VAR +hello + +$ cd /tmp +$ pwd +/tmp + +$ echo "line 1" && echo "" && echo "line 3" +line 1 + +line 3 + +$ echo "match ..." +match ... diff --git a/shout-rs/test/setup-shared.shout b/shout-rs/test/setup-shared.shout new file mode 100644 index 0000000..5f9c20b --- /dev/null +++ b/shout-rs/test/setup-shared.shout @@ -0,0 +1 @@ +export READY=yes diff --git a/shout-rs/test/setup-user.shout b/shout-rs/test/setup-user.shout new file mode 100644 index 0000000..023c5c2 --- /dev/null +++ b/shout-rs/test/setup-user.shout @@ -0,0 +1,4 @@ +@setup setup-shared.shout + +$ echo $READY +yes diff --git a/shout-rs/test/teardown-setup.shout b/shout-rs/test/teardown-setup.shout new file mode 100644 index 0000000..a2b0c42 --- /dev/null +++ b/shout-rs/test/teardown-setup.shout @@ -0,0 +1,2 @@ +export READY=yes +@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.cleanup.db" diff --git a/shout-rs/test/teardown.shout b/shout-rs/test/teardown.shout new file mode 100644 index 0000000..6cda4c4 --- /dev/null +++ b/shout-rs/test/teardown.shout @@ -0,0 +1,4 @@ +@setup teardown-setup.shout + +$ touch marker.txt && ls marker.txt +marker.txt