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:
parent
3536f0d48b
commit
df6db32e39
5 changed files with 116 additions and 14 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -202,7 +202,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "testscript-rs"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arbitrary",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(®ex_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(®ex_quoted_pattern) {
|
||||
// Escape regex metacharacters in the value
|
||||
let escaped_value = regex::escape(value);
|
||||
result = result.replace(®ex_quoted_pattern, &escaped_value);
|
||||
}
|
||||
}
|
||||
|
||||
// Then handle normal $VAR and ${VAR} patterns
|
||||
for (key, value) in &self.env_vars {
|
||||
let patterns = [
|
||||
format!("${{{}}}", key), // ${VAR}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue