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::*;
10pub use parser::ParseResult;
11
12pub fn parse(
13    content: &str,
14    source_type: source::SourceType,
15    limits: &ResourceLimits,
16) -> Result<ParseResult, Error> {
17    parser::parse(content, source_type, limits)
18}
19
20#[cfg(test)]
21mod assignment_continuation_tests;
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 year", "year"),
315            ("6 month", "month"),
316            ("52 week", "week"),
317            ("365 day", "day"),
318            ("24 hour", "hour"),
319            ("60 minute", "minute"),
320            ("3600 second", "second"),
321            ("1000 millisecond", "millisecond"),
322            ("500000 microsecond", "microsecond"),
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 hour) > 2",
348                "duration conversion in comparison with parens",
349            ),
350            (
351                "(meeting_time as minute) >= 30",
352                "duration conversion with gte",
353            ),
354            (
355                "(project_length as day) < 100",
356                "duration conversion with lt",
357            ),
358            (
359                "(delay as second) is 60",
360                "duration conversion with equality",
361            ),
362            (
363                "(1 hour) > (30 minute)",
364                "duration conversions on both sides",
365            ),
366            ("duration as hour > 2", "duration conversion without parens"),
367            (
368                "meeting_time as second > 3600",
369                "variable duration conversion in comparison",
370            ),
371            (
372                "project_length as day > deadline_days",
373                "two variables with duration conversion",
374            ),
375            (
376                "duration as hour >= 1 and duration as hour <= 8",
377                "multiple duration comparisons",
378            ),
379            (
380                "(2024-06-01...2024-06-15) as day as number >= 7",
381                "chained as conversion before comparison",
382            ),
383            ("duration as hour as number > 2", "chained as on duration"),
384        ];
385
386        for (expr, description) in test_cases {
387            let input = format!("spec test\nrule test: {}", expr);
388            let result = parse(
389                &input,
390                crate::parsing::source::SourceType::Volatile,
391                &ResourceLimits::default(),
392            );
393            assert!(
394                result.is_ok(),
395                "Failed to parse {} ({}): {:?}",
396                expr,
397                description,
398                result.err()
399            );
400        }
401    }
402
403    #[test]
404    fn parse_rejects_token_after_unit_conversion() {
405        let result = parse(
406            "spec test\nuses lemma units\nrule ok: (2024-06-01...2024-06-15) as day foo",
407            crate::parsing::source::SourceType::Volatile,
408            &ResourceLimits::default(),
409        );
410        let err = result.expect_err("expected parse error");
411        let msg = err.to_string();
412        assert!(
413            msg.contains("Unexpected token") && msg.contains("foo"),
414            "expected error at 'foo', got: {}",
415            msg
416        );
417        assert!(
418            !msg.contains("Expected 'data'"),
419            "should not defer to spec-level error, got: {}",
420            msg
421        );
422    }
423
424    #[test]
425    fn parse_unit_conversion_before_next_spec() {
426        let result = parse(
427            r#"spec pricing
428rule hourly_rate: 150 eur
429  unless loyalty is "silver" then 140 eur
430  unless loyalty is "gold" then 125 usd as eur
431
432spec other
433rule x: 1"#,
434            crate::parsing::source::SourceType::Volatile,
435            &ResourceLimits::default(),
436        );
437        assert!(
438            result.is_ok(),
439            "unless branch ending with 'as' must parse before next spec: {:?}",
440            result.err()
441        );
442    }
443
444    #[test]
445    fn parse_unit_conversion_before_sibling_rule() {
446        let result = parse(
447            r#"spec s
448rule a: 100 usd as eur
449rule b: 1"#,
450            crate::parsing::source::SourceType::Volatile,
451            &ResourceLimits::default(),
452        );
453        assert!(
454            result.is_ok(),
455            "rule ending with 'as' must parse before sibling rule: {:?}",
456            result.err()
457        );
458    }
459
460    #[test]
461    fn parse_unit_conversion_before_uses() {
462        let result = parse(
463            r#"spec s
464rule rate: 10 usd as eur
465uses lemma units
466rule hour: 1 hour"#,
467            crate::parsing::source::SourceType::Volatile,
468            &ResourceLimits::default(),
469        );
470        assert!(
471            result.is_ok(),
472            "rule ending with 'as' must parse before uses: {:?}",
473            result.err()
474        );
475    }
476
477    /// Every token that may follow a completed rule / unless expression ending in `as`.
478    #[test]
479    fn parse_unit_conversion_before_expression_boundaries() {
480        let cases: &[(&str, &str)] = &[
481            (
482                "sibling data",
483                r#"spec s
484rule rate: 10 usd as eur
485data price: 100 eur"#,
486            ),
487            (
488                "sibling uses",
489                r#"spec s
490rule rate: 10 usd as eur
491uses other"#,
492            ),
493            (
494                "sibling meta",
495                r#"spec s
496rule rate: 10 usd as eur
497meta version: 1"#,
498            ),
499            (
500                "another unless",
501                r#"spec s
502rule rate: 10 usd
503  unless active then 5 usd as eur
504  unless premium then 3 usd as eur"#,
505            ),
506            (
507                "eof",
508                r#"spec s
509rule rate: 10 usd as eur"#,
510            ),
511            (
512                "next repo",
513                r#"spec s
514rule rate: 10 usd as eur
515
516repo other
517spec t
518rule x: 1"#,
519            ),
520            (
521                "unless then before next unless",
522                r#"spec s
523rule rate: 10 usd
524  unless a then 1 usd as eur
525  unless b then 2"#,
526            ),
527            (
528                "chained as before sibling rule",
529                r#"spec s
530rule rate: (2024-01-01...2024-01-02) as day as number
531rule other: 1"#,
532            ),
533        ];
534
535        for (label, source) in cases {
536            let result = parse(
537                source,
538                crate::parsing::source::SourceType::Volatile,
539                &ResourceLimits::default(),
540            );
541            assert!(
542                result.is_ok(),
543                "unit conversion before {label} must parse: {:?}",
544                result.err()
545            );
546        }
547    }
548
549    #[test]
550    fn parse_rejects_plain_number_plus_converted_operand() {
551        let result = parse(
552            r#"spec test
553data c: measure
554  -> unit eur: 1
555  -> unit usd: 0.84
556rule z: 5 + c as usd"#,
557            crate::parsing::source::SourceType::Volatile,
558            &ResourceLimits::default(),
559        );
560        let err = result.expect_err("expected parse error for 5 + c as usd");
561        let msg = err.to_string();
562        assert!(
563            msg.contains("plain number") || msg.contains("each operand"),
564            "expected conversion-before-+ error, got: {msg}"
565        );
566    }
567
568    #[test]
569    fn parse_accepts_conversion_on_each_additive_operand() {
570        let cases: &[(&str, &str)] = &[
571            (
572                "money",
573                r#"spec test
574data c: measure
575  -> unit eur: 1
576  -> unit usd: 0.84
577rule z: 5 as usd + c as usd"#,
578            ),
579            (
580                "duration + literal",
581                r#"spec test
582uses lemma units
583rule z: duration as hour + 1"#,
584            ),
585            (
586                "duration + comparison",
587                r#"spec test
588uses lemma units
589data duration: units.duration
590  -> suggest 1 hour
591rule z: duration as hour + 1 > 0"#,
592            ),
593            (
594                "date range + ref",
595                r#"spec test
596uses lemma units
597data age: date range
598data c: measure
599  -> unit eur: 1
600rule z: age as day + c"#,
601            ),
602        ];
603        for (label, source) in cases {
604            let result = parse(
605                source,
606                crate::parsing::source::SourceType::Volatile,
607                &ResourceLimits::default(),
608            );
609            assert!(
610                result.is_ok(),
611                "expected {label} to parse, got: {:?}",
612                result.err()
613            );
614        }
615    }
616
617    fn rule_expression(source: &str, rule_name: &str) -> Expression {
618        let parsed = parse(
619            source,
620            crate::parsing::source::SourceType::Volatile,
621            &ResourceLimits::default(),
622        )
623        .expect("expected parse");
624        let spec = parsed
625            .flatten_specs()
626            .into_iter()
627            .next()
628            .expect("expected one spec");
629        spec.rules
630            .iter()
631            .find(|rule| rule.name == rule_name)
632            .unwrap_or_else(|| panic!("rule '{rule_name}' not found"))
633            .expression
634            .clone()
635    }
636
637    fn assert_multiply_range_side(expression: &Expression, range_on_left: bool, label: &str) {
638        let ExpressionKind::Arithmetic(left, ArithmeticComputation::Multiply, right) =
639            &expression.kind
640        else {
641            panic!("{label}: expected Multiply, got {:?}", expression.kind);
642        };
643        let (range, other) = if range_on_left {
644            (left.as_ref(), right.as_ref())
645        } else {
646            (right.as_ref(), left.as_ref())
647        };
648        assert!(
649            matches!(range.kind, ExpressionKind::RangeLiteral(..)),
650            "{label}: expected RangeLiteral on {} of *, got {:?}",
651            if range_on_left { "left" } else { "right" },
652            range.kind
653        );
654        assert!(
655            !matches!(other.kind, ExpressionKind::RangeLiteral(..)),
656            "{label}: expected non-range on other side of *"
657        );
658    }
659
660    #[test]
661    fn parse_range_binds_tighter_than_multiply() {
662        let base = r#"spec test
663uses lemma units
664data rate: measure -> unit eur: 1
665data period_start: 2026-01-01
666data period_end: 2026-01-02
667"#;
668        assert_multiply_range_side(
669            &rule_expression(
670                &format!("{base}rule rhs: rate * period_start...period_end"),
671                "rhs",
672            ),
673            false,
674            "rate * period_start...period_end",
675        );
676        assert_multiply_range_side(
677            &rule_expression(
678                &format!("{base}rule lhs: period_start...period_end * rate"),
679                "lhs",
680            ),
681            true,
682            "period_start...period_end * rate",
683        );
684    }
685
686    #[test]
687    fn parse_range_additive_right_endpoint_binds_inside_literal() {
688        let expression = rule_expression(
689            r#"spec test
690uses lemma units
691data start: date
692data length: units.duration
693rule valid: now in start...start + length"#,
694            "valid",
695        );
696        let ExpressionKind::RangeContainment(value, range) = &expression.kind else {
697            panic!("expected RangeContainment, got {:?}", expression.kind);
698        };
699        assert!(
700            matches!(value.kind, ExpressionKind::Now),
701            "expected now as containment value, got {:?}",
702            value.kind
703        );
704        let ExpressionKind::RangeLiteral(left, right) = &range.kind else {
705            panic!(
706                "expected RangeLiteral as containment range, got {:?}",
707                range.kind
708            );
709        };
710        assert!(
711            matches!(left.kind, ExpressionKind::Reference(..)),
712            "expected start reference as left endpoint, got {:?}",
713            left.kind
714        );
715        let ExpressionKind::Arithmetic(add_left, ArithmeticComputation::Add, add_right) =
716            &right.kind
717        else {
718            panic!(
719                "expected start + length as right endpoint, got {:?}",
720                right.kind
721            );
722        };
723        assert!(
724            matches!(add_left.kind, ExpressionKind::Reference(..)),
725            "expected start reference in right endpoint add, got {:?}",
726            add_left.kind
727        );
728        assert!(
729            matches!(add_right.kind, ExpressionKind::Reference(..)),
730            "expected length reference in right endpoint add, got {:?}",
731            add_right.kind
732        );
733    }
734
735    #[test]
736    fn parse_range_multiply_with_conversion_without_inner_parens() {
737        let expression = rule_expression(
738            r#"spec test
739uses lemma units
740data money: measure -> unit eur: 1
741data rate: measure -> unit eur_per_hour: eur/hour
742data hourly_rate: 50 eur_per_hour
743data period_start: 2026-01-01
744data period_end: 2026-01-02
745rule pay: (hourly_rate * period_start...period_end) as eur"#,
746            "pay",
747        );
748        let ExpressionKind::UnitConversion(inner, _) = &expression.kind else {
749            panic!("expected UnitConversion, got {:?}", expression.kind);
750        };
751        assert_multiply_range_side(inner, false, "pay");
752    }
753
754    #[test]
755    fn parse_error_includes_attribute_and_parse_error_spec_name() {
756        let result = parse(
757            r#"
758spec test
759data name: "Unclosed string
760data age: 25
761"#,
762            crate::parsing::source::SourceType::Volatile,
763            &ResourceLimits::default(),
764        );
765
766        match result {
767            Err(Error::Parsing(details)) => {
768                let src = details
769                    .source
770                    .as_ref()
771                    .expect("BUG: parsing errors always have source");
772                assert_eq!(
773                    src.source_type,
774                    crate::parsing::source::SourceType::Volatile
775                );
776            }
777            Err(e) => panic!("Expected Parse error, got: {e:?}"),
778            Ok(_) => panic!("Expected parse error for unclosed string"),
779        }
780    }
781
782    #[test]
783    fn parse_single_spec_file() {
784        let input = r#"spec somespec
785data name: "Alice""#;
786        let parsed = parse(
787            input,
788            crate::parsing::source::SourceType::Volatile,
789            &ResourceLimits::default(),
790        )
791        .unwrap();
792        let specs = parsed.flatten_specs();
793        assert_eq!(specs.len(), 1);
794        assert_eq!(specs[0].name, "somespec");
795    }
796
797    #[test]
798    fn parse_uses_registry_spec_explicit_alias() {
799        let input = r#"spec example
800uses external: @user/workspace somespec"#;
801        let specs = parse(
802            input,
803            crate::parsing::source::SourceType::Volatile,
804            &ResourceLimits::default(),
805        )
806        .unwrap()
807        .into_flattened_specs();
808        assert_eq!(specs.len(), 1);
809        assert_eq!(specs[0].data.len(), 1);
810        match &specs[0].data[0].value {
811            crate::parsing::ast::DataValue::Import {
812                spec_ref,
813                bindings: _,
814            } => {
815                assert_eq!(spec_ref.name, "somespec");
816                let repository_hdr = spec_ref
817                    .repository
818                    .as_ref()
819                    .expect("expected repository qualifier");
820                assert_eq!(repository_hdr.name, "@user/workspace");
821            }
822            other => panic!("Expected Import, got: {:?}", other),
823        }
824    }
825
826    #[test]
827    fn parse_multiple_specs_cross_reference_in_file() {
828        let input = r#"spec spec_a
829data x: 10
830
831spec spec_b
832data y: 20
833uses a: spec_a"#;
834        let parsed = parse(
835            input,
836            crate::parsing::source::SourceType::Volatile,
837            &ResourceLimits::default(),
838        )
839        .unwrap();
840        let specs = parsed.flatten_specs();
841        assert_eq!(specs.len(), 2);
842        assert_eq!(specs[0].name, "spec_a");
843        assert_eq!(specs[1].name, "spec_b");
844    }
845
846    #[test]
847    fn parse_uses_registry_spec_default_alias() {
848        let input = "spec example\nuses @owner/repo somespec";
849        let specs = parse(
850            input,
851            crate::parsing::source::SourceType::Volatile,
852            &ResourceLimits::default(),
853        )
854        .unwrap()
855        .into_flattened_specs();
856        match &specs[0].data[0].value {
857            crate::parsing::ast::DataValue::Import {
858                spec_ref,
859                bindings: _,
860            } => {
861                assert_eq!(spec_ref.name, "somespec");
862                let repository_hdr = spec_ref
863                    .repository
864                    .as_ref()
865                    .expect("expected repository qualifier");
866                assert_eq!(repository_hdr.name, "@owner/repo");
867            }
868            other => panic!("Expected Import, got: {:?}", other),
869        }
870    }
871
872    #[test]
873    fn parse_uses_local_spec_default_alias() {
874        let input = "spec example\nuses myspec";
875        let specs = parse(
876            input,
877            crate::parsing::source::SourceType::Volatile,
878            &ResourceLimits::default(),
879        )
880        .unwrap()
881        .into_flattened_specs();
882        match &specs[0].data[0].value {
883            crate::parsing::ast::DataValue::Import {
884                spec_ref,
885                bindings: _,
886            } => {
887                assert_eq!(spec_ref.name, "myspec");
888                assert!(
889                    spec_ref.repository.is_none(),
890                    "same-repository reference must omit repository qualifier"
891                );
892            }
893            other => panic!("Expected Import, got: {:?}", other),
894        }
895    }
896
897    #[test]
898    fn parse_spec_name_with_trailing_dot_is_error() {
899        let input = "spec myspec.\ndata x: 1";
900        let result = parse(
901            input,
902            crate::parsing::source::SourceType::Volatile,
903            &ResourceLimits::default(),
904        );
905        assert!(
906            result.is_err(),
907            "Trailing dot after spec name should be a parse error"
908        );
909    }
910
911    #[test]
912    fn parse_multiple_specs_in_same_file() {
913        let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
914        let result = parse(
915            input,
916            crate::parsing::source::SourceType::Volatile,
917            &ResourceLimits::default(),
918        )
919        .unwrap()
920        .into_flattened_specs();
921        assert_eq!(result.len(), 2);
922        assert_eq!(result[0].name, "myspec_a");
923        assert_eq!(result[1].name, "myspec_b");
924    }
925
926    #[test]
927    fn parse_uses_accepts_name_only() {
928        let input = "spec consumer\nuses other";
929        let result = parse(
930            input,
931            crate::parsing::source::SourceType::Volatile,
932            &ResourceLimits::default(),
933        );
934        assert!(result.is_ok(), "uses name should parse");
935        let specs = result.unwrap().into_flattened_specs();
936        let spec_ref = match &specs[0].data[0].value {
937            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
938            _ => panic!("expected Import"),
939        };
940        assert_eq!(spec_ref.name, "other");
941    }
942
943    #[test]
944    fn parse_uses_bare_year_effective() {
945        let input = "spec consumer\nuses other 2026";
946        let result = parse(
947            input,
948            crate::parsing::source::SourceType::Volatile,
949            &ResourceLimits::default(),
950        )
951        .unwrap();
952        let specs = result.into_flattened_specs();
953        let spec_ref = match &specs[0].data[0].value {
954            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
955            _ => panic!("expected Import"),
956        };
957        assert_eq!(spec_ref.name, "other");
958        let eff = spec_ref.effective.as_ref().expect("effective");
959        assert_eq!(eff.year, 2026);
960        assert_eq!(eff.month, 1);
961        assert_eq!(eff.day, 1);
962    }
963
964    #[test]
965    fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
966        let input = "spec consumer\nuses @iso/countries alpha2 2026";
967        let result = parse(
968            input,
969            crate::parsing::source::SourceType::Volatile,
970            &ResourceLimits::default(),
971        )
972        .unwrap();
973        let spec = &result.flatten_specs()[0];
974        let sr = match &spec.data[0].value {
975            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
976            _ => panic!("expected Import"),
977        };
978        let rs = sr
979            .repository_span
980            .as_ref()
981            .expect("repository_span should be set for @-qualified uses");
982        let ts = sr
983            .target_span
984            .as_ref()
985            .expect("target_span should cover spec name and effective");
986        assert_eq!(&input[rs.start..rs.end], "@iso/countries");
987        assert_eq!(&input[ts.start..ts.end], "alpha2 2026");
988    }
989
990    #[test]
991    fn parse_uses_alias_no_comma_continuation() {
992        let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
993        let result = parse(
994            input,
995            crate::parsing::source::SourceType::Volatile,
996            &ResourceLimits::default(),
997        )
998        .unwrap();
999        let data = &result.flatten_specs()[0].data;
1000        assert_eq!(data.len(), 2);
1001        assert_eq!(data[0].reference.name, "alias");
1002        let sr = match &data[0].value {
1003            crate::parsing::ast::DataValue::Import { spec_ref, .. } => spec_ref,
1004            _ => panic!("expected Import"),
1005        };
1006        assert_eq!(sr.name, "retail");
1007        let repository_hdr = sr
1008            .repository
1009            .as_ref()
1010            .expect("expected repository qualifier");
1011        assert_eq!(repository_hdr.name, "pricing");
1012    }
1013
1014    #[test]
1015    fn parse_data_qualified_type_with_effective_and_repository_on_uses() {
1016        let input = "spec consumer\nuses @iso/countries alpha2 2026-06-01\ndata country: alpha2.code -> option \"NL\"";
1017        let result = parse(
1018            input,
1019            crate::parsing::source::SourceType::Volatile,
1020            &ResourceLimits::default(),
1021        )
1022        .unwrap()
1023        .into_flattened_specs();
1024        let spec_ref = match &result[0].data[0].value {
1025            crate::parsing::ast::DataValue::Import { spec_ref: sr, .. } => sr,
1026            other => panic!("expected Import on uses row, got: {:?}", other),
1027        };
1028        assert_eq!(spec_ref.name, "alpha2");
1029
1030        let eff = spec_ref
1031            .effective
1032            .as_ref()
1033            .expect("expected effective datetime");
1034        assert_eq!(eff.year, 2026);
1035        assert_eq!(eff.month, 6);
1036
1037        let qualifier = spec_ref
1038            .repository
1039            .as_ref()
1040            .expect("expected repository qualifier");
1041        assert_eq!(qualifier.name, "@iso/countries");
1042
1043        match &result[0].data[1].value {
1044            crate::parsing::ast::DataValue::Definition {
1045                base,
1046                constraints,
1047                value,
1048            } => {
1049                assert!(value.is_none());
1050                assert_eq!(
1051                    base.as_ref().expect("expected base"),
1052                    &crate::parsing::ast::ParentType::Qualified {
1053                        spec_alias: "alpha2".into(),
1054                        inner: Box::new(crate::parsing::ast::ParentType::Custom {
1055                            name: "code".into(),
1056                        }),
1057                    }
1058                );
1059
1060                let cs = constraints
1061                    .as_ref()
1062                    .expect("expected trailing constraint chain");
1063                assert_eq!(cs.len(), 1);
1064            }
1065            other => panic!("expected Definition, got: {:?}", other),
1066        }
1067    }
1068
1069    #[test]
1070    fn parse_error_is_returned_for_garbage_input() {
1071        let result = parse(
1072            r#"
1073spec test
1074this is not valid lemma syntax @#$%
1075"#,
1076            crate::parsing::source::SourceType::Volatile,
1077            &ResourceLimits::default(),
1078        );
1079
1080        assert!(result.is_err(), "Should fail on malformed input");
1081        match result {
1082            Err(Error::Parsing { .. }) => {
1083                // Expected
1084            }
1085            Err(e) => panic!("Expected Parse error, got: {e:?}"),
1086            Ok(_) => panic!("Expected parse error"),
1087        }
1088    }
1089
1090    // ─── Parser-level pins for DataValue variants ────────────────────
1091
1092    #[test]
1093    fn parse_local_with_literal_rejected() {
1094        let err = parse(
1095            r#"spec s
1096with x: 42"#,
1097            crate::parsing::source::SourceType::Volatile,
1098            &ResourceLimits::default(),
1099        )
1100        .unwrap_err();
1101        let msg = err.to_string();
1102        assert!(
1103            msg.contains("Standalone") || msg.contains("uses"),
1104            "expected standalone with rejection, got: {msg}"
1105        );
1106    }
1107
1108    #[test]
1109    fn parse_local_with_import_reference_rejected() {
1110        let err = parse(
1111            r#"spec s
1112uses i: inner
1113with copy: i.v"#,
1114            crate::parsing::source::SourceType::Volatile,
1115            &ResourceLimits::default(),
1116        )
1117        .unwrap_err();
1118        let msg = err.to_string();
1119        assert!(
1120            msg.contains("Standalone") || msg.contains("uses"),
1121            "expected standalone with rejection, got: {msg}"
1122        );
1123    }
1124
1125    #[test]
1126    fn parse_local_with_dotted_rhs_rejected() {
1127        let err = parse(
1128            r#"spec s
1129with x: a.something"#,
1130            crate::parsing::source::SourceType::Volatile,
1131            &ResourceLimits::default(),
1132        )
1133        .unwrap_err();
1134        let msg = err.to_string();
1135        assert!(
1136            msg.contains("Standalone") || msg.contains("uses"),
1137            "expected standalone with rejection, got: {msg}"
1138        );
1139    }
1140
1141    #[test]
1142    fn parse_local_with_multi_segment_rhs_rejected() {
1143        let err = parse(
1144            r#"spec s
1145with x: alpha.beta.gamma.delta"#,
1146            crate::parsing::source::SourceType::Volatile,
1147            &ResourceLimits::default(),
1148        )
1149        .unwrap_err();
1150        let msg = err.to_string();
1151        assert!(
1152            msg.contains("Standalone") || msg.contains("uses"),
1153            "expected standalone with rejection, got: {msg}"
1154        );
1155    }
1156
1157    /// `data x: notdotted` (local LHS, non-dotted RHS) MUST stay a
1158    /// Definition with an explicit custom parent — not silently reinterpreted as a Reference.
1159    #[test]
1160    fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1161        let input = r#"spec s
1162data x: myothertype"#;
1163        let result = parse(
1164            input,
1165            crate::parsing::source::SourceType::Volatile,
1166            &ResourceLimits::default(),
1167        )
1168        .unwrap()
1169        .into_flattened_specs();
1170        let value = &result[0].data[0].value;
1171        assert!(
1172            matches!(
1173                value,
1174                crate::parsing::ast::DataValue::Definition {
1175                    base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1176                    ..
1177                }
1178            ),
1179            "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1180            value
1181        );
1182    }
1183
1184    /// `uses` block `-> with path: reference` stores reference in binding rhs.
1185    #[test]
1186    fn parse_uses_binding_non_dotted_rhs_is_reference() {
1187        let input = r#"spec s
1188uses child: other
1189  -> with slot: somename"#;
1190        let result = parse(
1191            input,
1192            crate::parsing::source::SourceType::Volatile,
1193            &ResourceLimits::default(),
1194        )
1195        .unwrap()
1196        .into_flattened_specs();
1197        let bindings = match &result[0].data[0].value {
1198            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1199            other => panic!("expected Import, got: {:?}", other),
1200        };
1201        assert_eq!(bindings.len(), 1);
1202        assert!(
1203            matches!(
1204                &bindings[0].rhs,
1205                crate::parsing::ast::WithRhs::Reference { .. }
1206            ),
1207            "non-dotted RHS must yield Reference; got: {:?}",
1208            bindings[0].rhs
1209        );
1210    }
1211
1212    /// `data x: spec …` is invalid; spec imports use `uses`.
1213    #[test]
1214    fn parse_data_colon_spec_rhs_is_rejected() {
1215        let result = parse(
1216            r#"
1217spec s
1218data x: spec other
1219"#,
1220            crate::parsing::source::SourceType::Volatile,
1221            &ResourceLimits::default(),
1222        );
1223        match result {
1224            Ok(_) => panic!("`data x: spec other` must fail to parse"),
1225            Err(err) => {
1226                let msg = err.to_string();
1227                assert!(
1228                    msg.contains("uses") && msg.contains("spec"),
1229                    "error must direct to `uses` for spec import, got: {msg}"
1230                );
1231            }
1232        }
1233    }
1234
1235    /// `uses` block dotted path and dotted reference rhs preserved.
1236    #[test]
1237    fn parse_uses_binding_with_dotted_rhs_preserves_both_sides() {
1238        let input = r#"spec s
1239uses outer: other
1240  -> with inner: target.field"#;
1241        let result = parse(
1242            input,
1243            crate::parsing::source::SourceType::Volatile,
1244            &ResourceLimits::default(),
1245        )
1246        .unwrap()
1247        .into_flattened_specs();
1248        let bindings = match &result[0].data[0].value {
1249            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1250            other => panic!("expected Import, got: {:?}", other),
1251        };
1252        assert_eq!(bindings.len(), 1);
1253        assert_eq!(bindings[0].path.segments, vec![] as Vec<String>);
1254        assert_eq!(bindings[0].path.name, "inner");
1255        match &bindings[0].rhs {
1256            crate::parsing::ast::WithRhs::Reference { target } => {
1257                assert_eq!(target.segments, vec!["target"]);
1258                assert_eq!(target.name, "field");
1259            }
1260            other => panic!("expected Reference rhs, got: {:?}", other),
1261        }
1262    }
1263
1264    #[test]
1265    fn parse_deprecated_standalone_with_merges_into_import_bindings() {
1266        let input = r#"spec inner
1267data x: number
1268
1269spec outer
1270uses i: inner
1271with i.x: 42
1272rule r: i.x"#;
1273        let result = parse(
1274            input,
1275            crate::parsing::source::SourceType::Volatile,
1276            &ResourceLimits::default(),
1277        )
1278        .unwrap()
1279        .into_flattened_specs();
1280        let outer = result.iter().find(|s| s.name == "outer").unwrap();
1281        let bindings = match &outer.data[0].value {
1282            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1283            other => panic!("expected Import, got: {:?}", other),
1284        };
1285        assert_eq!(bindings.len(), 1);
1286        assert!(bindings[0].deprecated_standalone_with);
1287        assert_eq!(bindings[0].path.name, "x");
1288        match &bindings[0].rhs {
1289            crate::parsing::ast::WithRhs::Literal(crate::parsing::ast::Value::Number(n)) => {
1290                assert_eq!(n, &rust_decimal::Decimal::from(42));
1291            }
1292            other => panic!("expected literal 42, got: {:?}", other),
1293        }
1294    }
1295
1296    #[test]
1297    fn parse_deprecated_standalone_with_before_uses_merges() {
1298        let input = r#"spec inner
1299data slot: number
1300
1301spec outer
1302with i.slot: 99
1303uses i: inner
1304rule r: i.slot"#;
1305        let result = parse(
1306            input,
1307            crate::parsing::source::SourceType::Volatile,
1308            &ResourceLimits::default(),
1309        )
1310        .unwrap()
1311        .into_flattened_specs();
1312        let outer = result.iter().find(|s| s.name == "outer").unwrap();
1313        let bindings = match &outer.data[0].value {
1314            crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1315            other => panic!("expected Import, got: {:?}", other),
1316        };
1317        assert_eq!(bindings.len(), 1);
1318        assert!(bindings[0].deprecated_standalone_with);
1319    }
1320
1321    #[test]
1322    fn parse_standalone_with_without_matching_uses_errors() {
1323        let result = parse(
1324            r#"spec s
1325with outer.inner: 1"#,
1326            crate::parsing::source::SourceType::Volatile,
1327            &ResourceLimits::default(),
1328        );
1329        match result {
1330            Ok(_) => panic!("standalone with without uses must not parse"),
1331            Err(err) => {
1332                let msg = err.to_string();
1333                assert!(
1334                    msg.contains("uses") && msg.contains("outer"),
1335                    "error should mention required uses; got: {msg}"
1336                );
1337            }
1338        }
1339    }
1340
1341    #[test]
1342    fn parse_data_on_binding_path_is_rejected_with_with_hint() {
1343        let result = parse(
1344            r#"spec s
1345data outer.inner: 1"#,
1346            crate::parsing::source::SourceType::Volatile,
1347            &ResourceLimits::default(),
1348        );
1349        match result {
1350            Ok(_) => panic!("data with binding path must not parse"),
1351            Err(err) => {
1352                let msg = err.to_string();
1353                assert!(
1354                    msg.contains("with"),
1355                    "error should steer authors toward with; got: {msg}"
1356                );
1357            }
1358        }
1359    }
1360
1361    #[test]
1362    fn parse_bare_file_yields_single_anonymous_repository_group() {
1363        let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1364        let parsed = parse(
1365            input,
1366            crate::parsing::source::SourceType::Volatile,
1367            &ResourceLimits::default(),
1368        )
1369        .unwrap();
1370        assert_eq!(parsed.repositories.len(), 1);
1371        let (repo, specs) = parsed.repositories.iter().next().unwrap();
1372        assert!(repo.name.is_none());
1373        assert_eq!(specs.len(), 2);
1374        assert_eq!(specs[0].name, "a");
1375        assert_eq!(specs[1].name, "b");
1376    }
1377
1378    #[test]
1379    fn parse_repo_sections_preserve_order_and_names() {
1380        let input = r#"repo r1
1381
1382spec a
1383data x: 1
1384
1385repo r2
1386
1387spec b
1388data y: 2"#;
1389        let parsed = parse(
1390            input,
1391            crate::parsing::source::SourceType::Volatile,
1392            &ResourceLimits::default(),
1393        )
1394        .unwrap();
1395        assert_eq!(parsed.repositories.len(), 2);
1396        let keys: Vec<_> = parsed.repositories.keys().collect();
1397        assert_eq!(keys[0].name.as_deref(), Some("r1"));
1398        assert_eq!(keys[1].name.as_deref(), Some("r2"));
1399    }
1400
1401    #[test]
1402    fn parse_duplicate_repo_name_merges_spec_lists() {
1403        let input = r#"repo dup
1404
1405spec a
1406data x: 1
1407
1408repo dup
1409
1410spec b
1411data y: 2"#;
1412        let parsed = parse(
1413            input,
1414            crate::parsing::source::SourceType::Volatile,
1415            &ResourceLimits::default(),
1416        )
1417        .unwrap();
1418        assert_eq!(parsed.repositories.len(), 1);
1419        assert_eq!(parsed.flatten_specs().len(), 2);
1420    }
1421
1422    #[test]
1423    fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1424        let input = "repo empty";
1425        let parsed = parse(
1426            input,
1427            crate::parsing::source::SourceType::Volatile,
1428            &ResourceLimits::default(),
1429        )
1430        .unwrap();
1431        assert_eq!(parsed.repositories.len(), 1);
1432        let (_repo, specs) = parsed.repositories.iter().next().unwrap();
1433        assert_eq!(specs.len(), 0);
1434    }
1435
1436    #[test]
1437    fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1438        let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1439        let parsed = parse(
1440            input,
1441            crate::parsing::source::SourceType::Volatile,
1442            &ResourceLimits::default(),
1443        )
1444        .unwrap();
1445        assert_eq!(parsed.repositories.len(), 2);
1446        let names: Vec<_> = parsed
1447            .repositories
1448            .keys()
1449            .map(|r| r.name.as_deref())
1450            .collect();
1451        assert_eq!(names, vec![Some("a"), Some("b")]);
1452        assert!(parsed.repositories.values().next().unwrap().is_empty());
1453        assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1454    }
1455
1456    #[test]
1457    fn parse_spec_named_repo_keyword_should_be_rejected() {
1458        assert!(
1459            parse(
1460                "spec repo\ndata x: 1",
1461                crate::parsing::source::SourceType::Volatile,
1462                &ResourceLimits::default(),
1463            )
1464            .is_err(),
1465            "spec must not be allowed to use reserved keyword `repo` as its name"
1466        );
1467    }
1468
1469    #[test]
1470    fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1471        assert!(
1472            parse(
1473                "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1474                crate::parsing::source::SourceType::Volatile,
1475                &ResourceLimits::default(),
1476            )
1477            .is_err(),
1478            "repository name cannot be the token `spec`"
1479        );
1480    }
1481
1482    #[test]
1483    fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
1484        assert!(
1485            parse(
1486                "repo data\n\nspec z\ndata q: 1\nrule r: q",
1487                crate::parsing::source::SourceType::Volatile,
1488                &ResourceLimits::default(),
1489            )
1490            .is_err(),
1491            "repository name cannot be the token `data`"
1492        );
1493    }
1494
1495    #[test]
1496    fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
1497        assert!(
1498            parse(
1499                "repo rule\n\nspec z\ndata q: 1\nrule r: q",
1500                crate::parsing::source::SourceType::Volatile,
1501                &ResourceLimits::default(),
1502            )
1503            .is_err(),
1504            "repository name cannot be the token `rule`"
1505        );
1506    }
1507
1508    #[test]
1509    fn parse_data_named_repo_keyword_is_rejected() {
1510        let err = parse(
1511            "spec s\ndata repo: 1",
1512            crate::parsing::source::SourceType::Volatile,
1513            &ResourceLimits::default(),
1514        )
1515        .unwrap_err();
1516        assert!(
1517            err.to_string().contains("repo"),
1518            "data named repo should not parse: {}",
1519            err
1520        );
1521    }
1522
1523    #[test]
1524    fn parse_rule_named_repo_keyword_is_rejected() {
1525        let err = parse(
1526            "spec s\ndata x: 1\nrule repo: x",
1527            crate::parsing::source::SourceType::Volatile,
1528            &ResourceLimits::default(),
1529        )
1530        .unwrap_err();
1531        let msg = err.to_string();
1532        assert!(
1533            msg.contains("repo") || msg.contains("reserved"),
1534            "rule named repo should not parse: {msg}"
1535        );
1536    }
1537
1538    #[test]
1539    fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
1540        let parsed = parse(
1541            "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
1542            crate::parsing::source::SourceType::Volatile,
1543            &ResourceLimits::default(),
1544        )
1545        .unwrap();
1546        assert_eq!(parsed.repositories.len(), 1);
1547        assert_eq!(
1548            parsed.repositories.keys().next().unwrap().name.as_deref(),
1549            Some("warehouse")
1550        );
1551    }
1552
1553    #[test]
1554    fn parse_repo_name_case_insensitive_same_repository_merged() {
1555        let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
1556        let parsed = parse(
1557            input,
1558            crate::parsing::source::SourceType::Volatile,
1559            &ResourceLimits::default(),
1560        )
1561        .unwrap();
1562        assert_eq!(
1563            parsed.repositories.len(),
1564            1,
1565            "Foo and foo are the same repository after canonicalization"
1566        );
1567        let specs: Vec<_> = parsed.repositories.values().next().unwrap().clone();
1568        assert_eq!(specs.len(), 2);
1569        assert_eq!(specs[0].name, "a");
1570        assert_eq!(specs[1].name, "b");
1571    }
1572
1573    #[test]
1574    fn parse_repo_empty_name_errors() {
1575        let err = parse(
1576            "repo \nspec a\ndata x: 1",
1577            crate::parsing::source::SourceType::Volatile,
1578            &ResourceLimits::default(),
1579        )
1580        .unwrap_err();
1581        assert!(
1582            !err.to_string().is_empty(),
1583            "empty repo name should not parse quietly: {err}"
1584        );
1585    }
1586
1587    #[test]
1588    fn parse_repo_numeric_name_behavior() {
1589        let input = "repo 123\n\nspec a\ndata x: 1";
1590        let result = parse(
1591            input,
1592            crate::parsing::source::SourceType::Volatile,
1593            &ResourceLimits::default(),
1594        );
1595        match result {
1596            Ok(parsed) => {
1597                assert_eq!(
1598                    parsed.repositories.keys().next().unwrap().name.as_deref(),
1599                    Some("123"),
1600                    "if numeric repo names parse, identity must be stable"
1601                );
1602            }
1603            Err(e) => {
1604                assert!(
1605                    !e.to_string().is_empty(),
1606                    "rejecting numeric repo name is ok if explicit: {e}"
1607                );
1608            }
1609        }
1610    }
1611
1612    #[test]
1613    fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
1614        let input = r#"repo dup
1615
1616spec a
1617data x: 1
1618
1619repo dup
1620
1621spec b
1622data y: 2
1623
1624repo dup
1625
1626spec c
1627data z: 3"#;
1628        let parsed = parse(
1629            input,
1630            crate::parsing::source::SourceType::Volatile,
1631            &ResourceLimits::default(),
1632        )
1633        .unwrap();
1634        assert_eq!(parsed.repositories.len(), 1);
1635        let specs = parsed.repositories.values().next().unwrap();
1636        assert_eq!(
1637            specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1638            vec!["a", "b", "c"]
1639        );
1640    }
1641
1642    #[test]
1643    fn parse_repo_single_section_roundtrips_through_formatter() {
1644        let input = "repo r\n\nspec a\ndata x: 1";
1645        let parsed = parse(
1646            input,
1647            crate::parsing::source::SourceType::Volatile,
1648            &ResourceLimits::default(),
1649        )
1650        .unwrap();
1651        let formatted = format_parse_result(&parsed);
1652        let again = parse(
1653            &formatted,
1654            crate::parsing::source::SourceType::Volatile,
1655            &ResourceLimits::default(),
1656        )
1657        .unwrap();
1658        assert_eq!(again.repositories.len(), parsed.repositories.len());
1659        assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
1660        assert_eq!(
1661            again.flatten_specs()[0].name,
1662            parsed.flatten_specs()[0].name
1663        );
1664    }
1665
1666    #[test]
1667    fn parse_repo_two_sections_roundtrips_through_formatter() {
1668        let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
1669        let parsed = parse(
1670            input,
1671            crate::parsing::source::SourceType::Volatile,
1672            &ResourceLimits::default(),
1673        )
1674        .unwrap();
1675        let formatted = format_parse_result(&parsed);
1676        let again = parse(
1677            &formatted,
1678            crate::parsing::source::SourceType::Volatile,
1679            &ResourceLimits::default(),
1680        )
1681        .unwrap();
1682        assert_eq!(again.repositories.len(), 2);
1683        assert_eq!(again.flatten_specs().len(), 2);
1684    }
1685
1686    #[test]
1687    fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
1688        let input = r#"repo dup
1689
1690spec a
1691data x: 1
1692
1693repo dup
1694
1695spec b
1696data y: 2"#;
1697        let parsed = parse(
1698            input,
1699            crate::parsing::source::SourceType::Volatile,
1700            &ResourceLimits::default(),
1701        )
1702        .unwrap();
1703        let formatted = format_parse_result(&parsed);
1704        let again = parse(
1705            &formatted,
1706            crate::parsing::source::SourceType::Volatile,
1707            &ResourceLimits::default(),
1708        )
1709        .unwrap();
1710        assert_eq!(
1711            again.repositories.len(),
1712            1,
1713            "formatted duplicate-repo file must still merge to one logical repo"
1714        );
1715        assert_eq!(again.flatten_specs().len(), 2);
1716    }
1717
1718    #[test]
1719    fn parse_rejects_data_named_measure() {
1720        let result = parse(
1721            "spec s\ndata measure: 1",
1722            crate::parsing::source::SourceType::Volatile,
1723            &ResourceLimits::default(),
1724        );
1725        assert!(
1726            result.is_err(),
1727            "data named measure (type keyword) must be rejected"
1728        );
1729    }
1730
1731    #[test]
1732    fn parse_rejects_data_named_number() {
1733        let result = parse(
1734            "spec s\ndata number: 42",
1735            crate::parsing::source::SourceType::Volatile,
1736            &ResourceLimits::default(),
1737        );
1738        assert!(
1739            result.is_err(),
1740            "data named number (type keyword) must be rejected"
1741        );
1742    }
1743
1744    #[test]
1745    fn parse_rejects_data_named_text() {
1746        let result = parse(
1747            "spec s\ndata text: \"hello\"",
1748            crate::parsing::source::SourceType::Volatile,
1749            &ResourceLimits::default(),
1750        );
1751        assert!(
1752            result.is_err(),
1753            "data named text (type keyword) must be rejected"
1754        );
1755    }
1756
1757    #[test]
1758    fn parse_rejects_data_named_date() {
1759        let result = parse(
1760            "spec s\ndata date: 2024-01-01",
1761            crate::parsing::source::SourceType::Volatile,
1762            &ResourceLimits::default(),
1763        );
1764        assert!(
1765            result.is_err(),
1766            "data named date (type keyword) must be rejected"
1767        );
1768    }
1769
1770    #[test]
1771    fn parse_rejects_data_named_boolean() {
1772        let result = parse(
1773            "spec s\ndata boolean: true",
1774            crate::parsing::source::SourceType::Volatile,
1775            &ResourceLimits::default(),
1776        );
1777        assert!(
1778            result.is_err(),
1779            "data named boolean (type keyword) must be rejected"
1780        );
1781    }
1782
1783    #[test]
1784    fn parse_rejects_data_named_ratio() {
1785        let result = parse(
1786            "spec s\ndata ratio: 5%",
1787            crate::parsing::source::SourceType::Volatile,
1788            &ResourceLimits::default(),
1789        );
1790        assert!(
1791            result.is_err(),
1792            "data named ratio (type keyword) must be rejected"
1793        );
1794    }
1795
1796    #[test]
1797    fn parse_rejects_rule_named_measure() {
1798        let result = parse(
1799            "spec s\ndata x: 1\nrule measure: x",
1800            crate::parsing::source::SourceType::Volatile,
1801            &ResourceLimits::default(),
1802        );
1803        assert!(
1804            result.is_err(),
1805            "rule named measure (type keyword) must be rejected"
1806        );
1807    }
1808
1809    #[test]
1810    fn parse_rejects_rule_named_number() {
1811        let result = parse(
1812            "spec s\ndata x: 1\nrule number: x",
1813            crate::parsing::source::SourceType::Volatile,
1814            &ResourceLimits::default(),
1815        );
1816        assert!(
1817            result.is_err(),
1818            "rule named number (type keyword) must be rejected"
1819        );
1820    }
1821
1822    #[test]
1823    fn parse_rejects_rule_named_text() {
1824        let result = parse(
1825            "spec s\ndata x: 1\nrule text: x",
1826            crate::parsing::source::SourceType::Volatile,
1827            &ResourceLimits::default(),
1828        );
1829        assert!(
1830            result.is_err(),
1831            "rule named text (type keyword) must be rejected"
1832        );
1833    }
1834
1835    #[test]
1836    fn parse_rejects_rule_named_date() {
1837        let result = parse(
1838            "spec s\ndata x: 1\nrule date: x",
1839            crate::parsing::source::SourceType::Volatile,
1840            &ResourceLimits::default(),
1841        );
1842        assert!(
1843            result.is_err(),
1844            "rule named date (type keyword) must be rejected"
1845        );
1846    }
1847
1848    #[test]
1849    fn parse_rejects_rule_named_boolean() {
1850        let result = parse(
1851            "spec s\ndata x: 1\nrule boolean: x",
1852            crate::parsing::source::SourceType::Volatile,
1853            &ResourceLimits::default(),
1854        );
1855        assert!(
1856            result.is_err(),
1857            "rule named boolean (type keyword) must be rejected"
1858        );
1859    }
1860
1861    #[test]
1862    fn parse_rejects_rule_named_ratio() {
1863        let result = parse(
1864            "spec s\ndata x: 1\nrule ratio: x",
1865            crate::parsing::source::SourceType::Volatile,
1866            &ResourceLimits::default(),
1867        );
1868        assert!(
1869            result.is_err(),
1870            "rule named ratio (type keyword) must be rejected"
1871        );
1872    }
1873}