Add Rust implementation of the shout test runner
Rewrites the shout CLI in Rust for better performance, with parallel test execution via rayon and the same .shout file format semantics.
This commit is contained in:
parent
371627beeb
commit
1c7c0da4b7
113
shout-rs/Cargo.lock
generated
Normal file
113
shout-rs/Cargo.lock
generated
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
9
shout-rs/Cargo.toml
Normal file
9
shout-rs/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[package]
|
||||||
|
name = "shout"
|
||||||
|
version = "0.0.18"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
rayon = "1"
|
||||||
|
regex = "1"
|
||||||
34
shout-rs/src/duration.rs
Normal file
34
shout-rs/src/duration.rs
Normal file
|
|
@ -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<u64, ParseDurationError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
188
shout-rs/src/format.rs
Normal file
188
shout-rs/src/format.rs
Normal file
|
|
@ -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<DiffLine>,
|
||||||
|
pub exit_code_mismatch: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TestResult {
|
||||||
|
pub path: String,
|
||||||
|
pub passed: bool,
|
||||||
|
pub command_count: usize,
|
||||||
|
pub failures: Vec<FailedCommand>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
538
shout-rs/src/main.rs
Normal file
538
shout-rs/src/main.rs
Normal file
|
|
@ -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<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 parse_args() -> Option<(&'static str, TestOpts)> {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
if args.is_empty() {
|
||||||
|
eprintln!("Usage: shout <test|example|version> [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<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();
|
||||||
|
|
||||||
|
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<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() {
|
||||||
|
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 {
|
||||||
|
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<TestResult> = 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 });
|
||||||
|
}
|
||||||
123
shout-rs/src/matching.rs
Normal file
123
shout-rs/src/matching.rs
Normal file
|
|
@ -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<String> = 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<DiffLine> {
|
||||||
|
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
|
||||||
|
}
|
||||||
260
shout-rs/src/parse.rs
Normal file
260
shout-rs/src/parse.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
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<Command>,
|
||||||
|
pub directives: Vec<Directive>,
|
||||||
|
pub teardown_commands: Vec<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String> {
|
||||||
|
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<String>, 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::<i32>() {
|
||||||
|
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<ShoutFile, ParseError> {
|
||||||
|
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<ShoutFile, ParseError> {
|
||||||
|
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<Command> = 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
436
shout-rs/src/run.rs
Normal file
436
shout-rs/src/run.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
pub exit_code: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct FileResult {
|
||||||
|
pub file: ShoutFile,
|
||||||
|
pub results: Vec<CommandResult>,
|
||||||
|
pub tmp_dir: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RunOptions {
|
||||||
|
pub clean_env: bool,
|
||||||
|
pub path_dirs: Vec<String>,
|
||||||
|
pub env_vars: Vec<(String, String)>,
|
||||||
|
pub source_dir: Option<String>,
|
||||||
|
pub project_dir: Option<String>,
|
||||||
|
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<String>>, Vec<i32>) {
|
||||||
|
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<String> = 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<String> = 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<String> {
|
||||||
|
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::<i32>() {
|
||||||
|
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::<usize>() {
|
||||||
|
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::<Vec<u8>>();
|
||||||
|
|
||||||
|
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<String> = 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<CommandResult> = 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
|
||||||
|
}
|
||||||
115
shout-rs/src/update.rs
Normal file
115
shout-rs/src/update.rs
Normal file
|
|
@ -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<String> = 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<String> = 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::<i32>().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")
|
||||||
|
}
|
||||||
9
shout-rs/test/basic.shout
Normal file
9
shout-rs/test/basic.shout
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
$ echo hello
|
||||||
|
hello
|
||||||
|
|
||||||
|
$ echo one && echo two
|
||||||
|
one
|
||||||
|
two
|
||||||
|
|
||||||
|
$ echo "working directory: $(basename $PWD)"
|
||||||
|
working directory: ...
|
||||||
5
shout-rs/test/comments.shout
Normal file
5
shout-rs/test/comments.shout
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
$ echo hello # this is a comment
|
||||||
|
hello
|
||||||
|
|
||||||
|
$ echo "keep # this"
|
||||||
|
keep # this
|
||||||
9
shout-rs/test/dollar-sign-output.shout
Normal file
9
shout-rs/test/dollar-sign-output.shout
Normal file
|
|
@ -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
|
||||||
5
shout-rs/test/env.shout
Normal file
5
shout-rs/test/env.shout
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
@env GREETING=hello
|
||||||
|
@env TARGET=world
|
||||||
|
|
||||||
|
$ echo "$GREETING $TARGET"
|
||||||
|
hello world
|
||||||
28
shout-rs/test/features.shout
Normal file
28
shout-rs/test/features.shout
Normal file
|
|
@ -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 ...
|
||||||
1
shout-rs/test/setup-shared.shout
Normal file
1
shout-rs/test/setup-shared.shout
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export READY=yes
|
||||||
4
shout-rs/test/setup-user.shout
Normal file
4
shout-rs/test/setup-user.shout
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
@setup setup-shared.shout
|
||||||
|
|
||||||
|
$ echo $READY
|
||||||
|
yes
|
||||||
2
shout-rs/test/teardown-setup.shout
Normal file
2
shout-rs/test/teardown-setup.shout
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export READY=yes
|
||||||
|
@teardown rm -f "$SHOUT_PROJECT_DIR/data/test.cleanup.db"
|
||||||
4
shout-rs/test/teardown.shout
Normal file
4
shout-rs/test/teardown.shout
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
@setup teardown-setup.shout
|
||||||
|
|
||||||
|
$ touch marker.txt && ls marker.txt
|
||||||
|
marker.txt
|
||||||
Loading…
Reference in New Issue
Block a user