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