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// ============================================================================
21// Tests
22// ============================================================================
23
24#[cfg(test)]
25mod tests {
26    use super::{parse, ArithmeticComputation, Expression, ExpressionKind};
27    use crate::formatting::format_parse_result;
28    use crate::Error;
29    use crate::ResourceLimits;
30
31    #[test]
32    fn parse_empty_input_returns_no_specs() {
33        let result = parse(
34            "",
35            crate::parsing::source::SourceType::Volatile,
36            &ResourceLimits::default(),
37        )
38        .unwrap()
39        .into_flattened_specs();
40        assert_eq!(result.len(), 0);
41    }
42
43    #[test]
44    fn parse_workspace_file_yields_expected_spec_datas_and_rules() {
45        let input = r#"spec person
46data name: "John Doe"
47rule adult: true"#;
48        let result = parse(
49            input,
50            crate::parsing::source::SourceType::Volatile,
51            &ResourceLimits::default(),
52        )
53        .unwrap()
54        .into_flattened_specs();
55        assert_eq!(result.len(), 1);
56        assert_eq!(result[0].name, "person");
57        assert_eq!(result[0].data.len(), 1);
58        assert_eq!(result[0].rules.len(), 1);
59        assert_eq!(result[0].rules[0].name, "adult");
60    }
61
62    #[test]
63    fn mixing_data_and_rules_is_collected_into_spec() {
64        let input = r#"spec test
65data name: "John"
66rule is_adult: age >= 18
67data age: 25
68rule can_drink: age >= 21
69data status: "active"
70rule is_eligible: is_adult and status is "active""#;
71
72        let result = parse(
73            input,
74            crate::parsing::source::SourceType::Volatile,
75            &ResourceLimits::default(),
76        )
77        .unwrap()
78        .into_flattened_specs();
79        assert_eq!(result.len(), 1);
80        assert_eq!(result[0].data.len(), 3);
81        assert_eq!(result[0].rules.len(), 3);
82    }
83
84    #[test]
85    fn parse_simple_spec_collects_data() {
86        let input = r#"spec person
87data name: "John"
88data age: 25"#;
89        let result = parse(
90            input,
91            crate::parsing::source::SourceType::Volatile,
92            &ResourceLimits::default(),
93        )
94        .unwrap()
95        .into_flattened_specs();
96        assert_eq!(result.len(), 1);
97        assert_eq!(result[0].name, "person");
98        assert_eq!(result[0].data.len(), 2);
99    }
100
101    #[test]
102    fn parse_dotted_spec_name() {
103        let input = r#"spec contracts.employment.jack
104data name: "Jack""#;
105        let result = parse(
106            input,
107            crate::parsing::source::SourceType::Volatile,
108            &ResourceLimits::default(),
109        )
110        .unwrap()
111        .into_flattened_specs();
112        assert_eq!(result.len(), 1);
113        assert_eq!(result[0].name, "contracts.employment.jack");
114    }
115
116    #[test]
117    fn parse_slashed_spec_name() {
118        let input = "spec contracts/employment/jack\ndata x: 1";
119        let result = parse(
120            input,
121            crate::parsing::source::SourceType::Volatile,
122            &ResourceLimits::default(),
123        )
124        .unwrap()
125        .into_flattened_specs();
126        assert_eq!(result.len(), 1);
127        assert_eq!(result[0].name, "contracts/employment/jack");
128    }
129
130    #[test]
131    fn parse_spec_name_no_version_tag() {
132        let input = "spec myspec\nrule x: 1";
133        let result = parse(
134            input,
135            crate::parsing::source::SourceType::Volatile,
136            &ResourceLimits::default(),
137        )
138        .unwrap()
139        .into_flattened_specs();
140        assert_eq!(result.len(), 1);
141        assert_eq!(result[0].name, "myspec");
142        assert_eq!(result[0].effective_from(), None);
143    }
144
145    #[test]
146    fn parse_commentary_block_is_attached_to_spec() {
147        let input = r#"spec person
148"""
149This is a markdown comment
150uses **bold** text
151"""
152data name: "John""#;
153        let result = parse(
154            input,
155            crate::parsing::source::SourceType::Volatile,
156            &ResourceLimits::default(),
157        )
158        .unwrap()
159        .into_flattened_specs();
160        assert_eq!(result.len(), 1);
161        assert!(result[0].commentary.is_some());
162        assert!(result[0].commentary.as_ref().unwrap().contains("**bold**"));
163    }
164
165    #[test]
166    fn parse_spec_with_rule_collects_rule() {
167        let input = r#"spec person
168rule is_adult: age >= 18"#;
169        let result = parse(
170            input,
171            crate::parsing::source::SourceType::Volatile,
172            &ResourceLimits::default(),
173        )
174        .unwrap()
175        .into_flattened_specs();
176        assert_eq!(result.len(), 1);
177        assert_eq!(result[0].rules.len(), 1);
178        assert_eq!(result[0].rules[0].name, "is_adult");
179    }
180
181    #[test]
182    fn parse_multiple_specs_returns_all_specs() {
183        let input = r#"spec person
184data name: "John"
185
186spec company
187data name: "Acme Corp""#;
188        let result = parse(
189            input,
190            crate::parsing::source::SourceType::Volatile,
191            &ResourceLimits::default(),
192        )
193        .unwrap()
194        .into_flattened_specs();
195        assert_eq!(result.len(), 2);
196        assert_eq!(result[0].name, "person");
197        assert_eq!(result[1].name, "company");
198    }
199
200    #[test]
201    fn parse_allows_duplicate_data_names() {
202        let input = r#"spec person
203data name: "John"
204data name: "Jane""#;
205        let result = parse(
206            input,
207            crate::parsing::source::SourceType::Volatile,
208            &ResourceLimits::default(),
209        );
210        assert!(
211            result.is_ok(),
212            "Parser should succeed even with duplicate data"
213        );
214    }
215
216    #[test]
217    fn parse_allows_duplicate_rule_names() {
218        let input = r#"spec person
219rule is_adult: age >= 18
220rule is_adult: age >= 21"#;
221        let result = parse(
222            input,
223            crate::parsing::source::SourceType::Volatile,
224            &ResourceLimits::default(),
225        );
226        assert!(
227            result.is_ok(),
228            "Parser should succeed even with duplicate rules"
229        );
230    }
231
232    #[test]
233    fn parse_rejects_malformed_input() {
234        let input = "invalid syntax here";
235        let result = parse(
236            input,
237            crate::parsing::source::SourceType::Volatile,
238            &ResourceLimits::default(),
239        );
240        assert!(result.is_err());
241    }
242
243    #[test]
244    fn parse_handles_whitespace_variants_in_expressions() {
245        let test_cases = vec![
246            ("spec test\nrule test: 2+3", "no spaces in arithmetic"),
247            ("spec test\nrule test: age>=18", "no spaces in comparison"),
248            (
249                "spec test\nrule test: age >= 18 and salary>50000",
250                "spaces around and keyword",
251            ),
252            (
253                "spec test\nrule test: age  >=  18  and  salary  >  50000",
254                "extra spaces",
255            ),
256            (
257                "spec test\nrule test: \n  age >= 18 \n  and \n  salary > 50000",
258                "newlines in expression",
259            ),
260        ];
261
262        for (input, description) in test_cases {
263            let result = parse(
264                input,
265                crate::parsing::source::SourceType::Volatile,
266                &ResourceLimits::default(),
267            );
268            assert!(
269                result.is_ok(),
270                "Failed to parse {} ({}): {:?}",
271                input,
272                description,
273                result.err()
274            );
275        }
276    }
277
278    #[test]
279    fn parse_error_cases_are_rejected() {
280        let error_cases = vec![
281            (
282                "spec test\ndata name: \"unclosed string",
283                "unclosed string literal",
284            ),
285            ("spec test\nrule test: (2 + 3", "unclosed parenthesis"),
286            ("spec test\nrule test: 2 + 3)", "extra closing paren"),
287            ("spec test\ndata spec: 123", "reserved keyword as data name"),
288            (
289                "spec test\nrule rule: true",
290                "reserved keyword as rule name",
291            ),
292        ];
293
294        for (input, description) in error_cases {
295            let result = parse(
296                input,
297                crate::parsing::source::SourceType::Volatile,
298                &ResourceLimits::default(),
299            );
300            assert!(
301                result.is_err(),
302                "Expected error for {} but got success",
303                description
304            );
305        }
306    }
307
308    #[test]
309    fn parse_duration_literals_in_rules() {
310        let test_cases = vec![
311            ("2 years", "years"),
312            ("6 months", "months"),
313            ("52 weeks", "weeks"),
314            ("365 days", "days"),
315            ("24 hours", "hours"),
316            ("60 minutes", "minutes"),
317            ("3600 seconds", "seconds"),
318            ("1000 milliseconds", "milliseconds"),
319            ("500000 microseconds", "microseconds"),
320            ("50 percent", "percent"),
321        ];
322
323        for (expr, description) in test_cases {
324            let input = format!("spec test\nrule test: {}", expr);
325            let result = parse(
326                &input,
327                crate::parsing::source::SourceType::Volatile,
328                &ResourceLimits::default(),
329            );
330            assert!(
331                result.is_ok(),
332                "Failed to parse literal {} ({}): {:?}",
333                expr,
334                description,
335                result.err()
336            );
337        }
338    }
339
340    #[test]
341    fn parse_comparisons_with_duration_unit_conversions() {
342        let test_cases = vec![
343            (
344                "(duration as hours) > 2",
345                "duration conversion in comparison with parens",
346            ),
347            (
348                "(meeting_time as minutes) >= 30",
349                "duration conversion with gte",
350            ),
351            (
352                "(project_length as days) < 100",
353                "duration conversion with lt",
354            ),
355            (
356                "(delay as seconds) is 60",
357                "duration conversion with equality",
358            ),
359            (
360                "(1 hours) > (30 minutes)",
361                "duration conversions on both sides",
362            ),
363            (
364                "duration as hours > 2",
365                "duration conversion without parens",
366            ),
367            (
368                "meeting_time as seconds > 3600",
369                "variable duration conversion in comparison",
370            ),
371            (
372                "project_length as days > deadline_days",
373                "two variables with duration conversion",
374            ),
375            (
376                "duration as hours >= 1 and duration as hours <= 8",
377                "multiple duration comparisons",
378            ),
379            (
380                "(2024-06-01...2024-06-15) as days as number >= 7",
381                "chained as conversion before comparison",
382            ),
383            ("duration as hours 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 days 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 hours: 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 with",
489                r#"spec s
490rule rate: 10 usd as eur
491with cfg.rate: rate"#,
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 days 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 hours + 1"#,
584            ),
585            (
586                "duration + comparison",
587                r#"spec test
588uses lemma units
589data duration: units.duration
590  -> default 1 hour
591rule z: duration as hours + 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 days + 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(spec_ref) => {
812                assert_eq!(spec_ref.name, "somespec");
813                let repository_hdr = spec_ref
814                    .repository
815                    .as_ref()
816                    .expect("expected repository qualifier");
817                assert_eq!(repository_hdr.name, "@user/workspace");
818            }
819            other => panic!("Expected Import, got: {:?}", other),
820        }
821    }
822
823    #[test]
824    fn parse_multiple_specs_cross_reference_in_file() {
825        let input = r#"spec spec_a
826data x: 10
827
828spec spec_b
829data y: 20
830uses a: spec_a"#;
831        let parsed = parse(
832            input,
833            crate::parsing::source::SourceType::Volatile,
834            &ResourceLimits::default(),
835        )
836        .unwrap();
837        let specs = parsed.flatten_specs();
838        assert_eq!(specs.len(), 2);
839        assert_eq!(specs[0].name, "spec_a");
840        assert_eq!(specs[1].name, "spec_b");
841    }
842
843    #[test]
844    fn parse_uses_registry_spec_default_alias() {
845        let input = "spec example\nuses @owner/repo somespec";
846        let specs = parse(
847            input,
848            crate::parsing::source::SourceType::Volatile,
849            &ResourceLimits::default(),
850        )
851        .unwrap()
852        .into_flattened_specs();
853        match &specs[0].data[0].value {
854            crate::parsing::ast::DataValue::Import(spec_ref) => {
855                assert_eq!(spec_ref.name, "somespec");
856                let repository_hdr = spec_ref
857                    .repository
858                    .as_ref()
859                    .expect("expected repository qualifier");
860                assert_eq!(repository_hdr.name, "@owner/repo");
861            }
862            other => panic!("Expected Import, got: {:?}", other),
863        }
864    }
865
866    #[test]
867    fn parse_uses_local_spec_default_alias() {
868        let input = "spec example\nuses myspec";
869        let specs = parse(
870            input,
871            crate::parsing::source::SourceType::Volatile,
872            &ResourceLimits::default(),
873        )
874        .unwrap()
875        .into_flattened_specs();
876        match &specs[0].data[0].value {
877            crate::parsing::ast::DataValue::Import(spec_ref) => {
878                assert_eq!(spec_ref.name, "myspec");
879                assert!(
880                    spec_ref.repository.is_none(),
881                    "same-repository reference must omit repository qualifier"
882                );
883            }
884            other => panic!("Expected Import, got: {:?}", other),
885        }
886    }
887
888    #[test]
889    fn parse_spec_name_with_trailing_dot_is_error() {
890        let input = "spec myspec.\ndata x: 1";
891        let result = parse(
892            input,
893            crate::parsing::source::SourceType::Volatile,
894            &ResourceLimits::default(),
895        );
896        assert!(
897            result.is_err(),
898            "Trailing dot after spec name should be a parse error"
899        );
900    }
901
902    #[test]
903    fn parse_multiple_specs_in_same_file() {
904        let input = "spec myspec_a\nrule x: 1\n\nspec myspec_b\nrule x: 2";
905        let result = parse(
906            input,
907            crate::parsing::source::SourceType::Volatile,
908            &ResourceLimits::default(),
909        )
910        .unwrap()
911        .into_flattened_specs();
912        assert_eq!(result.len(), 2);
913        assert_eq!(result[0].name, "myspec_a");
914        assert_eq!(result[1].name, "myspec_b");
915    }
916
917    #[test]
918    fn parse_uses_accepts_name_only() {
919        let input = "spec consumer\nuses other";
920        let result = parse(
921            input,
922            crate::parsing::source::SourceType::Volatile,
923            &ResourceLimits::default(),
924        );
925        assert!(result.is_ok(), "uses name should parse");
926        let specs = result.unwrap().into_flattened_specs();
927        let spec_ref = match &specs[0].data[0].value {
928            crate::parsing::ast::DataValue::Import(r) => r,
929            _ => panic!("expected Import"),
930        };
931        assert_eq!(spec_ref.name, "other");
932    }
933
934    #[test]
935    fn parse_uses_bare_year_effective() {
936        let input = "spec consumer\nuses other 2026";
937        let result = parse(
938            input,
939            crate::parsing::source::SourceType::Volatile,
940            &ResourceLimits::default(),
941        )
942        .unwrap();
943        let specs = result.into_flattened_specs();
944        let spec_ref = match &specs[0].data[0].value {
945            crate::parsing::ast::DataValue::Import(r) => r,
946            _ => panic!("expected Import"),
947        };
948        assert_eq!(spec_ref.name, "other");
949        let eff = spec_ref.effective.as_ref().expect("effective");
950        assert_eq!(eff.year, 2026);
951        assert_eq!(eff.month, 1);
952        assert_eq!(eff.day, 1);
953    }
954
955    #[test]
956    fn parse_uses_registry_spec_ref_records_repository_and_target_spans() {
957        let input = "spec consumer\nuses @iso/countries alpha2 2026";
958        let result = parse(
959            input,
960            crate::parsing::source::SourceType::Volatile,
961            &ResourceLimits::default(),
962        )
963        .unwrap();
964        let spec = &result.flatten_specs()[0];
965        let sr = match &spec.data[0].value {
966            crate::parsing::ast::DataValue::Import(r) => r,
967            _ => panic!("expected Import"),
968        };
969        let rs = sr
970            .repository_span
971            .as_ref()
972            .expect("repository_span should be set for @-qualified uses");
973        let ts = sr
974            .target_span
975            .as_ref()
976            .expect("target_span should cover spec name and effective");
977        assert_eq!(&input[rs.start..rs.end], "@iso/countries");
978        assert_eq!(&input[ts.start..ts.end], "alpha2 2026");
979    }
980
981    #[test]
982    fn parse_uses_alias_no_comma_continuation() {
983        let input = "spec consumer\nuses alias: pricing retail\ndata x: 1";
984        let result = parse(
985            input,
986            crate::parsing::source::SourceType::Volatile,
987            &ResourceLimits::default(),
988        )
989        .unwrap();
990        let data = &result.flatten_specs()[0].data;
991        assert_eq!(data.len(), 2);
992        assert_eq!(data[0].reference.name, "alias");
993        let sr = match &data[0].value {
994            crate::parsing::ast::DataValue::Import(r) => r,
995            _ => panic!("expected Import"),
996        };
997        assert_eq!(sr.name, "retail");
998        let repository_hdr = sr
999            .repository
1000            .as_ref()
1001            .expect("expected repository qualifier");
1002        assert_eq!(repository_hdr.name, "pricing");
1003    }
1004
1005    #[test]
1006    fn parse_data_qualified_type_with_effective_and_repository_on_uses() {
1007        let input = "spec consumer\nuses @iso/countries alpha2 2026-06-01\ndata country: alpha2.code -> option \"NL\"";
1008        let result = parse(
1009            input,
1010            crate::parsing::source::SourceType::Volatile,
1011            &ResourceLimits::default(),
1012        )
1013        .unwrap()
1014        .into_flattened_specs();
1015        let spec_ref = match &result[0].data[0].value {
1016            crate::parsing::ast::DataValue::Import(sr) => sr,
1017            other => panic!("expected Import on uses row, got: {:?}", other),
1018        };
1019        assert_eq!(spec_ref.name, "alpha2");
1020
1021        let eff = spec_ref
1022            .effective
1023            .as_ref()
1024            .expect("expected effective datetime");
1025        assert_eq!(eff.year, 2026);
1026        assert_eq!(eff.month, 6);
1027
1028        let qualifier = spec_ref
1029            .repository
1030            .as_ref()
1031            .expect("expected repository qualifier");
1032        assert_eq!(qualifier.name, "@iso/countries");
1033
1034        match &result[0].data[1].value {
1035            crate::parsing::ast::DataValue::Definition {
1036                base,
1037                constraints,
1038                value,
1039            } => {
1040                assert!(value.is_none());
1041                assert_eq!(
1042                    base.as_ref().expect("expected base"),
1043                    &crate::parsing::ast::ParentType::Qualified {
1044                        spec_alias: "alpha2".into(),
1045                        inner: Box::new(crate::parsing::ast::ParentType::Custom {
1046                            name: "code".into(),
1047                        }),
1048                    }
1049                );
1050
1051                let cs = constraints
1052                    .as_ref()
1053                    .expect("expected trailing constraint chain");
1054                assert_eq!(cs.len(), 1);
1055            }
1056            other => panic!("expected Definition, got: {:?}", other),
1057        }
1058    }
1059
1060    #[test]
1061    fn parse_error_is_returned_for_garbage_input() {
1062        let result = parse(
1063            r#"
1064spec test
1065this is not valid lemma syntax @#$%
1066"#,
1067            crate::parsing::source::SourceType::Volatile,
1068            &ResourceLimits::default(),
1069        );
1070
1071        assert!(result.is_err(), "Should fail on malformed input");
1072        match result {
1073            Err(Error::Parsing { .. }) => {
1074                // Expected
1075            }
1076            Err(e) => panic!("Expected Parse error, got: {e:?}"),
1077            Ok(_) => panic!("Expected parse error"),
1078        }
1079    }
1080
1081    // ─── Parser-level pins for DataValue variants ────────────────────
1082
1083    #[test]
1084    fn parse_local_with_literal_rejected() {
1085        let err = parse(
1086            r#"spec s
1087with x: 42"#,
1088            crate::parsing::source::SourceType::Volatile,
1089            &ResourceLimits::default(),
1090        )
1091        .unwrap_err();
1092        let msg = err.to_string();
1093        assert!(
1094            msg.contains("imported spec") || msg.contains("alias.field"),
1095            "expected local with rejection, got: {msg}"
1096        );
1097    }
1098
1099    #[test]
1100    fn parse_local_with_import_reference_rejected() {
1101        let err = parse(
1102            r#"spec s
1103uses i: inner
1104with copy: i.v"#,
1105            crate::parsing::source::SourceType::Volatile,
1106            &ResourceLimits::default(),
1107        )
1108        .unwrap_err();
1109        let msg = err.to_string();
1110        assert!(
1111            msg.contains("imported spec") || msg.contains("alias.field"),
1112            "expected local with rejection, got: {msg}"
1113        );
1114    }
1115
1116    #[test]
1117    fn parse_local_with_dotted_rhs_rejected() {
1118        let err = parse(
1119            r#"spec s
1120with x: a.something"#,
1121            crate::parsing::source::SourceType::Volatile,
1122            &ResourceLimits::default(),
1123        )
1124        .unwrap_err();
1125        let msg = err.to_string();
1126        assert!(
1127            msg.contains("imported spec") || msg.contains("alias.field"),
1128            "expected local with rejection, got: {msg}"
1129        );
1130    }
1131
1132    #[test]
1133    fn parse_local_with_multi_segment_rhs_rejected() {
1134        let err = parse(
1135            r#"spec s
1136with x: alpha.beta.gamma.delta"#,
1137            crate::parsing::source::SourceType::Volatile,
1138            &ResourceLimits::default(),
1139        )
1140        .unwrap_err();
1141        let msg = err.to_string();
1142        assert!(
1143            msg.contains("imported spec") || msg.contains("alias.field"),
1144            "expected local with rejection, got: {msg}"
1145        );
1146    }
1147
1148    /// `data x: notdotted` (local LHS, non-dotted RHS) MUST stay a
1149    /// Definition with an explicit custom parent — not silently reinterpreted as a Reference.
1150    #[test]
1151    fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1152        let input = r#"spec s
1153data x: myothertype"#;
1154        let result = parse(
1155            input,
1156            crate::parsing::source::SourceType::Volatile,
1157            &ResourceLimits::default(),
1158        )
1159        .unwrap()
1160        .into_flattened_specs();
1161        let value = &result[0].data[0].value;
1162        assert!(
1163            matches!(
1164                value,
1165                crate::parsing::ast::DataValue::Definition {
1166                    base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1167                    ..
1168                }
1169            ),
1170            "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1171            value
1172        );
1173    }
1174
1175    /// `with x.y: notdotted` (binding LHS, non-dotted RHS) is parsed as [`DataValue::With`] with a reference payload.
1176    #[test]
1177    fn parse_binding_non_dotted_rhs_is_with_reference() {
1178        let input = r#"spec s
1179with child.slot: somename"#;
1180        let result = parse(
1181            input,
1182            crate::parsing::source::SourceType::Volatile,
1183            &ResourceLimits::default(),
1184        )
1185        .unwrap()
1186        .into_flattened_specs();
1187        let value = &result[0].data[0].value;
1188        assert!(
1189            matches!(
1190                value,
1191                crate::parsing::ast::DataValue::With(
1192                    crate::parsing::ast::WithRhs::Reference { .. }
1193                )
1194            ),
1195            "non-dotted RHS in binding context must yield With(Reference); got: {:?}",
1196            value
1197        );
1198    }
1199
1200    /// `data x: spec …` is invalid; spec imports use `uses`.
1201    #[test]
1202    fn parse_data_colon_spec_rhs_is_rejected() {
1203        let result = parse(
1204            r#"
1205spec s
1206data x: spec other
1207"#,
1208            crate::parsing::source::SourceType::Volatile,
1209            &ResourceLimits::default(),
1210        );
1211        match result {
1212            Ok(_) => panic!("`data x: spec other` must fail to parse"),
1213            Err(err) => {
1214                let msg = err.to_string();
1215                assert!(
1216                    msg.contains("uses") && msg.contains("spec"),
1217                    "error must direct to `uses` for spec import, got: {msg}"
1218                );
1219            }
1220        }
1221    }
1222
1223    /// `with x.y: z.w` (binding LHS, dotted RHS) → Reference with two LHS
1224    /// segments and two RHS segments.
1225    #[test]
1226    fn parse_binding_with_dotted_rhs_preserves_both_sides() {
1227        let input = r#"spec s
1228with outer.inner: target.field"#;
1229        let result = parse(
1230            input,
1231            crate::parsing::source::SourceType::Volatile,
1232            &ResourceLimits::default(),
1233        )
1234        .unwrap()
1235        .into_flattened_specs();
1236        let datum = &result[0].data[0];
1237        assert_eq!(datum.reference.segments, vec!["outer"]);
1238        assert_eq!(datum.reference.name, "inner");
1239        match &datum.value {
1240            crate::parsing::ast::DataValue::With(crate::parsing::ast::WithRhs::Reference {
1241                target,
1242            }) => {
1243                assert_eq!(target.segments, vec!["target"]);
1244                assert_eq!(target.name, "field");
1245            }
1246            other => panic!("expected With(Reference), got: {:?}", other),
1247        }
1248    }
1249
1250    #[test]
1251    fn parse_data_on_binding_path_is_rejected_with_with_hint() {
1252        let result = parse(
1253            r#"spec s
1254data outer.inner: 1"#,
1255            crate::parsing::source::SourceType::Volatile,
1256            &ResourceLimits::default(),
1257        );
1258        match result {
1259            Ok(_) => panic!("data with binding path must not parse"),
1260            Err(err) => {
1261                let msg = err.to_string();
1262                assert!(
1263                    msg.contains("with"),
1264                    "error should steer authors toward with; got: {msg}"
1265                );
1266            }
1267        }
1268    }
1269
1270    #[test]
1271    fn parse_bare_file_yields_single_anonymous_repository_group() {
1272        let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1273        let parsed = parse(
1274            input,
1275            crate::parsing::source::SourceType::Volatile,
1276            &ResourceLimits::default(),
1277        )
1278        .unwrap();
1279        assert_eq!(parsed.repositories.len(), 1);
1280        let (repo, specs) = parsed.repositories.iter().next().unwrap();
1281        assert!(repo.name.is_none());
1282        assert_eq!(specs.len(), 2);
1283        assert_eq!(specs[0].name, "a");
1284        assert_eq!(specs[1].name, "b");
1285    }
1286
1287    #[test]
1288    fn parse_repo_sections_preserve_order_and_names() {
1289        let input = r#"repo r1
1290
1291spec a
1292data x: 1
1293
1294repo r2
1295
1296spec b
1297data y: 2"#;
1298        let parsed = parse(
1299            input,
1300            crate::parsing::source::SourceType::Volatile,
1301            &ResourceLimits::default(),
1302        )
1303        .unwrap();
1304        assert_eq!(parsed.repositories.len(), 2);
1305        let keys: Vec<_> = parsed.repositories.keys().collect();
1306        assert_eq!(keys[0].name.as_deref(), Some("r1"));
1307        assert_eq!(keys[1].name.as_deref(), Some("r2"));
1308    }
1309
1310    #[test]
1311    fn parse_duplicate_repo_name_merges_spec_lists() {
1312        let input = r#"repo dup
1313
1314spec a
1315data x: 1
1316
1317repo dup
1318
1319spec b
1320data y: 2"#;
1321        let parsed = parse(
1322            input,
1323            crate::parsing::source::SourceType::Volatile,
1324            &ResourceLimits::default(),
1325        )
1326        .unwrap();
1327        assert_eq!(parsed.repositories.len(), 1);
1328        assert_eq!(parsed.flatten_specs().len(), 2);
1329    }
1330
1331    #[test]
1332    fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1333        let input = "repo empty";
1334        let parsed = parse(
1335            input,
1336            crate::parsing::source::SourceType::Volatile,
1337            &ResourceLimits::default(),
1338        )
1339        .unwrap();
1340        assert_eq!(parsed.repositories.len(), 1);
1341        let (_repo, specs) = parsed.repositories.iter().next().unwrap();
1342        assert_eq!(specs.len(), 0);
1343    }
1344
1345    #[test]
1346    fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1347        let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1348        let parsed = parse(
1349            input,
1350            crate::parsing::source::SourceType::Volatile,
1351            &ResourceLimits::default(),
1352        )
1353        .unwrap();
1354        assert_eq!(parsed.repositories.len(), 2);
1355        let names: Vec<_> = parsed
1356            .repositories
1357            .keys()
1358            .map(|r| r.name.as_deref())
1359            .collect();
1360        assert_eq!(names, vec![Some("a"), Some("b")]);
1361        assert!(parsed.repositories.values().next().unwrap().is_empty());
1362        assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1363    }
1364
1365    #[test]
1366    fn parse_spec_named_repo_keyword_should_be_rejected() {
1367        assert!(
1368            parse(
1369                "spec repo\ndata x: 1",
1370                crate::parsing::source::SourceType::Volatile,
1371                &ResourceLimits::default(),
1372            )
1373            .is_err(),
1374            "spec must not be allowed to use reserved keyword `repo` as its name"
1375        );
1376    }
1377
1378    #[test]
1379    fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1380        assert!(
1381            parse(
1382                "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1383                crate::parsing::source::SourceType::Volatile,
1384                &ResourceLimits::default(),
1385            )
1386            .is_err(),
1387            "repository name cannot be the token `spec`"
1388        );
1389    }
1390
1391    #[test]
1392    fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
1393        assert!(
1394            parse(
1395                "repo data\n\nspec z\ndata q: 1\nrule r: q",
1396                crate::parsing::source::SourceType::Volatile,
1397                &ResourceLimits::default(),
1398            )
1399            .is_err(),
1400            "repository name cannot be the token `data`"
1401        );
1402    }
1403
1404    #[test]
1405    fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
1406        assert!(
1407            parse(
1408                "repo rule\n\nspec z\ndata q: 1\nrule r: q",
1409                crate::parsing::source::SourceType::Volatile,
1410                &ResourceLimits::default(),
1411            )
1412            .is_err(),
1413            "repository name cannot be the token `rule`"
1414        );
1415    }
1416
1417    #[test]
1418    fn parse_data_named_repo_keyword_is_rejected() {
1419        let err = parse(
1420            "spec s\ndata repo: 1",
1421            crate::parsing::source::SourceType::Volatile,
1422            &ResourceLimits::default(),
1423        )
1424        .unwrap_err();
1425        assert!(
1426            err.to_string().contains("repo"),
1427            "data named repo should not parse: {}",
1428            err
1429        );
1430    }
1431
1432    #[test]
1433    fn parse_rule_named_repo_keyword_is_rejected() {
1434        let err = parse(
1435            "spec s\ndata x: 1\nrule repo: x",
1436            crate::parsing::source::SourceType::Volatile,
1437            &ResourceLimits::default(),
1438        )
1439        .unwrap_err();
1440        let msg = err.to_string();
1441        assert!(
1442            msg.contains("repo") || msg.contains("reserved"),
1443            "rule named repo should not parse: {msg}"
1444        );
1445    }
1446
1447    #[test]
1448    fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
1449        let parsed = parse(
1450            "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
1451            crate::parsing::source::SourceType::Volatile,
1452            &ResourceLimits::default(),
1453        )
1454        .unwrap();
1455        assert_eq!(parsed.repositories.len(), 1);
1456        assert_eq!(
1457            parsed.repositories.keys().next().unwrap().name.as_deref(),
1458            Some("warehouse")
1459        );
1460    }
1461
1462    #[test]
1463    fn parse_repo_name_case_insensitive_same_repository_merged() {
1464        let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
1465        let parsed = parse(
1466            input,
1467            crate::parsing::source::SourceType::Volatile,
1468            &ResourceLimits::default(),
1469        )
1470        .unwrap();
1471        assert_eq!(
1472            parsed.repositories.len(),
1473            1,
1474            "Foo and foo are the same repository after canonicalization"
1475        );
1476        let specs: Vec<_> = parsed.repositories.values().next().unwrap().clone();
1477        assert_eq!(specs.len(), 2);
1478        assert_eq!(specs[0].name, "a");
1479        assert_eq!(specs[1].name, "b");
1480    }
1481
1482    #[test]
1483    fn parse_repo_empty_name_errors() {
1484        let err = parse(
1485            "repo \nspec a\ndata x: 1",
1486            crate::parsing::source::SourceType::Volatile,
1487            &ResourceLimits::default(),
1488        )
1489        .unwrap_err();
1490        assert!(
1491            !err.to_string().is_empty(),
1492            "empty repo name should not parse quietly: {err}"
1493        );
1494    }
1495
1496    #[test]
1497    fn parse_repo_numeric_name_behavior() {
1498        let input = "repo 123\n\nspec a\ndata x: 1";
1499        let result = parse(
1500            input,
1501            crate::parsing::source::SourceType::Volatile,
1502            &ResourceLimits::default(),
1503        );
1504        match result {
1505            Ok(parsed) => {
1506                assert_eq!(
1507                    parsed.repositories.keys().next().unwrap().name.as_deref(),
1508                    Some("123"),
1509                    "if numeric repo names parse, identity must be stable"
1510                );
1511            }
1512            Err(e) => {
1513                assert!(
1514                    !e.to_string().is_empty(),
1515                    "rejecting numeric repo name is ok if explicit: {e}"
1516                );
1517            }
1518        }
1519    }
1520
1521    #[test]
1522    fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
1523        let input = r#"repo dup
1524
1525spec a
1526data x: 1
1527
1528repo dup
1529
1530spec b
1531data y: 2
1532
1533repo dup
1534
1535spec c
1536data z: 3"#;
1537        let parsed = parse(
1538            input,
1539            crate::parsing::source::SourceType::Volatile,
1540            &ResourceLimits::default(),
1541        )
1542        .unwrap();
1543        assert_eq!(parsed.repositories.len(), 1);
1544        let specs = parsed.repositories.values().next().unwrap();
1545        assert_eq!(
1546            specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1547            vec!["a", "b", "c"]
1548        );
1549    }
1550
1551    #[test]
1552    fn parse_repo_single_section_roundtrips_through_formatter() {
1553        let input = "repo r\n\nspec a\ndata x: 1";
1554        let parsed = parse(
1555            input,
1556            crate::parsing::source::SourceType::Volatile,
1557            &ResourceLimits::default(),
1558        )
1559        .unwrap();
1560        let formatted = format_parse_result(&parsed);
1561        let again = parse(
1562            &formatted,
1563            crate::parsing::source::SourceType::Volatile,
1564            &ResourceLimits::default(),
1565        )
1566        .unwrap();
1567        assert_eq!(again.repositories.len(), parsed.repositories.len());
1568        assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
1569        assert_eq!(
1570            again.flatten_specs()[0].name,
1571            parsed.flatten_specs()[0].name
1572        );
1573    }
1574
1575    #[test]
1576    fn parse_repo_two_sections_roundtrips_through_formatter() {
1577        let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
1578        let parsed = parse(
1579            input,
1580            crate::parsing::source::SourceType::Volatile,
1581            &ResourceLimits::default(),
1582        )
1583        .unwrap();
1584        let formatted = format_parse_result(&parsed);
1585        let again = parse(
1586            &formatted,
1587            crate::parsing::source::SourceType::Volatile,
1588            &ResourceLimits::default(),
1589        )
1590        .unwrap();
1591        assert_eq!(again.repositories.len(), 2);
1592        assert_eq!(again.flatten_specs().len(), 2);
1593    }
1594
1595    #[test]
1596    fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
1597        let input = r#"repo dup
1598
1599spec a
1600data x: 1
1601
1602repo dup
1603
1604spec b
1605data y: 2"#;
1606        let parsed = parse(
1607            input,
1608            crate::parsing::source::SourceType::Volatile,
1609            &ResourceLimits::default(),
1610        )
1611        .unwrap();
1612        let formatted = format_parse_result(&parsed);
1613        let again = parse(
1614            &formatted,
1615            crate::parsing::source::SourceType::Volatile,
1616            &ResourceLimits::default(),
1617        )
1618        .unwrap();
1619        assert_eq!(
1620            again.repositories.len(),
1621            1,
1622            "formatted duplicate-repo file must still merge to one logical repo"
1623        );
1624        assert_eq!(again.flatten_specs().len(), 2);
1625    }
1626
1627    #[test]
1628    fn parse_rejects_data_named_measure() {
1629        let result = parse(
1630            "spec s\ndata measure: 1",
1631            crate::parsing::source::SourceType::Volatile,
1632            &ResourceLimits::default(),
1633        );
1634        assert!(
1635            result.is_err(),
1636            "data named measure (type keyword) must be rejected"
1637        );
1638    }
1639
1640    #[test]
1641    fn parse_rejects_data_named_number() {
1642        let result = parse(
1643            "spec s\ndata number: 42",
1644            crate::parsing::source::SourceType::Volatile,
1645            &ResourceLimits::default(),
1646        );
1647        assert!(
1648            result.is_err(),
1649            "data named number (type keyword) must be rejected"
1650        );
1651    }
1652
1653    #[test]
1654    fn parse_rejects_data_named_text() {
1655        let result = parse(
1656            "spec s\ndata text: \"hello\"",
1657            crate::parsing::source::SourceType::Volatile,
1658            &ResourceLimits::default(),
1659        );
1660        assert!(
1661            result.is_err(),
1662            "data named text (type keyword) must be rejected"
1663        );
1664    }
1665
1666    #[test]
1667    fn parse_rejects_data_named_date() {
1668        let result = parse(
1669            "spec s\ndata date: 2024-01-01",
1670            crate::parsing::source::SourceType::Volatile,
1671            &ResourceLimits::default(),
1672        );
1673        assert!(
1674            result.is_err(),
1675            "data named date (type keyword) must be rejected"
1676        );
1677    }
1678
1679    #[test]
1680    fn parse_rejects_data_named_boolean() {
1681        let result = parse(
1682            "spec s\ndata boolean: true",
1683            crate::parsing::source::SourceType::Volatile,
1684            &ResourceLimits::default(),
1685        );
1686        assert!(
1687            result.is_err(),
1688            "data named boolean (type keyword) must be rejected"
1689        );
1690    }
1691
1692    #[test]
1693    fn parse_rejects_data_named_ratio() {
1694        let result = parse(
1695            "spec s\ndata ratio: 5%",
1696            crate::parsing::source::SourceType::Volatile,
1697            &ResourceLimits::default(),
1698        );
1699        assert!(
1700            result.is_err(),
1701            "data named ratio (type keyword) must be rejected"
1702        );
1703    }
1704
1705    #[test]
1706    fn parse_rejects_rule_named_measure() {
1707        let result = parse(
1708            "spec s\ndata x: 1\nrule measure: x",
1709            crate::parsing::source::SourceType::Volatile,
1710            &ResourceLimits::default(),
1711        );
1712        assert!(
1713            result.is_err(),
1714            "rule named measure (type keyword) must be rejected"
1715        );
1716    }
1717
1718    #[test]
1719    fn parse_rejects_rule_named_number() {
1720        let result = parse(
1721            "spec s\ndata x: 1\nrule number: x",
1722            crate::parsing::source::SourceType::Volatile,
1723            &ResourceLimits::default(),
1724        );
1725        assert!(
1726            result.is_err(),
1727            "rule named number (type keyword) must be rejected"
1728        );
1729    }
1730
1731    #[test]
1732    fn parse_rejects_rule_named_text() {
1733        let result = parse(
1734            "spec s\ndata x: 1\nrule text: x",
1735            crate::parsing::source::SourceType::Volatile,
1736            &ResourceLimits::default(),
1737        );
1738        assert!(
1739            result.is_err(),
1740            "rule named text (type keyword) must be rejected"
1741        );
1742    }
1743
1744    #[test]
1745    fn parse_rejects_rule_named_date() {
1746        let result = parse(
1747            "spec s\ndata x: 1\nrule date: x",
1748            crate::parsing::source::SourceType::Volatile,
1749            &ResourceLimits::default(),
1750        );
1751        assert!(
1752            result.is_err(),
1753            "rule named date (type keyword) must be rejected"
1754        );
1755    }
1756
1757    #[test]
1758    fn parse_rejects_rule_named_boolean() {
1759        let result = parse(
1760            "spec s\ndata x: 1\nrule boolean: x",
1761            crate::parsing::source::SourceType::Volatile,
1762            &ResourceLimits::default(),
1763        );
1764        assert!(
1765            result.is_err(),
1766            "rule named boolean (type keyword) must be rejected"
1767        );
1768    }
1769
1770    #[test]
1771    fn parse_rejects_rule_named_ratio() {
1772        let result = parse(
1773            "spec s\ndata x: 1\nrule ratio: x",
1774            crate::parsing::source::SourceType::Volatile,
1775            &ResourceLimits::default(),
1776        );
1777        assert!(
1778            result.is_err(),
1779            "rule named ratio (type keyword) must be rejected"
1780        );
1781    }
1782}