Skip to main content

cctr_corpus/
lib.rs

1//! Corpus test file parser.
2//!
3//! Parses `.txt` corpus test files into structured test cases using winnow.
4//!
5//! # File Format
6//!
7//! ```text
8//! ===
9//! test name
10//! ===
11//! command to run
12//! ---
13//! expected output
14//!
15//! ===
16//! test with variables
17//! ===
18//! some_command
19//! ---
20//! Completed in {{ time: number }}s
21//! ---
22//! where
23//! * time > 0
24//! * time < 60
25//! ```
26//!
27//! ## Skip Directives
28//!
29//! Tests can be conditionally skipped using `%skip` directives:
30//!
31//! ```text
32//! %skip                           # unconditional skip
33//! %skip(not yet implemented)      # unconditional skip with message
34//! %skip if: test "$OS" = "Win"    # conditional skip
35//! %skip(unix only) if: test ...   # conditional skip with message
36//! ```
37//!
38//! File-level skips go at the top of the file before any tests.
39//! Test-level skips go after the test name, before the closing `===`.
40
41use std::path::{Path, PathBuf};
42use thiserror::Error;
43use winnow::combinator::{alt, opt, repeat};
44use winnow::error::ContextError;
45use winnow::prelude::*;
46use winnow::token::{take_till, take_while};
47
48// ============ Data Types ============
49
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum VarType {
52    Number,
53    String,
54    JsonString,
55    JsonBool,
56    JsonArray,
57    JsonObject,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct VariableDecl {
62    pub name: String,
63    pub var_type: Option<VarType>,
64}
65
66#[derive(Debug, Clone, PartialEq, Default)]
67pub struct SkipDirective {
68    pub message: Option<String>,
69    pub condition: Option<String>,
70    pub platform: Option<PlatformCondition>,
71}
72
73/// Platform condition for skip directives.
74/// Supports: `windows`, `unix`, `macos`, `linux`
75/// With modifiers: `not windows`, `unix or windows`
76#[derive(Debug, Clone, PartialEq)]
77pub enum PlatformCondition {
78    Is(Platform),
79    Not(Platform),
80    Or(Vec<Platform>),
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub enum Platform {
85    Windows,
86    Unix,
87    MacOS,
88    Linux,
89}
90
91#[derive(Debug, Clone, PartialEq)]
92pub struct TestCase {
93    pub name: String,
94    pub command: String,
95    pub expected_output: String,
96    pub file_path: PathBuf,
97    pub start_line: usize,
98    pub end_line: usize,
99    pub variables: Vec<VariableDecl>,
100    pub constraints: Vec<String>,
101    pub skip: Option<SkipDirective>,
102}
103
104impl TestCase {
105    pub fn variable_names(&self) -> Vec<&str> {
106        self.variables.iter().map(|v| v.name.as_str()).collect()
107    }
108}
109
110#[derive(Debug, Clone, PartialEq)]
111pub struct CorpusFile {
112    pub file_skip: Option<SkipDirective>,
113    pub tests: Vec<TestCase>,
114}
115
116#[derive(Error, Debug)]
117pub enum ParseError {
118    #[error("IO error: {0}")]
119    Io(#[from] std::io::Error),
120    #[error("parse error at line {line}: {message}")]
121    Parse { line: usize, message: String },
122}
123
124// ============ Public API ============
125
126pub fn parse_file(path: &Path) -> Result<CorpusFile, ParseError> {
127    let content = std::fs::read_to_string(path)?;
128    parse_content(&content, path)
129}
130
131pub fn parse_content(content: &str, path: &Path) -> Result<CorpusFile, ParseError> {
132    let mut state = ParseState::new(content, path);
133    match corpus_file(&mut state) {
134        Ok(file) => Ok(file),
135        Err(_) => Err(ParseError::Parse {
136            line: state.current_line,
137            message: state
138                .error_message
139                .unwrap_or_else(|| "failed to parse corpus file".to_string()),
140        }),
141    }
142}
143
144// ============ Parse State ============
145
146struct ParseState<'a> {
147    input: &'a str,
148    path: &'a Path,
149    current_line: usize,
150    delimiter_len: usize,
151    error_message: Option<String>,
152}
153
154impl<'a> ParseState<'a> {
155    fn new(input: &'a str, path: &'a Path) -> Self {
156        Self {
157            input,
158            path,
159            current_line: 1,
160            delimiter_len: 3,
161            error_message: None,
162        }
163    }
164}
165
166// ============ Type Annotation Parsing ============
167
168fn parse_type_annotation(type_str: &str) -> Option<VarType> {
169    match type_str.to_lowercase().as_str() {
170        "number" => Some(VarType::Number),
171        "string" => Some(VarType::String),
172        "json string" => Some(VarType::JsonString),
173        "json bool" => Some(VarType::JsonBool),
174        "json array" => Some(VarType::JsonArray),
175        "json object" => Some(VarType::JsonObject),
176        _ => None,
177    }
178}
179
180const RESERVED_KEYWORDS: &[&str] = &[
181    "true",
182    "false",
183    "null",
184    "and",
185    "or",
186    "not",
187    "in",
188    "forall",
189    "contains",
190    "startswith",
191    "endswith",
192    "matches",
193    "len",
194    "type",
195    "keys",
196    "values",
197    "sum",
198    "min",
199    "max",
200    "abs",
201    "unique",
202    "lower",
203    "upper",
204    "number",
205    "string",
206    "bool",
207    "array",
208    "object",
209    "env",
210];
211
212fn is_reserved_keyword(name: &str) -> bool {
213    RESERVED_KEYWORDS.contains(&name)
214}
215
216fn parse_placeholder(content: &str) -> Result<(String, Option<VarType>), String> {
217    let content = content.trim();
218    let (name, var_type) = if let Some(colon_pos) = content.find(':') {
219        let name = content[..colon_pos].trim().to_string();
220        let type_str = content[colon_pos + 1..].trim();
221        (name, parse_type_annotation(type_str))
222    } else {
223        (content.to_string(), None)
224    };
225
226    if is_reserved_keyword(&name) {
227        return Err(format!(
228            "'{}' is a reserved keyword and cannot be used as a variable name",
229            name
230        ));
231    }
232
233    Ok((name, var_type))
234}
235
236fn extract_variables_from_expected(expected: &str) -> Result<Vec<VariableDecl>, String> {
237    let mut variables = Vec::new();
238    let mut seen = std::collections::HashSet::new();
239    let mut remaining = expected;
240
241    while let Some(start) = remaining.find("{{") {
242        if let Some(end) = remaining[start..].find("}}") {
243            let content = &remaining[start + 2..start + end];
244            let (name, var_type) = parse_placeholder(content)?;
245            if !name.is_empty() && seen.insert(name.clone()) {
246                variables.push(VariableDecl { name, var_type });
247            }
248            remaining = &remaining[start + end + 2..];
249        } else {
250            break;
251        }
252    }
253
254    Ok(variables)
255}
256
257// ============ Winnow Parsers ============
258
259fn header_sep(input: &mut &str) -> ModalResult<usize> {
260    let line: &str = take_while(1.., '=').parse_next(input)?;
261    if line.len() >= 3 {
262        Ok(line.len())
263    } else {
264        Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
265    }
266}
267
268fn check_header_sep_exact(line: &str, expected_len: usize) -> Option<String> {
269    let trimmed = line.trim();
270    if trimmed.chars().all(|c| c == '=') && trimmed.len() >= 3 && trimmed.len() != expected_len {
271        Some(format!(
272            "delimiter length mismatch: expected {} '=' characters but found {}",
273            expected_len,
274            trimmed.len()
275        ))
276    } else {
277        None
278    }
279}
280
281fn header_sep_exact(input: &mut &str, len: usize) -> ModalResult<()> {
282    let line: &str = take_while(1.., '=').parse_next(input)?;
283    if line.len() == len {
284        Ok(())
285    } else {
286        Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
287    }
288}
289
290fn dash_sep_exact(input: &mut &str, len: usize) -> ModalResult<()> {
291    let line: &str = take_while(1.., '-').parse_next(input)?;
292    if line.len() == len {
293        Ok(())
294    } else {
295        Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
296    }
297}
298
299fn line_content<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
300    take_till(0.., |c| c == '\n' || c == '\r').parse_next(input)
301}
302
303fn newline(input: &mut &str) -> ModalResult<()> {
304    alt(("\r\n".value(()), "\n".value(()), "\r".value(()))).parse_next(input)
305}
306
307fn opt_newline(input: &mut &str) -> ModalResult<()> {
308    opt(newline).map(|_| ()).parse_next(input)
309}
310
311fn blank_line(input: &mut &str) -> ModalResult<()> {
312    (take_while(0.., ' '), newline)
313        .map(|_| ())
314        .parse_next(input)
315}
316
317fn skip_blank_lines(input: &mut &str) -> ModalResult<()> {
318    repeat(0.., blank_line)
319        .map(|_: Vec<()>| ())
320        .parse_next(input)
321}
322
323fn is_any_separator_line(line: &str) -> bool {
324    let trimmed = line.trim();
325    (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '='))
326        || (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-'))
327}
328
329// ============ Skip Directive Parser ============
330
331fn skip_message(input: &mut &str) -> ModalResult<String> {
332    '('.parse_next(input)?;
333    let msg: &str = take_till(0.., ')').parse_next(input)?;
334    ')'.parse_next(input)?;
335    Ok(msg.to_string())
336}
337
338fn skip_condition(input: &mut &str) -> ModalResult<String> {
339    let _ = take_while(0.., ' ').parse_next(input)?;
340    "if:".parse_next(input)?;
341    let _ = take_while(0.., ' ').parse_next(input)?;
342    let condition = line_content.parse_next(input)?;
343    Ok(condition.trim().to_string())
344}
345
346fn platform_name(input: &mut &str) -> ModalResult<Platform> {
347    let name: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric()).parse_next(input)?;
348    match name.to_lowercase().as_str() {
349        "windows" => Ok(Platform::Windows),
350        "unix" => Ok(Platform::Unix),
351        "macos" => Ok(Platform::MacOS),
352        "linux" => Ok(Platform::Linux),
353        _ => Err(winnow::error::ErrMode::Backtrack(ContextError::new())),
354    }
355}
356
357fn platform_condition(input: &mut &str) -> ModalResult<PlatformCondition> {
358    let _ = take_while(0.., ' ').parse_next(input)?;
359    "platform:".parse_next(input)?;
360    let _ = take_while(0.., ' ').parse_next(input)?;
361
362    // Check for "not"
363    if opt("not ").parse_next(input)?.is_some() {
364        let _ = take_while(0.., ' ').parse_next(input)?;
365        let platform = platform_name.parse_next(input)?;
366        return Ok(PlatformCondition::Not(platform));
367    }
368
369    // Parse first platform
370    let first = platform_name.parse_next(input)?;
371
372    // Check for "or" chain
373    let _ = take_while(0.., ' ').parse_next(input)?;
374    if opt("or ").parse_next(input)?.is_some() {
375        let mut platforms = vec![first];
376        loop {
377            let _ = take_while(0.., ' ').parse_next(input)?;
378            let platform = platform_name.parse_next(input)?;
379            platforms.push(platform);
380            let _ = take_while(0.., ' ').parse_next(input)?;
381            if opt("or ").parse_next(input)?.is_none() {
382                break;
383            }
384        }
385        return Ok(PlatformCondition::Or(platforms));
386    }
387
388    Ok(PlatformCondition::Is(first))
389}
390
391fn skip_directive(input: &mut &str) -> ModalResult<SkipDirective> {
392    "%skip".parse_next(input)?;
393    let message = opt(skip_message).parse_next(input)?;
394
395    // Try platform condition first, then shell condition
396    let platform = opt(platform_condition).parse_next(input)?;
397    let condition = if platform.is_none() {
398        opt(skip_condition).parse_next(input)?
399    } else {
400        None
401    };
402
403    if message.is_none() && condition.is_none() && platform.is_none() {
404        let _ = line_content.parse_next(input)?;
405    }
406
407    opt_newline.parse_next(input)?;
408
409    Ok(SkipDirective {
410        message,
411        condition,
412        platform,
413    })
414}
415
416fn try_skip_directive(input: &mut &str) -> ModalResult<Option<SkipDirective>> {
417    let _ = take_while(0.., ' ').parse_next(input)?;
418    if input.starts_with("%skip") {
419        Ok(Some(skip_directive.parse_next(input)?))
420    } else {
421        Ok(None)
422    }
423}
424
425// ============ Test Case Parser ============
426
427fn description_line(input: &mut &str) -> ModalResult<String> {
428    let content = line_content.parse_next(input)?;
429    opt_newline.parse_next(input)?;
430    Ok(content.trim().to_string())
431}
432
433fn read_block_until_separator(input: &mut &str, delimiter_len: usize) -> String {
434    let mut lines = Vec::new();
435
436    loop {
437        if input.is_empty() {
438            break;
439        }
440
441        let peek_line = input.lines().next().unwrap_or("");
442        let trimmed = peek_line.trim();
443
444        // Only exact-length separators terminate the block
445        // Any other length (shorter or longer) is treated as content
446        if is_any_separator_line(peek_line) && trimmed.len() == delimiter_len {
447            break;
448        }
449
450        let line = line_content.parse_next(input).unwrap_or("");
451        opt_newline.parse_next(input).ok();
452        lines.push(line);
453    }
454
455    while lines.last().is_some_and(|s| s.trim().is_empty()) {
456        lines.pop();
457    }
458
459    lines.join("\n")
460}
461
462fn constraint_line(input: &mut &str) -> ModalResult<String> {
463    let _ = take_while(0.., ' ').parse_next(input)?;
464    let _ = opt('*').parse_next(input)?;
465    let _ = take_while(0.., ' ').parse_next(input)?;
466
467    let content = line_content.parse_next(input)?;
468    opt_newline.parse_next(input)?;
469
470    let trimmed = content.trim();
471    if trimmed.is_empty() || trimmed == "where" {
472        Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
473    } else {
474        Ok(trimmed.to_string())
475    }
476}
477
478fn where_section(input: &mut &str, delimiter_len: usize) -> ModalResult<Vec<String>> {
479    dash_sep_exact(input, delimiter_len)?;
480    opt_newline.parse_next(input)?;
481
482    let _ = take_while(0.., ' ').parse_next(input)?;
483    "where".parse_next(input)?;
484    opt_newline.parse_next(input)?;
485
486    let constraints: Vec<String> = repeat(0.., constraint_line).parse_next(input)?;
487    Ok(constraints)
488}
489
490// ============ Main Parsers ============
491
492fn test_case(state: &mut ParseState) -> Result<TestCase, winnow::error::ErrMode<ContextError>> {
493    let input = &mut state.input;
494
495    skip_blank_lines.parse_next(input)?;
496
497    let start_line = state.current_line;
498
499    let delimiter_len = header_sep.parse_next(input)?;
500    state.delimiter_len = delimiter_len;
501    opt_newline.parse_next(input)?;
502    state.current_line += 1;
503
504    let name = description_line.parse_next(input)?;
505    state.current_line += 1;
506
507    let skip = try_skip_directive.parse_next(input)?;
508    if skip.is_some() {
509        state.current_line += 1;
510    }
511
512    if let Some(err) = input
513        .lines()
514        .next()
515        .and_then(|l| check_header_sep_exact(l, delimiter_len))
516    {
517        state.error_message = Some(err);
518        return Err(winnow::error::ErrMode::Backtrack(ContextError::new()));
519    }
520    header_sep_exact(input, delimiter_len)?;
521    opt_newline.parse_next(input)?;
522    state.current_line += 1;
523
524    let command_start = state.current_line;
525    let command = read_block_until_separator(input, delimiter_len);
526    state.current_line = command_start + command.lines().count().max(1);
527
528    dash_sep_exact(input, delimiter_len)?;
529    opt_newline.parse_next(input)?;
530    state.current_line += 1;
531
532    let expected_start = state.current_line;
533    let expected_output = read_block_until_separator(input, delimiter_len);
534    let expected_lines = expected_output.lines().count();
535    state.current_line =
536        expected_start + expected_lines.max(if expected_output.is_empty() { 0 } else { 1 });
537
538    let constraints = opt(|i: &mut &str| where_section(i, delimiter_len))
539        .parse_next(input)?
540        .unwrap_or_default();
541    if !constraints.is_empty() {
542        state.current_line += 2 + constraints.len();
543    }
544
545    skip_blank_lines.parse_next(input)?;
546
547    let end_line = state.current_line;
548
549    let variables = extract_variables_from_expected(&expected_output)
550        .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))?;
551
552    Ok(TestCase {
553        name,
554        command,
555        expected_output,
556        file_path: state.path.to_path_buf(),
557        start_line,
558        end_line,
559        variables,
560        constraints,
561        skip,
562    })
563}
564
565fn corpus_file(state: &mut ParseState) -> Result<CorpusFile, winnow::error::ErrMode<ContextError>> {
566    let input = &mut state.input;
567
568    skip_blank_lines.parse_next(input)?;
569
570    let file_skip = try_skip_directive.parse_next(input)?;
571    if file_skip.is_some() {
572        state.current_line += 1;
573    }
574
575    skip_blank_lines.parse_next(input)?;
576
577    let mut tests = Vec::new();
578
579    while !state.input.is_empty() {
580        let peeked = state.input.trim_start();
581        if peeked.is_empty() {
582            break;
583        }
584
585        if !peeked.starts_with("===") {
586            break;
587        }
588
589        let tc = test_case(state)?;
590        tests.push(tc);
591    }
592
593    Ok(CorpusFile { file_skip, tests })
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use std::io::Write;
600    use tempfile::NamedTempFile;
601
602    fn parse_test(content: &str) -> CorpusFile {
603        parse_content(content, Path::new("<test>")).unwrap()
604    }
605
606    #[test]
607    fn test_parse_single_test() {
608        let content = r#"===
609test name
610===
611echo hello
612---
613hello
614"#;
615        let file = parse_test(content);
616        assert!(file.file_skip.is_none());
617        assert_eq!(file.tests.len(), 1);
618        assert_eq!(file.tests[0].name, "test name");
619        assert_eq!(file.tests[0].command, "echo hello");
620        assert_eq!(file.tests[0].expected_output, "hello");
621        assert!(file.tests[0].variables.is_empty());
622        assert!(file.tests[0].constraints.is_empty());
623        assert!(file.tests[0].skip.is_none());
624    }
625
626    #[test]
627    fn test_parse_multiple_tests() {
628        let content = r#"===
629first test
630===
631echo first
632---
633first
634
635===
636second test
637===
638echo second
639---
640second
641"#;
642        let file = parse_test(content);
643        assert_eq!(file.tests.len(), 2);
644        assert_eq!(file.tests[0].name, "first test");
645        assert_eq!(file.tests[1].name, "second test");
646    }
647
648    #[test]
649    fn test_parse_multiline_output() {
650        let content = r#"===
651multiline test
652===
653echo -e "line1\nline2\nline3"
654---
655line1
656line2
657line3
658"#;
659        let file = parse_test(content);
660        assert_eq!(file.tests.len(), 1);
661        assert_eq!(file.tests[0].expected_output, "line1\nline2\nline3");
662    }
663
664    #[test]
665    fn test_parse_empty_expected() {
666        let content = r#"===
667exit only test
668===
669true
670---
671"#;
672        let file = parse_test(content);
673        assert_eq!(file.tests.len(), 1);
674        assert_eq!(file.tests[0].expected_output, "");
675    }
676
677    #[test]
678    fn test_parse_with_inline_type() {
679        let content = r#"===
680timing test
681===
682time_command
683---
684Completed in {{ n: number }}s
685"#;
686        let file = parse_test(content);
687        assert_eq!(file.tests.len(), 1);
688        assert_eq!(
689            file.tests[0].expected_output,
690            "Completed in {{ n: number }}s"
691        );
692        assert_eq!(file.tests[0].variables.len(), 1);
693        assert_eq!(file.tests[0].variables[0].name, "n");
694        assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::Number));
695    }
696
697    #[test]
698    fn test_parse_with_constraints() {
699        let content = r#"===
700timing test
701===
702time_command
703---
704Completed in {{ n: number }}s
705---
706where
707* n > 0
708* n < 60
709"#;
710        let file = parse_test(content);
711        assert_eq!(file.tests.len(), 1);
712        assert_eq!(file.tests[0].variables.len(), 1);
713        assert_eq!(file.tests[0].constraints.len(), 2);
714        assert_eq!(file.tests[0].constraints[0], "n > 0");
715        assert_eq!(file.tests[0].constraints[1], "n < 60");
716    }
717
718    #[test]
719    fn test_parse_multiple_variables() {
720        let content = r#"===
721multi var test
722===
723some_command
724---
725{{ count: number }} items in {{ time: number }}s: {{ msg: string }}
726---
727where
728* count > 0
729* time < 10
730"#;
731        let file = parse_test(content);
732        assert_eq!(file.tests.len(), 1);
733        assert_eq!(file.tests[0].variables.len(), 3);
734        assert_eq!(file.tests[0].variables[0].name, "count");
735        assert_eq!(file.tests[0].variables[1].name, "time");
736        assert_eq!(file.tests[0].variables[2].name, "msg");
737        assert_eq!(file.tests[0].variables[2].var_type, Some(VarType::String));
738    }
739
740    #[test]
741    fn test_parse_duck_typed_variable() {
742        let content = r#"===
743duck typed
744===
745echo "val: 42"
746---
747val: {{ x }}
748---
749where
750* x > 0
751"#;
752        let file = parse_test(content);
753        assert_eq!(file.tests.len(), 1);
754        assert_eq!(file.tests[0].variables.len(), 1);
755        assert_eq!(file.tests[0].variables[0].name, "x");
756        assert_eq!(file.tests[0].variables[0].var_type, None);
757    }
758
759    #[test]
760    fn test_parse_empty_string_var() {
761        let content = r#"===
762empty string
763===
764echo "val: "
765---
766val: {{ s: string }}
767---
768where
769* len(s) == 0
770"#;
771        let file = parse_test(content);
772        assert_eq!(file.tests.len(), 1);
773        assert_eq!(file.tests[0].name, "empty string");
774        assert_eq!(file.tests[0].expected_output, "val: {{ s: string }}");
775        assert_eq!(file.tests[0].variables.len(), 1);
776        assert_eq!(file.tests[0].variables[0].name, "s");
777        assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::String));
778        assert_eq!(file.tests[0].constraints.len(), 1);
779        assert_eq!(file.tests[0].constraints[0], "len(s) == 0");
780    }
781
782    #[test]
783    fn test_skip_unconditional() {
784        let content = r#"===
785skipped test
786%skip
787===
788echo hello
789---
790hello
791"#;
792        let file = parse_test(content);
793        assert_eq!(file.tests.len(), 1);
794        let skip = file.tests[0].skip.as_ref().unwrap();
795        assert!(skip.message.is_none());
796        assert!(skip.condition.is_none());
797    }
798
799    #[test]
800    fn test_skip_with_message() {
801        let content = r#"===
802skipped test
803%skip(not yet implemented)
804===
805echo hello
806---
807hello
808"#;
809        let file = parse_test(content);
810        assert_eq!(file.tests.len(), 1);
811        let skip = file.tests[0].skip.as_ref().unwrap();
812        assert_eq!(skip.message.as_deref(), Some("not yet implemented"));
813        assert!(skip.condition.is_none());
814    }
815
816    #[test]
817    fn test_skip_with_condition() {
818        let content = r#"===
819unix only test
820%skip if: test "$OS" = "Windows_NT"
821===
822echo hello
823---
824hello
825"#;
826        let file = parse_test(content);
827        assert_eq!(file.tests.len(), 1);
828        let skip = file.tests[0].skip.as_ref().unwrap();
829        assert!(skip.message.is_none());
830        assert_eq!(
831            skip.condition.as_deref(),
832            Some(r#"test "$OS" = "Windows_NT""#)
833        );
834    }
835
836    #[test]
837    fn test_skip_with_message_and_condition() {
838        let content = r#"===
839unix only test
840%skip(requires bash) if: test "$OS" = "Windows_NT"
841===
842echo hello
843---
844hello
845"#;
846        let file = parse_test(content);
847        assert_eq!(file.tests.len(), 1);
848        let skip = file.tests[0].skip.as_ref().unwrap();
849        assert_eq!(skip.message.as_deref(), Some("requires bash"));
850        assert_eq!(
851            skip.condition.as_deref(),
852            Some(r#"test "$OS" = "Windows_NT""#)
853        );
854    }
855
856    #[test]
857    fn test_file_level_skip() {
858        let content = r#"%skip(windows tests) if: test "$OS" != "Windows_NT"
859
860===
861test 1
862===
863echo hello
864---
865hello
866"#;
867        let file = parse_test(content);
868        let file_skip = file.file_skip.as_ref().unwrap();
869        assert_eq!(file_skip.message.as_deref(), Some("windows tests"));
870        assert_eq!(
871            file_skip.condition.as_deref(),
872            Some(r#"test "$OS" != "Windows_NT""#)
873        );
874        assert_eq!(file.tests.len(), 1);
875    }
876
877    #[test]
878    fn test_file_level_skip_unconditional() {
879        let content = r#"%skip(all tests disabled)
880
881===
882test 1
883===
884echo hello
885---
886hello
887"#;
888        let file = parse_test(content);
889        let file_skip = file.file_skip.as_ref().unwrap();
890        assert_eq!(file_skip.message.as_deref(), Some("all tests disabled"));
891        assert!(file_skip.condition.is_none());
892    }
893
894    #[test]
895    fn test_parse_file() {
896        let mut f = NamedTempFile::new().unwrap();
897        write!(f, "===\ntest\n===\necho hi\n---\nhi\n").unwrap();
898
899        let file = parse_file(f.path()).unwrap();
900        assert_eq!(file.tests.len(), 1);
901        assert_eq!(file.tests[0].name, "test");
902        assert_eq!(file.tests[0].file_path, f.path());
903    }
904
905    #[test]
906    fn test_multiline_command() {
907        let content = r#"===
908multiline command
909===
910echo "line 1"
911echo "line 2"
912echo "line 3"
913---
914line 1
915line 2
916line 3
917"#;
918        let file = parse_test(content);
919        assert_eq!(file.tests.len(), 1);
920        assert_eq!(
921            file.tests[0].command,
922            "echo \"line 1\"\necho \"line 2\"\necho \"line 3\""
923        );
924    }
925
926    #[test]
927    fn test_line_numbers() {
928        let content = r#"===
929first test
930===
931echo hello
932---
933hello
934
935===
936second test
937===
938echo world
939---
940world
941"#;
942        let file = parse_test(content);
943        assert_eq!(file.tests.len(), 2);
944        assert_eq!(file.tests[0].start_line, 1);
945        // Just verify we have reasonable line tracking
946        assert!(file.tests[0].start_line < file.tests[0].end_line);
947        assert!(file.tests[1].start_line < file.tests[1].end_line);
948    }
949
950    #[test]
951    fn test_longer_delimiters() {
952        let content = r#"=====
953test with longer delimiters
954=====
955echo hello
956-----
957hello
958"#;
959        let file = parse_test(content);
960        assert_eq!(file.tests.len(), 1);
961        assert_eq!(file.tests[0].name, "test with longer delimiters");
962        assert_eq!(file.tests[0].command, "echo hello");
963        assert_eq!(file.tests[0].expected_output, "hello");
964    }
965
966    #[test]
967    fn test_dash_separator_in_output() {
968        let content = r#"====
969test with --- in output
970====
971echo "---"
972----
973---
974"#;
975        let file = parse_test(content);
976        assert_eq!(file.tests.len(), 1);
977        assert_eq!(file.tests[0].expected_output, "---");
978    }
979
980    #[test]
981    fn test_dash_separators_in_output() {
982        // With 5-char delimiters, shorter --- and ---- can appear in output
983        // But ----- is the closing delimiter so it terminates the block
984        let content = r#"=====
985test with various dash separators in output
986=====
987printf "---\n----\n"
988-----
989---
990----
991"#;
992        let file = parse_test(content);
993        assert_eq!(file.tests.len(), 1);
994        assert_eq!(file.tests[0].expected_output, "---\n----");
995    }
996
997    #[test]
998    fn test_shorter_equals_in_output_is_content() {
999        // Shorter === in expected output is treated as content when using longer delimiters
1000        // Only === of same or longer length signals a new test
1001        let content = r#"=====
1002test with === and ==== in output
1003=====
1004echo "==="
1005-----
1006===
1007
1008=====
1009second test
1010=====
1011echo "===="
1012-----
1013====
1014"#;
1015        let file = parse_test(content);
1016        assert_eq!(file.tests.len(), 2);
1017        assert_eq!(file.tests[0].expected_output, "===");
1018        assert_eq!(file.tests[1].expected_output, "====");
1019    }
1020
1021    #[test]
1022    fn test_same_length_equals_ends_block() {
1023        // === of same length or longer signals new test
1024        let content = r#"====
1025first test
1026====
1027echo "hello"
1028----
1029hello
1030
1031====
1032second test same length
1033====
1034echo "world"
1035----
1036world
1037"#;
1038        let file = parse_test(content);
1039        assert_eq!(file.tests.len(), 2);
1040        assert_eq!(file.tests[0].expected_output, "hello");
1041        assert_eq!(file.tests[1].expected_output, "world");
1042    }
1043
1044    #[test]
1045    fn test_longer_delimiters_with_constraints() {
1046        let content = r#"====
1047test with constraints
1048====
1049echo "count: 42"
1050----
1051count: {{ n: number }}
1052----
1053where
1054* n > 0
1055"#;
1056        let file = parse_test(content);
1057        assert_eq!(file.tests.len(), 1);
1058        assert_eq!(file.tests[0].constraints.len(), 1);
1059        assert_eq!(file.tests[0].constraints[0], "n > 0");
1060    }
1061
1062    #[test]
1063    fn test_mismatched_header_delimiter_error() {
1064        let content = r#"====
1065test name
1066===
1067echo hello
1068---
1069hello
1070"#;
1071        let result = parse_content(content, Path::new("<test>"));
1072        assert!(result.is_err());
1073        let err = result.unwrap_err();
1074        assert!(
1075            err.to_string().contains("delimiter length mismatch"),
1076            "Error should mention delimiter mismatch: {}",
1077            err
1078        );
1079        assert!(
1080            err.to_string().contains("expected 4") && err.to_string().contains("found 3"),
1081            "Error should mention expected 4 and found 3: {}",
1082            err
1083        );
1084    }
1085
1086    #[test]
1087    fn test_wrong_dash_length_treated_as_content() {
1088        // With simplified logic, wrong-length delimiters are treated as content
1089        // This test uses 4-char delimiters but has --- in content
1090        let content = r#"====
1091test name
1092====
1093echo hello
1094----
1095---
1096hello
1097"#;
1098        let file = parse_test(content);
1099        assert_eq!(file.tests.len(), 1);
1100        assert_eq!(file.tests[0].expected_output, "---\nhello");
1101    }
1102
1103    #[test]
1104    fn test_multiple_tests_same_delimiter_length() {
1105        // Multiple tests must use the same delimiter length
1106        // (or longer delimiter tests can follow shorter ones, but not vice versa)
1107        let content = r#"===
1108first test
1109===
1110echo "short"
1111---
1112short
1113
1114===
1115second test
1116===
1117echo "world"
1118---
1119world
1120"#;
1121        let file = parse_test(content);
1122        assert_eq!(file.tests.len(), 2);
1123        assert_eq!(file.tests[0].expected_output, "short");
1124        assert_eq!(file.tests[1].expected_output, "world");
1125    }
1126
1127    #[test]
1128    fn test_all_tests_must_use_same_delimiter_length() {
1129        // With exact-match logic, all tests in a file must use the same delimiter length
1130        // A longer delimiter after shorter is treated as content of the first test
1131        let content = r#"===
1132first test
1133===
1134echo "short"
1135---
1136short
1137
1138=====
1139this looks like a test but is content
1140=====
1141"#;
1142        let file = parse_test(content);
1143        assert_eq!(file.tests.len(), 1);
1144        // The ===== block is included as content
1145        assert!(file.tests[0].expected_output.contains("====="));
1146    }
1147}