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