1use crate::error::Error;
2use crate::limits::ResourceLimits;
3
4pub mod ast;
5pub mod lexer;
6pub mod parser;
7pub mod source;
8
9pub use ast::*;
10pub use parser::ParseResult;
11
12pub fn parse(
13 content: &str,
14 source_type: source::SourceType,
15 limits: &ResourceLimits,
16) -> Result<ParseResult, Error> {
17 parser::parse(content, source_type, limits)
18}
19
20#[cfg(test)]
21mod assignment_continuation_tests;
22
23#[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_error_is_returned_for_garbage_input() {
1071 let result = parse(
1072 r#"
1073spec test
1074this is not valid lemma syntax @#$%
1075"#,
1076 crate::parsing::source::SourceType::Volatile,
1077 &ResourceLimits::default(),
1078 );
1079
1080 assert!(result.is_err(), "Should fail on malformed input");
1081 match result {
1082 Err(Error::Parsing { .. }) => {
1083 }
1085 Err(e) => panic!("Expected Parse error, got: {e:?}"),
1086 Ok(_) => panic!("Expected parse error"),
1087 }
1088 }
1089
1090 #[test]
1093 fn parse_local_with_literal_rejected() {
1094 let err = parse(
1095 r#"spec s
1096with x: 42"#,
1097 crate::parsing::source::SourceType::Volatile,
1098 &ResourceLimits::default(),
1099 )
1100 .unwrap_err();
1101 let msg = err.to_string();
1102 assert!(
1103 msg.contains("Standalone") || msg.contains("uses"),
1104 "expected standalone with rejection, got: {msg}"
1105 );
1106 }
1107
1108 #[test]
1109 fn parse_local_with_import_reference_rejected() {
1110 let err = parse(
1111 r#"spec s
1112uses i: inner
1113with copy: i.v"#,
1114 crate::parsing::source::SourceType::Volatile,
1115 &ResourceLimits::default(),
1116 )
1117 .unwrap_err();
1118 let msg = err.to_string();
1119 assert!(
1120 msg.contains("Standalone") || msg.contains("uses"),
1121 "expected standalone with rejection, got: {msg}"
1122 );
1123 }
1124
1125 #[test]
1126 fn parse_local_with_dotted_rhs_rejected() {
1127 let err = parse(
1128 r#"spec s
1129with x: a.something"#,
1130 crate::parsing::source::SourceType::Volatile,
1131 &ResourceLimits::default(),
1132 )
1133 .unwrap_err();
1134 let msg = err.to_string();
1135 assert!(
1136 msg.contains("Standalone") || msg.contains("uses"),
1137 "expected standalone with rejection, got: {msg}"
1138 );
1139 }
1140
1141 #[test]
1142 fn parse_local_with_multi_segment_rhs_rejected() {
1143 let err = parse(
1144 r#"spec s
1145with x: alpha.beta.gamma.delta"#,
1146 crate::parsing::source::SourceType::Volatile,
1147 &ResourceLimits::default(),
1148 )
1149 .unwrap_err();
1150 let msg = err.to_string();
1151 assert!(
1152 msg.contains("Standalone") || msg.contains("uses"),
1153 "expected standalone with rejection, got: {msg}"
1154 );
1155 }
1156
1157 #[test]
1160 fn parse_local_non_dotted_rhs_stays_definition_with_custom_base() {
1161 let input = r#"spec s
1162data x: myothertype"#;
1163 let result = parse(
1164 input,
1165 crate::parsing::source::SourceType::Volatile,
1166 &ResourceLimits::default(),
1167 )
1168 .unwrap()
1169 .into_flattened_specs();
1170 let value = &result[0].data[0].value;
1171 assert!(
1172 matches!(
1173 value,
1174 crate::parsing::ast::DataValue::Definition {
1175 base: Some(crate::parsing::ast::ParentType::Custom { .. }),
1176 ..
1177 }
1178 ),
1179 "non-dotted local RHS must stay Definition with custom base, got: {:?}",
1180 value
1181 );
1182 }
1183
1184 #[test]
1186 fn parse_uses_binding_non_dotted_rhs_is_reference() {
1187 let input = r#"spec s
1188uses child: other
1189 -> with slot: somename"#;
1190 let result = parse(
1191 input,
1192 crate::parsing::source::SourceType::Volatile,
1193 &ResourceLimits::default(),
1194 )
1195 .unwrap()
1196 .into_flattened_specs();
1197 let bindings = match &result[0].data[0].value {
1198 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1199 other => panic!("expected Import, got: {:?}", other),
1200 };
1201 assert_eq!(bindings.len(), 1);
1202 assert!(
1203 matches!(
1204 &bindings[0].rhs,
1205 crate::parsing::ast::WithRhs::Reference { .. }
1206 ),
1207 "non-dotted RHS must yield Reference; got: {:?}",
1208 bindings[0].rhs
1209 );
1210 }
1211
1212 #[test]
1214 fn parse_data_colon_spec_rhs_is_rejected() {
1215 let result = parse(
1216 r#"
1217spec s
1218data x: spec other
1219"#,
1220 crate::parsing::source::SourceType::Volatile,
1221 &ResourceLimits::default(),
1222 );
1223 match result {
1224 Ok(_) => panic!("`data x: spec other` must fail to parse"),
1225 Err(err) => {
1226 let msg = err.to_string();
1227 assert!(
1228 msg.contains("uses") && msg.contains("spec"),
1229 "error must direct to `uses` for spec import, got: {msg}"
1230 );
1231 }
1232 }
1233 }
1234
1235 #[test]
1237 fn parse_uses_binding_with_dotted_rhs_preserves_both_sides() {
1238 let input = r#"spec s
1239uses outer: other
1240 -> with inner: target.field"#;
1241 let result = parse(
1242 input,
1243 crate::parsing::source::SourceType::Volatile,
1244 &ResourceLimits::default(),
1245 )
1246 .unwrap()
1247 .into_flattened_specs();
1248 let bindings = match &result[0].data[0].value {
1249 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1250 other => panic!("expected Import, got: {:?}", other),
1251 };
1252 assert_eq!(bindings.len(), 1);
1253 assert_eq!(bindings[0].path.segments, vec![] as Vec<String>);
1254 assert_eq!(bindings[0].path.name, "inner");
1255 match &bindings[0].rhs {
1256 crate::parsing::ast::WithRhs::Reference { target } => {
1257 assert_eq!(target.segments, vec!["target"]);
1258 assert_eq!(target.name, "field");
1259 }
1260 other => panic!("expected Reference rhs, got: {:?}", other),
1261 }
1262 }
1263
1264 #[test]
1265 fn parse_deprecated_standalone_with_merges_into_import_bindings() {
1266 let input = r#"spec inner
1267data x: number
1268
1269spec outer
1270uses i: inner
1271with i.x: 42
1272rule r: i.x"#;
1273 let result = parse(
1274 input,
1275 crate::parsing::source::SourceType::Volatile,
1276 &ResourceLimits::default(),
1277 )
1278 .unwrap()
1279 .into_flattened_specs();
1280 let outer = result.iter().find(|s| s.name == "outer").unwrap();
1281 let bindings = match &outer.data[0].value {
1282 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1283 other => panic!("expected Import, got: {:?}", other),
1284 };
1285 assert_eq!(bindings.len(), 1);
1286 assert!(bindings[0].deprecated_standalone_with);
1287 assert_eq!(bindings[0].path.name, "x");
1288 match &bindings[0].rhs {
1289 crate::parsing::ast::WithRhs::Literal(crate::parsing::ast::Value::Number(n)) => {
1290 assert_eq!(n, &rust_decimal::Decimal::from(42));
1291 }
1292 other => panic!("expected literal 42, got: {:?}", other),
1293 }
1294 }
1295
1296 #[test]
1297 fn parse_deprecated_standalone_with_before_uses_merges() {
1298 let input = r#"spec inner
1299data slot: number
1300
1301spec outer
1302with i.slot: 99
1303uses i: inner
1304rule r: i.slot"#;
1305 let result = parse(
1306 input,
1307 crate::parsing::source::SourceType::Volatile,
1308 &ResourceLimits::default(),
1309 )
1310 .unwrap()
1311 .into_flattened_specs();
1312 let outer = result.iter().find(|s| s.name == "outer").unwrap();
1313 let bindings = match &outer.data[0].value {
1314 crate::parsing::ast::DataValue::Import { bindings, .. } => bindings,
1315 other => panic!("expected Import, got: {:?}", other),
1316 };
1317 assert_eq!(bindings.len(), 1);
1318 assert!(bindings[0].deprecated_standalone_with);
1319 }
1320
1321 #[test]
1322 fn parse_standalone_with_without_matching_uses_errors() {
1323 let result = parse(
1324 r#"spec s
1325with outer.inner: 1"#,
1326 crate::parsing::source::SourceType::Volatile,
1327 &ResourceLimits::default(),
1328 );
1329 match result {
1330 Ok(_) => panic!("standalone with without uses must not parse"),
1331 Err(err) => {
1332 let msg = err.to_string();
1333 assert!(
1334 msg.contains("uses") && msg.contains("outer"),
1335 "error should mention required uses; got: {msg}"
1336 );
1337 }
1338 }
1339 }
1340
1341 #[test]
1342 fn parse_data_on_binding_path_is_rejected_with_with_hint() {
1343 let result = parse(
1344 r#"spec s
1345data outer.inner: 1"#,
1346 crate::parsing::source::SourceType::Volatile,
1347 &ResourceLimits::default(),
1348 );
1349 match result {
1350 Ok(_) => panic!("data with binding path must not parse"),
1351 Err(err) => {
1352 let msg = err.to_string();
1353 assert!(
1354 msg.contains("with"),
1355 "error should steer authors toward with; got: {msg}"
1356 );
1357 }
1358 }
1359 }
1360
1361 #[test]
1362 fn parse_bare_file_yields_single_anonymous_repository_group() {
1363 let input = "spec a\ndata x: 1\nspec b\ndata y: 2";
1364 let parsed = parse(
1365 input,
1366 crate::parsing::source::SourceType::Volatile,
1367 &ResourceLimits::default(),
1368 )
1369 .unwrap();
1370 assert_eq!(parsed.repositories.len(), 1);
1371 let (repo, specs) = parsed.repositories.iter().next().unwrap();
1372 assert!(repo.name.is_none());
1373 assert_eq!(specs.len(), 2);
1374 assert_eq!(specs[0].name, "a");
1375 assert_eq!(specs[1].name, "b");
1376 }
1377
1378 #[test]
1379 fn parse_repo_sections_preserve_order_and_names() {
1380 let input = r#"repo r1
1381
1382spec a
1383data x: 1
1384
1385repo r2
1386
1387spec b
1388data y: 2"#;
1389 let parsed = parse(
1390 input,
1391 crate::parsing::source::SourceType::Volatile,
1392 &ResourceLimits::default(),
1393 )
1394 .unwrap();
1395 assert_eq!(parsed.repositories.len(), 2);
1396 let keys: Vec<_> = parsed.repositories.keys().collect();
1397 assert_eq!(keys[0].name.as_deref(), Some("r1"));
1398 assert_eq!(keys[1].name.as_deref(), Some("r2"));
1399 }
1400
1401 #[test]
1402 fn parse_duplicate_repo_name_merges_spec_lists() {
1403 let input = r#"repo dup
1404
1405spec a
1406data x: 1
1407
1408repo dup
1409
1410spec b
1411data y: 2"#;
1412 let parsed = parse(
1413 input,
1414 crate::parsing::source::SourceType::Volatile,
1415 &ResourceLimits::default(),
1416 )
1417 .unwrap();
1418 assert_eq!(parsed.repositories.len(), 1);
1419 assert_eq!(parsed.flatten_specs().len(), 2);
1420 }
1421
1422 #[test]
1423 fn parse_repo_with_no_specs_then_eof_yields_empty_spec_vec_for_that_repo() {
1424 let input = "repo empty";
1425 let parsed = parse(
1426 input,
1427 crate::parsing::source::SourceType::Volatile,
1428 &ResourceLimits::default(),
1429 )
1430 .unwrap();
1431 assert_eq!(parsed.repositories.len(), 1);
1432 let (_repo, specs) = parsed.repositories.iter().next().unwrap();
1433 assert_eq!(specs.len(), 0);
1434 }
1435
1436 #[test]
1437 fn parse_repo_followed_by_repo_without_specs_first_repo_empty_second_has_spec() {
1438 let input = "repo a\n\nrepo b\n\nspec s\ndata x: 1";
1439 let parsed = parse(
1440 input,
1441 crate::parsing::source::SourceType::Volatile,
1442 &ResourceLimits::default(),
1443 )
1444 .unwrap();
1445 assert_eq!(parsed.repositories.len(), 2);
1446 let names: Vec<_> = parsed
1447 .repositories
1448 .keys()
1449 .map(|r| r.name.as_deref())
1450 .collect();
1451 assert_eq!(names, vec![Some("a"), Some("b")]);
1452 assert!(parsed.repositories.values().next().unwrap().is_empty());
1453 assert_eq!(parsed.repositories.values().nth(1).unwrap().len(), 1);
1454 }
1455
1456 #[test]
1457 fn parse_spec_named_repo_keyword_should_be_rejected() {
1458 assert!(
1459 parse(
1460 "spec repo\ndata x: 1",
1461 crate::parsing::source::SourceType::Volatile,
1462 &ResourceLimits::default(),
1463 )
1464 .is_err(),
1465 "spec must not be allowed to use reserved keyword `repo` as its name"
1466 );
1467 }
1468
1469 #[test]
1470 fn parse_repo_declaration_cannot_use_spec_keyword_as_repository_name() {
1471 assert!(
1472 parse(
1473 "repo spec\n\nspec z\ndata q: 1\nrule r: q",
1474 crate::parsing::source::SourceType::Volatile,
1475 &ResourceLimits::default(),
1476 )
1477 .is_err(),
1478 "repository name cannot be the token `spec`"
1479 );
1480 }
1481
1482 #[test]
1483 fn parse_repo_declaration_cannot_use_data_keyword_as_repository_name() {
1484 assert!(
1485 parse(
1486 "repo data\n\nspec z\ndata q: 1\nrule r: q",
1487 crate::parsing::source::SourceType::Volatile,
1488 &ResourceLimits::default(),
1489 )
1490 .is_err(),
1491 "repository name cannot be the token `data`"
1492 );
1493 }
1494
1495 #[test]
1496 fn parse_repo_declaration_cannot_use_rule_keyword_as_repository_name() {
1497 assert!(
1498 parse(
1499 "repo rule\n\nspec z\ndata q: 1\nrule r: q",
1500 crate::parsing::source::SourceType::Volatile,
1501 &ResourceLimits::default(),
1502 )
1503 .is_err(),
1504 "repository name cannot be the token `rule`"
1505 );
1506 }
1507
1508 #[test]
1509 fn parse_data_named_repo_keyword_is_rejected() {
1510 let err = parse(
1511 "spec s\ndata repo: 1",
1512 crate::parsing::source::SourceType::Volatile,
1513 &ResourceLimits::default(),
1514 )
1515 .unwrap_err();
1516 assert!(
1517 err.to_string().contains("repo"),
1518 "data named repo should not parse: {}",
1519 err
1520 );
1521 }
1522
1523 #[test]
1524 fn parse_rule_named_repo_keyword_is_rejected() {
1525 let err = parse(
1526 "spec s\ndata x: 1\nrule repo: x",
1527 crate::parsing::source::SourceType::Volatile,
1528 &ResourceLimits::default(),
1529 )
1530 .unwrap_err();
1531 let msg = err.to_string();
1532 assert!(
1533 msg.contains("repo") || msg.contains("reserved"),
1534 "rule named repo should not parse: {msg}"
1535 );
1536 }
1537
1538 #[test]
1539 fn parse_repo_declaration_accepts_non_keyword_repository_identifier() {
1540 let parsed = parse(
1541 "repo warehouse\n\nspec z\ndata q: 1\nrule r: q",
1542 crate::parsing::source::SourceType::Volatile,
1543 &ResourceLimits::default(),
1544 )
1545 .unwrap();
1546 assert_eq!(parsed.repositories.len(), 1);
1547 assert_eq!(
1548 parsed.repositories.keys().next().unwrap().name.as_deref(),
1549 Some("warehouse")
1550 );
1551 }
1552
1553 #[test]
1554 fn parse_repo_name_case_insensitive_same_repository_merged() {
1555 let input = "repo Foo\n\nspec a\ndata x: 1\n\nrepo foo\n\nspec b\ndata y: 2";
1556 let parsed = parse(
1557 input,
1558 crate::parsing::source::SourceType::Volatile,
1559 &ResourceLimits::default(),
1560 )
1561 .unwrap();
1562 assert_eq!(
1563 parsed.repositories.len(),
1564 1,
1565 "Foo and foo are the same repository after canonicalization"
1566 );
1567 let specs: Vec<_> = parsed.repositories.values().next().unwrap().clone();
1568 assert_eq!(specs.len(), 2);
1569 assert_eq!(specs[0].name, "a");
1570 assert_eq!(specs[1].name, "b");
1571 }
1572
1573 #[test]
1574 fn parse_repo_empty_name_errors() {
1575 let err = parse(
1576 "repo \nspec a\ndata x: 1",
1577 crate::parsing::source::SourceType::Volatile,
1578 &ResourceLimits::default(),
1579 )
1580 .unwrap_err();
1581 assert!(
1582 !err.to_string().is_empty(),
1583 "empty repo name should not parse quietly: {err}"
1584 );
1585 }
1586
1587 #[test]
1588 fn parse_repo_numeric_name_behavior() {
1589 let input = "repo 123\n\nspec a\ndata x: 1";
1590 let result = parse(
1591 input,
1592 crate::parsing::source::SourceType::Volatile,
1593 &ResourceLimits::default(),
1594 );
1595 match result {
1596 Ok(parsed) => {
1597 assert_eq!(
1598 parsed.repositories.keys().next().unwrap().name.as_deref(),
1599 Some("123"),
1600 "if numeric repo names parse, identity must be stable"
1601 );
1602 }
1603 Err(e) => {
1604 assert!(
1605 !e.to_string().is_empty(),
1606 "rejecting numeric repo name is ok if explicit: {e}"
1607 );
1608 }
1609 }
1610 }
1611
1612 #[test]
1613 fn parse_duplicate_repo_three_sections_preserves_spec_order_abc() {
1614 let input = r#"repo dup
1615
1616spec a
1617data x: 1
1618
1619repo dup
1620
1621spec b
1622data y: 2
1623
1624repo dup
1625
1626spec c
1627data z: 3"#;
1628 let parsed = parse(
1629 input,
1630 crate::parsing::source::SourceType::Volatile,
1631 &ResourceLimits::default(),
1632 )
1633 .unwrap();
1634 assert_eq!(parsed.repositories.len(), 1);
1635 let specs = parsed.repositories.values().next().unwrap();
1636 assert_eq!(
1637 specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
1638 vec!["a", "b", "c"]
1639 );
1640 }
1641
1642 #[test]
1643 fn parse_repo_single_section_roundtrips_through_formatter() {
1644 let input = "repo r\n\nspec a\ndata x: 1";
1645 let parsed = parse(
1646 input,
1647 crate::parsing::source::SourceType::Volatile,
1648 &ResourceLimits::default(),
1649 )
1650 .unwrap();
1651 let formatted = format_parse_result(&parsed);
1652 let again = parse(
1653 &formatted,
1654 crate::parsing::source::SourceType::Volatile,
1655 &ResourceLimits::default(),
1656 )
1657 .unwrap();
1658 assert_eq!(again.repositories.len(), parsed.repositories.len());
1659 assert_eq!(again.flatten_specs().len(), parsed.flatten_specs().len());
1660 assert_eq!(
1661 again.flatten_specs()[0].name,
1662 parsed.flatten_specs()[0].name
1663 );
1664 }
1665
1666 #[test]
1667 fn parse_repo_two_sections_roundtrips_through_formatter() {
1668 let input = "repo r1\n\nspec a\ndata x: 1\n\nrepo r2\n\nspec b\ndata y: 2";
1669 let parsed = parse(
1670 input,
1671 crate::parsing::source::SourceType::Volatile,
1672 &ResourceLimits::default(),
1673 )
1674 .unwrap();
1675 let formatted = format_parse_result(&parsed);
1676 let again = parse(
1677 &formatted,
1678 crate::parsing::source::SourceType::Volatile,
1679 &ResourceLimits::default(),
1680 )
1681 .unwrap();
1682 assert_eq!(again.repositories.len(), 2);
1683 assert_eq!(again.flatten_specs().len(), 2);
1684 }
1685
1686 #[test]
1687 fn parse_repo_duplicate_merge_formatter_emits_single_repo_block_or_equivalent_parse() {
1688 let input = r#"repo dup
1689
1690spec a
1691data x: 1
1692
1693repo dup
1694
1695spec b
1696data y: 2"#;
1697 let parsed = parse(
1698 input,
1699 crate::parsing::source::SourceType::Volatile,
1700 &ResourceLimits::default(),
1701 )
1702 .unwrap();
1703 let formatted = format_parse_result(&parsed);
1704 let again = parse(
1705 &formatted,
1706 crate::parsing::source::SourceType::Volatile,
1707 &ResourceLimits::default(),
1708 )
1709 .unwrap();
1710 assert_eq!(
1711 again.repositories.len(),
1712 1,
1713 "formatted duplicate-repo file must still merge to one logical repo"
1714 );
1715 assert_eq!(again.flatten_specs().len(), 2);
1716 }
1717
1718 #[test]
1719 fn parse_rejects_data_named_measure() {
1720 let result = parse(
1721 "spec s\ndata measure: 1",
1722 crate::parsing::source::SourceType::Volatile,
1723 &ResourceLimits::default(),
1724 );
1725 assert!(
1726 result.is_err(),
1727 "data named measure (type keyword) must be rejected"
1728 );
1729 }
1730
1731 #[test]
1732 fn parse_rejects_data_named_number() {
1733 let result = parse(
1734 "spec s\ndata number: 42",
1735 crate::parsing::source::SourceType::Volatile,
1736 &ResourceLimits::default(),
1737 );
1738 assert!(
1739 result.is_err(),
1740 "data named number (type keyword) must be rejected"
1741 );
1742 }
1743
1744 #[test]
1745 fn parse_rejects_data_named_text() {
1746 let result = parse(
1747 "spec s\ndata text: \"hello\"",
1748 crate::parsing::source::SourceType::Volatile,
1749 &ResourceLimits::default(),
1750 );
1751 assert!(
1752 result.is_err(),
1753 "data named text (type keyword) must be rejected"
1754 );
1755 }
1756
1757 #[test]
1758 fn parse_rejects_data_named_date() {
1759 let result = parse(
1760 "spec s\ndata date: 2024-01-01",
1761 crate::parsing::source::SourceType::Volatile,
1762 &ResourceLimits::default(),
1763 );
1764 assert!(
1765 result.is_err(),
1766 "data named date (type keyword) must be rejected"
1767 );
1768 }
1769
1770 #[test]
1771 fn parse_rejects_data_named_boolean() {
1772 let result = parse(
1773 "spec s\ndata boolean: true",
1774 crate::parsing::source::SourceType::Volatile,
1775 &ResourceLimits::default(),
1776 );
1777 assert!(
1778 result.is_err(),
1779 "data named boolean (type keyword) must be rejected"
1780 );
1781 }
1782
1783 #[test]
1784 fn parse_rejects_data_named_ratio() {
1785 let result = parse(
1786 "spec s\ndata ratio: 5%",
1787 crate::parsing::source::SourceType::Volatile,
1788 &ResourceLimits::default(),
1789 );
1790 assert!(
1791 result.is_err(),
1792 "data named ratio (type keyword) must be rejected"
1793 );
1794 }
1795
1796 #[test]
1797 fn parse_rejects_rule_named_measure() {
1798 let result = parse(
1799 "spec s\ndata x: 1\nrule measure: x",
1800 crate::parsing::source::SourceType::Volatile,
1801 &ResourceLimits::default(),
1802 );
1803 assert!(
1804 result.is_err(),
1805 "rule named measure (type keyword) must be rejected"
1806 );
1807 }
1808
1809 #[test]
1810 fn parse_rejects_rule_named_number() {
1811 let result = parse(
1812 "spec s\ndata x: 1\nrule number: x",
1813 crate::parsing::source::SourceType::Volatile,
1814 &ResourceLimits::default(),
1815 );
1816 assert!(
1817 result.is_err(),
1818 "rule named number (type keyword) must be rejected"
1819 );
1820 }
1821
1822 #[test]
1823 fn parse_rejects_rule_named_text() {
1824 let result = parse(
1825 "spec s\ndata x: 1\nrule text: x",
1826 crate::parsing::source::SourceType::Volatile,
1827 &ResourceLimits::default(),
1828 );
1829 assert!(
1830 result.is_err(),
1831 "rule named text (type keyword) must be rejected"
1832 );
1833 }
1834
1835 #[test]
1836 fn parse_rejects_rule_named_date() {
1837 let result = parse(
1838 "spec s\ndata x: 1\nrule date: x",
1839 crate::parsing::source::SourceType::Volatile,
1840 &ResourceLimits::default(),
1841 );
1842 assert!(
1843 result.is_err(),
1844 "rule named date (type keyword) must be rejected"
1845 );
1846 }
1847
1848 #[test]
1849 fn parse_rejects_rule_named_boolean() {
1850 let result = parse(
1851 "spec s\ndata x: 1\nrule boolean: x",
1852 crate::parsing::source::SourceType::Volatile,
1853 &ResourceLimits::default(),
1854 );
1855 assert!(
1856 result.is_err(),
1857 "rule named boolean (type keyword) must be rejected"
1858 );
1859 }
1860
1861 #[test]
1862 fn parse_rejects_rule_named_ratio() {
1863 let result = parse(
1864 "spec s\ndata x: 1\nrule ratio: x",
1865 crate::parsing::source::SourceType::Volatile,
1866 &ResourceLimits::default(),
1867 );
1868 assert!(
1869 result.is_err(),
1870 "rule named ratio (type keyword) must be rejected"
1871 );
1872 }
1873}