1
0
Fork 0
mirror of https://github.com/imjasonh/testscript-rs synced 2026-07-08 09:05:34 +00:00

feat: Add Go testscript compatibility for stdout/stderr commands

- Add @R syntax for regex quoting in environment variables (${VAR@R})
- Add -count=N option support for stdout/stderr commands
- Fix empty quoted string parsing in command tokenization
- Add comprehensive test coverage for stdout/stderr matching behavior

Signed-off-by: Jason Hall <imjasonh@gmail.com>
This commit is contained in:
Jason Hall 2026-01-01 13:24:19 -05:00
parent 3536f0d48b
commit df6db32e39
5 changed files with 116 additions and 14 deletions

2
Cargo.lock generated
View file

@ -202,7 +202,7 @@ dependencies = [
[[package]]
name = "testscript-rs"
version = "0.2.9"
version = "0.2.10"
dependencies = [
"anyhow",
"arbitrary",

View file

@ -1,6 +1,6 @@
[package]
name = "testscript-rs"
version = "0.2.9"
version = "0.2.10"
edition = "2021"
authors = ["Jason Hall"]
license = "Apache-2.0"

View file

@ -200,6 +200,7 @@ fn parse_command_tokens(input: &str) -> Result<Vec<String>> {
let mut current_token = String::new();
let mut in_quotes = false;
let mut quote_char = '"';
let mut just_closed_quotes = false;
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
@ -208,21 +209,26 @@ fn parse_command_tokens(input: &str) -> Result<Vec<String>> {
if in_quotes && ch == quote_char {
// End of quoted string
in_quotes = false;
just_closed_quotes = true;
} else if !in_quotes {
// Start of quoted string
in_quotes = true;
quote_char = ch;
just_closed_quotes = false;
} else {
// Different quote type inside quotes - treat as literal
current_token.push(ch);
just_closed_quotes = false;
}
}
' ' | '\t' => {
if in_quotes {
current_token.push(ch);
} else if !current_token.is_empty() {
just_closed_quotes = false;
} else if !current_token.is_empty() || just_closed_quotes {
tokens.push(current_token.clone());
current_token.clear();
just_closed_quotes = false;
}
}
'\\' => {
@ -262,12 +268,13 @@ fn parse_command_tokens(input: &str) -> Result<Vec<String>> {
}
_ => {
current_token.push(ch);
just_closed_quotes = false;
}
}
}
// Add final token if any
if !current_token.is_empty() {
// Add final token if any (including empty tokens that were quoted)
if !current_token.is_empty() || just_closed_quotes {
tokens.push(current_token);
}
@ -305,6 +312,17 @@ mod tests {
let tokens = parse_command_tokens("stdout 'hello world\\n'").unwrap();
assert_eq!(tokens, vec!["stdout", "hello world\n"]);
// Test empty quoted strings
let tokens = parse_command_tokens("stdout \"\"").unwrap();
assert_eq!(tokens, vec!["stdout", ""]);
let tokens = parse_command_tokens("stdout ''").unwrap();
assert_eq!(tokens, vec!["stdout", ""]);
// Test mixed empty and non-empty tokens
let tokens = parse_command_tokens("exec echo \"\" \"hello\" \"\"").unwrap();
assert_eq!(tokens, vec!["exec", "echo", "", "hello", ""]);
}
#[test]

View file

@ -261,6 +261,64 @@ impl TestEnvironment {
Ok(())
}
/// Compare command output with expected content and count matches
pub fn compare_output_with_count(
&self,
output_type: &str,
expected: &str,
expected_count: usize,
) -> Result<()> {
let actual = match self.last_output {
Some(ref output) => match output_type {
"stdout" => String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_string(),
"stderr" => String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_string(),
_ => return Err(Error::command_error(output_type, "Unknown output type")),
},
None => {
return Err(Error::command_error(
output_type,
"No command output available",
))
}
};
// Substitute environment variables in the expected pattern
let expected_substituted = self.substitute_env_vars(expected);
// Use regex to count matches
let regex_pattern = if expected_substituted.contains('^')
|| expected_substituted.contains('$')
|| expected_substituted.contains('[')
|| expected_substituted.contains('(')
|| expected_substituted.contains('*')
|| expected_substituted.contains('.')
{
// Already a regex pattern
format!("(?su){}", expected_substituted)
} else {
// Treat as literal string and escape for regex
format!("(?su){}", regex::escape(&expected_substituted))
};
let regex = Regex::new(&regex_pattern)
.map_err(|e| Error::command_error(output_type, format!("Invalid regex: {}", e)))?;
let match_count = regex.find_iter(&actual).count();
if match_count != expected_count {
return Err(Error::OutputCompare {
expected: format!("{} (count: {})", expected_substituted, expected_count),
actual: format!("{} (count: {})", actual, match_count),
});
}
Ok(())
}
/// Set environment variable
pub fn set_env_var(&mut self, key: &str, value: &str) {
self.env_vars.insert(key.to_string(), value.to_string());
@ -316,7 +374,17 @@ impl TestEnvironment {
pub fn substitute_env_vars(&self, input: &str) -> String {
let mut result = input.to_string();
// Handle $VAR and ${VAR} patterns
// First handle ${VAR@R} patterns for regex quoting
for (key, value) in &self.env_vars {
let regex_quoted_pattern = format!("${{{}@R}}", key);
if result.contains(&regex_quoted_pattern) {
// Escape regex metacharacters in the value
let escaped_value = regex::escape(value);
result = result.replace(&regex_quoted_pattern, &escaped_value);
}
}
// Then handle normal $VAR and ${VAR} patterns
for (key, value) in &self.env_vars {
let patterns = [
format!("${{{}}}", key), // ${VAR}

View file

@ -310,14 +310,26 @@ fn execute_command_inner(
env.compare_files_with_env(&command.args[0], &command.args[1])?;
}
"stdout" | "stderr" => {
if command.args.len() != 1 {
return Err(Error::command_error(
&command.name,
"Expected exactly 1 argument",
));
// Parse arguments to handle -count=N option
let mut count_option: Option<usize> = None;
let mut pattern_arg = None;
for arg in &command.args {
if arg.starts_with("-count=") {
let count_str = &arg[7..]; // Skip "-count="
count_option =
Some(count_str.parse::<usize>().map_err(|_| {
Error::command_error(&command.name, "Invalid count value")
})?);
} else if pattern_arg.is_none() {
pattern_arg = Some(arg);
} else {
return Err(Error::command_error(&command.name, "Too many arguments"));
}
}
let expected = &command.args[0];
let expected = pattern_arg
.ok_or_else(|| Error::command_error(&command.name, "Expected pattern argument"))?;
// Check if argument is a filename or literal text
let expected_content = if expected == "-" {
@ -328,10 +340,14 @@ fn execute_command_inner(
file_content.trim_end().to_string()
} else {
// It's literal text
expected.clone()
expected.to_string()
};
env.compare_output(&command.name, &expected_content)?;
if let Some(count) = count_option {
env.compare_output_with_count(&command.name, &expected_content, count)?;
} else {
env.compare_output(&command.name, &expected_content)?;
}
}
"cd" => {
if command.args.len() != 1 {