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