Rewrites the shout CLI in Rust for better performance, with parallel test execution via rayon and the same .shout file format semantics.
261 lines
8.2 KiB
Rust
261 lines
8.2 KiB
Rust
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,
|
|
})
|
|
}
|