Skip to main content

lemma/parsing/
mod.rs

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// ============================================================================
24// Tests
25// ============================================================================
26
27#[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    /// Every token that may follow a completed rule / unless expression ending in `as`.
481    #[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                // Expected
1130            }
1131            Err(e) => panic!("Expected Parse error, got: {e:?}"),
1132            Ok(_) => panic!("Expected parse error"),
1133        }
1134    }
1135
1136    // ─── Parser-level pins for DataValue variants ────────────────────
1137
1138    /// `fill x: a.b` (local LHS, dotted RHS) must be parsed as [`DataValue::Fill`] with a reference payload.
1139    #[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    /// `fill x: a.b.c.d` (3+ segment RHS) must parse and preserve segments.
1170    #[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    /// `fill x: a.b -> minimum 5` is rejected; constraint chains belong on `data`.
1195    #[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    /// `data x: notdotted` (local LHS, non-dotted RHS) MUST stay a
1213    /// Definition with an explicit custom parent — not silently reinterpreted as a Reference.
1214    #[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    /// `fill x.y: notdotted` (binding LHS, non-dotted RHS) is parsed as [`DataValue::Fill`] with a reference payload.
1240    #[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    /// `data x: spec …` is invalid; spec imports use `uses`.
1265    #[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    /// `fill x.y: z.w` (binding LHS, dotted RHS) → Reference with two LHS
1288    /// segments and two RHS segments.
1289    #[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}