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    if let Some(err) = input
579        .lines()
580        .next()
581        .and_then(|l| check_header_sep_exact(l, delimiter_len))
582    {
583        state.error_message = Some(err);
584        return Err(winnow::error::ErrMode::Backtrack(ContextError::new()));
585    }
586    header_sep_exact(input, delimiter_len)?;
587    opt_newline.parse_next(input)?;
588    state.current_line += 1;
589
590    let command_start = state.current_line;
591    let command = read_block_until_separator(input, delimiter_len);
592    state.current_line = command_start + command.lines().count().max(1);
593
594    dash_sep_exact(input, delimiter_len)?;
595    opt_newline.parse_next(input)?;
596    state.current_line += 1;
597
598    let expected_start = state.current_line;
599    let expected_output = read_block_until_separator(input, delimiter_len);
600    let expected_lines = expected_output.lines().count();
601    state.current_line =
602        expected_start + expected_lines.max(if expected_output.is_empty() { 0 } else { 1 });
603
604    let constraints = opt(|i: &mut &str| where_section(i, delimiter_len))
605        .parse_next(input)?
606        .unwrap_or_default();
607    if !constraints.is_empty() {
608        state.current_line += 2 + constraints.len();
609    }
610
611    skip_blank_lines.parse_next(input)?;
612
613    let end_line = state.current_line;
614
615    let variables = extract_variables_from_expected(&expected_output)
616        .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))?;
617
618    Ok(TestCase {
619        name,
620        command,
621        expected_output,
622        file_path: state.path.to_path_buf(),
623        start_line,
624        end_line,
625        variables,
626        constraints,
627        skip,
628    })
629}
630
631fn corpus_file(state: &mut ParseState) -> Result<CorpusFile, winnow::error::ErrMode<ContextError>> {
632    let input = &mut state.input;
633
634    skip_blank_lines.parse_next(input)?;
635
636    // Parse file-level directives (skip, shell, platform can appear in any order)
637    let mut file_skip = None;
638    let mut file_shell = None;
639    let mut file_platform = Vec::new();
640
641    loop {
642        let _ = take_while(0.., ' ').parse_next(input)?;
643        if input.starts_with("%skip") && file_skip.is_none() {
644            file_skip = Some(skip_directive.parse_next(input)?);
645            state.current_line += 1;
646            skip_blank_lines.parse_next(input)?;
647        } else if input.starts_with("%shell") && file_shell.is_none() {
648            file_shell = Some(shell_directive.parse_next(input)?);
649            state.current_line += 1;
650            skip_blank_lines.parse_next(input)?;
651        } else if input.starts_with("%platform") && file_platform.is_empty() {
652            file_platform = platform_directive.parse_next(input)?;
653            state.current_line += 1;
654            skip_blank_lines.parse_next(input)?;
655        } else {
656            break;
657        }
658    }
659
660    let mut tests = Vec::new();
661
662    while !state.input.is_empty() {
663        let peeked = state.input.trim_start();
664        if peeked.is_empty() {
665            break;
666        }
667
668        if !peeked.starts_with("===") {
669            break;
670        }
671
672        let tc = test_case(state)?;
673        tests.push(tc);
674    }
675
676    Ok(CorpusFile {
677        file_skip,
678        file_shell,
679        file_platform,
680        tests,
681    })
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use std::io::Write;
688    use tempfile::NamedTempFile;
689
690    fn parse_test(content: &str) -> CorpusFile {
691        parse_content(content, Path::new("<test>")).unwrap()
692    }
693
694    #[test]
695    fn test_parse_single_test() {
696        let content = r#"===
697test name
698===
699echo hello
700---
701hello
702"#;
703        let file = parse_test(content);
704        assert!(file.file_skip.is_none());
705        assert!(file.file_shell.is_none());
706        assert!(file.file_platform.is_empty());
707        assert_eq!(file.tests.len(), 1);
708        assert_eq!(file.tests[0].name, "test name");
709        assert_eq!(file.tests[0].command, "echo hello");
710        assert_eq!(file.tests[0].expected_output, "hello");
711        assert!(file.tests[0].variables.is_empty());
712        assert!(file.tests[0].constraints.is_empty());
713        assert!(file.tests[0].skip.is_none());
714    }
715
716    #[test]
717    fn test_parse_multiple_tests() {
718        let content = r#"===
719first test
720===
721echo first
722---
723first
724
725===
726second test
727===
728echo second
729---
730second
731"#;
732        let file = parse_test(content);
733        assert_eq!(file.tests.len(), 2);
734        assert_eq!(file.tests[0].name, "first test");
735        assert_eq!(file.tests[1].name, "second test");
736    }
737
738    #[test]
739    fn test_parse_multiline_output() {
740        let content = r#"===
741multiline test
742===
743echo -e "line1\nline2\nline3"
744---
745line1
746line2
747line3
748"#;
749        let file = parse_test(content);
750        assert_eq!(file.tests.len(), 1);
751        assert_eq!(file.tests[0].expected_output, "line1\nline2\nline3");
752    }
753
754    #[test]
755    fn test_parse_empty_expected() {
756        let content = r#"===
757exit only test
758===
759true
760---
761"#;
762        let file = parse_test(content);
763        assert_eq!(file.tests.len(), 1);
764        assert_eq!(file.tests[0].expected_output, "");
765    }
766
767    #[test]
768    fn test_parse_with_inline_type() {
769        let content = r#"===
770timing test
771===
772time_command
773---
774Completed in {{ n: number }}s
775"#;
776        let file = parse_test(content);
777        assert_eq!(file.tests.len(), 1);
778        assert_eq!(
779            file.tests[0].expected_output,
780            "Completed in {{ n: number }}s"
781        );
782        assert_eq!(file.tests[0].variables.len(), 1);
783        assert_eq!(file.tests[0].variables[0].name, "n");
784        assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::Number));
785    }
786
787    #[test]
788    fn test_parse_with_constraints() {
789        let content = r#"===
790timing test
791===
792time_command
793---
794Completed in {{ n: number }}s
795---
796where
797* n > 0
798* n < 60
799"#;
800        let file = parse_test(content);
801        assert_eq!(file.tests.len(), 1);
802        assert_eq!(file.tests[0].variables.len(), 1);
803        assert_eq!(file.tests[0].constraints.len(), 2);
804        assert_eq!(file.tests[0].constraints[0], "n > 0");
805        assert_eq!(file.tests[0].constraints[1], "n < 60");
806    }
807
808    #[test]
809    fn test_parse_multiple_variables() {
810        let content = r#"===
811multi var test
812===
813some_command
814---
815{{ count: number }} items in {{ time: number }}s: {{ msg: string }}
816---
817where
818* count > 0
819* time < 10
820"#;
821        let file = parse_test(content);
822        assert_eq!(file.tests.len(), 1);
823        assert_eq!(file.tests[0].variables.len(), 3);
824        assert_eq!(file.tests[0].variables[0].name, "count");
825        assert_eq!(file.tests[0].variables[1].name, "time");
826        assert_eq!(file.tests[0].variables[2].name, "msg");
827        assert_eq!(file.tests[0].variables[2].var_type, Some(VarType::String));
828    }
829
830    #[test]
831    fn test_parse_duck_typed_variable() {
832        let content = r#"===
833duck typed
834===
835echo "val: 42"
836---
837val: {{ x }}
838---
839where
840* x > 0
841"#;
842        let file = parse_test(content);
843        assert_eq!(file.tests.len(), 1);
844        assert_eq!(file.tests[0].variables.len(), 1);
845        assert_eq!(file.tests[0].variables[0].name, "x");
846        assert_eq!(file.tests[0].variables[0].var_type, None);
847    }
848
849    #[test]
850    fn test_parse_empty_string_var() {
851        let content = r#"===
852empty string
853===
854echo "val: "
855---
856val: {{ s: string }}
857---
858where
859* len(s) == 0
860"#;
861        let file = parse_test(content);
862        assert_eq!(file.tests.len(), 1);
863        assert_eq!(file.tests[0].name, "empty string");
864        assert_eq!(file.tests[0].expected_output, "val: {{ s: string }}");
865        assert_eq!(file.tests[0].variables.len(), 1);
866        assert_eq!(file.tests[0].variables[0].name, "s");
867        assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::String));
868        assert_eq!(file.tests[0].constraints.len(), 1);
869        assert_eq!(file.tests[0].constraints[0], "len(s) == 0");
870    }
871
872    #[test]
873    fn test_skip_unconditional() {
874        let content = r#"===
875skipped test
876%skip
877===
878echo hello
879---
880hello
881"#;
882        let file = parse_test(content);
883        assert_eq!(file.tests.len(), 1);
884        let skip = file.tests[0].skip.as_ref().unwrap();
885        assert!(skip.message.is_none());
886        assert!(skip.condition.is_none());
887    }
888
889    #[test]
890    fn test_skip_with_message() {
891        let content = r#"===
892skipped test
893%skip(not yet implemented)
894===
895echo hello
896---
897hello
898"#;
899        let file = parse_test(content);
900        assert_eq!(file.tests.len(), 1);
901        let skip = file.tests[0].skip.as_ref().unwrap();
902        assert_eq!(skip.message.as_deref(), Some("not yet implemented"));
903        assert!(skip.condition.is_none());
904    }
905
906    #[test]
907    fn test_skip_with_condition() {
908        let content = r#"===
909unix only test
910%skip if: test "$OS" = "Windows_NT"
911===
912echo hello
913---
914hello
915"#;
916        let file = parse_test(content);
917        assert_eq!(file.tests.len(), 1);
918        let skip = file.tests[0].skip.as_ref().unwrap();
919        assert!(skip.message.is_none());
920        assert_eq!(
921            skip.condition.as_deref(),
922            Some(r#"test "$OS" = "Windows_NT""#)
923        );
924    }
925
926    #[test]
927    fn test_skip_with_message_and_condition() {
928        let content = r#"===
929unix only test
930%skip(requires bash) if: test "$OS" = "Windows_NT"
931===
932echo hello
933---
934hello
935"#;
936        let file = parse_test(content);
937        assert_eq!(file.tests.len(), 1);
938        let skip = file.tests[0].skip.as_ref().unwrap();
939        assert_eq!(skip.message.as_deref(), Some("requires bash"));
940        assert_eq!(
941            skip.condition.as_deref(),
942            Some(r#"test "$OS" = "Windows_NT""#)
943        );
944    }
945
946    #[test]
947    fn test_file_level_skip() {
948        let content = r#"%skip(windows tests) if: test "$OS" != "Windows_NT"
949
950===
951test 1
952===
953echo hello
954---
955hello
956"#;
957        let file = parse_test(content);
958        let file_skip = file.file_skip.as_ref().unwrap();
959        assert_eq!(file_skip.message.as_deref(), Some("windows tests"));
960        assert_eq!(
961            file_skip.condition.as_deref(),
962            Some(r#"test "$OS" != "Windows_NT""#)
963        );
964        assert_eq!(file.tests.len(), 1);
965    }
966
967    #[test]
968    fn test_file_level_skip_unconditional() {
969        let content = r#"%skip(all tests disabled)
970
971===
972test 1
973===
974echo hello
975---
976hello
977"#;
978        let file = parse_test(content);
979        let file_skip = file.file_skip.as_ref().unwrap();
980        assert_eq!(file_skip.message.as_deref(), Some("all tests disabled"));
981        assert!(file_skip.condition.is_none());
982    }
983
984    #[test]
985    fn test_parse_file() {
986        let mut f = NamedTempFile::new().unwrap();
987        write!(f, "===\ntest\n===\necho hi\n---\nhi\n").unwrap();
988
989        let file = parse_file(f.path()).unwrap();
990        assert_eq!(file.tests.len(), 1);
991        assert_eq!(file.tests[0].name, "test");
992        assert_eq!(file.tests[0].file_path, f.path());
993    }
994
995    #[test]
996    fn test_multiline_command() {
997        let content = r#"===
998multiline command
999===
1000echo "line 1"
1001echo "line 2"
1002echo "line 3"
1003---
1004line 1
1005line 2
1006line 3
1007"#;
1008        let file = parse_test(content);
1009        assert_eq!(file.tests.len(), 1);
1010        assert_eq!(
1011            file.tests[0].command,
1012            "echo \"line 1\"\necho \"line 2\"\necho \"line 3\""
1013        );
1014    }
1015
1016    #[test]
1017    fn test_line_numbers() {
1018        let content = r#"===
1019first test
1020===
1021echo hello
1022---
1023hello
1024
1025===
1026second test
1027===
1028echo world
1029---
1030world
1031"#;
1032        let file = parse_test(content);
1033        assert_eq!(file.tests.len(), 2);
1034        assert_eq!(file.tests[0].start_line, 1);
1035        // Just verify we have reasonable line tracking
1036        assert!(file.tests[0].start_line < file.tests[0].end_line);
1037        assert!(file.tests[1].start_line < file.tests[1].end_line);
1038    }
1039
1040    #[test]
1041    fn test_longer_delimiters() {
1042        let content = r#"=====
1043test with longer delimiters
1044=====
1045echo hello
1046-----
1047hello
1048"#;
1049        let file = parse_test(content);
1050        assert_eq!(file.tests.len(), 1);
1051        assert_eq!(file.tests[0].name, "test with longer delimiters");
1052        assert_eq!(file.tests[0].command, "echo hello");
1053        assert_eq!(file.tests[0].expected_output, "hello");
1054    }
1055
1056    #[test]
1057    fn test_dash_separator_in_output() {
1058        let content = r#"====
1059test with --- in output
1060====
1061echo "---"
1062----
1063---
1064"#;
1065        let file = parse_test(content);
1066        assert_eq!(file.tests.len(), 1);
1067        assert_eq!(file.tests[0].expected_output, "---");
1068    }
1069
1070    #[test]
1071    fn test_dash_separators_in_output() {
1072        // With 5-char delimiters, shorter --- and ---- can appear in output
1073        // But ----- is the closing delimiter so it terminates the block
1074        let content = r#"=====
1075test with various dash separators in output
1076=====
1077printf "---\n----\n"
1078-----
1079---
1080----
1081"#;
1082        let file = parse_test(content);
1083        assert_eq!(file.tests.len(), 1);
1084        assert_eq!(file.tests[0].expected_output, "---\n----");
1085    }
1086
1087    #[test]
1088    fn test_shorter_equals_in_output_is_content() {
1089        // Shorter === in expected output is treated as content when using longer delimiters
1090        // Only === of same or longer length signals a new test
1091        let content = r#"=====
1092test with === and ==== in output
1093=====
1094echo "==="
1095-----
1096===
1097
1098=====
1099second test
1100=====
1101echo "===="
1102-----
1103====
1104"#;
1105        let file = parse_test(content);
1106        assert_eq!(file.tests.len(), 2);
1107        assert_eq!(file.tests[0].expected_output, "===");
1108        assert_eq!(file.tests[1].expected_output, "====");
1109    }
1110
1111    #[test]
1112    fn test_same_length_equals_ends_block() {
1113        // === of same length or longer signals new test
1114        let content = r#"====
1115first test
1116====
1117echo "hello"
1118----
1119hello
1120
1121====
1122second test same length
1123====
1124echo "world"
1125----
1126world
1127"#;
1128        let file = parse_test(content);
1129        assert_eq!(file.tests.len(), 2);
1130        assert_eq!(file.tests[0].expected_output, "hello");
1131        assert_eq!(file.tests[1].expected_output, "world");
1132    }
1133
1134    #[test]
1135    fn test_longer_delimiters_with_constraints() {
1136        let content = r#"====
1137test with constraints
1138====
1139echo "count: 42"
1140----
1141count: {{ n: number }}
1142----
1143where
1144* n > 0
1145"#;
1146        let file = parse_test(content);
1147        assert_eq!(file.tests.len(), 1);
1148        assert_eq!(file.tests[0].constraints.len(), 1);
1149        assert_eq!(file.tests[0].constraints[0], "n > 0");
1150    }
1151
1152    #[test]
1153    fn test_mismatched_header_delimiter_error() {
1154        let content = r#"====
1155test name
1156===
1157echo hello
1158---
1159hello
1160"#;
1161        let result = parse_content(content, Path::new("<test>"));
1162        assert!(result.is_err());
1163        let err = result.unwrap_err();
1164        assert!(
1165            err.to_string().contains("delimiter length mismatch"),
1166            "Error should mention delimiter mismatch: {}",
1167            err
1168        );
1169        assert!(
1170            err.to_string().contains("expected 4") && err.to_string().contains("found 3"),
1171            "Error should mention expected 4 and found 3: {}",
1172            err
1173        );
1174    }
1175
1176    #[test]
1177    fn test_wrong_dash_length_treated_as_content() {
1178        // With simplified logic, wrong-length delimiters are treated as content
1179        // This test uses 4-char delimiters but has --- in content
1180        let content = r#"====
1181test name
1182====
1183echo hello
1184----
1185---
1186hello
1187"#;
1188        let file = parse_test(content);
1189        assert_eq!(file.tests.len(), 1);
1190        assert_eq!(file.tests[0].expected_output, "---\nhello");
1191    }
1192
1193    #[test]
1194    fn test_multiple_tests_same_delimiter_length() {
1195        // Multiple tests must use the same delimiter length
1196        // (or longer delimiter tests can follow shorter ones, but not vice versa)
1197        let content = r#"===
1198first test
1199===
1200echo "short"
1201---
1202short
1203
1204===
1205second test
1206===
1207echo "world"
1208---
1209world
1210"#;
1211        let file = parse_test(content);
1212        assert_eq!(file.tests.len(), 2);
1213        assert_eq!(file.tests[0].expected_output, "short");
1214        assert_eq!(file.tests[1].expected_output, "world");
1215    }
1216
1217    #[test]
1218    fn test_all_tests_must_use_same_delimiter_length() {
1219        // With exact-match logic, all tests in a file must use the same delimiter length
1220        // A longer delimiter after shorter is treated as content of the first test
1221        let content = r#"===
1222first test
1223===
1224echo "short"
1225---
1226short
1227
1228=====
1229this looks like a test but is content
1230=====
1231"#;
1232        let file = parse_test(content);
1233        assert_eq!(file.tests.len(), 1);
1234        // The ===== block is included as content
1235        assert!(file.tests[0].expected_output.contains("====="));
1236    }
1237
1238    #[test]
1239    fn test_shell_directive_file_level_bash() {
1240        let content = r#"%shell bash
1241
1242===
1243test 1
1244===
1245echo hello
1246---
1247hello
1248"#;
1249        let file = parse_test(content);
1250        assert_eq!(file.file_shell, Some(Shell::Bash));
1251        assert_eq!(file.tests.len(), 1);
1252    }
1253
1254    #[test]
1255    fn test_shell_directive_file_level_powershell() {
1256        let content = r#"%shell powershell
1257
1258===
1259test 1
1260===
1261echo hello
1262---
1263hello
1264"#;
1265        let file = parse_test(content);
1266        assert_eq!(file.file_shell, Some(Shell::PowerShell));
1267    }
1268
1269    #[test]
1270    fn test_shell_directive_file_level_sh() {
1271        let content = r#"%shell sh
1272
1273===
1274test 1
1275===
1276echo hello
1277---
1278hello
1279"#;
1280        let file = parse_test(content);
1281        assert_eq!(file.file_shell, Some(Shell::Sh));
1282    }
1283
1284    #[test]
1285    fn test_shell_directive_file_level_zsh() {
1286        let content = r#"%shell zsh
1287
1288===
1289test 1
1290===
1291echo hello
1292---
1293hello
1294"#;
1295        let file = parse_test(content);
1296        assert_eq!(file.file_shell, Some(Shell::Zsh));
1297    }
1298
1299    #[test]
1300    fn test_shell_directive_file_level_cmd() {
1301        let content = r#"%shell cmd
1302
1303===
1304test 1
1305===
1306echo hello
1307---
1308hello
1309"#;
1310        let file = parse_test(content);
1311        assert_eq!(file.file_shell, Some(Shell::Cmd));
1312    }
1313
1314    #[test]
1315    fn test_platform_directive_single() {
1316        let content = r#"%platform windows
1317
1318===
1319test 1
1320===
1321echo hello
1322---
1323hello
1324"#;
1325        let file = parse_test(content);
1326        assert_eq!(file.file_platform, vec![Platform::Windows]);
1327    }
1328
1329    #[test]
1330    fn test_platform_directive_multiple() {
1331        let content = r#"%platform linux, macos
1332
1333===
1334test 1
1335===
1336echo hello
1337---
1338hello
1339"#;
1340        let file = parse_test(content);
1341        assert_eq!(file.file_platform, vec![Platform::Linux, Platform::MacOS]);
1342    }
1343
1344    #[test]
1345    fn test_platform_directive_macos() {
1346        let content = r#"%platform macos
1347
1348===
1349test 1
1350===
1351echo hello
1352---
1353hello
1354"#;
1355        let file = parse_test(content);
1356        assert_eq!(file.file_platform, vec![Platform::MacOS]);
1357    }
1358
1359    #[test]
1360    fn test_all_directives_file_level() {
1361        let content = r#"%shell bash
1362%platform unix
1363%skip(not ready yet)
1364
1365===
1366test 1
1367===
1368echo hello
1369---
1370hello
1371"#;
1372        let file = parse_test(content);
1373        assert_eq!(file.file_shell, Some(Shell::Bash));
1374        assert_eq!(file.file_platform, vec![Platform::Unix]);
1375        assert!(file.file_skip.is_some());
1376        assert_eq!(
1377            file.file_skip.as_ref().unwrap().message.as_deref(),
1378            Some("not ready yet")
1379        );
1380    }
1381
1382    #[test]
1383    fn test_directives_any_order() {
1384        let content = r#"%platform windows
1385%skip(windows only)
1386%shell powershell
1387
1388===
1389test 1
1390===
1391echo hello
1392---
1393hello
1394"#;
1395        let file = parse_test(content);
1396        assert_eq!(file.file_shell, Some(Shell::PowerShell));
1397        assert_eq!(file.file_platform, vec![Platform::Windows]);
1398        assert!(file.file_skip.is_some());
1399    }
1400
1401    #[test]
1402    fn test_skip_with_condition_file_level() {
1403        let content = r#"%skip(needs feature) if: test -f /nonexistent
1404
1405===
1406test 1
1407===
1408echo hello
1409---
1410hello
1411"#;
1412        let file = parse_test(content);
1413        assert!(file.file_skip.is_some());
1414        let skip = file.file_skip.unwrap();
1415        assert_eq!(skip.message.as_deref(), Some("needs feature"));
1416        assert_eq!(skip.condition.as_deref(), Some("test -f /nonexistent"));
1417    }
1418
1419    #[test]
1420    fn test_skip_test_level_with_condition() {
1421        let content = r#"===
1422test with skip
1423%skip(not ready) if: false
1424===
1425echo hello
1426---
1427hello
1428"#;
1429        let file = parse_test(content);
1430        assert_eq!(file.tests.len(), 1);
1431        assert!(file.tests[0].skip.is_some());
1432        let skip = file.tests[0].skip.as_ref().unwrap();
1433        assert_eq!(skip.message.as_deref(), Some("not ready"));
1434        assert_eq!(skip.condition.as_deref(), Some("false"));
1435    }
1436
1437    #[test]
1438    fn test_shell_platform_valid_bash_unix() {
1439        let content = r#"%shell bash
1440%platform unix
1441
1442===
1443test
1444===
1445echo hello
1446---
1447hello
1448"#;
1449        let file = parse_test(content);
1450        assert_eq!(file.file_shell, Some(Shell::Bash));
1451        assert_eq!(file.file_platform, vec![Platform::Unix]);
1452    }
1453
1454    #[test]
1455    fn test_shell_platform_valid_powershell_windows() {
1456        let content = r#"%shell powershell
1457%platform windows
1458
1459===
1460test
1461===
1462echo hello
1463---
1464hello
1465"#;
1466        let file = parse_test(content);
1467        assert_eq!(file.file_shell, Some(Shell::PowerShell));
1468        assert_eq!(file.file_platform, vec![Platform::Windows]);
1469    }
1470
1471    #[test]
1472    fn test_shell_platform_invalid_cmd_unix() {
1473        let content = r#"%shell cmd
1474%platform unix
1475
1476===
1477test
1478===
1479echo hello
1480---
1481hello
1482"#;
1483        let result = parse_content(content, Path::new("<test>"));
1484        assert!(result.is_err());
1485        let err = result.unwrap_err();
1486        assert!(err.to_string().contains("not compatible"));
1487    }
1488
1489    #[test]
1490    fn test_shell_platform_invalid_zsh_windows() {
1491        let content = r#"%shell zsh
1492%platform windows
1493
1494===
1495test
1496===
1497echo hello
1498---
1499hello
1500"#;
1501        let result = parse_content(content, Path::new("<test>"));
1502        assert!(result.is_err());
1503        let err = result.unwrap_err();
1504        assert!(err.to_string().contains("not compatible"));
1505    }
1506}