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)]
67pub struct SkipDirective {
68 pub message: Option<String>,
69 pub condition: Option<String>,
70 pub platform: Option<PlatformCondition>,
71}
72
73#[derive(Debug, Clone, PartialEq)]
77pub enum PlatformCondition {
78 Is(Platform),
79 Not(Platform),
80 Or(Vec<Platform>),
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub enum Platform {
85 Windows,
86 Unix,
87 MacOS,
88 Linux,
89}
90
91#[derive(Debug, Clone, PartialEq)]
92pub struct TestCase {
93 pub name: String,
94 pub command: String,
95 pub expected_output: String,
96 pub file_path: PathBuf,
97 pub start_line: usize,
98 pub end_line: usize,
99 pub variables: Vec<VariableDecl>,
100 pub constraints: Vec<String>,
101 pub skip: Option<SkipDirective>,
102}
103
104impl TestCase {
105 pub fn variable_names(&self) -> Vec<&str> {
106 self.variables.iter().map(|v| v.name.as_str()).collect()
107 }
108}
109
110#[derive(Debug, Clone, PartialEq)]
111pub struct CorpusFile {
112 pub file_skip: Option<SkipDirective>,
113 pub tests: Vec<TestCase>,
114}
115
116#[derive(Error, Debug)]
117pub enum ParseError {
118 #[error("IO error: {0}")]
119 Io(#[from] std::io::Error),
120 #[error("parse error at line {line}: {message}")]
121 Parse { line: usize, message: String },
122}
123
124pub fn parse_file(path: &Path) -> Result<CorpusFile, ParseError> {
127 let content = std::fs::read_to_string(path)?;
128 parse_content(&content, path)
129}
130
131pub fn parse_content(content: &str, path: &Path) -> Result<CorpusFile, ParseError> {
132 let mut state = ParseState::new(content, path);
133 match corpus_file(&mut state) {
134 Ok(file) => Ok(file),
135 Err(_) => Err(ParseError::Parse {
136 line: state.current_line,
137 message: state
138 .error_message
139 .unwrap_or_else(|| "failed to parse corpus file".to_string()),
140 }),
141 }
142}
143
144struct ParseState<'a> {
147 input: &'a str,
148 path: &'a Path,
149 current_line: usize,
150 delimiter_len: usize,
151 error_message: Option<String>,
152}
153
154impl<'a> ParseState<'a> {
155 fn new(input: &'a str, path: &'a Path) -> Self {
156 Self {
157 input,
158 path,
159 current_line: 1,
160 delimiter_len: 3,
161 error_message: None,
162 }
163 }
164}
165
166fn parse_type_annotation(type_str: &str) -> Option<VarType> {
169 match type_str.to_lowercase().as_str() {
170 "number" => Some(VarType::Number),
171 "string" => Some(VarType::String),
172 "json string" => Some(VarType::JsonString),
173 "json bool" => Some(VarType::JsonBool),
174 "json array" => Some(VarType::JsonArray),
175 "json object" => Some(VarType::JsonObject),
176 _ => None,
177 }
178}
179
180const RESERVED_KEYWORDS: &[&str] = &[
181 "true",
182 "false",
183 "null",
184 "and",
185 "or",
186 "not",
187 "in",
188 "forall",
189 "contains",
190 "startswith",
191 "endswith",
192 "matches",
193 "len",
194 "type",
195 "keys",
196 "values",
197 "sum",
198 "min",
199 "max",
200 "abs",
201 "unique",
202 "lower",
203 "upper",
204 "number",
205 "string",
206 "bool",
207 "array",
208 "object",
209 "env",
210];
211
212fn is_reserved_keyword(name: &str) -> bool {
213 RESERVED_KEYWORDS.contains(&name)
214}
215
216fn parse_placeholder(content: &str) -> Result<(String, Option<VarType>), String> {
217 let content = content.trim();
218 let (name, var_type) = if let Some(colon_pos) = content.find(':') {
219 let name = content[..colon_pos].trim().to_string();
220 let type_str = content[colon_pos + 1..].trim();
221 (name, parse_type_annotation(type_str))
222 } else {
223 (content.to_string(), None)
224 };
225
226 if is_reserved_keyword(&name) {
227 return Err(format!(
228 "'{}' is a reserved keyword and cannot be used as a variable name",
229 name
230 ));
231 }
232
233 Ok((name, var_type))
234}
235
236fn extract_variables_from_expected(expected: &str) -> Result<Vec<VariableDecl>, String> {
237 let mut variables = Vec::new();
238 let mut seen = std::collections::HashSet::new();
239 let mut remaining = expected;
240
241 while let Some(start) = remaining.find("{{") {
242 if let Some(end) = remaining[start..].find("}}") {
243 let content = &remaining[start + 2..start + end];
244 let (name, var_type) = parse_placeholder(content)?;
245 if !name.is_empty() && seen.insert(name.clone()) {
246 variables.push(VariableDecl { name, var_type });
247 }
248 remaining = &remaining[start + end + 2..];
249 } else {
250 break;
251 }
252 }
253
254 Ok(variables)
255}
256
257fn header_sep(input: &mut &str) -> ModalResult<usize> {
260 let line: &str = take_while(1.., '=').parse_next(input)?;
261 if line.len() >= 3 {
262 Ok(line.len())
263 } else {
264 Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
265 }
266}
267
268fn check_header_sep_exact(line: &str, expected_len: usize) -> Option<String> {
269 let trimmed = line.trim();
270 if trimmed.chars().all(|c| c == '=') && trimmed.len() >= 3 && trimmed.len() != expected_len {
271 Some(format!(
272 "delimiter length mismatch: expected {} '=' characters but found {}",
273 expected_len,
274 trimmed.len()
275 ))
276 } else {
277 None
278 }
279}
280
281fn header_sep_exact(input: &mut &str, len: usize) -> ModalResult<()> {
282 let line: &str = take_while(1.., '=').parse_next(input)?;
283 if line.len() == len {
284 Ok(())
285 } else {
286 Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
287 }
288}
289
290fn dash_sep_exact(input: &mut &str, len: usize) -> ModalResult<()> {
291 let line: &str = take_while(1.., '-').parse_next(input)?;
292 if line.len() == len {
293 Ok(())
294 } else {
295 Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
296 }
297}
298
299fn line_content<'a>(input: &mut &'a str) -> ModalResult<&'a str> {
300 take_till(0.., |c| c == '\n' || c == '\r').parse_next(input)
301}
302
303fn newline(input: &mut &str) -> ModalResult<()> {
304 alt(("\r\n".value(()), "\n".value(()), "\r".value(()))).parse_next(input)
305}
306
307fn opt_newline(input: &mut &str) -> ModalResult<()> {
308 opt(newline).map(|_| ()).parse_next(input)
309}
310
311fn blank_line(input: &mut &str) -> ModalResult<()> {
312 (take_while(0.., ' '), newline)
313 .map(|_| ())
314 .parse_next(input)
315}
316
317fn skip_blank_lines(input: &mut &str) -> ModalResult<()> {
318 repeat(0.., blank_line)
319 .map(|_: Vec<()>| ())
320 .parse_next(input)
321}
322
323fn is_any_separator_line(line: &str) -> bool {
324 let trimmed = line.trim();
325 (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '='))
326 || (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '-'))
327}
328
329fn skip_message(input: &mut &str) -> ModalResult<String> {
332 '('.parse_next(input)?;
333 let msg: &str = take_till(0.., ')').parse_next(input)?;
334 ')'.parse_next(input)?;
335 Ok(msg.to_string())
336}
337
338fn skip_condition(input: &mut &str) -> ModalResult<String> {
339 let _ = take_while(0.., ' ').parse_next(input)?;
340 "if:".parse_next(input)?;
341 let _ = take_while(0.., ' ').parse_next(input)?;
342 let condition = line_content.parse_next(input)?;
343 Ok(condition.trim().to_string())
344}
345
346fn platform_name(input: &mut &str) -> ModalResult<Platform> {
347 let name: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric()).parse_next(input)?;
348 match name.to_lowercase().as_str() {
349 "windows" => Ok(Platform::Windows),
350 "unix" => Ok(Platform::Unix),
351 "macos" => Ok(Platform::MacOS),
352 "linux" => Ok(Platform::Linux),
353 _ => Err(winnow::error::ErrMode::Backtrack(ContextError::new())),
354 }
355}
356
357fn platform_condition(input: &mut &str) -> ModalResult<PlatformCondition> {
358 let _ = take_while(0.., ' ').parse_next(input)?;
359 "platform:".parse_next(input)?;
360 let _ = take_while(0.., ' ').parse_next(input)?;
361
362 if opt("not ").parse_next(input)?.is_some() {
364 let _ = take_while(0.., ' ').parse_next(input)?;
365 let platform = platform_name.parse_next(input)?;
366 return Ok(PlatformCondition::Not(platform));
367 }
368
369 let first = platform_name.parse_next(input)?;
371
372 let _ = take_while(0.., ' ').parse_next(input)?;
374 if opt("or ").parse_next(input)?.is_some() {
375 let mut platforms = vec![first];
376 loop {
377 let _ = take_while(0.., ' ').parse_next(input)?;
378 let platform = platform_name.parse_next(input)?;
379 platforms.push(platform);
380 let _ = take_while(0.., ' ').parse_next(input)?;
381 if opt("or ").parse_next(input)?.is_none() {
382 break;
383 }
384 }
385 return Ok(PlatformCondition::Or(platforms));
386 }
387
388 Ok(PlatformCondition::Is(first))
389}
390
391fn skip_directive(input: &mut &str) -> ModalResult<SkipDirective> {
392 "%skip".parse_next(input)?;
393 let message = opt(skip_message).parse_next(input)?;
394
395 let platform = opt(platform_condition).parse_next(input)?;
397 let condition = if platform.is_none() {
398 opt(skip_condition).parse_next(input)?
399 } else {
400 None
401 };
402
403 if message.is_none() && condition.is_none() && platform.is_none() {
404 let _ = line_content.parse_next(input)?;
405 }
406
407 opt_newline.parse_next(input)?;
408
409 Ok(SkipDirective {
410 message,
411 condition,
412 platform,
413 })
414}
415
416fn try_skip_directive(input: &mut &str) -> ModalResult<Option<SkipDirective>> {
417 let _ = take_while(0.., ' ').parse_next(input)?;
418 if input.starts_with("%skip") {
419 Ok(Some(skip_directive.parse_next(input)?))
420 } else {
421 Ok(None)
422 }
423}
424
425fn description_line(input: &mut &str) -> ModalResult<String> {
428 let content = line_content.parse_next(input)?;
429 opt_newline.parse_next(input)?;
430 Ok(content.trim().to_string())
431}
432
433fn read_block_until_separator(input: &mut &str, delimiter_len: usize) -> String {
434 let mut lines = Vec::new();
435
436 loop {
437 if input.is_empty() {
438 break;
439 }
440
441 let peek_line = input.lines().next().unwrap_or("");
442 let trimmed = peek_line.trim();
443
444 if is_any_separator_line(peek_line) && trimmed.len() == delimiter_len {
447 break;
448 }
449
450 let line = line_content.parse_next(input).unwrap_or("");
451 opt_newline.parse_next(input).ok();
452 lines.push(line);
453 }
454
455 while lines.last().is_some_and(|s| s.trim().is_empty()) {
456 lines.pop();
457 }
458
459 lines.join("\n")
460}
461
462fn constraint_line(input: &mut &str) -> ModalResult<String> {
463 let _ = take_while(0.., ' ').parse_next(input)?;
464 let _ = opt('*').parse_next(input)?;
465 let _ = take_while(0.., ' ').parse_next(input)?;
466
467 let content = line_content.parse_next(input)?;
468 opt_newline.parse_next(input)?;
469
470 let trimmed = content.trim();
471 if trimmed.is_empty() || trimmed == "where" {
472 Err(winnow::error::ErrMode::Backtrack(ContextError::new()))
473 } else {
474 Ok(trimmed.to_string())
475 }
476}
477
478fn where_section(input: &mut &str, delimiter_len: usize) -> ModalResult<Vec<String>> {
479 dash_sep_exact(input, delimiter_len)?;
480 opt_newline.parse_next(input)?;
481
482 let _ = take_while(0.., ' ').parse_next(input)?;
483 "where".parse_next(input)?;
484 opt_newline.parse_next(input)?;
485
486 let constraints: Vec<String> = repeat(0.., constraint_line).parse_next(input)?;
487 Ok(constraints)
488}
489
490fn test_case(state: &mut ParseState) -> Result<TestCase, winnow::error::ErrMode<ContextError>> {
493 let input = &mut state.input;
494
495 skip_blank_lines.parse_next(input)?;
496
497 let start_line = state.current_line;
498
499 let delimiter_len = header_sep.parse_next(input)?;
500 state.delimiter_len = delimiter_len;
501 opt_newline.parse_next(input)?;
502 state.current_line += 1;
503
504 let name = description_line.parse_next(input)?;
505 state.current_line += 1;
506
507 let skip = try_skip_directive.parse_next(input)?;
508 if skip.is_some() {
509 state.current_line += 1;
510 }
511
512 if let Some(err) = input
513 .lines()
514 .next()
515 .and_then(|l| check_header_sep_exact(l, delimiter_len))
516 {
517 state.error_message = Some(err);
518 return Err(winnow::error::ErrMode::Backtrack(ContextError::new()));
519 }
520 header_sep_exact(input, delimiter_len)?;
521 opt_newline.parse_next(input)?;
522 state.current_line += 1;
523
524 let command_start = state.current_line;
525 let command = read_block_until_separator(input, delimiter_len);
526 state.current_line = command_start + command.lines().count().max(1);
527
528 dash_sep_exact(input, delimiter_len)?;
529 opt_newline.parse_next(input)?;
530 state.current_line += 1;
531
532 let expected_start = state.current_line;
533 let expected_output = read_block_until_separator(input, delimiter_len);
534 let expected_lines = expected_output.lines().count();
535 state.current_line =
536 expected_start + expected_lines.max(if expected_output.is_empty() { 0 } else { 1 });
537
538 let constraints = opt(|i: &mut &str| where_section(i, delimiter_len))
539 .parse_next(input)?
540 .unwrap_or_default();
541 if !constraints.is_empty() {
542 state.current_line += 2 + constraints.len();
543 }
544
545 skip_blank_lines.parse_next(input)?;
546
547 let end_line = state.current_line;
548
549 let variables = extract_variables_from_expected(&expected_output)
550 .map_err(|_| winnow::error::ErrMode::Backtrack(ContextError::new()))?;
551
552 Ok(TestCase {
553 name,
554 command,
555 expected_output,
556 file_path: state.path.to_path_buf(),
557 start_line,
558 end_line,
559 variables,
560 constraints,
561 skip,
562 })
563}
564
565fn corpus_file(state: &mut ParseState) -> Result<CorpusFile, winnow::error::ErrMode<ContextError>> {
566 let input = &mut state.input;
567
568 skip_blank_lines.parse_next(input)?;
569
570 let file_skip = try_skip_directive.parse_next(input)?;
571 if file_skip.is_some() {
572 state.current_line += 1;
573 }
574
575 skip_blank_lines.parse_next(input)?;
576
577 let mut tests = Vec::new();
578
579 while !state.input.is_empty() {
580 let peeked = state.input.trim_start();
581 if peeked.is_empty() {
582 break;
583 }
584
585 if !peeked.starts_with("===") {
586 break;
587 }
588
589 let tc = test_case(state)?;
590 tests.push(tc);
591 }
592
593 Ok(CorpusFile { file_skip, tests })
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use std::io::Write;
600 use tempfile::NamedTempFile;
601
602 fn parse_test(content: &str) -> CorpusFile {
603 parse_content(content, Path::new("<test>")).unwrap()
604 }
605
606 #[test]
607 fn test_parse_single_test() {
608 let content = r#"===
609test name
610===
611echo hello
612---
613hello
614"#;
615 let file = parse_test(content);
616 assert!(file.file_skip.is_none());
617 assert_eq!(file.tests.len(), 1);
618 assert_eq!(file.tests[0].name, "test name");
619 assert_eq!(file.tests[0].command, "echo hello");
620 assert_eq!(file.tests[0].expected_output, "hello");
621 assert!(file.tests[0].variables.is_empty());
622 assert!(file.tests[0].constraints.is_empty());
623 assert!(file.tests[0].skip.is_none());
624 }
625
626 #[test]
627 fn test_parse_multiple_tests() {
628 let content = r#"===
629first test
630===
631echo first
632---
633first
634
635===
636second test
637===
638echo second
639---
640second
641"#;
642 let file = parse_test(content);
643 assert_eq!(file.tests.len(), 2);
644 assert_eq!(file.tests[0].name, "first test");
645 assert_eq!(file.tests[1].name, "second test");
646 }
647
648 #[test]
649 fn test_parse_multiline_output() {
650 let content = r#"===
651multiline test
652===
653echo -e "line1\nline2\nline3"
654---
655line1
656line2
657line3
658"#;
659 let file = parse_test(content);
660 assert_eq!(file.tests.len(), 1);
661 assert_eq!(file.tests[0].expected_output, "line1\nline2\nline3");
662 }
663
664 #[test]
665 fn test_parse_empty_expected() {
666 let content = r#"===
667exit only test
668===
669true
670---
671"#;
672 let file = parse_test(content);
673 assert_eq!(file.tests.len(), 1);
674 assert_eq!(file.tests[0].expected_output, "");
675 }
676
677 #[test]
678 fn test_parse_with_inline_type() {
679 let content = r#"===
680timing test
681===
682time_command
683---
684Completed in {{ n: number }}s
685"#;
686 let file = parse_test(content);
687 assert_eq!(file.tests.len(), 1);
688 assert_eq!(
689 file.tests[0].expected_output,
690 "Completed in {{ n: number }}s"
691 );
692 assert_eq!(file.tests[0].variables.len(), 1);
693 assert_eq!(file.tests[0].variables[0].name, "n");
694 assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::Number));
695 }
696
697 #[test]
698 fn test_parse_with_constraints() {
699 let content = r#"===
700timing test
701===
702time_command
703---
704Completed in {{ n: number }}s
705---
706where
707* n > 0
708* n < 60
709"#;
710 let file = parse_test(content);
711 assert_eq!(file.tests.len(), 1);
712 assert_eq!(file.tests[0].variables.len(), 1);
713 assert_eq!(file.tests[0].constraints.len(), 2);
714 assert_eq!(file.tests[0].constraints[0], "n > 0");
715 assert_eq!(file.tests[0].constraints[1], "n < 60");
716 }
717
718 #[test]
719 fn test_parse_multiple_variables() {
720 let content = r#"===
721multi var test
722===
723some_command
724---
725{{ count: number }} items in {{ time: number }}s: {{ msg: string }}
726---
727where
728* count > 0
729* time < 10
730"#;
731 let file = parse_test(content);
732 assert_eq!(file.tests.len(), 1);
733 assert_eq!(file.tests[0].variables.len(), 3);
734 assert_eq!(file.tests[0].variables[0].name, "count");
735 assert_eq!(file.tests[0].variables[1].name, "time");
736 assert_eq!(file.tests[0].variables[2].name, "msg");
737 assert_eq!(file.tests[0].variables[2].var_type, Some(VarType::String));
738 }
739
740 #[test]
741 fn test_parse_duck_typed_variable() {
742 let content = r#"===
743duck typed
744===
745echo "val: 42"
746---
747val: {{ x }}
748---
749where
750* x > 0
751"#;
752 let file = parse_test(content);
753 assert_eq!(file.tests.len(), 1);
754 assert_eq!(file.tests[0].variables.len(), 1);
755 assert_eq!(file.tests[0].variables[0].name, "x");
756 assert_eq!(file.tests[0].variables[0].var_type, None);
757 }
758
759 #[test]
760 fn test_parse_empty_string_var() {
761 let content = r#"===
762empty string
763===
764echo "val: "
765---
766val: {{ s: string }}
767---
768where
769* len(s) == 0
770"#;
771 let file = parse_test(content);
772 assert_eq!(file.tests.len(), 1);
773 assert_eq!(file.tests[0].name, "empty string");
774 assert_eq!(file.tests[0].expected_output, "val: {{ s: string }}");
775 assert_eq!(file.tests[0].variables.len(), 1);
776 assert_eq!(file.tests[0].variables[0].name, "s");
777 assert_eq!(file.tests[0].variables[0].var_type, Some(VarType::String));
778 assert_eq!(file.tests[0].constraints.len(), 1);
779 assert_eq!(file.tests[0].constraints[0], "len(s) == 0");
780 }
781
782 #[test]
783 fn test_skip_unconditional() {
784 let content = r#"===
785skipped test
786%skip
787===
788echo hello
789---
790hello
791"#;
792 let file = parse_test(content);
793 assert_eq!(file.tests.len(), 1);
794 let skip = file.tests[0].skip.as_ref().unwrap();
795 assert!(skip.message.is_none());
796 assert!(skip.condition.is_none());
797 }
798
799 #[test]
800 fn test_skip_with_message() {
801 let content = r#"===
802skipped test
803%skip(not yet implemented)
804===
805echo hello
806---
807hello
808"#;
809 let file = parse_test(content);
810 assert_eq!(file.tests.len(), 1);
811 let skip = file.tests[0].skip.as_ref().unwrap();
812 assert_eq!(skip.message.as_deref(), Some("not yet implemented"));
813 assert!(skip.condition.is_none());
814 }
815
816 #[test]
817 fn test_skip_with_condition() {
818 let content = r#"===
819unix only test
820%skip if: test "$OS" = "Windows_NT"
821===
822echo hello
823---
824hello
825"#;
826 let file = parse_test(content);
827 assert_eq!(file.tests.len(), 1);
828 let skip = file.tests[0].skip.as_ref().unwrap();
829 assert!(skip.message.is_none());
830 assert_eq!(
831 skip.condition.as_deref(),
832 Some(r#"test "$OS" = "Windows_NT""#)
833 );
834 }
835
836 #[test]
837 fn test_skip_with_message_and_condition() {
838 let content = r#"===
839unix only test
840%skip(requires bash) if: test "$OS" = "Windows_NT"
841===
842echo hello
843---
844hello
845"#;
846 let file = parse_test(content);
847 assert_eq!(file.tests.len(), 1);
848 let skip = file.tests[0].skip.as_ref().unwrap();
849 assert_eq!(skip.message.as_deref(), Some("requires bash"));
850 assert_eq!(
851 skip.condition.as_deref(),
852 Some(r#"test "$OS" = "Windows_NT""#)
853 );
854 }
855
856 #[test]
857 fn test_file_level_skip() {
858 let content = r#"%skip(windows tests) if: test "$OS" != "Windows_NT"
859
860===
861test 1
862===
863echo hello
864---
865hello
866"#;
867 let file = parse_test(content);
868 let file_skip = file.file_skip.as_ref().unwrap();
869 assert_eq!(file_skip.message.as_deref(), Some("windows tests"));
870 assert_eq!(
871 file_skip.condition.as_deref(),
872 Some(r#"test "$OS" != "Windows_NT""#)
873 );
874 assert_eq!(file.tests.len(), 1);
875 }
876
877 #[test]
878 fn test_file_level_skip_unconditional() {
879 let content = r#"%skip(all tests disabled)
880
881===
882test 1
883===
884echo hello
885---
886hello
887"#;
888 let file = parse_test(content);
889 let file_skip = file.file_skip.as_ref().unwrap();
890 assert_eq!(file_skip.message.as_deref(), Some("all tests disabled"));
891 assert!(file_skip.condition.is_none());
892 }
893
894 #[test]
895 fn test_parse_file() {
896 let mut f = NamedTempFile::new().unwrap();
897 write!(f, "===\ntest\n===\necho hi\n---\nhi\n").unwrap();
898
899 let file = parse_file(f.path()).unwrap();
900 assert_eq!(file.tests.len(), 1);
901 assert_eq!(file.tests[0].name, "test");
902 assert_eq!(file.tests[0].file_path, f.path());
903 }
904
905 #[test]
906 fn test_multiline_command() {
907 let content = r#"===
908multiline command
909===
910echo "line 1"
911echo "line 2"
912echo "line 3"
913---
914line 1
915line 2
916line 3
917"#;
918 let file = parse_test(content);
919 assert_eq!(file.tests.len(), 1);
920 assert_eq!(
921 file.tests[0].command,
922 "echo \"line 1\"\necho \"line 2\"\necho \"line 3\""
923 );
924 }
925
926 #[test]
927 fn test_line_numbers() {
928 let content = r#"===
929first test
930===
931echo hello
932---
933hello
934
935===
936second test
937===
938echo world
939---
940world
941"#;
942 let file = parse_test(content);
943 assert_eq!(file.tests.len(), 2);
944 assert_eq!(file.tests[0].start_line, 1);
945 assert!(file.tests[0].start_line < file.tests[0].end_line);
947 assert!(file.tests[1].start_line < file.tests[1].end_line);
948 }
949
950 #[test]
951 fn test_longer_delimiters() {
952 let content = r#"=====
953test with longer delimiters
954=====
955echo hello
956-----
957hello
958"#;
959 let file = parse_test(content);
960 assert_eq!(file.tests.len(), 1);
961 assert_eq!(file.tests[0].name, "test with longer delimiters");
962 assert_eq!(file.tests[0].command, "echo hello");
963 assert_eq!(file.tests[0].expected_output, "hello");
964 }
965
966 #[test]
967 fn test_dash_separator_in_output() {
968 let content = r#"====
969test with --- in output
970====
971echo "---"
972----
973---
974"#;
975 let file = parse_test(content);
976 assert_eq!(file.tests.len(), 1);
977 assert_eq!(file.tests[0].expected_output, "---");
978 }
979
980 #[test]
981 fn test_dash_separators_in_output() {
982 let content = r#"=====
985test with various dash separators in output
986=====
987printf "---\n----\n"
988-----
989---
990----
991"#;
992 let file = parse_test(content);
993 assert_eq!(file.tests.len(), 1);
994 assert_eq!(file.tests[0].expected_output, "---\n----");
995 }
996
997 #[test]
998 fn test_shorter_equals_in_output_is_content() {
999 let content = r#"=====
1002test with === and ==== in output
1003=====
1004echo "==="
1005-----
1006===
1007
1008=====
1009second test
1010=====
1011echo "===="
1012-----
1013====
1014"#;
1015 let file = parse_test(content);
1016 assert_eq!(file.tests.len(), 2);
1017 assert_eq!(file.tests[0].expected_output, "===");
1018 assert_eq!(file.tests[1].expected_output, "====");
1019 }
1020
1021 #[test]
1022 fn test_same_length_equals_ends_block() {
1023 let content = r#"====
1025first test
1026====
1027echo "hello"
1028----
1029hello
1030
1031====
1032second test same length
1033====
1034echo "world"
1035----
1036world
1037"#;
1038 let file = parse_test(content);
1039 assert_eq!(file.tests.len(), 2);
1040 assert_eq!(file.tests[0].expected_output, "hello");
1041 assert_eq!(file.tests[1].expected_output, "world");
1042 }
1043
1044 #[test]
1045 fn test_longer_delimiters_with_constraints() {
1046 let content = r#"====
1047test with constraints
1048====
1049echo "count: 42"
1050----
1051count: {{ n: number }}
1052----
1053where
1054* n > 0
1055"#;
1056 let file = parse_test(content);
1057 assert_eq!(file.tests.len(), 1);
1058 assert_eq!(file.tests[0].constraints.len(), 1);
1059 assert_eq!(file.tests[0].constraints[0], "n > 0");
1060 }
1061
1062 #[test]
1063 fn test_mismatched_header_delimiter_error() {
1064 let content = r#"====
1065test name
1066===
1067echo hello
1068---
1069hello
1070"#;
1071 let result = parse_content(content, Path::new("<test>"));
1072 assert!(result.is_err());
1073 let err = result.unwrap_err();
1074 assert!(
1075 err.to_string().contains("delimiter length mismatch"),
1076 "Error should mention delimiter mismatch: {}",
1077 err
1078 );
1079 assert!(
1080 err.to_string().contains("expected 4") && err.to_string().contains("found 3"),
1081 "Error should mention expected 4 and found 3: {}",
1082 err
1083 );
1084 }
1085
1086 #[test]
1087 fn test_wrong_dash_length_treated_as_content() {
1088 let content = r#"====
1091test name
1092====
1093echo hello
1094----
1095---
1096hello
1097"#;
1098 let file = parse_test(content);
1099 assert_eq!(file.tests.len(), 1);
1100 assert_eq!(file.tests[0].expected_output, "---\nhello");
1101 }
1102
1103 #[test]
1104 fn test_multiple_tests_same_delimiter_length() {
1105 let content = r#"===
1108first test
1109===
1110echo "short"
1111---
1112short
1113
1114===
1115second test
1116===
1117echo "world"
1118---
1119world
1120"#;
1121 let file = parse_test(content);
1122 assert_eq!(file.tests.len(), 2);
1123 assert_eq!(file.tests[0].expected_output, "short");
1124 assert_eq!(file.tests[1].expected_output, "world");
1125 }
1126
1127 #[test]
1128 fn test_all_tests_must_use_same_delimiter_length() {
1129 let content = r#"===
1132first test
1133===
1134echo "short"
1135---
1136short
1137
1138=====
1139this looks like a test but is content
1140=====
1141"#;
1142 let file = parse_test(content);
1143 assert_eq!(file.tests.len(), 1);
1144 assert!(file.tests[0].expected_output.contains("====="));
1146 }
1147}