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/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/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 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