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/// Skip directive - unconditional or conditional (with shell command)
67#[derive(Debug, Clone, PartialEq, Default)]
68pub struct SkipDirective {
69    pub message: Option<String>,
70    /// Shell command condition - if exits 0, test is skipped
71    pub condition: Option<String>,
72}
73
74/// Supported platforms
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum Platform {
77    Windows,
78    Unix,
79    MacOS,
80    Linux,
81}
82
83/// Shell to use for running commands.
84/// Default: bash on Unix, powershell on Windows
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub enum Shell {
87    /// Bourne shell (sh)
88    Sh,
89    /// Bash shell (default on Unix)
90    Bash,
91    /// Zsh shell
92    Zsh,
93    /// PowerShell (default on Windows)
94    PowerShell,
95    /// Windows cmd.exe
96    Cmd,
97}
98
99#[derive(Debug, Clone, PartialEq)]
100pub struct TestCase {
101    pub name: String,
102    pub command: String,
103    pub expected_output: String,
104    pub file_path: PathBuf,
105    pub start_line: usize,
106    pub end_line: usize,
107    pub variables: Vec<VariableDecl>,
108    pub constraints: Vec<String>,
109    pub skip: Option<SkipDirective>,
110}
111
112impl TestCase {
113    pub fn variable_names(&self) -> Vec<&str> {
114        self.variables.iter().map(|v| v.name.as_str()).collect()
115    }
116}
117
118#[derive(Debug, Clone, PartialEq)]
119pub struct CorpusFile {
120    pub file_skip: Option<SkipDirective>,
121    pub file_shell: Option<Shell>,
122    pub file_platform: Vec<Platform>,
123    pub tests: Vec<TestCase>,
124}
125
126#[derive(Error, Debug)]
127pub enum ParseError {
128    #[error("IO error: {0}")]
129    Io(#[from] std::io::Error),
130    #[error("parse error at line {line}: {message}")]
131    Parse { line: usize, message: String },
132}
133
134// ============ Public API ============
135
136pub fn parse_file(path: &Path) -> Result<CorpusFile, ParseError> {
137    let content = std::fs::read_to_string(path)?;
138    parse_content(&content, path)
139}
140
141pub fn parse_content(content: &str, path: &Path) -> Result<CorpusFile, ParseError> {
142    let mut state = ParseState::new(content, path);
143    match corpus_file(&mut state) {
144        Ok(file) => {
145            // Validate shell/platform compatibility
146            if let Some(shell) = file.file_shell {
147                if !file.file_platform.is_empty() {
148                    validate_shell_platform(shell, &file.file_platform)?;
149                }
150            }
151            Ok(file)
152        }
153        Err(_) => Err(ParseError::Parse {
154            line: state.current_line,
155            message: state
156                .error_message
157                .unwrap_or_else(|| "failed to parse corpus file".to_string()),
158        }),
159    }
160}
161
162/// Validate that the shell is compatible with the specified platforms
163fn validate_shell_platform(shell: Shell, platforms: &[Platform]) -> Result<(), ParseError> {
164    let is_windows_shell = matches!(shell, Shell::PowerShell | Shell::Cmd);
165
166    let has_windows = platforms.contains(&Platform::Windows);
167    let has_unix = platforms
168        .iter()
169        .any(|p| matches!(p, Platform::Unix | Platform::MacOS | Platform::Linux));
170
171    // Windows-only shells can't run on Unix platforms
172    if is_windows_shell && has_unix && !has_windows {
173        return Err(ParseError::Parse {
174            line: 1,
175            message: format!(
176                "shell '{:?}' is not compatible with platforms {:?}",
177                shell, platforms
178            ),
179        });
180    }
181
182    // Unix-only shells (sh, zsh) can't run on Windows
183    if matches!(shell, Shell::Sh | Shell::Zsh) && has_windows && !has_unix {
184        return Err(ParseError::Parse {
185            line: 1,
186            message: format!(
187                "shell '{:?}' is not compatible with platforms {:?}",
188                shell, platforms
189            ),
190        });
191    }
192
193    // cmd is Windows-only
194    if shell == Shell::Cmd && has_unix && !has_windows {
195        return Err(ParseError::Parse {
196            line: 1,
197            message: format!(
198                "shell 'cmd' is only available on Windows, but platforms are {:?}",
199                platforms
200            ),
201        });
202    }
203
204    Ok(())
205}
206
207// ============ Parse State ============
208
209struct ParseState<'a> {
210    input: &'a str,
211    path: &'a Path,
212    current_line: usize,
213    delimiter_len: usize,
214    error_message: Option<String>,
215}
216
217impl<'a> ParseState<'a> {
218    fn new(input: &'a str, path: &'a Path) -> Self {
219        Self {
220            input,
221            path,
222            current_line: 1,
223            delimiter_len: 3,
224            error_message: None,
225        }
226    }
227}
228
229// ============ Type Annotation Parsing ============
230
231fn parse_type_annotation(type_str: &str) -> Option<VarType> {
232    match type_str.to_lowercase().as_str() {
233        "number" => Some(VarType::Number),
234        "string" => Some(VarType::String),
235        "json string" => Some(VarType::JsonString),
236        "json bool" => Some(VarType::JsonBool),
237        "json array" => Some(VarType::JsonArray),
238        "json object" => Some(VarType::JsonObject),
239        _ => None,
240    }
241}
242
243const RESERVED_KEYWORDS: &[&str] = &[
244    "true",
245    "false",
246    "null",
247    "and",
248    "or",
249    "not",
250    "in",
251    "forall",
252    "contains",
253    "startswith",
254    "endswith",
255    "matches",
256    "len",
257    "type",
258    "keys",
259    "values",
260    "sum",
261    "min",
262    "max",
263    "abs",
264    "unique",
265    "lower",
266    "upper",
267    "number",
268    "string",
269    "bool",
270    "array",
271    "object",
272    "env",
273];
274
275fn is_reserved_keyword(name: &str) -> bool {
276    RESERVED_KEYWORDS.contains(&name)
277}
278
279fn parse_placeholder(content: &str) -> Result<(String, Option<VarType>), String> {
280    let content = content.trim();
281    let (name, var_type) = if let Some(colon_pos) = content.find(':') {
282        let name = content[..colon_pos].trim().to_string();
283        let type_str = content[colon_pos + 1..].trim();
284        (name, parse_type_annotation(type_str))
285    } else {
286        (content.to_string(), None)
287    };
288
289    if is_reserved_keyword(&name) {
290        return Err(format!(
291            "'{}' is a reserved keyword and cannot be used as a variable name",
292            name
293        ));
294    }
295
296    Ok((name, var_type))
297}
298
299fn extract_variables_from_expected(expected: &str) -> Result<Vec<VariableDecl>, String> {
300    let mut variables = Vec::new();
301    let mut seen = std::collections::HashSet::new();
302    let mut remaining = expected;
303
304    while let Some(start) = remaining.find("{{") {
305        if let Some(end) = remaining[start..].find("}}") {
306            let content = &remaining[start + 2..start + end];
307            let (name, var_type) = parse_placeholder(content)?;
308            if !name.is_empty() && seen.insert(name.clone()) {
309                variables.push(VariableDecl { name, var_type });
310            }
311            remaining = &remaining[start + end + 2..];
312        } else {
313            break;
314        }
315    }
316
317    Ok(variables)
318}
319
320// ============ Winnow Parsers ============
321
322fn header_sep(input: &mut &str) -> ModalResult<usize> {
323    let line: &str = take_while(1.., '=').parse_next(input)?;
324    if line.len() >= 3 {
325        Ok(line.len())
326    } else {
327        Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
328    }
329}
330
331fn check_header_sep_exact(line: &str, expected_len: usize) -> Option<String> {
332    let trimmed = line.trim();
333    if trimmed.chars().all(|c| c == '=') && trimmed.len() >= 3 && trimmed.len() != expected_len {
334        Some(format!(
335            "delimiter length mismatch: expected {} '=' characters but found {}",
336            expected_len,
337            trimmed.len()
338        ))
339    } else {
340        None
341    }
342}
343
344fn header_sep_exact(input: &mut &str, len: usize) -> ModalResult<()> {
345    let line: &str = take_while(1.., '=').parse_next(input)?;
346    if line.len() == len {
347        Ok(())
348    } else {
349        Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
350    }
351}
352
353fn dash_sep_exact(input: &mut &str, len: usize) -> ModalResult<()> {
354    let line: &str = take_while(1.., '-').parse_next(input)?;
355    if line.len() == len {
356        Ok(())
357    } else {
358        Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
359    }
360}
361
362fn line_content<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
363    take_till(0.., |c| c == '\n' || c == '\r').parse_next(input)
364}
365
366fn newline(input: &mut &str) -> ModalResult<()> {
367    alt(("\r\n".value(()), "\n".value(()), "\r".value(()))).parse_next(input)
368}
369
370fn opt_newline(input: &mut &str) -> ModalResult<()> {
371    opt(newline).map(|_| ()).parse_next(input)
372}
373
374fn blank_line(input: &mut &str) -> ModalResult<()> {
375    (take_while(0.., ' '), newline)
376        .map(|_| ())
377        .parse_next(input)
378}
379
380fn skip_blank_lines(input: &mut &str) -> ModalResult<()> {
381    repeat(0.., blank_line)
382        .map(|_: Vec<()>| ())
383        .parse_next(input)
384}
385
386fn is_any_separator_line(line: &str) -> bool {
387    let trimmed = line.trim();
388    (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '='))
389        || (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-'))
390}
391
392// ============ Skip Directive Parser ============
393
394fn skip_message(input: &mut &str) -> ModalResult<String> {
395    '('.parse_next(input)?;
396    let msg: &str = take_till(0.., ')').parse_next(input)?;
397    ')'.parse_next(input)?;
398    Ok(msg.to_string())
399}
400
401fn skip_condition(input: &mut &str) -> ModalResult<String> {
402    let _ = take_while(0.., ' ').parse_next(input)?;
403    "if:".parse_next(input)?;
404    let _ = take_while(0.., ' ').parse_next(input)?;
405    let condition = line_content.parse_next(input)?;
406    Ok(condition.trim().to_string())
407}
408
409fn platform_name(input: &mut &str) -> ModalResult<Platform> {
410    let name: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric()).parse_next(input)?;
411    match name.to_lowercase().as_str() {
412        "windows" => Ok(Platform::Windows),
413        "unix" => Ok(Platform::Unix),
414        "macos" => Ok(Platform::MacOS),
415        "linux" => Ok(Platform::Linux),
416        _ => Err(winnow::error::ErrMode::Backtrack(ContextError::new())),
417    }
418}
419
420/// Parse %platform directive with comma-separated platforms
421/// e.g., %platform windows or %platform unix
422fn platform_directive(input: &mut &str) -> ModalResult<Vec<Platform>> {
423    "%platform".parse_next(input)?;
424    let _ = take_while(0.., ' ').parse_next(input)?;
425
426    let mut platforms = Vec::new();
427
428    // Parse first platform (required)
429    let first = platform_name.parse_next(input)?;
430    platforms.push(first);
431
432    // Parse additional comma-separated platforms
433    loop {
434        let _ = take_while(0.., ' ').parse_next(input)?;
435        if opt(',').parse_next(input)?.is_none() {
436            break;
437        }
438        let _ = take_while(0.., ' ').parse_next(input)?;
439        let platform = platform_name.parse_next(input)?;
440        platforms.push(platform);
441    }
442
443    let _ = line_content.parse_next(input)?;
444    opt_newline.parse_next(input)?;
445
446    Ok(platforms)
447}
448
449fn skip_directive(input: &mut &str) -> ModalResult<SkipDirective> {
450    "%skip".parse_next(input)?;
451    let message = opt(skip_message).parse_next(input)?;
452    let condition = opt(skip_condition).parse_next(input)?;
453
454    if message.is_none() && condition.is_none() {
455        let _ = line_content.parse_next(input)?;
456    }
457
458    opt_newline.parse_next(input)?;
459
460    Ok(SkipDirective { message, condition })
461}
462
463// ============ Shell Directive Parser ============
464
465fn shell_name(input: &mut &str) -> ModalResult<Shell> {
466    let name: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric()).parse_next(input)?;
467    match name.to_lowercase().as_str() {
468        "sh" => Ok(Shell::Sh),
469        "bash" => Ok(Shell::Bash),
470        "zsh" => Ok(Shell::Zsh),
471        "powershell" => Ok(Shell::PowerShell),
472        "cmd" => Ok(Shell::Cmd),
473        _ => Err(winnow::error::ErrMode::Backtrack(ContextError::new())),
474    }
475}
476
477fn shell_directive(input: &mut &str) -> ModalResult<Shell> {
478    "%shell".parse_next(input)?;
479    let _ = take_while(0.., ' ').parse_next(input)?;
480    let shell = shell_name.parse_next(input)?;
481    let _ = line_content.parse_next(input)?;
482    opt_newline.parse_next(input)?;
483    Ok(shell)
484}
485
486// ============ Test Case Parser ============
487
488fn description_line(input: &mut &str) -> ModalResult<String> {
489    let content = line_content.parse_next(input)?;
490    opt_newline.parse_next(input)?;
491    Ok(content.trim().to_string())
492}
493
494fn read_block_until_separator(input: &mut &str, delimiter_len: usize) -> String {
495    let mut lines = Vec::new();
496
497    loop {
498        if input.is_empty() {
499            break;
500        }
501
502        let peek_line = input.lines().next().unwrap_or("");
503        let trimmed = peek_line.trim();
504
505        // Only exact-length separators terminate the block
506        // Any other length (shorter or longer) is treated as content
507        if is_any_separator_line(peek_line) && trimmed.len() == delimiter_len {
508            break;
509        }
510
511        let line = line_content.parse_next(input).unwrap_or("");
512        opt_newline.parse_next(input).ok();
513        lines.push(line);
514    }
515
516    while lines.last().is_some_and(|s| s.trim().is_empty()) {
517        lines.pop();
518    }
519
520    lines.join("\n")
521}
522
523fn constraint_line(input: &mut &str) -> ModalResult<String> {
524    let _ = take_while(0.., ' ').parse_next(input)?;
525    let _ = opt('*').parse_next(input)?;
526    let _ = take_while(0.., ' ').parse_next(input)?;
527
528    let content = line_content.parse_next(input)?;
529    opt_newline.parse_next(input)?;
530
531    let trimmed = content.trim();
532    if trimmed.is_empty() || trimmed == "where" {
533        Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
534    } else {
535        Ok(trimmed.to_string())
536    }
537}
538
539fn where_section(input: &mut &str, delimiter_len: usize) -> ModalResult<Vec<String>> {
540    dash_sep_exact(input, delimiter_len)?;
541    opt_newline.parse_next(input)?;
542
543    let _ = take_while(0.., ' ').parse_next(input)?;
544    "where".parse_next(input)?;
545    opt_newline.parse_next(input)?;
546
547    let constraints: Vec<String> = repeat(0.., constraint_line).parse_next(input)?;
548    Ok(constraints)
549}
550
551// ============ Main Parsers ============
552
553fn test_case(state: &mut ParseState) -> Result<TestCase, winnow::error::ErrMode<ContextError>> {
554    let input = &mut state.input;
555
556    skip_blank_lines.parse_next(input)?;
557
558    let start_line = state.current_line;
559
560    let delimiter_len = header_sep.parse_next(input)?;
561    state.delimiter_len = delimiter_len;
562    opt_newline.parse_next(input)?;
563    state.current_line += 1;
564
565    let name = description_line.parse_next(input)?;
566    state.current_line += 1;
567
568    // Parse test-level skip directive (only %skip allowed at test level)
569    let _ = take_while(0.., ' ').parse_next(input)?;
570    let skip = if input.starts_with("%skip") {
571        let s = Some(skip_directive.parse_next(input)?);
572        state.current_line += 1;
573        s
574    } else {
575        None
576    };
577
578    // Check for directives that are only allowed at file level
579    let _ = take_while(0.., ' ').parse_next(input)?;
580    if input.starts_with("%platform") {
581        state.error_message =
582            Some("%platform is only allowed at file level, not inside test headers".to_string());
583        return Err(winnow::error::ErrMode::Backtrack(ContextError::new()));
584    }
585    if input.starts_with("%shell") {
586        state.error_message =
587            Some("%shell is only allowed at file level, not inside test headers".to_string());
588        return Err(winnow::error::ErrMode::Backtrack(ContextError::new()));
589    }
590
591    if let Some(err) = input
592        .lines()
593        .next()
594        .and_then(|l| check_header_sep_exact(l, delimiter_len))
595    {
596        state.error_message = Some(err);
597        return Err(winnow::error::ErrMode::Backtrack(ContextError::new()));
598    }
599    header_sep_exact(input, delimiter_len)?;
600    opt_newline.parse_next(input)?;
601    state.current_line += 1;
602
603    let command_start = state.current_line;
604    let command = read_block_until_separator(input, delimiter_len);
605    state.current_line = command_start + command.lines().count().max(1);
606
607    dash_sep_exact(input, delimiter_len)?;
608    opt_newline.parse_next(input)?;
609    state.current_line += 1;
610
611    let expected_start = state.current_line;
612    let expected_output = read_block_until_separator(input, delimiter_len);
613    let expected_lines = expected_output.lines().count();
614    state.current_line =
615        expected_start + expected_lines.max(if expected_output.is_empty() { 0 } else { 1 });
616
617    let constraints = opt(|i: &mut &str| where_section(i, delimiter_len))
618        .parse_next(input)?
619        .unwrap_or_default();
620    if !constraints.is_empty() {
621        state.current_line += 2 + constraints.len();
622    }
623
624    skip_blank_lines.parse_next(input)?;
625
626    let end_line = state.current_line;
627
628    let variables = extract_variables_from_expected(&expected_output)
629        .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))?;
630
631    Ok(TestCase {
632        name,
633        command,
634        expected_output,
635        file_path: state.path.to_path_buf(),
636        start_line,
637        end_line,
638        variables,
639        constraints,
640        skip,
641    })
642}
643
644fn corpus_file(state: &mut ParseState) -> Result<CorpusFile, winnow::error::ErrMode<ContextError>> {
645    let input = &mut state.input;
646
647    skip_blank_lines.parse_next(input)?;
648
649    // Parse file-level directives (skip, shell, platform can appear in any order)
650    let mut file_skip = None;
651    let mut file_shell = None;
652    let mut file_platform = Vec::new();
653
654    loop {
655        let _ = take_while(0.., ' ').parse_next(input)?;
656        if input.starts_with("%skip") && file_skip.is_none() {
657            file_skip = Some(skip_directive.parse_next(input)?);
658            state.current_line += 1;
659            skip_blank_lines.parse_next(input)?;
660        } else if input.starts_with("%shell") && file_shell.is_none() {
661            file_shell = Some(shell_directive.parse_next(input)?);
662            state.current_line += 1;
663            skip_blank_lines.parse_next(input)?;
664        } else if input.starts_with("%platform") && file_platform.is_empty() {
665            file_platform = platform_directive.parse_next(input)?;
666            state.current_line += 1;
667            skip_blank_lines.parse_next(input)?;
668        } else {
669            break;
670        }
671    }
672
673    let mut tests = Vec::new();
674
675    while !state.input.is_empty() {
676        let peeked = state.input.trim_start();
677        if peeked.is_empty() {
678            break;
679        }
680
681        if !peeked.starts_with("===") {
682            break;
683        }
684
685        let tc = test_case(state)?;
686        tests.push(tc);
687    }
688
689    Ok(CorpusFile {
690        file_skip,
691        file_shell,
692        file_platform,
693        tests,
694    })
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use std::io::Write;
701    use tempfile::NamedTempFile;
702
703    fn parse_test(content: &str) -> CorpusFile {
704        parse_content(content, Path::new("<test>")).unwrap()
705    }
706
707    #[test]
708    fn test_parse_single_test() {
709        let content = r#"===
710test name
711===
712echo hello
713---
714hello
715"#;
716        let file = parse_test(content);
717        assert!(file.file_skip.is_none());
718        assert!(file.file_shell.is_none());
719        assert!(file.file_platform.is_empty());
720        assert_eq!(file.tests.len(), 1);
721        assert_eq!(file.tests[0].name, "test name");
722        assert_eq!(file.tests[0].command, "echo hello");
723        assert_eq!(file.tests[0].expected_output, "hello");
724        assert!(file.tests[0].variables.is_empty());
725        assert!(file.tests[0].constraints.is_empty());
726        assert!(file.tests[0].skip.is_none());
727    }
728
729    #[test]
730    fn test_parse_multiple_tests() {
731        let content = r#"===
732first test
733===
734echo first
735---
736first
737
738===
739second test
740===
741echo second
742---
743second
744"#;
745        let file = parse_test(content);
746        assert_eq!(file.tests.len(), 2);
747        assert_eq!(file.tests[0].name, "first test");
748        assert_eq!(file.tests[1].name, "second test");
749    }
750
751    #[test]
752    fn test_parse_multiline_output() {
753        let content = r#"===
754multiline test
755===
756echo -e "line1\nline2\nline3"
757---
758line1
759line2
760line3
761"#;
762        let file = parse_test(content);
763        assert_eq!(file.tests.len(), 1);
764        assert_eq!(file.tests[0].expected_output, "line1\nline2\nline3");
765    }
766
767    #[test]
768    fn test_parse_empty_expected() {
769        let content = r#"===
770exit only test
771===
772true
773---
774"#;
775        let file = parse_test(content);
776        assert_eq!(file.tests.len(), 1);
777        assert_eq!(file.tests[0].expected_output, "");
778    }
779
780    #[test]
781    fn test_parse_with_inline_type() {
782        let content = r#"===
783timing test
784===
785time_command
786---
787Completed in {{ n: number }}s
788"#;
789        let file = parse_test(content);
790        assert_eq!(file.tests.len(), 1);
791        assert_eq!(
792            file.tests[0].expected_output,
793            "Completed in {{ n: number }}s"
794        );
795        assert_eq!(file.tests[0].variables.len(), 1);
796        assert_eq!(file.tests[0].variables[0].name, "n");
797        assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::Number));
798    }
799
800    #[test]
801    fn test_parse_with_constraints() {
802        let content = r#"===
803timing test
804===
805time_command
806---
807Completed in {{ n: number }}s
808---
809where
810* n > 0
811* n < 60
812"#;
813        let file = parse_test(content);
814        assert_eq!(file.tests.len(), 1);
815        assert_eq!(file.tests[0].variables.len(), 1);
816        assert_eq!(file.tests[0].constraints.len(), 2);
817        assert_eq!(file.tests[0].constraints[0], "n > 0");
818        assert_eq!(file.tests[0].constraints[1], "n < 60");
819    }
820
821    #[test]
822    fn test_parse_multiple_variables() {
823        let content = r#"===
824multi var test
825===
826some_command
827---
828{{ count: number }} items in {{ time: number }}s: {{ msg: string }}
829---
830where
831* count > 0
832* time < 10
833"#;
834        let file = parse_test(content);
835        assert_eq!(file.tests.len(), 1);
836        assert_eq!(file.tests[0].variables.len(), 3);
837        assert_eq!(file.tests[0].variables[0].name, "count");
838        assert_eq!(file.tests[0].variables[1].name, "time");
839        assert_eq!(file.tests[0].variables[2].name, "msg");
840        assert_eq!(file.tests[0].variables[2].var_type, Some(VarType::String));
841    }
842
843    #[test]
844    fn test_parse_duck_typed_variable() {
845        let content = r#"===
846duck typed
847===
848echo "val: 42"
849---
850val: {{ x }}
851---
852where
853* x > 0
854"#;
855        let file = parse_test(content);
856        assert_eq!(file.tests.len(), 1);
857        assert_eq!(file.tests[0].variables.len(), 1);
858        assert_eq!(file.tests[0].variables[0].name, "x");
859        assert_eq!(file.tests[0].variables[0].var_type, None);
860    }
861
862    #[test]
863    fn test_parse_empty_string_var() {
864        let content = r#"===
865empty string
866===
867echo "val: "
868---
869val: {{ s: string }}
870---
871where
872* len(s) == 0
873"#;
874        let file = parse_test(content);
875        assert_eq!(file.tests.len(), 1);
876        assert_eq!(file.tests[0].name, "empty string");
877        assert_eq!(file.tests[0].expected_output, "val: {{ s: string }}");
878        assert_eq!(file.tests[0].variables.len(), 1);
879        assert_eq!(file.tests[0].variables[0].name, "s");
880        assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::String));
881        assert_eq!(file.tests[0].constraints.len(), 1);
882        assert_eq!(file.tests[0].constraints[0], "len(s) == 0");
883    }
884
885    #[test]
886    fn test_skip_unconditional() {
887        let content = r#"===
888skipped test
889%skip
890===
891echo hello
892---
893hello
894"#;
895        let file = parse_test(content);
896        assert_eq!(file.tests.len(), 1);
897        let skip = file.tests[0].skip.as_ref().unwrap();
898        assert!(skip.message.is_none());
899        assert!(skip.condition.is_none());
900    }
901
902    #[test]
903    fn test_skip_with_message() {
904        let content = r#"===
905skipped test
906%skip(not yet implemented)
907===
908echo hello
909---
910hello
911"#;
912        let file = parse_test(content);
913        assert_eq!(file.tests.len(), 1);
914        let skip = file.tests[0].skip.as_ref().unwrap();
915        assert_eq!(skip.message.as_deref(), Some("not yet implemented"));
916        assert!(skip.condition.is_none());
917    }
918
919    #[test]
920    fn test_skip_with_condition() {
921        let content = r#"===
922unix only test
923%skip if: test "$OS" = "Windows_NT"
924===
925echo hello
926---
927hello
928"#;
929        let file = parse_test(content);
930        assert_eq!(file.tests.len(), 1);
931        let skip = file.tests[0].skip.as_ref().unwrap();
932        assert!(skip.message.is_none());
933        assert_eq!(
934            skip.condition.as_deref(),
935            Some(r#"test "$OS" = "Windows_NT""#)
936        );
937    }
938
939    #[test]
940    fn test_skip_with_message_and_condition() {
941        let content = r#"===
942unix only test
943%skip(requires bash) if: test "$OS" = "Windows_NT"
944===
945echo hello
946---
947hello
948"#;
949        let file = parse_test(content);
950        assert_eq!(file.tests.len(), 1);
951        let skip = file.tests[0].skip.as_ref().unwrap();
952        assert_eq!(skip.message.as_deref(), Some("requires bash"));
953        assert_eq!(
954            skip.condition.as_deref(),
955            Some(r#"test "$OS" = "Windows_NT""#)
956        );
957    }
958
959    #[test]
960    fn test_file_level_skip() {
961        let content = r#"%skip(windows tests) if: test "$OS" != "Windows_NT"
962
963===
964test 1
965===
966echo hello
967---
968hello
969"#;
970        let file = parse_test(content);
971        let file_skip = file.file_skip.as_ref().unwrap();
972        assert_eq!(file_skip.message.as_deref(), Some("windows tests"));
973        assert_eq!(
974            file_skip.condition.as_deref(),
975            Some(r#"test "$OS" != "Windows_NT""#)
976        );
977        assert_eq!(file.tests.len(), 1);
978    }
979
980    #[test]
981    fn test_file_level_skip_unconditional() {
982        let content = r#"%skip(all tests disabled)
983
984===
985test 1
986===
987echo hello
988---
989hello
990"#;
991        let file = parse_test(content);
992        let file_skip = file.file_skip.as_ref().unwrap();
993        assert_eq!(file_skip.message.as_deref(), Some("all tests disabled"));
994        assert!(file_skip.condition.is_none());
995    }
996
997    #[test]
998    fn test_parse_file() {
999        let mut f = NamedTempFile::new().unwrap();
1000        write!(f, "===\ntest\n===\necho hi\n---\nhi\n").unwrap();
1001
1002        let file = parse_file(f.path()).unwrap();
1003        assert_eq!(file.tests.len(), 1);
1004        assert_eq!(file.tests[0].name, "test");
1005        assert_eq!(file.tests[0].file_path, f.path());
1006    }
1007
1008    #[test]
1009    fn test_multiline_command() {
1010        let content = r#"===
1011multiline command
1012===
1013echo "line 1"
1014echo "line 2"
1015echo "line 3"
1016---
1017line 1
1018line 2
1019line 3
1020"#;
1021        let file = parse_test(content);
1022        assert_eq!(file.tests.len(), 1);
1023        assert_eq!(
1024            file.tests[0].command,
1025            "echo \"line 1\"\necho \"line 2\"\necho \"line 3\""
1026        );
1027    }
1028
1029    #[test]
1030    fn test_line_numbers() {
1031        let content = r#"===
1032first test
1033===
1034echo hello
1035---
1036hello
1037
1038===
1039second test
1040===
1041echo world
1042---
1043world
1044"#;
1045        let file = parse_test(content);
1046        assert_eq!(file.tests.len(), 2);
1047        assert_eq!(file.tests[0].start_line, 1);
1048        // Just verify we have reasonable line tracking
1049        assert!(file.tests[0].start_line < file.tests[0].end_line);
1050        assert!(file.tests[1].start_line < file.tests[1].end_line);
1051    }
1052
1053    #[test]
1054    fn test_longer_delimiters() {
1055        let content = r#"=====
1056test with longer delimiters
1057=====
1058echo hello
1059-----
1060hello
1061"#;
1062        let file = parse_test(content);
1063        assert_eq!(file.tests.len(), 1);
1064        assert_eq!(file.tests[0].name, "test with longer delimiters");
1065        assert_eq!(file.tests[0].command, "echo hello");
1066        assert_eq!(file.tests[0].expected_output, "hello");
1067    }
1068
1069    #[test]
1070    fn test_dash_separator_in_output() {
1071        let content = r#"====
1072test with --- in output
1073====
1074echo "---"
1075----
1076---
1077"#;
1078        let file = parse_test(content);
1079        assert_eq!(file.tests.len(), 1);
1080        assert_eq!(file.tests[0].expected_output, "---");
1081    }
1082
1083    #[test]
1084    fn test_dash_separators_in_output() {
1085        // With 5-char delimiters, shorter --- and ---- can appear in output
1086        // But ----- is the closing delimiter so it terminates the block
1087        let content = r#"=====
1088test with various dash separators in output
1089=====
1090printf "---\n----\n"
1091-----
1092---
1093----
1094"#;
1095        let file = parse_test(content);
1096        assert_eq!(file.tests.len(), 1);
1097        assert_eq!(file.tests[0].expected_output, "---\n----");
1098    }
1099
1100    #[test]
1101    fn test_shorter_equals_in_output_is_content() {
1102        // Shorter === in expected output is treated as content when using longer delimiters
1103        // Only === of same or longer length signals a new test
1104        let content = r#"=====
1105test with === and ==== in output
1106=====
1107echo "==="
1108-----
1109===
1110
1111=====
1112second test
1113=====
1114echo "===="
1115-----
1116====
1117"#;
1118        let file = parse_test(content);
1119        assert_eq!(file.tests.len(), 2);
1120        assert_eq!(file.tests[0].expected_output, "===");
1121        assert_eq!(file.tests[1].expected_output, "====");
1122    }
1123
1124    #[test]
1125    fn test_same_length_equals_ends_block() {
1126        // === of same length or longer signals new test
1127        let content = r#"====
1128first test
1129====
1130echo "hello"
1131----
1132hello
1133
1134====
1135second test same length
1136====
1137echo "world"
1138----
1139world
1140"#;
1141        let file = parse_test(content);
1142        assert_eq!(file.tests.len(), 2);
1143        assert_eq!(file.tests[0].expected_output, "hello");
1144        assert_eq!(file.tests[1].expected_output, "world");
1145    }
1146
1147    #[test]
1148    fn test_longer_delimiters_with_constraints() {
1149        let content = r#"====
1150test with constraints
1151====
1152echo "count: 42"
1153----
1154count: {{ n: number }}
1155----
1156where
1157* n > 0
1158"#;
1159        let file = parse_test(content);
1160        assert_eq!(file.tests.len(), 1);
1161        assert_eq!(file.tests[0].constraints.len(), 1);
1162        assert_eq!(file.tests[0].constraints[0], "n > 0");
1163    }
1164
1165    #[test]
1166    fn test_mismatched_header_delimiter_error() {
1167        let content = r#"====
1168test name
1169===
1170echo hello
1171---
1172hello
1173"#;
1174        let result = parse_content(content, Path::new("<test>"));
1175        assert!(result.is_err());
1176        let err = result.unwrap_err();
1177        assert!(
1178            err.to_string().contains("delimiter length mismatch"),
1179            "Error should mention delimiter mismatch: {}",
1180            err
1181        );
1182        assert!(
1183            err.to_string().contains("expected 4") && err.to_string().contains("found 3"),
1184            "Error should mention expected 4 and found 3: {}",
1185            err
1186        );
1187    }
1188
1189    #[test]
1190    fn test_wrong_dash_length_treated_as_content() {
1191        // With simplified logic, wrong-length delimiters are treated as content
1192        // This test uses 4-char delimiters but has --- in content
1193        let content = r#"====
1194test name
1195====
1196echo hello
1197----
1198---
1199hello
1200"#;
1201        let file = parse_test(content);
1202        assert_eq!(file.tests.len(), 1);
1203        assert_eq!(file.tests[0].expected_output, "---\nhello");
1204    }
1205
1206    #[test]
1207    fn test_multiple_tests_same_delimiter_length() {
1208        // Multiple tests must use the same delimiter length
1209        // (or longer delimiter tests can follow shorter ones, but not vice versa)
1210        let content = r#"===
1211first test
1212===
1213echo "short"
1214---
1215short
1216
1217===
1218second test
1219===
1220echo "world"
1221---
1222world
1223"#;
1224        let file = parse_test(content);
1225        assert_eq!(file.tests.len(), 2);
1226        assert_eq!(file.tests[0].expected_output, "short");
1227        assert_eq!(file.tests[1].expected_output, "world");
1228    }
1229
1230    #[test]
1231    fn test_all_tests_must_use_same_delimiter_length() {
1232        // With exact-match logic, all tests in a file must use the same delimiter length
1233        // A longer delimiter after shorter is treated as content of the first test
1234        let content = r#"===
1235first test
1236===
1237echo "short"
1238---
1239short
1240
1241=====
1242this looks like a test but is content
1243=====
1244"#;
1245        let file = parse_test(content);
1246        assert_eq!(file.tests.len(), 1);
1247        // The ===== block is included as content
1248        assert!(file.tests[0].expected_output.contains("====="));
1249    }
1250
1251    #[test]
1252    fn test_shell_directive_file_level_bash() {
1253        let content = r#"%shell bash
1254
1255===
1256test 1
1257===
1258echo hello
1259---
1260hello
1261"#;
1262        let file = parse_test(content);
1263        assert_eq!(file.file_shell, Some(Shell::Bash));
1264        assert_eq!(file.tests.len(), 1);
1265    }
1266
1267    #[test]
1268    fn test_shell_directive_file_level_powershell() {
1269        let content = r#"%shell powershell
1270
1271===
1272test 1
1273===
1274echo hello
1275---
1276hello
1277"#;
1278        let file = parse_test(content);
1279        assert_eq!(file.file_shell, Some(Shell::PowerShell));
1280    }
1281
1282    #[test]
1283    fn test_shell_directive_file_level_sh() {
1284        let content = r#"%shell sh
1285
1286===
1287test 1
1288===
1289echo hello
1290---
1291hello
1292"#;
1293        let file = parse_test(content);
1294        assert_eq!(file.file_shell, Some(Shell::Sh));
1295    }
1296
1297    #[test]
1298    fn test_shell_directive_file_level_zsh() {
1299        let content = r#"%shell zsh
1300
1301===
1302test 1
1303===
1304echo hello
1305---
1306hello
1307"#;
1308        let file = parse_test(content);
1309        assert_eq!(file.file_shell, Some(Shell::Zsh));
1310    }
1311
1312    #[test]
1313    fn test_shell_directive_file_level_cmd() {
1314        let content = r#"%shell cmd
1315
1316===
1317test 1
1318===
1319echo hello
1320---
1321hello
1322"#;
1323        let file = parse_test(content);
1324        assert_eq!(file.file_shell, Some(Shell::Cmd));
1325    }
1326
1327    #[test]
1328    fn test_platform_directive_single() {
1329        let content = r#"%platform windows
1330
1331===
1332test 1
1333===
1334echo hello
1335---
1336hello
1337"#;
1338        let file = parse_test(content);
1339        assert_eq!(file.file_platform, vec![Platform::Windows]);
1340    }
1341
1342    #[test]
1343    fn test_platform_directive_multiple() {
1344        let content = r#"%platform linux, macos
1345
1346===
1347test 1
1348===
1349echo hello
1350---
1351hello
1352"#;
1353        let file = parse_test(content);
1354        assert_eq!(file.file_platform, vec![Platform::Linux, Platform::MacOS]);
1355    }
1356
1357    #[test]
1358    fn test_platform_directive_macos() {
1359        let content = r#"%platform macos
1360
1361===
1362test 1
1363===
1364echo hello
1365---
1366hello
1367"#;
1368        let file = parse_test(content);
1369        assert_eq!(file.file_platform, vec![Platform::MacOS]);
1370    }
1371
1372    #[test]
1373    fn test_all_directives_file_level() {
1374        let content = r#"%shell bash
1375%platform unix
1376%skip(not ready yet)
1377
1378===
1379test 1
1380===
1381echo hello
1382---
1383hello
1384"#;
1385        let file = parse_test(content);
1386        assert_eq!(file.file_shell, Some(Shell::Bash));
1387        assert_eq!(file.file_platform, vec![Platform::Unix]);
1388        assert!(file.file_skip.is_some());
1389        assert_eq!(
1390            file.file_skip.as_ref().unwrap().message.as_deref(),
1391            Some("not ready yet")
1392        );
1393    }
1394
1395    #[test]
1396    fn test_directives_any_order() {
1397        let content = r#"%platform windows
1398%skip(windows only)
1399%shell powershell
1400
1401===
1402test 1
1403===
1404echo hello
1405---
1406hello
1407"#;
1408        let file = parse_test(content);
1409        assert_eq!(file.file_shell, Some(Shell::PowerShell));
1410        assert_eq!(file.file_platform, vec![Platform::Windows]);
1411        assert!(file.file_skip.is_some());
1412    }
1413
1414    #[test]
1415    fn test_skip_with_condition_file_level() {
1416        let content = r#"%skip(needs feature) if: test -f /nonexistent
1417
1418===
1419test 1
1420===
1421echo hello
1422---
1423hello
1424"#;
1425        let file = parse_test(content);
1426        assert!(file.file_skip.is_some());
1427        let skip = file.file_skip.unwrap();
1428        assert_eq!(skip.message.as_deref(), Some("needs feature"));
1429        assert_eq!(skip.condition.as_deref(), Some("test -f /nonexistent"));
1430    }
1431
1432    #[test]
1433    fn test_skip_test_level_with_condition() {
1434        let content = r#"===
1435test with skip
1436%skip(not ready) if: false
1437===
1438echo hello
1439---
1440hello
1441"#;
1442        let file = parse_test(content);
1443        assert_eq!(file.tests.len(), 1);
1444        assert!(file.tests[0].skip.is_some());
1445        let skip = file.tests[0].skip.as_ref().unwrap();
1446        assert_eq!(skip.message.as_deref(), Some("not ready"));
1447        assert_eq!(skip.condition.as_deref(), Some("false"));
1448    }
1449
1450    #[test]
1451    fn test_shell_platform_valid_bash_unix() {
1452        let content = r#"%shell bash
1453%platform unix
1454
1455===
1456test
1457===
1458echo hello
1459---
1460hello
1461"#;
1462        let file = parse_test(content);
1463        assert_eq!(file.file_shell, Some(Shell::Bash));
1464        assert_eq!(file.file_platform, vec![Platform::Unix]);
1465    }
1466
1467    #[test]
1468    fn test_shell_platform_valid_powershell_windows() {
1469        let content = r#"%shell powershell
1470%platform windows
1471
1472===
1473test
1474===
1475echo hello
1476---
1477hello
1478"#;
1479        let file = parse_test(content);
1480        assert_eq!(file.file_shell, Some(Shell::PowerShell));
1481        assert_eq!(file.file_platform, vec![Platform::Windows]);
1482    }
1483
1484    #[test]
1485    fn test_shell_platform_invalid_cmd_unix() {
1486        let content = r#"%shell cmd
1487%platform unix
1488
1489===
1490test
1491===
1492echo hello
1493---
1494hello
1495"#;
1496        let result = parse_content(content, Path::new("<test>"));
1497        assert!(result.is_err());
1498        let err = result.unwrap_err();
1499        assert!(err.to_string().contains("not compatible"));
1500    }
1501
1502    #[test]
1503    fn test_shell_platform_invalid_zsh_windows() {
1504        let content = r#"%shell zsh
1505%platform windows
1506
1507===
1508test
1509===
1510echo hello
1511---
1512hello
1513"#;
1514        let result = parse_content(content, Path::new("<test>"));
1515        assert!(result.is_err());
1516        let err = result.unwrap_err();
1517        assert!(err.to_string().contains("not compatible"));
1518    }
1519}