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