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