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}
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
134pub 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 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
162fn 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 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 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 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
207struct 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
229fn 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
320fn 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
392fn 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
420fn 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 let first = platform_name.parse_next(input)?;
430 platforms.push(first);
431
432 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
463fn 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
486fn 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 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
551fn 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 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 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 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 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 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 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 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 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 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 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}