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#[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 #[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 }
1168 Err(e) => panic!("Expected Parse error, got: {e:?}"),
1169 Ok(_) => panic!("Expected parse error"),
1170 }
1171 }
1172
1173 #[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 #[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 #[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 #[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 #[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}