diff --git a/Cargo.lock b/Cargo.lock index d24ceb5..6d7e7d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,7 +202,7 @@ dependencies = [ [[package]] name = "testscript-rs" -version = "0.2.9" +version = "0.2.10" dependencies = [ "anyhow", "arbitrary", diff --git a/Cargo.toml b/Cargo.toml index d02a40e..67c8aec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/parser.rs b/src/parser.rs index c68e645..e625e53 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -200,6 +200,7 @@ fn parse_command_tokens(input: &str) -> Result> { 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> { 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> { } _ => { 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] diff --git a/src/run/environment.rs b/src/run/environment.rs index b7ef0ad..41b15f4 100644 --- a/src/run/environment.rs +++ b/src/run/environment.rs @@ -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} diff --git a/src/run/execution.rs b/src/run/execution.rs index 95c8731..65a25e9 100644 --- a/src/run/execution.rs +++ b/src/run/execution.rs @@ -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 = 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::().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 {