1use 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#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum VarType {
52 Number,
53 String,
54 JsonString,
55 JsonBool,
56 JsonArray,
57 JsonObject,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct VariableDecl {
62 pub name: String,
63 pub var_type: Option<VarType>,
64}
65
66#[derive(Debug, Clone, PartialEq, Default)]
68pub struct SkipDirective {
69 pub message: Option<String>,
70 pub condition: Option<String>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum Platform {
77 Windows,
78 Unix,
79 MacOS,
80 Linux,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
86pub enum Shell {
87 Sh,
89 Bash,
91 Zsh,
93 PowerShell,
95 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 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
136pub 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 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
164fn 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 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 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 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
209struct 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
231fn 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
322fn 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
394fn 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
422fn 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 let first = platform_name.parse_next(input)?;
432 platforms.push(first);
433
434 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
465fn 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
488fn 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 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
553fn 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 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 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 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 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 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 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 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 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 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 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 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}