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