From a6550898fa8af154d06d796b3c41a91262cce77d Mon Sep 17 00:00:00 2001 From: Andreas Jansson Date: Tue, 31 Mar 2026 10:24:25 +0200 Subject: [PATCH 1/2] Add optional variable modifier for test patterns Add support for optional variables that mark output lines which may or may not appear. Syntax: {{ var: optional type }} or {{ var: optional }} for duck-typed. An optional variable must occupy an entire line by itself. When the actual output doesn't contain that line, the test still passes and the variable is not captured. When present, the variable captures normally and can be used in constraints. This is useful for commands that conditionally print progress or diagnostic lines depending on execution time. Examples: {{ progress: optional string }} {{ n: optional number }} {{ data: optional json object }} {{ x: optional }} Implementation: - Parser: extract 'optional' prefix in parse_placeholder - Matcher: build_regex wraps optional-only lines in (?:...)? groups with correct newline handling for start/middle/end positions - 10 matcher unit tests, 5 parser unit tests, 18 corpus tests --- .beads/issues.jsonl | 2 + .gitignore | 1 + README.md | 35 +++ crates/cctr-corpus/src/lib.rs | 115 ++++++- crates/cctr/src/matcher.rs | 291 ++++++++++++++++-- .../fixture/tests/optional_vars.txt | 163 ++++++++++ test/optional_vars/optional_vars.txt | 9 + 7 files changed, 585 insertions(+), 31 deletions(-) create mode 100644 test/optional_vars/fixture/tests/optional_vars.txt create mode 100644 test/optional_vars/optional_vars.txt diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4d542b1..b67a4ca 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,7 @@ {"id":"cctr-0a7","title":"Add corpus setup/teardown tests","description":"Add tests for the corpus-level setup/teardown feature.\n\n## Location\n`test/corpus_setup/`\n\n## Test Cases\n\n1. **Corpus setup runs before suites**\n - `_setup_corpus.txt` creates a marker file\n - Suite setup verifies marker exists\n \n2. **Corpus teardown runs after suites**\n - Suite creates a marker\n - `_teardown_corpus.txt` verifies suite ran and cleans up\n\n3. **Corpus setup failure skips all suites**\n - `_setup_corpus.txt` with a failing test\n - Verify suites are skipped\n\n4. **Corpus teardown runs even when suites fail**\n - Suite with failing test\n - `_teardown_corpus.txt` still executes\n\n5. **CCTR_CORPUS_WORK_DIR available to suites**\n - Corpus setup writes to CCTR_CORPUS_WORK_DIR\n - Suite reads from CCTR_CORPUS_WORK_DIR\n\n## Acceptance Criteria\n- All test cases pass\n- Tests serve as documentation for the feature","status":"closed","priority":1,"issue_type":"task","owner":"ajansson@cloudflare.com","created_at":"2026-01-26T00:26:34.690997+01:00","created_by":"Andreas Jansson","updated_at":"2026-01-26T00:42:37.913884+01:00","closed_at":"2026-01-26T00:42:37.913884+01:00","close_reason":"Scrapped - design issue with corpus root detection","dependencies":[{"issue_id":"cctr-0a7","depends_on_id":"cctr-k5x","type":"blocks","created_at":"2026-01-26T00:26:39.971625+01:00","created_by":"Andreas Jansson"},{"issue_id":"cctr-0a7","depends_on_id":"cctr-3hh","type":"blocks","created_at":"2026-01-26T00:26:40.08634+01:00","created_by":"Andreas Jansson"}]} +{"id":"cctr-1rz","title":"Support # comment lines in corpus file headers","description":"Comment lines starting with # should be allowed at the top of corpus files (before and between file-level directives, and between the last directive and the first === test delimiter). Currently, a line like '# This tests the frobulator' between '%shell bash' and the first '===' causes the parser to stop and silently return 0 tests.\n\nThe fix is in cctr-corpus/src/lib.rs in the corpus_file() function. The skip_blank_lines calls (or a new skip_blank_and_comment_lines helper) should also consume lines starting with optional whitespace followed by '#'. The comment lines should be discarded.\n\nSpecifically, the while loop at line ~693 that checks '!peeked.starts_with(\"===\")' should also skip comment lines before giving up. And skip_blank_lines before and after directives should also skip comments.\n\nThis would allow corpus files to have documentation comments like:\n\n %shell bash\n\n # These tests verify the bridge protocol\n # See docs/protocol.md for format details\n\n ===\n first test\n ===","status":"open","priority":3,"issue_type":"task","owner":"ajansson@cloudflare.com","created_at":"2026-03-21T00:28:05.810823+01:00","created_by":"Andreas Jansson","updated_at":"2026-03-21T00:28:05.810823+01:00"} {"id":"cctr-2fp","title":"Improve constraint failure error messages","status":"closed","priority":1,"issue_type":"task","owner":"ajansson@cloudflare.com","created_at":"2026-01-23T21:31:21.141892+01:00","created_by":"Andreas Jansson","updated_at":"2026-01-23T21:45:26.383303+01:00","closed_at":"2026-01-23T21:45:26.383303+01:00","close_reason":"Implemented improved constraint failure error messages that show variable bindings"} +{"id":"cctr-395","title":"Show warning when .txt file in test directory fails to parse","description":"When cctr discovers .txt files in a test suite directory, it silently ignores files that fail to parse. This makes it very hard to debug why tests aren't running. For example, if a file has comment lines (# ...) between the %shell directive and the first === test header, the parser stops and returns 0 tests. The file is silently treated as having no tests.\n\nThe fix: in list_tests() and run_suite(), when parse_file() returns Ok but with 0 tests, OR when parse_file() returns Err, emit a warning to stderr like:\n\n warning: test/cli/basic.txt: 0 tests found (parse stopped at line 3: expected '===' delimiter)\n\nThe relevant code paths are:\n- discover.rs: corpus_files() returns the file paths\n- main.rs:182 list_tests() calls parse_file() per corpus file\n- runner.rs:732 run_suite() iterates corpus_files() and calls run_corpus_file()\n- cctr-corpus/src/lib.rs:657 corpus_file() parser - when the while loop breaks because input doesn't start with '===', the remaining unparsed input and line number are available in ParseState but discarded","status":"open","priority":2,"issue_type":"task","owner":"ajansson@cloudflare.com","created_at":"2026-03-21T00:27:49.555329+01:00","created_by":"Andreas Jansson","updated_at":"2026-03-21T00:27:49.555329+01:00"} {"id":"cctr-3hh","title":"Run corpus teardown after all suites complete","description":"Update the main runner to execute `_teardown_corpus.txt` once after all suites complete.\n\n## Location\n`crates/cctr/src/main.rs` (or runner orchestration)\n\n## Changes Needed\n\n1. After all suites complete (regardless of pass/fail), run corpus teardown\n2. Use the same corpus work directory from setup\n3. Report teardown results\n4. Clean up corpus temp directory after teardown\n\n## Error Handling\n- Corpus teardown runs even if suites failed\n- Teardown failures are reported but don't change overall exit code logic\n- (Or should teardown failure cause non-zero exit? TBD)\n\n## Acceptance Criteria\n- Corpus teardown runs after all suites\n- Teardown runs even when suites fail\n- Corpus work directory is cleaned up","status":"closed","priority":1,"issue_type":"task","owner":"ajansson@cloudflare.com","created_at":"2026-01-26T00:26:25.394876+01:00","created_by":"Andreas Jansson","updated_at":"2026-01-26T00:34:37.348448+01:00","closed_at":"2026-01-26T00:34:37.348448+01:00","close_reason":"Implemented","dependencies":[{"issue_id":"cctr-3hh","depends_on_id":"cctr-j9r","type":"blocks","created_at":"2026-01-26T00:26:39.85443+01:00","created_by":"Andreas Jansson"}]} {"id":"cctr-43k","title":"Support reading test from stdin with cctr -","status":"closed","priority":1,"issue_type":"feature","owner":"ajansson@cloudflare.com","created_at":"2026-01-23T17:18:30.307949+01:00","created_by":"Andreas Jansson","updated_at":"2026-01-23T17:21:53.963326+01:00","closed_at":"2026-01-23T17:21:53.963326+01:00","close_reason":"Closed"} {"id":"cctr-4oh","title":"Add JSON types: json_string, json_bool, json_array, json_object","status":"closed","priority":1,"issue_type":"feature","owner":"ajansson@cloudflare.com","created_at":"2026-01-23T16:00:49.052473+01:00","created_by":"Andreas Jansson","updated_at":"2026-01-23T16:12:49.545177+01:00","closed_at":"2026-01-23T16:12:49.545177+01:00","close_reason":"Closed"} diff --git a/.gitignore b/.gitignore index 0bd9ee9..e2c53ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.greger +.greger/ target TODO.md .DS_Store diff --git a/README.md b/README.md index 4d56f2d..181e2a5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ See the [test/](https://github.com/andreasjansson/cctr/tree/main/test) directory - [Exit-only tests](#exit-only-tests) - [Multiline output](#multiline-output) - [Variables](#variables) + - [Optional variables](#optional-variables) - [Constraints](#constraints) - [Comparison operators](#comparison-operators) - [Arithmetic operators](#arithmetic-operators) @@ -524,6 +525,40 @@ Access patterns: JSON values may contain `null`, which can be tested with `== null` or `type(x) == null`. +### Optional variables + +Use the `optional` modifier to mark a variable that may or may not appear in the output. An optional variable must occupy an entire line by itself. + +``` +=== +test with optional progress line +=== +./my-command +--- +{{ progress: optional string }} +result: {{ value: number }} +--- +where +* value > 0 +``` + +This test passes whether or not a progress line appears before the result. If the output is `result: 42`, the optional line is skipped. If the output is `Processing...\nresult: 42`, the variable `progress` captures `Processing...`. + +The `optional` modifier works with any type: `optional number`, `optional string`, `optional json object`, etc. With no type specified, `{{ x: optional }}` uses duck typing. + +Multiple consecutive optional lines are supported: + +``` +=== +command with optional headers +=== +./verbose-command +--- +{{ line1: optional string }} +{{ line2: optional string }} +actual output +``` + ## Constraints Add a `where` section to validate captured variables with expressions: diff --git a/crates/cctr-corpus/src/lib.rs b/crates/cctr-corpus/src/lib.rs index 1dcb44f..ebba832 100644 --- a/crates/cctr-corpus/src/lib.rs +++ b/crates/cctr-corpus/src/lib.rs @@ -61,6 +61,7 @@ pub enum VarType { pub struct VariableDecl { pub name: String, pub var_type: Option, + pub optional: bool, } /// Skip directive - unconditional or conditional (with shell command) @@ -272,20 +273,35 @@ const RESERVED_KEYWORDS: &[&str] = &[ "array", "object", "env", + "optional", ]; fn is_reserved_keyword(name: &str) -> bool { RESERVED_KEYWORDS.contains(&name) } -fn parse_placeholder(content: &str) -> Result<(String, Option), String> { +fn parse_placeholder(content: &str) -> Result<(String, Option, bool), String> { let content = content.trim(); - let (name, var_type) = if let Some(colon_pos) = content.find(':') { + let (name, var_type, optional) = if let Some(colon_pos) = content.find(':') { let name = content[..colon_pos].trim().to_string(); let type_str = content[colon_pos + 1..].trim(); - (name, parse_type_annotation(type_str)) + let (optional, type_str) = if let Some(rest) = type_str + .strip_prefix("optional") + .filter(|r| r.is_empty() || r.starts_with(' ')) + { + let rest = rest.trim(); + (true, rest) + } else { + (false, type_str) + }; + let var_type = if type_str.is_empty() { + None + } else { + parse_type_annotation(type_str) + }; + (name, var_type, optional) } else { - (content.to_string(), None) + (content.to_string(), None, false) }; if is_reserved_keyword(&name) { @@ -295,7 +311,7 @@ fn parse_placeholder(content: &str) -> Result<(String, Option), String> )); } - Ok((name, var_type)) + Ok((name, var_type, optional)) } fn extract_variables_from_expected(expected: &str) -> Result, String> { @@ -306,9 +322,13 @@ fn extract_variables_from_expected(expected: &str) -> Result, while let Some(start) = remaining.find("{{") { if let Some(end) = remaining[start..].find("}}") { let content = &remaining[start + 2..start + end]; - let (name, var_type) = parse_placeholder(content)?; + let (name, var_type, optional) = parse_placeholder(content)?; if !name.is_empty() && seen.insert(name.clone()) { - variables.push(VariableDecl { name, var_type }); + variables.push(VariableDecl { + name, + var_type, + optional, + }); } remaining = &remaining[start + end + 2..]; } else { @@ -1592,4 +1612,85 @@ hello assert_eq!(file.tests.len(), 1); assert!(!file.tests[0].require); } + + #[test] + fn test_optional_string_variable() { + let content = r#"=== +optional test +=== +some_command +--- +{{ header: optional string }} +fixed line +"#; + let file = parse_test(content); + assert_eq!(file.tests.len(), 1); + assert_eq!(file.tests[0].variables.len(), 1); + assert_eq!(file.tests[0].variables[0].name, "header"); + assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::String)); + assert!(file.tests[0].variables[0].optional); + } + + #[test] + fn test_optional_number_variable() { + let content = r#"=== +optional number +=== +some_command +--- +{{ n: optional number }} +result: done +"#; + let file = parse_test(content); + assert_eq!(file.tests[0].variables[0].name, "n"); + assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::Number)); + assert!(file.tests[0].variables[0].optional); + } + + #[test] + fn test_optional_duck_typed() { + let content = r#"=== +optional duck +=== +some_command +--- +{{ x: optional }} +footer +"#; + let file = parse_test(content); + assert_eq!(file.tests[0].variables[0].name, "x"); + assert_eq!(file.tests[0].variables[0].var_type, None); + assert!(file.tests[0].variables[0].optional); + } + + #[test] + fn test_non_optional_variable() { + let content = r#"=== +regular var +=== +some_command +--- +{{ x: number }} +"#; + let file = parse_test(content); + assert!(!file.tests[0].variables[0].optional); + } + + #[test] + fn test_optional_json_object() { + let content = r#"=== +optional json +=== +some_command +--- +{{ data: optional json object }} +result +"#; + let file = parse_test(content); + assert_eq!( + file.tests[0].variables[0].var_type, + Some(VarType::JsonObject) + ); + assert!(file.tests[0].variables[0].optional); + } } diff --git a/crates/cctr/src/matcher.rs b/crates/cctr/src/matcher.rs index 9b375a3..7089442 100644 --- a/crates/cctr/src/matcher.rs +++ b/crates/cctr/src/matcher.rs @@ -120,6 +120,10 @@ fn duck_type_value(text: &str) -> Value { Value::String(text.to_string()) } +fn regex_str_push_capture(result: &mut String, var_name: &str, capture_pattern: &str) { + result.push_str(&format!("(?P<{}>{})", var_name, capture_pattern)); +} + pub struct MatchResult { pub matched: bool, pub captured: HashMap, @@ -198,6 +202,7 @@ impl<'a> Matcher<'a> { } /// Strip type annotations from placeholders: {{ x: number }} -> {{ x }} + /// Also strips optional modifier: {{ x: optional number }} -> {{ x }} fn strip_type_annotations(&self, pattern: &str) -> String { let re = Regex::new(r"\{\{\s*(\w+)\s*:\s*[^}]+\}\}").unwrap(); re.replace_all(pattern, "{{ $1 }}").to_string() @@ -212,31 +217,18 @@ impl<'a> Matcher<'a> { bindings } - fn build_regex(&self, pattern: &str) -> Result { - let var_pattern = Regex::new(r"\{\{\s*(\w+)\s*\}\}").unwrap(); - - // Check for duplicate variable names - let mut seen_vars = std::collections::HashSet::new(); - for cap in var_pattern.captures_iter(pattern) { - let var_name = cap.get(1).unwrap().as_str(); - if self.variables.iter().any(|v| v.name == var_name) && !seen_vars.insert(var_name) { - return Err(MatchError::DuplicateVariable(var_name.to_string())); - } - } - - let mut regex_str = String::new(); + fn build_line_regex(&self, line: &str, var_pattern: &Regex) -> Result { + let mut result = String::new(); let mut last_end = 0; - for cap in var_pattern.captures_iter(pattern) { + for cap in var_pattern.captures_iter(line) { let full_match = cap.get(0).unwrap(); let var_name = cap.get(1).unwrap().as_str(); - let literal = &pattern[last_end..full_match.start()]; - regex_str.push_str(®ex::escape(literal)); + let literal = &line[last_end..full_match.start()]; + result.push_str(®ex::escape(literal)); if let Some(var) = self.variables.iter().find(|v| v.name == var_name) { - // For JSON types, we use a greedy approach that captures balanced brackets/braces. - // The actual JSON validation happens in extract_values via serde_json. let capture_pattern = match var.var_type { Some(VarType::Number) => r"-?\d+(?:\.\d+)?", Some(VarType::String) => r".*?", @@ -244,20 +236,94 @@ impl<'a> Matcher<'a> { Some(VarType::JsonBool) => r"true|false", Some(VarType::JsonArray) => r"\[[\s\S]*\]", Some(VarType::JsonObject) => r"\{[\s\S]*\}", - // Duck-typed: match anything (greedy but stops at next literal) None => r".*?", }; - regex_str.push_str(&format!("(?P<{}>{})", var_name, capture_pattern)); + regex_str_push_capture(&mut result, var_name, capture_pattern); } else { - regex_str.push_str(®ex::escape( - &pattern[full_match.start()..full_match.end()], - )); + result.push_str(®ex::escape(&line[full_match.start()..full_match.end()])); } last_end = full_match.end(); } + result.push_str(®ex::escape(&line[last_end..])); - regex_str.push_str(®ex::escape(&pattern[last_end..])); + Ok(result) + } + + fn build_regex(&self, pattern: &str) -> Result { + let var_pattern = Regex::new(r"\{\{\s*(\w+)\s*\}\}").unwrap(); + + // Check for duplicate variable names + let mut seen_vars = std::collections::HashSet::new(); + for cap in var_pattern.captures_iter(pattern) { + let var_name = cap.get(1).unwrap().as_str(); + if self.variables.iter().any(|v| v.name == var_name) && !seen_vars.insert(var_name) { + return Err(MatchError::DuplicateVariable(var_name.to_string())); + } + } + + let lines: Vec<&str> = pattern.split('\n').collect(); + let is_optional_line: Vec = lines + .iter() + .map(|line| { + let trimmed = line.trim(); + if let Some(caps) = var_pattern.captures(trimmed) { + if caps.get(0).unwrap().as_str() == trimmed { + let var_name = caps.get(1).unwrap().as_str(); + return self + .variables + .iter() + .any(|v| v.name == var_name && v.optional); + } + } + false + }) + .collect(); + + // Build regex line by line to handle optional lines. + // + // For optional lines in the middle: "header\n{{ opt }}\nfooter" + // -> regex: header\n(?:(?P.*?)\n)?footer + // The \n before opt is always present (from header), the \n after opt is in the group. + // + // For optional lines at the start: "{{ opt }}\nfooter" + // -> regex: (?:(?P.*?)\n)?footer + // + // For optional lines at the end: "header\n{{ opt }}" + // -> regex: header(?:\n(?P.*?))? + // + // For optional-only pattern: "{{ opt }}" + // -> regex: (?:(?P.*?))? + let mut regex_str = String::new(); + let mut prev_was_optional = false; + + for (i, line) in lines.iter().enumerate() { + let line_regex = self.build_line_regex(line, &var_pattern)?; + + if is_optional_line[i] { + let is_last = i == lines.len() - 1; + if is_last && i > 0 { + // Last line (not first): \n is part of the optional group + regex_str.push_str(&format!("(?:\\n{})?", line_regex)); + } else if !is_last { + // First or middle line with more lines after: \n after content is in group + if i > 0 && !prev_was_optional { + regex_str.push_str("\\n"); + } + regex_str.push_str(&format!("(?:{}\\n)?", line_regex)); + } else { + // Only line + regex_str.push_str(&format!("(?:{})?", line_regex)); + } + prev_was_optional = true; + } else { + if i > 0 && !prev_was_optional { + regex_str.push_str("\\n"); + } + regex_str.push_str(&line_regex); + prev_was_optional = false; + } + } let regex_str = format!("(?s)^{}$", regex_str); Ok(Regex::new(®ex_str)?) @@ -363,6 +429,14 @@ mod tests { "json object" => VarType::JsonObject, _ => VarType::String, }), + optional: false, + } + } + + fn make_optional_var(name: &str, var_type: Option<&str>) -> VariableDecl { + VariableDecl { + optional: true, + ..make_var(name, var_type) } } @@ -632,4 +706,173 @@ mod tests { assert!(result.matched); assert_eq!(result.captured.get("x"), Some(&Value::Number(99.0))); } + + #[test] + fn test_optional_var_present_at_start() { + let vars = vec![ + make_optional_var("header", Some("string")), + make_var("val", Some("number")), + ]; + let matcher = Matcher::new(&vars, &[], &[]); + + let result = matcher + .matches( + "{{ header }}\nresult: {{ val }}", + "progress info\nresult: 42", + &no_prior(), + ) + .unwrap(); + assert!(result.matched); + assert_eq!( + result.captured.get("header"), + Some(&Value::String("progress info".to_string())) + ); + assert_eq!(result.captured.get("val"), Some(&Value::Number(42.0))); + } + + #[test] + fn test_optional_var_absent_at_start() { + let vars = vec![ + make_optional_var("header", Some("string")), + make_var("val", Some("number")), + ]; + let matcher = Matcher::new(&vars, &[], &[]); + + let result = matcher + .matches("{{ header }}\nresult: {{ val }}", "result: 42", &no_prior()) + .unwrap(); + assert!(result.matched); + assert!(!result.captured.contains_key("header")); + assert_eq!(result.captured.get("val"), Some(&Value::Number(42.0))); + } + + #[test] + fn test_optional_var_present_in_middle() { + let vars = vec![make_optional_var("mid", Some("string"))]; + let matcher = Matcher::new(&vars, &[], &[]); + + let result = matcher + .matches( + "header\n{{ mid }}\nfooter", + "header\nmiddle line\nfooter", + &no_prior(), + ) + .unwrap(); + assert!(result.matched); + assert_eq!( + result.captured.get("mid"), + Some(&Value::String("middle line".to_string())) + ); + } + + #[test] + fn test_optional_var_absent_in_middle() { + let vars = vec![make_optional_var("mid", Some("string"))]; + let matcher = Matcher::new(&vars, &[], &[]); + + let result = matcher + .matches("header\n{{ mid }}\nfooter", "header\nfooter", &no_prior()) + .unwrap(); + assert!(result.matched); + assert!(!result.captured.contains_key("mid")); + } + + #[test] + fn test_optional_var_present_at_end() { + let vars = vec![make_optional_var("trail", Some("string"))]; + let matcher = Matcher::new(&vars, &[], &[]); + + let result = matcher + .matches("header\n{{ trail }}", "header\ntrailer", &no_prior()) + .unwrap(); + assert!(result.matched); + assert_eq!( + result.captured.get("trail"), + Some(&Value::String("trailer".to_string())) + ); + } + + #[test] + fn test_optional_var_absent_at_end() { + let vars = vec![make_optional_var("trail", Some("string"))]; + let matcher = Matcher::new(&vars, &[], &[]); + + let result = matcher + .matches("header\n{{ trail }}", "header", &no_prior()) + .unwrap(); + assert!(result.matched); + assert!(!result.captured.contains_key("trail")); + } + + #[test] + fn test_optional_var_only_line() { + let vars = vec![make_optional_var("x", Some("string"))]; + let matcher = Matcher::new(&vars, &[], &[]); + + // Present + assert!( + matcher + .matches("{{ x }}", "hello", &no_prior()) + .unwrap() + .matched + ); + // Absent + assert!(matcher.matches("{{ x }}", "", &no_prior()).unwrap().matched); + } + + #[test] + fn test_optional_duck_typed() { + let vars = vec![make_optional_var("x", None)]; + let matcher = Matcher::new(&vars, &[], &[]); + + let result = matcher + .matches("header\n{{ x }}\nfooter", "header\n42\nfooter", &no_prior()) + .unwrap(); + assert!(result.matched); + assert_eq!(result.captured.get("x"), Some(&Value::Number(42.0))); + } + + #[test] + fn test_optional_with_constraint_when_present() { + let vars = vec![ + make_optional_var("opt", Some("number")), + make_var("val", Some("number")), + ]; + let constraints = vec!["val > 0".to_string()]; + let matcher = Matcher::new(&vars, &constraints, &[]); + + let result = matcher + .matches( + "{{ opt }}\nresult: {{ val }}", + "99\nresult: 42", + &no_prior(), + ) + .unwrap(); + assert!(result.matched); + } + + #[test] + fn test_optional_multiple_consecutive() { + let vars = vec![ + make_optional_var("a", Some("string")), + make_optional_var("b", Some("string")), + ]; + let matcher = Matcher::new(&vars, &[], &[]); + + // Both present + let result = matcher + .matches( + "{{ a }}\n{{ b }}\nfooter", + "line1\nline2\nfooter", + &no_prior(), + ) + .unwrap(); + assert!(result.matched); + + // Both absent + let result = matcher + .matches("{{ a }}\n{{ b }}\nfooter", "footer", &no_prior()) + .unwrap(); + assert!(result.matched); + } } diff --git a/test/optional_vars/fixture/tests/optional_vars.txt b/test/optional_vars/fixture/tests/optional_vars.txt new file mode 100644 index 0000000..de9f0ac --- /dev/null +++ b/test/optional_vars/fixture/tests/optional_vars.txt @@ -0,0 +1,163 @@ +=== +optional string at start - present +=== +printf 'progress line\nresult: 42\n' +--- +{{ header: optional string }} +result: 42 + +=== +optional string at start - absent +=== +printf 'result: 42\n' +--- +{{ header: optional string }} +result: 42 + +=== +optional string in middle - present +=== +printf 'header\noptional content\nfooter\n' +--- +header +{{ mid: optional string }} +footer + +=== +optional string in middle - absent +=== +printf 'header\nfooter\n' +--- +header +{{ mid: optional string }} +footer + +=== +optional string at end - present +=== +printf 'header\ntrailer line\n' +--- +header +{{ trail: optional string }} + +=== +optional string at end - absent +=== +printf 'header\n' +--- +header +{{ trail: optional string }} + +=== +optional number - present +=== +printf '42\nresult: ok\n' +--- +{{ n: optional number }} +result: ok +--- +where +* n == 42 + +=== +optional number - absent +=== +printf 'result: ok\n' +--- +{{ n: optional number }} +result: ok + +=== +optional duck typed - present as number +=== +printf '123\nfooter\n' +--- +{{ x: optional }} +footer +--- +where +* x == 123 + +=== +optional duck typed - absent +=== +printf 'footer\n' +--- +{{ x: optional }} +footer + +=== +optional only line - present +=== +printf 'hello\n' +--- +{{ x: optional string }} + +=== +optional only line - absent +=== +printf '' +--- +{{ x: optional string }} + +=== +two consecutive optional - both present +=== +printf 'line1\nline2\nfooter\n' +--- +{{ a: optional string }} +{{ b: optional string }} +footer + +=== +two consecutive optional - both absent +=== +printf 'footer\n' +--- +{{ a: optional string }} +{{ b: optional string }} +footer + +=== +two consecutive optional - first present second absent +=== +printf 'line1\nfooter\n' +--- +{{ a: optional string }} +{{ b: optional string }} +footer + +=== +optional with constraint when present +=== +printf '99\nresult: 42\n' +--- +{{ opt: optional number }} +result: {{ val: number }} +--- +where +* val == 42 + +=== +mixed optional and required +=== +printf 'header: test\nprogress info\ncount: 5\n' +--- +header: {{ name }} +{{ progress: optional string }} +count: {{ count: number }} +--- +where +* count == 5 + +=== +mixed optional and required - optional absent +=== +printf 'header: test\ncount: 5\n' +--- +header: {{ name }} +{{ progress: optional string }} +count: {{ count: number }} +--- +where +* count == 5 diff --git a/test/optional_vars/optional_vars.txt b/test/optional_vars/optional_vars.txt new file mode 100644 index 0000000..ae948d1 --- /dev/null +++ b/test/optional_vars/optional_vars.txt @@ -0,0 +1,9 @@ +=== +optional_vars passing tests +=== +cctr $CCTR_FIXTURE_DIR/tests/optional_vars.txt --no-color 2>&1 | tail -1 +--- +All {{ n: number }} tests passed in {{ t }}s +--- +where +* n == 18 From 0ec74d10a31fd2adc45a8d144e511fd95ed56a80 Mon Sep 17 00:00:00 2001 From: Andreas Jansson Date: Tue, 31 Mar 2026 11:32:45 +0200 Subject: [PATCH 2/2] Fix stdout/stderr ordering: interleave streams by arrival order Previously run_command read all of stdout then all of stderr, causing stderr to always appear after stdout regardless of when it was actually written. Now uses channel-based interleaving (same approach as run_command_streaming) to preserve temporal ordering of output lines from both streams. Adds 6 corpus tests verifying ordering with stderr-first, stdout-first, interleaved, and timed delay scenarios. --- crates/cctr/src/runner.rs | 71 +++++++++++++------ .../fixture/tests/stream_order.txt | 47 ++++++++++++ test/stream_order/stream_order.txt | 9 +++ 3 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 test/stream_order/fixture/tests/stream_order.txt create mode 100644 test/stream_order/stream_order.txt diff --git a/crates/cctr/src/runner.rs b/crates/cctr/src/runner.rs index f4f9502..2f19497 100644 --- a/crates/cctr/src/runner.rs +++ b/crates/cctr/src/runner.rs @@ -209,6 +209,8 @@ fn run_command( shell: Option, interruptible: bool, ) -> (String, i32) { + use std::sync::mpsc::channel; + let shell = shell.unwrap_or_else(default_shell); let mut cmd = build_command(command, work_dir, env_vars, shell); @@ -220,31 +222,58 @@ fn run_command( Err(e) => return (format!("Failed to execute command: {}", e), -1), }; - let exit_status = loop { - if interruptible && is_interrupted() { - let _ = child.kill(); - let _ = child.wait(); - return (String::new(), 130); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + + let (tx, rx) = channel::(); + + let tx_stdout = tx.clone(); + let stdout_handle = std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + let _ = tx_stdout.send(line); } - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) => std::thread::sleep(Duration::from_millis(10)), - Err(e) => return (format!("Failed to wait for command: {}", e), -1), + }); + + let tx_stderr = tx; + let stderr_handle = std::thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + let _ = tx_stderr.send(line); } - }; + }); - let exit_code = exit_status.code().unwrap_or(-1); - let mut stdout_str = String::new(); - let mut stderr_str = String::new(); - if let Some(mut r) = child.stdout.take() { - let _ = std::io::Read::read_to_string(&mut r, &mut stdout_str); - } - if let Some(mut r) = child.stderr.take() { - let _ = std::io::Read::read_to_string(&mut r, &mut stderr_str); + let mut output_lines = Vec::new(); + + loop { + match rx.recv_timeout(Duration::from_millis(10)) { + Ok(line) => { + let stripped = strip_ansi_escapes::strip_str(&line); + output_lines.push(stripped); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if interruptible && is_interrupted() { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_handle.join(); + let _ = stderr_handle.join(); + return (String::new(), 130); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } } - let combined = format!("{}{}", stdout_str, stderr_str); - let stripped = strip_ansi_escapes::strip_str(&combined); - let normalized = stripped.replace("\r\n", "\n"); + + let _ = stdout_handle.join(); + let _ = stderr_handle.join(); + + let exit_code = match child.wait() { + Ok(status) => status.code().unwrap_or(-1), + Err(e) => return (format!("Failed to wait for command: {}", e), -1), + }; + + let combined = output_lines.join("\n"); + let normalized = combined.replace("\r\n", "\n"); (normalized.trim_end_matches('\n').to_string(), exit_code) } diff --git a/test/stream_order/fixture/tests/stream_order.txt b/test/stream_order/fixture/tests/stream_order.txt new file mode 100644 index 0000000..9460f3d --- /dev/null +++ b/test/stream_order/fixture/tests/stream_order.txt @@ -0,0 +1,47 @@ +=== +stderr before stdout preserves order +=== +bash -c 'echo "first line" >&2; sleep 0.01; echo "second line"' +--- +first line +second line + +=== +stdout before stderr preserves order +=== +bash -c 'echo "first line"; sleep 0.01; echo "second line" >&2' +--- +first line +second line + +=== +interleaved stderr and stdout +=== +bash -c 'echo "line1" >&2; sleep 0.01; echo "line2"; sleep 0.01; echo "line3" >&2; sleep 0.01; echo "line4"' +--- +line1 +line2 +line3 +line4 + +=== +stderr only +=== +bash -c 'echo "error output" >&2' +--- +error output + +=== +stdout only +=== +echo "normal output" +--- +normal output + +=== +stderr first with delay before stdout +=== +bash -c 'echo "early stderr" >&2; sleep 0.05; echo "late stdout"' +--- +early stderr +late stdout diff --git a/test/stream_order/stream_order.txt b/test/stream_order/stream_order.txt new file mode 100644 index 0000000..73e0b23 --- /dev/null +++ b/test/stream_order/stream_order.txt @@ -0,0 +1,9 @@ +=== +stream ordering tests pass +=== +cctr $CCTR_FIXTURE_DIR/tests/stream_order.txt --no-color 2>&1 | tail -1 +--- +All {{ n: number }} tests passed in {{ t }}s +--- +where +* n == 6