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, }) }