1use crate::error::Error;
2use crate::limits::ResourceLimits;
3
4pub mod ast;
5pub mod lexer;
6pub mod parser;
7pub mod source;
8
9pub use ast::{DepthTracker, Span};
10pub use source::Source;
11
12pub use ast::*;
13pub use parser::ParseResult;
14
15pub fn parse(
16 content: &str,
17 source_type: source::SourceType,
18 limits: &ResourceLimits,
19) -> Result<ParseResult, Error> {
20 parser::parse(content, source_type, limits)
21}
22
23#[cfg(test)]
28mod tests {
29 use super::{parse, ArithmeticComputation, Expression, ExpressionKind};
30 use crate::formatting::format_parse_result;
31 use crate::Error;
32 use crate::ResourceLimits;
33
34 #[test]
35 fn parse_empty_input_returns_no_specs() {
36 let result = parse(
37 "",
38 crate::parsing::source::SourceType::Volatile,
39 &ResourceLimits::default(),
40 )
41 .unwrap()
42 .into_flattened_specs();
43 assert_eq!(result.len(), 0);
44 }
45
46 #[test]
47 fn parse_workspace_file_yields_expected_spec_datas_and_rules() {
48 let input = r#"spec person
49data name: "John Doe"
50rule adult: true"#;
51 let result = parse(
52 input,
53 crate::parsing::source::SourceType::Volatile,
54 &ResourceLimits::default(),
55 )
56 .unwrap()
57 .into_flattened_specs();
58 assert_eq!(result.len(), 1);
59 assert_eq!(result[0].name, "person");
60 assert_eq!(result[0].data.len(), 1);
61 assert_eq!(result[0].rules.len(), 1);
62 assert_eq!(result[0].rules[0].name, "adult");
63 }
64
65 #[test]
66 fn mixing_data_and_rules_is_collected_into_spec() {
67 let input = r#"spec test
68data name: "John"
69rule is_adult: age >= 18
70data age: 25
71rule can_drink: age >= 21
72data status: "active"
73rule is_eligible: is_adult and status is "active""#;
74
75 let result = parse(
76 input,
77 crate::parsing::source::SourceType::Volatile,
78 &ResourceLimits::default(),
79 )
80 .unwrap()
81 .into_flattened_specs();
82 assert_eq!(result.len(), 1);
83 assert_eq!(result[0].data.len(), 3);
84 assert_eq!(result[0].rules.len(), 3);
85 }
86
87 #[test]
88 fn parse_simple_spec_collects_data() {
89 let input = r#"spec person
90data name: "John"
91data age: 25"#;
92 let result = parse(
93 input,
94 crate::parsing::source::SourceType::Volatile,
95 &ResourceLimits::default(),
96 )
97 .unwrap()
98 .into_flattened_specs();
99 assert_eq!(result.len(), 1);
100 assert_eq!(result[0].name, "person");
101 assert_eq!(result[0].data.len(), 2);
102 }
103
104 #[test]
105 fn parse_dotted_spec_name() {
106 let input = r#"spec contracts.employment.jack
107data name: "Jack""#;
108 let result = parse(
109 input,
110 crate::parsing::source::SourceType::Volatile,
111 &ResourceLimits::default(),
112 )
113 .unwrap()
114 .into_flattened_specs();
115 assert_eq!(result.len(), 1);
116 assert_eq!(result[0].name, "contracts.employment.jack");
117 }
118
119 #[test]
120 fn parse_slashed_spec_name() {
121 let input = "spec contracts/employment/jack\ndata x: 1";
122 let result = parse(
123 input,
124 crate::parsing::source::SourceType::Volatile,
125 &ResourceLimits::default(),
126 )
127 .unwrap()
128 .into_flattened_specs();
129 assert_eq!(result.len(), 1);
130 assert_eq!(result[0].name, "contracts/employment/jack");
131 }
132
133 #[test]
134 fn parse_spec_name_no_version_tag() {
135 let input = "spec myspec\nrule x: 1";
136 let result = parse(
137 input,
138 crate::parsing::source::SourceType::Volatile,
139 &ResourceLimits::default(),
140 )
141 .unwrap()
142 .into_flattened_specs();
143 assert_eq!(result.len(), 1);
144 assert_eq!(result[0].name, "myspec");
145 assert_eq!(result[0].effective_from(), None);
146 }
147
148 #[test]
149 fn parse_commentary_block_is_attached_to_spec() {
150 let input = r#"spec person
151"""
152This is a markdown comment
153uses **bold** text
154"""
155data name: "John""#;
156 let result = parse(
157 input,
158 crate::parsing::source::SourceType::Volatile,
159 &ResourceLimits::default(),
160 )
161 .unwrap()
162 .into_flattened_specs();
163 assert_eq!(result.len(), 1);
164 assert!(result[0].commentary.is_some());
165 assert!(result[0].commentary.as_ref().unwrap().contains("**bold**"));
166 }
167
168 #[test]
169 fn parse_spec_with_rule_collects_rule() {
170 let input = r#"spec person
171rule is_adult: age >= 18"#;
172 let result = parse(
173 input,
174 crate::parsing::source::SourceType::Volatile,
175 &ResourceLimits::default(),
176 )
177 .unwrap()
178 .into_flattened_specs();
179 assert_eq!(result.len(), 1);
180 assert_eq!(result[0].rules.len(), 1);
181 assert_eq!(result[0].rules[0].name, "is_adult");
182 }
183
184 #[test]
185 fn parse_multiple_specs_returns_all_specs() {
186 let input = r#"spec person
187data name: "John"
188
189spec company
190data name: "Acme Corp""#;
191 let result = parse(
192 input,
193 crate::parsing::source::SourceType::Volatile,
194 &ResourceLimits::default(),
195 )
196 .unwrap()
197 .into_flattened_specs();
198 assert_eq!(result.len(), 2);
199 assert_eq!(result[0].name, "person");
200 assert_eq!(result[1].name, "company");
201 }
202
203 #[test]
204 fn parse_allows_duplicate_data_names() {
205 let input = r#"spec person
206data name: "John"
207data name: "Jane""#;
208 let result = parse(
209 input,
210 crate::parsing::source::SourceType::Volatile,
211 &ResourceLimits::default(),
212 );
213 assert!(
214 result.is_ok(),
215 "Parser should succeed even with duplicate data"
216 );
217 }
218
219 #[test]
220 fn parse_allows_duplicate_rule_names() {
221 let input = r#"spec person
222rule is_adult: age >= 18
223rule is_adult: age >= 21"#;
224 let result = parse(
225 input,
226 crate::parsing::source::SourceType::Volatile,
227 &ResourceLimits::default(),
228 );
229 assert!(
230 result.is_ok(),
231 "Parser should succeed even with duplicate rules"
232 );
233 }
234
235 #[test]
236 fn parse_rejects_malformed_input() {
237 let input = "invalid syntax here";
238 let result = parse(
239 input,
240 crate::parsing::source::SourceType::Volatile,
241 &ResourceLimits::default(),
242 );
243 assert!(result.is_err());
244 }
245
246 #[test]
247 fn parse_handles_whitespace_variants_in_expressions() {
248 let test_cases = vec![
249 ("spec test\nrule test: 2+3", "no spaces in arithmetic"),
250 ("spec test\nrule test: age>=18", "no spaces in comparison"),
251 (
252 "spec test\nrule test: age >= 18 and salary>50000",
253 "spaces around and keyword",
254 ),
255 (
256 "spec test\nrule test: age >= 18 and salary > 50000",
257 "extra spaces",
258 ),
259 (
260 "spec test\nrule test: \n age >= 18 \n and \n salary > 50000",
261 "newlines in expression",
262 ),
263 ];
264
265 for (input, description) in test_cases {
266 let result = parse(
267 input,
268 crate::parsing::source::SourceType::Volatile,
269 &ResourceLimits::default(),
270 );
271 assert!(
272 result.is_ok(),
273 "Failed to parse {} ({}): {:?}",
274 input,
275 description,
276 result.err()
277 );
278 }
279 }
280
281 #[test]
282 fn parse_error_cases_are_rejected() {
283 let error_cases = vec![
284 (
285 "spec test\ndata name: \"unclosed string",
286 "unclosed string literal",
287 ),
288 ("spec test\nrule test: (2 + 3", "unclosed parenthesis"),
289 ("spec test\nrule test: 2 + 3)", "extra closing paren"),
290 ("spec test\ndata spec: 123", "reserved keyword as data name"),
291 (
292 "spec test\nrule rule: true",
293 "reserved keyword as rule name",
294 ),
295 ];
296
297 for (input, description) in error_cases {
298 let result = parse(
299 input,
300 crate::parsing::source::SourceType::Volatile,
301 &ResourceLimits::default(),
302 );
303 assert!(
304 result.is_err(),
305 "Expected error for {} but got success",
306 description
307 );
308 }
309 }
310
311 #[test]
312 fn parse_duration_literals_in_rules() {
313 let test_cases = vec![
314 ("2 years", "years"),
315 ("6 months", "months"),
316 ("52 weeks", "weeks"),
317 ("365 days", "days"),
318 ("24 hours", "hours"),
319 ("60 minutes", "minutes"),
320 ("3600 seconds", "seconds"),
321 ("1000 milliseconds", "milliseconds"),
322 ("500000 microseconds", "microseconds"),
323 ("50 percent", "percent"),
324 ];
325
326 for (expr, description) in test_cases {
327 let input = format!("spec test\nrule test: {}", expr);
328 let result = parse(
329 &input,
330 crate::parsing::source::SourceType::Volatile,
331 &ResourceLimits::default(),
332 );
333 assert!(
334 result.is_ok(),
335 "Failed to parse literal {} ({}): {:?}",
336 expr,
337 description,
338 result.err()
339 );
340 }
341 }
342
343 #[test]
344 fn parse_comparisons_with_duration_unit_conversions() {
345 let test_cases = vec![
346 (
347 "(duration as hours) > 2",
348 "duration conversion in comparison with parens",
349 ),
350 (
351 "(meeting_time as minutes) >= 30",
352 "duration conversion with gte",
353 ),
354 (
355 "(project_length as days) < 100",
356 "duration conversion with lt",
357 ),
358 (
359 "(delay as seconds) is 60",
360 "duration conversion with equality",
361 ),
362 (
363 "(1 hours) > (30 minutes)",
364 "duration conversions on both sides",
365 ),
366 (
367 "duration as hours > 2",
368 "duration conversion without parens",
369 ),
370 (
371 "meeting_time as seconds > 3600",
372 "variable duration conversion in comparison",
373 ),
374 (
375 "project_length as days > deadline_days",
376 "two variables with duration conversion",
377 ),
378 (
379 "duration as hours >= 1 and duration as hours <= 8",
380 "multiple duration comparisons",
381 ),
382 (
383 "(2024-06-01...2024-06-15) as days as number >= 7",
384 "chained as conversion before comparison",
385 ),
386 ("duration as hours as number > 2", "chained as on duration"),
387 ];
388
389 for (expr, description) in test_cases {
390 let input = format!("spec test\nrule test: {}", expr);
391 let result = parse(
392 &input,
393 crate::parsing::source::SourceType::Volatile,
394 &ResourceLimits::default(),
395 );
396 assert!(
397 result.is_ok(),
398 "Failed to parse {} ({}): {:?}",
399 expr,
400 description,
401 result.err()
402 );
403 }
404 }
405
406 #[test]
407 fn parse_rejects_token_after_unit_conversion() {
408 let result = parse(
409 "spec test\nuses lemma si\nrule ok: (2024-06-01...2024-06-15) as days foo",
410 crate::parsing::source::SourceType::Volatile,
411 &ResourceLimits::default(),
412 );
413 let err = result.expect_err("expected parse error");
414 let msg = err.to_string();
415 assert!(
416 msg.contains("Unexpected token") && msg.contains("foo"),
417 "expected error at 'foo', got: {}",
418 msg
419 );
420 assert!(
421 !msg.contains("Expected 'data'"),
422 "should not defer to spec-level error, got: {}",
423 msg
424 );
425 }
426
427 #[test]
428 fn parse_unit_conversion_before_next_spec() {
429 let result = parse(
430 r#"spec pricing
431rule hourly_rate: 150 eur
432 unless loyalty is "silver" then 140 eur
433 unless loyalty is "gold" then 125 usd as eur
434
435spec other
436rule x: 1"#,
437 crate::parsing::source::SourceType::Volatile,
438 &ResourceLimits::default(),
439 );
440 assert!(
441 result.is_ok(),
442 "unless branch ending with 'as' must parse before next spec: {:?}",
443 result.err()
444 );
445 }
446
447 #[test]
448 fn parse_unit_conversion_before_sibling_rule() {
449 let result = parse(
450 r#"spec s
451rule a: 100 usd as eur
452rule b: 1"#,
453 crate::parsing::source::SourceType::Volatile,
454 &ResourceLimits::default(),
455 );
456 assert!(
457 result.is_ok(),
458 "rule ending with 'as' must parse before sibling rule: {:?}",
459 result.err()
460 );
461 }
462
463 #[test]
464 fn parse_unit_conversion_before_uses() {
465 let result = parse(
466 r#"spec s
467rule rate: 10 usd as eur
468uses lemma si
469rule hours: 1 hour"#,
470 crate::parsing::source::SourceType::Volatile,
471 &ResourceLimits::default(),
472 );
473 assert!(
474 result.is_ok(),
475 "rule ending with 'as' must parse before uses: {:?}",
476 result.err()
477 );
478 }
479
480 #[test]
482 fn parse_unit_conversion_before_expression_boundaries() {
483 let cases: &[(&str, &str)] = &[
484 (
485 "sibling data",
486 r#"spec s
487rule rate: 10 usd as eur
488data price: 100 eur"#,
489 ),
490 (
491 "sibling fill",
492 r#"spec s
493rule rate: 10 usd as eur
494fill price: rate"#,
495 ),
496 (
497 "sibling meta",
498 r#"spec s
499rule rate: 10 usd as eur
500meta version: 1"#,
501 ),
502 (
503 "another unless",
504 r#"spec s
505rule rate: 10 usd
506 unless active then 5 usd as eur
507 unless premium then 3 usd as eur"#,
508 ),
509 (
510 "eof",
511 r#"spec s
512rule rate: 10 usd as eur"#,
513 ),
514 (
515 "next repo",
516 r#"spec s
517rule rate: 10 usd as eur
518
519repo other
520spec t
521rule x: 1"#,
522 ),
523 (
524 "unless then before next unless",
525 r#"spec s
526rule rate: 10 usd
527 unless a then 1 usd as eur
528 unless b then 2"#,
529 ),
530 (
531 "chained as before sibling rule",
532 r#"spec s
533rule rate: (2024-01-01...2024-01-02) as days as number
534rule other: 1"#,
535 ),
536 ];
537
538 for (label, source) in cases {
539 let result = parse(
540 source,
541 crate::parsing::source::SourceType::Volatile,
542 &ResourceLimits::default(),
543 );
544 assert!(
545 result.is_ok(),
546 "unit conversion before {label} must parse: {:?}",
547 result.err()
548 );
549 }
550 }
551
552 #[test]
553 fn parse_rejects_plain_number_plus_converted_operand() {
554 let result = parse(
555 r#"spec test
556data c: quantity
557 -> unit eur 1
558 -> unit usd 0.84
559rule z: 5 + c as usd"#,
560 crate::parsing::source::SourceType::Volatile,
561 &ResourceLimits::default(),
562 );
563 let err = result.expect_err("expected parse error for 5 + c as usd");
564 let msg = err.to_string();
565 assert!(
566 msg.contains("plain number") || msg.contains("each operand"),
567 "expected conversion-before-+ error, got: {msg}"
568 );
569 }
570
571 #[test]
572 fn parse_accepts_conversion_on_each_additive_operand() {
573 let cases: &[(&str, &str)] = &[
574 (
575 "money",
576 r#"spec test
577data c: quantity
578 -> unit eur 1
579 -> unit usd 0.84
580rule z: 5 as usd + c as usd"#,
581 ),
582 (
583 "duration + literal",
584 r#"spec test
585uses lemma si
586rule z: duration as hours + 1"#,
587 ),
588 (
589 "duration + comparison",
590 r#"spec test
591uses lemma si
592data duration: si.duration
593 -> default 1 hour
594rule z: duration as hours + 1 > 0"#,
595 ),
596 (
597 "date range + ref",
598 r#"spec test
599uses lemma si
600data age: date range
601data c: quantity
602 -> unit eur 1
603rule z: age as days + c"#,
604 ),
605 ];
606 for (label, source) in cases {
607 let result = parse(
608 source,
609 crate::parsing::source::SourceType::Volatile,
610 &ResourceLimits::default(),
611 );
612 assert!(
613 result.is_ok(),
614 "expected {label} to parse, got: {:?}",
615 result.err()
616 );
617 }
618 }
619
620 fn rule_expression(source: &str, rule_name: &str) -> Expression {
621 let parsed = parse(
622 source,
623 crate::parsing::source::SourceType::Volatile,
624 &ResourceLimits::default(),
625 )
626 .expect("expected parse");
627 let spec = parsed
628 .flatten_specs()
629 .into_iter()
630 .next()
631 .expect("expected one spec");
632 spec.rules
633 .iter()
634 .find(|rule| rule.name == rule_name)
635 .unwrap_or_else(|| panic!("rule '{rule_name}' not found"))
636 .expression
637 .clone()
638 }
639
640 fn assert_multiply_range_side(expression: &Expression, range_on_left: bool, label: &str) {
641 let ExpressionKind::Arithmetic(left, ArithmeticComputation::Multiply, right) =
642 &expression.kind
643 else {
644 panic!("{label}: expected Multiply, got {:?}", expression.kind);
645 };
646 let (range, other) = if range_on_left {
647 (left.as_ref(), right.as_ref())
648 } else {
649 (right.as_ref(), left.as_ref())
650 };
651 assert!(
652 matches!(range.kind, ExpressionKind::RangeLiteral(..)),
653 "{label}: expected RangeLiteral on {} of *, got {:?}",
654 if range_on_left { "left" } else { "right" },
655 range.kind
656 );
657 assert!(
658 !matches!(other.kind, ExpressionKind::RangeLiteral(..)),
659 "{label}: expected non-range on other side of *"
660 );
661 }
662
663 #[test]
664 fn parse_range_binds_tighter_than_multiply() {
665 let base = r#"spec test
666uses lemma si
667data rate: quantity -> unit eur 1
668data period_start: 2026-01-01
669data period_end: 2026-01-02
670"#;
671 assert_multiply_range_side(
672 &rule_expression(
673 &format!("{base}rule rhs: rate * period_start...period_end"),
674 "rhs",
675 ),
676 false,
677 "rate * period_start...period_end",
678 );
679 assert_multiply_range_side(
680 &rule_expression(
681 &format!("{base}rule lhs: period_start...period_end * rate"),
682 "lhs",
683 ),
684 true,
685 "period_start...period_end * rate",
686 );
687 }
688
689 #[test]
690 fn parse_range_in_additive_term_before_plus() {
691 let expression = rule_expression(
692 r#"spec test
693uses lemma si
694data period_start: 2026-01-01
695data period_end: 2026-01-02
696rule span: period_start...period_end + 1 day"#,
697 "span",
698 );
699 let ExpressionKind::Arithmetic(left, ArithmeticComputation::Add, right) = &expression.kind
700 else {
701 panic!("expected Add, got {:?}", expression.kind);
702 };
703 assert!(matches!(left.kind, ExpressionKind::RangeLiteral(..)));
704 assert!(!matches!(right.kind, ExpressionKind::RangeLiteral(..)));
705 }
706
707 #[test]
708 fn parse_range_multiply_with_conversion_without_inner_parens() {
709 let expression = rule_expression(
710 r#"spec test
711uses lemma si
712data money: quantity -> unit eur 1
713data rate: quantity -> unit eur_per_hour eur/hour
714data hourly_rate: 50 eur_per_hour
715data period_start: 2026-01-01
716data period_end: 2026-01-02
717rule pay: (hourly_rate * period_start...period_end) as eur"#,
718 "pay",
719 );
720 let ExpressionKind::UnitConversion(inner, _) = &expression.kind else {
721 panic!("expected UnitConversion, got {:?}", expression.kind);
722 };
723 assert_multiply_range_side(inner, false, "pay");
724 }
725
726 #[test]
727 fn parse_error_includes_attribute_and_parse_error_spec_name() {
728 let result = parse(
729 r#"
730spec test
731data name: "Unclosed string
732data age: 25
733"#,
734 crate::parsing::source::SourceType::Volatile,
735 &ResourceLimits::default(),
736 );
737
738 match result {
739 Err(Error::Parsing(details)) => {
740 let src = details
741 .source
742 .as_ref()
743 .expect("BUG: parsing errors always have source");
744 assert_eq!(
745 src.source_type,
746 crate::parsing::source::SourceType::Volatile
747 );
748 }
749 Err(e) => panic!("Expected Parse error, got: {e:?}"),
750 Ok(_) => panic!("Expected parse error for unclosed string"),
751 }
752 }
753
754 #[test]
755 fn parse_single_spec_file() {
756 let input = r#"spec somespec
757data name: "Alice""#;
758 let parsed = parse(
759 input,
760 crate::parsing::source::SourceType::Volatile,
761 &ResourceLimits::default(),
762 )
763 .unwrap();
764 let specs = parsed.flatten_specs();
765 assert_eq!(specs.len(), 1);
766 assert_eq!(specs[0].name, "somespec");
767 }
768
769 #[test]
770 fn parse_uses_registry_spec_explicit_alias() {
771 let input = r#"spec example
772uses external: @user/workspace somespec"#;
773 let specs = parse(
774 input,
775 crate::parsing::source::SourceType::Volatile,
776 &ResourceLimits::default(),
777 )
778 .unwrap()
779 .into_flattened_specs();
780 assert_eq!(specs.len(), 1);
781 assert_eq!(specs[0].data.len(), 1);
782 match &specs[0].data[0].value {
783 crate::parsing::ast::DataValue::Import(spec_ref) => {
784 assert_eq!(spec_ref.name, "somespec");
785 let repository_hdr = spec_ref
786 .repository
787 .as_ref()
788 .expect("expected repository qualifier");
789 assert_eq!(repository_hdr.name, "@user/workspace");
790 }
791 other => panic!("Expected Import, got: {:?}", other),
792 }
793 }
794
795 #[test]
796 fn parse_multiple_specs_cross_reference_in_file() {
797 let input = r#"spec spec_a
798data x: 10
799
800spec spec_b
801data y: 20
802uses a: spec_a"#;
803 let parsed = parse(
804 input,
805 crate::parsing::source::SourceType::Volatile,
806 &ResourceLimits::default(),
807 )
808 .unwrap();
809 let specs = parsed.flatten_specs();
810 assert_eq!(specs.len(), 2);
811 assert_eq!(specs[0].name, "spec_a");
812 assert_eq!(specs[1].name, "spec_b");
813 }
814
815 #[test]
816 fn parse_uses_registry_spec_default_alias() {
817 let input = "spec example\nuses @owner/repo somespec";
818 let specs = parse(
819 input,
820 crate::parsing::source::SourceType::Volatile,
821 &ResourceLimits::default(),
822 )
823 .unwrap()
824 .into_flattened_specs();
825 match &specs[0].data[0].value {
826 crate::parsing::ast::DataValue::Import(spec_ref) => {
827 assert_eq!(spec_ref.name, "somespec");
828 let repository_hdr = spec_ref
829 .repository
830 .as_ref()
831 .expect("expected repository qualifier");
832 assert_eq!(repository_hdr.name, "@owner/repo");
833 }
834 other => panic!("Expected Import, got: {:?}", other),
835 }
836 }
837
838 #[test]
839 fn parse_uses_local_spec_default_alias() {
840 let input = "spec example\nuses myspec";
841 let specs = parse(
842 input,
843 crate::parsing::source::SourceType::Volatile,
844 &ResourceLimits::default(),
845 )
846 .unwrap()
847 .into_flattened_specs();
848 match &specs[0].data[0].value {
849 crate::parsing::ast::DataValue::Import(spec_ref) => {
850 assert_eq!(spec_ref.name, "myspec");
851 assert!(
852 spec_ref.repository.is_none(),
853 "same-repository reference must omit repository qualifier"
854 );
855 }
856 other => panic!("Expected Import, got: {:?}", other),
857 }
858 }
859
860 #[test]
861 fn parse_spec_name_with_trailing_dot_is_error() {
862 let input = "spec myspec.\ndata x: 1";
863 let result = parse(
864 input,
865 crate::parsing::source::SourceType::Volatile,
866 &ResourceLimits::default(),
867 );
868 assert!(
869 result.is_err(),
870 "Trailing dot after spec name should be a parse error"
871 );
872 }
873
874 #[test]
875 fn parse_multiple_specs_in_same_file() {
876 let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
877 let result = parse(
878 input,
879 crate::parsing::source::SourceType::Volatile,
880 &ResourceLimits::default(),
881 )
882 .unwrap()
883 .into_flattened_specs();
884 assert_eq!(result.len(), 2);
885 assert_eq!(result[0].name, "myspec_a");
886 assert_eq!(result[1].name, "myspec_b");
887 }
888
889 #[test]
890 fn parse_uses_accepts_name_only() {
891 let input = "spec consumer\nuses other";
892 let result = parse(
893 input,
894 crate::parsing::source::SourceType::Volatile,
895 &ResourceLimits::default(),
896 );
897 assert!(result.is_ok(), "uses name should parse");
898 let specs = result.unwrap().into_flattened_specs();
899 let spec_ref = match &specs[0].data[0].value {
900 crate::parsing::ast::DataValue::Import(r) => r,
901 _ => panic!("expected Import"),
902 };
903 assert_eq!(spec_ref.name, "other");
904 }
905
906 #[test]
907 fn parse_uses_bare_year_effective() {
908 let input = "spec consumer\nuses other 2026";
909 let result = parse(
910 input,
911 crate::parsing::source::SourceType::Volatile,
912 &ResourceLimits::default(),
913 )
914 .unwrap();
915 let specs = result.into_flattened_specs();
916 let spec_ref = match &specs[0].data[0].value {
917 crate::parsing::ast::DataValue::Import(r) => r,
918 _ => panic!("expected Import"),
919 };
920 assert_eq!(spec_ref.name, "other");
921 let eff = spec_ref.effective.as_ref().expect("effective");
922 assert_eq!(eff.year, 2026);
923 assert_eq!(eff.month, 1);
924 assert_eq!(eff.day, 1);
925 }
926
927 #[test]
928 fn parse_uses_comma_separated_bare() {
929 let input = "spec consumer\nuses a, b, c";
930 let result = parse(
931 input,
932 crate::parsing::source::SourceType::Volatile,
933 &ResourceLimits::default(),
934 )
935 .unwrap();
936 let data = &result.flatten_specs()[0].data;
937 assert_eq!(data.len(), 3);
938 for (i, expected) in ["a", "b", "c"].iter().enumerate() {
939 let sr = match &data[i].value {
940 crate::parsing::ast::DataValue::Import(r) => r,
941 _ => panic!("expected Import for item {i}"),
942 };
943 assert_eq!(sr.name, *expected);
944 assert_eq!(data[i].reference.name, *expected);
945 assert!(sr.effective.is_none());
946 }
947 }
948
949 #[test]
950 fn parse_uses_comma_separated_cross_repository() {
951 let input = "spec consumer\nuses pricing retail, pricing wholesale";
952 let result = parse(
953 input,
954 crate::parsing::source::SourceType::Volatile,
955 &ResourceLimits::default(),
956 )
957 .unwrap();
958 let data = &result.flatten_specs()[0].data;
959 assert_eq!(data.len(), 2);
960 let sr0 = match &data[0].value {
961 crate::parsing::ast::DataValue::Import(r) => r,
962 _ => panic!("expected Import"),
963 };
964 assert_eq!(sr0.name, "retail");
965 let repository_hdr0 = sr0
966 .repository
967 .as_ref()
968 .expect("expected repository qualifier");
969 assert_eq!(repository_hdr0.name, "pricing");
970 assert_eq!(data[0].reference.name, "retail");
971 let sr1 = match &data[1].value {
972 crate::parsing::ast::DataValue::Import(r) => r,
973 _ => panic!("expected Import"),
974 };
975 assert_eq!(sr1.name, "wholesale");
976 let repository_hdr1 = sr1
977 .repository
978 .as_ref()
979 .expect("expected repository qualifier");
980 assert_eq!(repository_hdr1.name, "pricing");
981 assert_eq!(data[1].reference.name, "wholesale");
982 }
983
984 #[test]
985 fn parse_uses_comma_separated_registry() {
986 let input = "spec consumer\nuses @org/repo spec_a, @org/repo spec_b";
987 let result = parse(
988 input,
989 crate::parsing::source::SourceType::Volatile,
990 &ResourceLimits::default(),
991 )
992 .unwrap();
993 let data = &result.flatten_specs()[0].data;
994 assert_eq!(data.len(), 2);
995 assert_eq!(data[0].reference.name, "spec_a");
996 assert_eq!(data[1].reference.name, "spec_b");
997 for sr in [&data[0].value, &data[1].value] {
998 let r = match sr {
999 crate::parsing::ast::DataValue::Import(r) => r,
1000 _ => panic!("expected Import"),
1001 };
1002 let repository_hdr = r
1003 .repository
1004 .as_ref()
1005 .expect("expected repository qualifier");
1006 assert_eq!(repository_hdr.name, "@org/repo");
1007 }
1008 }
1009
1010 #[test]
1011 fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
1012 let input = "spec consumer\nuses @lemma/std finance 2026";
1013 let result = parse(
1014 input,
1015 crate::parsing::source::SourceType::Volatile,
1016 &ResourceLimits::default(),
1017 )
1018 .unwrap();
1019 let spec = &result.flatten_specs()[0];
1020 let sr = match &spec.data[0].value {
1021 crate::parsing::ast::DataValue::Import(r) => r,
1022 _ => panic!("expected Import"),
1023 };
1024 let rs = sr
1025 .repository_span
1026 .as_ref()
1027 .expect("repository_span should be set for @-qualified uses");
1028 let ts = sr
1029 .target_span
1030 .as_ref()
1031 .expect("target_span should cover spec name and effective");
1032 assert_eq!(&input[rs.start..rs.end], "@lemma/std");
1033 assert_eq!(&input[ts.start..ts.end], "finance 2026");
1034 }
1035
1036 #[test]
1037 fn parse_uses_alias_no_comma_continuation() {
1038 let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
1039 let result = parse(
1040 input,
1041 crate::parsing::source::SourceType::Volatile,
1042 &ResourceLimits::default(),
1043 )
1044 .unwrap();
1045 let data = &result.flatten_specs()[0].data;
1046 assert_eq!(data.len(), 2);
1047 assert_eq!(data[0].reference.name, "alias");
1048 let sr = match &data[0].value {
1049 crate::parsing::ast::DataValue::Import(r) => r,
1050 _ => panic!("expected Import"),
1051 };
1052 assert_eq!(sr.name, "retail");
1053 let repository_hdr = sr
1054 .repository
1055 .as_ref()
1056 .expect("expected repository qualifier");
1057 assert_eq!(repository_hdr.name, "pricing");
1058 }
1059
1060 #[test]
1061 fn parse_data_qualified_type_with_effective_and_repository_on_uses() {
1062 let input = "spec consumer\nuses @lemma/std finance 2026-06-01\ndata price: finance.number -> minimum 0";
1063 let result = parse(
1064 input,
1065 crate::parsing::source::SourceType::Volatile,
1066 &ResourceLimits::default(),
1067 )
1068 .unwrap()
1069 .into_flattened_specs();
1070 let spec_ref = match &result[0].data[0].value {
1071 crate::parsing::ast::DataValue::Import(sr) => sr,
1072 other => panic!("expected Import on uses row, got: {:?}", other),
1073 };
1074 assert_eq!(spec_ref.name, "finance");
1075
1076 let eff = spec_ref
1077 .effective
1078 .as_ref()
1079 .expect("expected effective datetime");
1080 assert_eq!(eff.year, 2026);
1081 assert_eq!(eff.month, 6);
1082
1083 let qualifier = spec_ref
1084 .repository
1085 .as_ref()
1086 .expect("expected repository qualifier");
1087 assert_eq!(qualifier.name, "@lemma/std");
1088
1089 match &result[0].data[1].value {
1090 crate::parsing::ast::DataValue::Definition {
1091 base,
1092 constraints,
1093 value,
1094 } => {
1095 assert!(value.is_none());
1096 assert_eq!(
1097 base.as_ref().expect("expected base"),
1098 &crate::parsing::ast::ParentType::Qualified {
1099 spec_alias: "finance".into(),
1100 inner: Box::new(crate::parsing::ast::ParentType::Primitive {
1101 primitive: crate::parsing::ast::PrimitiveKind::Number,
1102 }),
1103 }
1104 );
1105
1106 let cs = constraints
1107 .as_ref()
1108 .expect("expected trailing constraint chain");
1109 assert_eq!(cs.len(), 1);
1110 }
1111 other => panic!("expected Definition, got: {:?}", other),
1112 }
1113 }
1114
1115 #[test]
1116 fn parse_error_is_returned_for_garbage_input() {
1117 let result = parse(
1118 r#"
1119spec test
1120this is not valid lemma syntax @#$%
1121"#,
1122 crate::parsing::source::SourceType::Volatile,
1123 &ResourceLimits::default(),
1124 );
1125
1126 assert!(result.is_err(), "Should fail on malformed input");
1127 match result {
1128 Err(Error::Parsing { .. }) => {
1129 }
1131 Err(e) => panic!("Expected Parse error, got: {e:?}"),
1132 Ok(_) => panic!("Expected parse error"),
1133 }
1134 }
1135
1136 #[test]
1140 fn parse_fill_with_dotted_rhs_is_fill_reference() {
1141 let input = r#"spec s
1142data a: number -> default 1
1143fill x: a.something"#;
1144 let result = parse(
1145 input,
1146 crate::parsing::source::SourceType::Volatile,
1147 &ResourceLimits::default(),
1148 )
1149 .unwrap()
1150 .into_flattened_specs();
1151 let x_value = &result[0]
1152 .data
1153 .iter()
1154 .find(|d| d.reference.name == "x")
1155 .expect("fill x not found")
1156 .value;
1157 assert!(
1158 matches!(
1159 x_value,
1160 crate::parsing::ast::DataValue::Fill(
1161 crate::parsing::ast::FillRhs::Reference { .. }
1162 )
1163 ),
1164 "dotted RHS must yield DataValue::Fill(Reference), got: {:?}",
1165 x_value
1166 );
1167 }
1168
1169 #[test]
1171 fn parse_fill_with_multi_segment_reference_rhs() {
1172 let input = r#"spec s
1173fill x: alpha.beta.gamma.delta"#;
1174 let result = parse(
1175 input,
1176 crate::parsing::source::SourceType::Volatile,
1177 &ResourceLimits::default(),
1178 )
1179 .unwrap()
1180 .into_flattened_specs();
1181 let value = &result[0].data[0].value;
1182 match value {
1183 crate::parsing::ast::DataValue::Fill(crate::parsing::ast::FillRhs::Reference {
1184 target,
1185 ..
1186 }) => {
1187 assert_eq!(target.segments, vec!["alpha", "beta", "gamma"]);
1188 assert_eq!(target.name, "delta");
1189 }
1190 other => panic!("expected Fill(Reference), got: {:?}", other),
1191 }
1192 }
1193
1194 #[test]
1196 fn parse_fill_reference_with_trailing_constraint_is_rejected() {
1197 let input = r#"spec s
1198fill x: foo.bar -> minimum 5"#;
1199 let err = parse(
1200 input,
1201 crate::parsing::source::SourceType::Volatile,
1202 &ResourceLimits::default(),
1203 )
1204 .unwrap_err();
1205 let msg = err.to_string();
1206 assert!(
1207 msg.contains("fill") && msg.contains("data"),
1208 "expected fill-vs-data constraint error, got: {msg}"
1209 );
1210 }
1211
1212 #[test]
1215 fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1216 let input = r#"spec s
1217data x: myothertype"#;
1218 let result = parse(
1219 input,
1220 crate::parsing::source::SourceType::Volatile,
1221 &ResourceLimits::default(),
1222 )
1223 .unwrap()
1224 .into_flattened_specs();
1225 let value = &result[0].data[0].value;
1226 assert!(
1227 matches!(
1228 value,
1229 crate::parsing::ast::DataValue::Definition {
1230 base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1231 ..
1232 }
1233 ),
1234 "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1235 value
1236 );
1237 }
1238
1239 #[test]
1241 fn parse_binding_non_dotted_rhs_is_fill_reference() {
1242 let input = r#"spec s
1243fill child.slot: somename"#;
1244 let result = parse(
1245 input,
1246 crate::parsing::source::SourceType::Volatile,
1247 &ResourceLimits::default(),
1248 )
1249 .unwrap()
1250 .into_flattened_specs();
1251 let value = &result[0].data[0].value;
1252 assert!(
1253 matches!(
1254 value,
1255 crate::parsing::ast::DataValue::Fill(
1256 crate::parsing::ast::FillRhs::Reference { .. }
1257 )
1258 ),
1259 "non-dotted RHS in binding context must yield Fill(Reference); got: {:?}",
1260 value
1261 );
1262 }
1263
1264 #[test]
1266 fn parse_data_colon_spec_rhs_is_rejected() {
1267 let result = parse(
1268 r#"
1269spec s
1270data x: spec other
1271"#,
1272 crate::parsing::source::SourceType::Volatile,
1273 &ResourceLimits::default(),
1274 );
1275 match result {
1276 Ok(_) => panic!("`data x: spec other` must fail to parse"),
1277 Err(err) => {
1278 let msg = err.to_string();
1279 assert!(
1280 msg.contains("uses") && msg.contains("spec"),
1281 "error must direct to `uses` for spec import, got: {msg}"
1282 );
1283 }
1284 }
1285 }
1286
1287 #[test]
1290 fn parse_binding_with_dotted_rhs_preserves_both_sides() {
1291 let input = r#"spec s
1292fill outer.inner: target.field"#;
1293 let result = parse(
1294 input,
1295 crate::parsing::source::SourceType::Volatile,
1296 &ResourceLimits::default(),
1297 )
1298 .unwrap()
1299 .into_flattened_specs();
1300 let datum = &result[0].data[0];
1301 assert_eq!(datum.reference.segments, vec!["outer"]);
1302 assert_eq!(datum.reference.name, "inner");
1303 match &datum.value {
1304 crate::parsing::ast::DataValue::Fill(crate::parsing::ast::FillRhs::Reference {
1305 target,
1306 }) => {
1307 assert_eq!(target.segments, vec!["target"]);
1308 assert_eq!(target.name, "field");
1309 }
1310 other => panic!("expected Fill(Reference), got: {:?}", other),
1311 }
1312 }
1313
1314 #[test]
1315 fn parse_data_on_binding_path_is_rejected_with_fill_hint() {
1316 let result = parse(
1317 r#"spec s
1318data outer.inner: 1"#,
1319 crate::parsing::source::SourceType::Volatile,
1320 &ResourceLimits::default(),
1321 );
1322 match result {
1323 Ok(_) => panic!("data with binding path must not parse"),
1324 Err(err) => {
1325 let msg = err.to_string();
1326 assert!(
1327 msg.contains("fill"),
1328 "error should steer authors toward fill; got: {msg}"
1329 );
1330 }
1331 }
1332 }
1333
1334 #[test]
1335 fn parse_bare_file_yields_single_anonymous_repository_group() {
1336 let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1337 let parsed = parse(
1338 input,
1339 crate::parsing::source::SourceType::Volatile,
1340 &ResourceLimits::default(),
1341 )
1342 .unwrap();
1343 assert_eq!(parsed.repositories.len(), 1);
1344 let (repo, specs) = parsed.repositories.iter().next().unwrap();
1345 assert!(repo.name.is_none());
1346 assert_eq!(specs.len(), 2);
1347 assert_eq!(specs[0].name, "a");
1348 assert_eq!(specs[1].name, "b");
1349 }
1350
1351 #[test]
1352 fn parse_repo_sections_preserve_order_and_names() {
1353 let input = r#"repo r1
1354
1355spec a
1356data x: 1
1357
1358repo r2
1359
1360spec b
1361data y: 2"#;
1362 let parsed = parse(
1363 input,
1364 crate::parsing::source::SourceType::Volatile,
1365 &ResourceLimits::default(),
1366 )
1367 .unwrap();
1368 assert_eq!(parsed.repositories.len(), 2);
1369 let keys: Vec<_> = parsed.repositories.keys().collect();
1370 assert_eq!(keys[0].name.as_deref(), Some("r1"));
1371 assert_eq!(keys[1].name.as_deref(), Some("r2"));
1372 }
1373
1374 #[test]
1375 fn parse_duplicate_repo_name_merges_spec_lists() {
1376 let input = r#"repo dup
1377
1378spec a
1379data x: 1
1380
1381repo dup
1382
1383spec b
1384data y: 2"#;
1385 let parsed = parse(
1386 input,
1387 crate::parsing::source::SourceType::Volatile,
1388 &ResourceLimits::default(),
1389 )
1390 .unwrap();
1391 assert_eq!(parsed.repositories.len(), 1);
1392 assert_eq!(parsed.flatten_specs().len(), 2);
1393 }
1394
1395 #[test]
1396 fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1397 let input = "repo empty";
1398 let parsed = parse(
1399 input,
1400 crate::parsing::source::SourceType::Volatile,
1401 &ResourceLimits::default(),
1402 )
1403 .unwrap();
1404 assert_eq!(parsed.repositories.len(), 1);
1405 let (_repo, specs) = parsed.repositories.iter().next().unwrap();
1406 assert_eq!(specs.len(), 0);
1407 }
1408
1409 #[test]
1410 fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1411 let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1412 let parsed = parse(
1413 input,
1414 crate::parsing::source::SourceType::Volatile,
1415 &ResourceLimits::default(),
1416 )
1417 .unwrap();
1418 assert_eq!(parsed.repositories.len(), 2);
1419 let names: Vec<_> = parsed
1420 .repositories
1421 .keys()
1422 .map(|r| r.name.as_deref())
1423 .collect();
1424 assert_eq!(names, vec![Some("a"), Some("b")]);
1425 assert!(parsed.repositories.values().next().unwrap().is_empty());
1426 assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1427 }
1428
1429 #[test]
1430 fn parse_spec_named_repo_keyword_should_be_rejected() {
1431 assert!(
1432 parse(
1433 "spec repo\ndata x: 1",
1434 crate::parsing::source::SourceType::Volatile,
1435 &ResourceLimits::default(),
1436 )
1437 .is_err(),
1438 "spec must not be allowed to use reserved keyword `repo` as its name"
1439 );
1440 }
1441
1442 #[test]
1443 fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1444 assert!(
1445 parse(
1446 "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1447 crate::parsing::source::SourceType::Volatile,
1448 &ResourceLimits::default(),
1449 )
1450 .is_err(),
1451 "repository name cannot be the token `spec`"
1452 );
1453 }
1454
1455 #[test]
1456 fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
1457 assert!(
1458 parse(
1459 "repo data\n\nspec z\ndata q: 1\nrule r: q",
1460 crate::parsing::source::SourceType::Volatile,
1461 &ResourceLimits::default(),
1462 )
1463 .is_err(),
1464 "repository name cannot be the token `data`"
1465 );
1466 }
1467
1468 #[test]
1469 fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
1470 assert!(
1471 parse(
1472 "repo rule\n\nspec z\ndata q: 1\nrule r: q",
1473 crate::parsing::source::SourceType::Volatile,
1474 &ResourceLimits::default(),
1475 )
1476 .is_err(),
1477 "repository name cannot be the token `rule`"
1478 );
1479 }
1480
1481 #[test]
1482 fn parse_data_named_repo_keyword_is_rejected() {
1483 let err = parse(
1484 "spec s\ndata repo: 1",
1485 crate::parsing::source::SourceType::Volatile,
1486 &ResourceLimits::default(),
1487 )
1488 .unwrap_err();
1489 assert!(
1490 err.to_string().contains("repo"),
1491 "data named repo should not parse: {}",
1492 err
1493 );
1494 }
1495
1496 #[test]
1497 fn parse_rule_named_repo_keyword_is_rejected() {
1498 let err = parse(
1499 "spec s\ndata x: 1\nrule repo: x",
1500 crate::parsing::source::SourceType::Volatile,
1501 &ResourceLimits::default(),
1502 )
1503 .unwrap_err();
1504 let msg = err.to_string();
1505 assert!(
1506 msg.contains("repo") || msg.contains("reserved"),
1507 "rule named repo should not parse: {msg}"
1508 );
1509 }
1510
1511 #[test]
1512 fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
1513 let parsed = parse(
1514 "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
1515 crate::parsing::source::SourceType::Volatile,
1516 &ResourceLimits::default(),
1517 )
1518 .unwrap();
1519 assert_eq!(parsed.repositories.len(), 1);
1520 assert_eq!(
1521 parsed.repositories.keys().next().unwrap().name.as_deref(),
1522 Some("warehouse")
1523 );
1524 }
1525
1526 #[test]
1527 fn parse_repo_name_case_distinctness_two_repositories_not_merged() {
1528 let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
1529 let parsed = parse(
1530 input,
1531 crate::parsing::source::SourceType::Volatile,
1532 &ResourceLimits::default(),
1533 )
1534 .unwrap();
1535 assert_eq!(
1536 parsed.repositories.len(),
1537 2,
1538 "Foo and foo must be distinct repository identities"
1539 );
1540 }
1541
1542 #[test]
1543 fn parse_repo_empty_name_errors() {
1544 let err = parse(
1545 "repo \nspec a\ndata x: 1",
1546 crate::parsing::source::SourceType::Volatile,
1547 &ResourceLimits::default(),
1548 )
1549 .unwrap_err();
1550 assert!(
1551 !err.to_string().is_empty(),
1552 "empty repo name should not parse quietly: {err}"
1553 );
1554 }
1555
1556 #[test]
1557 fn parse_repo_numeric_name_behavior() {
1558 let input = "repo 123\n\nspec a\ndata x: 1";
1559 let result = parse(
1560 input,
1561 crate::parsing::source::SourceType::Volatile,
1562 &ResourceLimits::default(),
1563 );
1564 match result {
1565 Ok(parsed) => {
1566 assert_eq!(
1567 parsed.repositories.keys().next().unwrap().name.as_deref(),
1568 Some("123"),
1569 "if numeric repo names parse, identity must be stable"
1570 );
1571 }
1572 Err(e) => {
1573 assert!(
1574 !e.to_string().is_empty(),
1575 "rejecting numeric repo name is ok if explicit: {e}"
1576 );
1577 }
1578 }
1579 }
1580
1581 #[test]
1582 fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
1583 let input = r#"repo dup
1584
1585spec a
1586data x: 1
1587
1588repo dup
1589
1590spec b
1591data y: 2
1592
1593repo dup
1594
1595spec c
1596data z: 3"#;
1597 let parsed = parse(
1598 input,
1599 crate::parsing::source::SourceType::Volatile,
1600 &ResourceLimits::default(),
1601 )
1602 .unwrap();
1603 assert_eq!(parsed.repositories.len(), 1);
1604 let specs = parsed.repositories.values().next().unwrap();
1605 assert_eq!(
1606 specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1607 vec!["a", "b", "c"]
1608 );
1609 }
1610
1611 #[test]
1612 fn parse_repo_single_section_roundtrips_through_formatter() {
1613 let input = "repo r\n\nspec a\ndata x: 1";
1614 let parsed = parse(
1615 input,
1616 crate::parsing::source::SourceType::Volatile,
1617 &ResourceLimits::default(),
1618 )
1619 .unwrap();
1620 let formatted = format_parse_result(&parsed);
1621 let again = parse(
1622 &formatted,
1623 crate::parsing::source::SourceType::Volatile,
1624 &ResourceLimits::default(),
1625 )
1626 .unwrap();
1627 assert_eq!(again.repositories.len(), parsed.repositories.len());
1628 assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
1629 assert_eq!(
1630 again.flatten_specs()[0].name,
1631 parsed.flatten_specs()[0].name
1632 );
1633 }
1634
1635 #[test]
1636 fn parse_repo_two_sections_roundtrips_through_formatter() {
1637 let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
1638 let parsed = parse(
1639 input,
1640 crate::parsing::source::SourceType::Volatile,
1641 &ResourceLimits::default(),
1642 )
1643 .unwrap();
1644 let formatted = format_parse_result(&parsed);
1645 let again = parse(
1646 &formatted,
1647 crate::parsing::source::SourceType::Volatile,
1648 &ResourceLimits::default(),
1649 )
1650 .unwrap();
1651 assert_eq!(again.repositories.len(), 2);
1652 assert_eq!(again.flatten_specs().len(), 2);
1653 }
1654
1655 #[test]
1656 fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
1657 let input = r#"repo dup
1658
1659spec a
1660data x: 1
1661
1662repo dup
1663
1664spec b
1665data y: 2"#;
1666 let parsed = parse(
1667 input,
1668 crate::parsing::source::SourceType::Volatile,
1669 &ResourceLimits::default(),
1670 )
1671 .unwrap();
1672 let formatted = format_parse_result(&parsed);
1673 let again = parse(
1674 &formatted,
1675 crate::parsing::source::SourceType::Volatile,
1676 &ResourceLimits::default(),
1677 )
1678 .unwrap();
1679 assert_eq!(
1680 again.repositories.len(),
1681 1,
1682 "formatted duplicate-repo file must still merge to one logical repo"
1683 );
1684 assert_eq!(again.flatten_specs().len(), 2);
1685 }
1686}