1use crate::parsing::ast::{
8 arithmetic_associativity, expression_precedence, operand_needs_parentheses, AsLemmaSource,
9 Constraint, DataValue, Expression, ExpressionKind, LemmaData, LemmaRule, LemmaSpec,
10 OperandSide,
11};
12use crate::parsing::{parse, ParseResult};
13use crate::{Error, ResourceLimits};
14
15pub const MAX_COLS: usize = 56;
19
20#[must_use]
29pub fn format_specs(specs: &[LemmaSpec]) -> String {
30 let refs: Vec<&LemmaSpec> = specs.iter().collect();
31 format_spec_refs(&refs)
32}
33
34#[must_use]
36pub fn format_spec_refs(specs: &[&LemmaSpec]) -> String {
37 let mut out = String::new();
38 for (index, spec) in specs.iter().enumerate() {
39 if index > 0 {
40 out.push_str("\n\n");
41 }
42 out.push_str(&format_spec(spec, MAX_COLS));
43 }
44 if !out.ends_with('\n') {
45 out.push('\n');
46 }
47 out
48}
49
50#[must_use]
52pub fn format_parse_result(result: &ParseResult) -> String {
53 let mut blocks: Vec<String> = Vec::new();
54 for (repo, specs) in &result.repositories {
55 let mut prefix = String::new();
56 if let Some(name) = repo.name.as_deref() {
57 prefix.push_str("repo ");
58 prefix.push_str(name);
59 prefix.push_str("\n\n");
60 }
61 if specs.is_empty() {
62 if !prefix.is_empty() {
63 blocks.push(prefix);
64 }
65 continue;
66 }
67 let body = format_specs(specs.as_slice());
68 if prefix.is_empty() {
69 blocks.push(body);
70 } else {
71 prefix.push_str(&body);
72 blocks.push(prefix);
73 }
74 }
75 let mut out = blocks.join("\n\n");
76 if !out.ends_with('\n') {
77 out.push('\n');
78 }
79 out
80}
81
82pub fn format_source(
86 source: &str,
87 source_type: crate::parsing::source::SourceType,
88) -> Result<String, Error> {
89 let limits = ResourceLimits::default();
90 let result = parse(source, source_type, &limits)?;
91 Ok(format_parse_result(&result))
92}
93
94pub(crate) fn format_spec(spec: &LemmaSpec, max_cols: usize) -> String {
99 let mut out = String::new();
100 out.push_str("spec ");
101 out.push_str(&spec.name);
102 if let crate::parsing::ast::EffectiveDate::DateTimeValue(ref af) = spec.effective_from {
103 out.push(' ');
104 out.push_str(&af.to_string());
105 }
106 out.push('\n');
107
108 if let Some(ref commentary) = spec.commentary {
109 out.push_str("\"\"\"\n");
110 out.push_str(commentary);
111 out.push_str("\n\"\"\"\n");
112 }
113
114 for meta in &spec.meta_fields {
115 out.push_str(&format!(
116 "meta {}: {}\n",
117 meta.key,
118 AsLemmaSource(&meta.value)
119 ));
120 }
121
122 if !spec.data.is_empty() {
123 format_sorted_data(&spec.data, &mut out, "");
124 }
125
126 if !spec.rules.is_empty() {
127 out.push('\n');
128 for (index, rule) in spec.rules.iter().enumerate() {
129 if index > 0 {
130 out.push('\n');
131 }
132 let rule_text = format_rule(rule, max_cols);
133 for line in rule_text.lines() {
134 out.push_str(line);
135 out.push('\n');
136 }
137 }
138 }
139
140 out
141}
142
143const DATA_CONSTRAINT_INDENT: &str = " ";
149
150fn data_constraints_nonempty(constraints: &Option<Vec<Constraint>>) -> bool {
151 constraints.as_ref().is_some_and(|v| !v.is_empty())
152}
153
154fn data_value_has_arrow_constraints(value: &DataValue) -> bool {
155 match value {
156 DataValue::Definition { constraints, .. } => data_constraints_nonempty(constraints),
157 DataValue::With(_) => false,
158 _ => false,
159 }
160}
161
162fn data_value_rhs_for_spec_body(value: &DataValue, continuation_prefix: &str) -> String {
163 match value {
164 DataValue::Definition {
165 base,
166 constraints,
167 value,
168 } if data_constraints_nonempty(constraints) => {
169 let cs = constraints
170 .as_ref()
171 .expect("BUG: constraints checked above");
172 let head: String = if base.is_none() {
173 match value {
174 Some(v) => format!("{}", AsLemmaSource(v)),
175 None => String::new(),
176 }
177 } else {
178 match base.as_ref() {
179 Some(b) => format!("{}", b),
180 None => String::new(),
181 }
182 };
183 let mut out = head;
184 for (cmd, args) in cs {
185 out.push('\n');
186 out.push_str(continuation_prefix);
187 out.push_str("-> ");
188 out.push_str(&crate::parsing::ast::format_constraint_as_source(cmd, args));
189 }
190 out
191 }
192 DataValue::With(crate::parsing::ast::WithRhs::Reference { target }) => target.to_string(),
193 _ => format!("{}", AsLemmaSource(value)),
194 }
195}
196
197fn data_declaration_keyword(data: &LemmaData) -> &'static str {
198 match &data.value {
199 DataValue::Import(_) => unreachable!("BUG: format_data called on Import row"),
200 DataValue::With(_) => "with",
201 DataValue::Definition { .. } => "data",
202 }
203}
204
205fn format_data(data: &LemmaData, line_prefix: &str) -> String {
206 let kw = data_declaration_keyword(data);
207 let ref_str = format!("{}", data.reference);
208 let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
209 let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
210 if let Some((first, rest)) = rhs.split_once('\n') {
211 format!("{kw} {}: {}\n{}", ref_str, first, rest)
212 } else {
213 format!("{kw} {}: {}", ref_str, rhs)
214 }
215}
216
217fn data_line_prefix_len_before_rhs(keyword: &str, ref_str: &str) -> usize {
219 keyword.len() + 1 + ref_str.len() + 2
220}
221
222fn data_is_simple_single_line(data: &LemmaData, line_prefix: &str) -> bool {
223 if data_value_has_arrow_constraints(&data.value) {
224 return false;
225 }
226 let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
227 let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
228 !rhs.contains('\n')
229}
230
231fn push_formatted_simple_data_line_padded(
232 out: &mut String,
233 data: &LemmaData,
234 line_prefix: &str,
235 target_prefix_len_before_rhs: usize,
236) {
237 let kw = data_declaration_keyword(data);
238 let ref_str = format!("{}", data.reference);
239 let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
240 let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
241 let base = data_line_prefix_len_before_rhs(kw, &ref_str);
242 let gap = 1 + target_prefix_len_before_rhs.saturating_sub(base);
243 out.push_str(line_prefix);
244 out.push_str(kw);
245 out.push(' ');
246 out.push_str(&ref_str);
247 out.push(':');
248 out.push_str(&" ".repeat(gap));
249 out.push_str(&rhs);
250}
251
252fn emit_data_row_group(rows: &[&LemmaData], line_prefix: &str, out: &mut String) {
253 let mut i = 0;
254 while i < rows.len() {
255 if data_is_simple_single_line(rows[i], line_prefix) {
256 let run_start = i;
257 i += 1;
258 while i < rows.len() && data_is_simple_single_line(rows[i], line_prefix) {
259 i += 1;
260 }
261 let run_end = i;
262 let target = (run_start..run_end)
263 .map(|k| {
264 let row = rows[k];
265 let kw = data_declaration_keyword(row);
266 let ref_str = format!("{}", row.reference);
267 data_line_prefix_len_before_rhs(kw, &ref_str)
268 })
269 .max()
270 .expect("BUG: non-empty run");
271 for row in rows[run_start..run_end].iter().copied() {
272 push_formatted_simple_data_line_padded(out, row, line_prefix, target);
273 out.push('\n');
274 }
275 } else {
276 let row = rows[i];
277 out.push_str(line_prefix);
278 out.push_str(&format_data(row, line_prefix));
279 out.push('\n');
280 if data_value_has_arrow_constraints(&row.value) && i + 1 < rows.len() {
281 out.push('\n');
282 }
283 i += 1;
284 }
285 }
286}
287
288fn format_import_row(data: &LemmaData) -> String {
289 let alias = &data.reference.name;
290 if let DataValue::Import(spec_ref) = &data.value {
291 let spec_name = &spec_ref.name;
292 let last_segment = spec_name.rsplit('/').next().unwrap_or(spec_name);
293 if alias == last_segment {
294 format!("uses {}", spec_ref)
295 } else {
296 format!("uses {}: {}", alias, spec_ref)
297 }
298 } else {
299 unreachable!("BUG: format_import_row called on non-Import data")
300 }
301}
302
303fn format_sorted_data(data: &[LemmaData], out: &mut String, line_prefix: &str) {
309 let mut regular: Vec<&LemmaData> = Vec::new();
310 let mut imports: Vec<&LemmaData> = Vec::new();
311 let mut overrides: Vec<&LemmaData> = Vec::new();
312
313 for data in data {
314 if !data.reference.is_local() {
315 overrides.push(data);
316 } else if matches!(&data.value, DataValue::Import(_)) {
317 imports.push(data);
318 } else {
319 regular.push(data);
320 }
321 }
322
323 let emit_group =
324 |rows: &[&LemmaData], out: &mut String| emit_data_row_group(rows, line_prefix, out);
325
326 if !imports.is_empty() {
327 out.push('\n');
328
329 for (i, row) in imports.iter().enumerate() {
330 if i > 0 {
331 out.push('\n');
332 }
333 out.push_str(line_prefix);
334 out.push_str(&format_import_row(row));
335 out.push('\n');
336 let ref_name = &row.reference.name;
337 let binding_overrides: Vec<&LemmaData> = overrides
338 .iter()
339 .filter(|o| {
340 o.reference.segments.first().map(|s| s.as_str()) == Some(ref_name.as_str())
341 })
342 .copied()
343 .collect();
344 if !binding_overrides.is_empty() {
345 emit_data_row_group(&binding_overrides, line_prefix, out);
346 }
347 }
348 }
349
350 if !regular.is_empty() {
351 out.push('\n');
352 emit_group(®ular, out);
353 }
354
355 let matched_prefixes: Vec<&str> = imports.iter().map(|f| f.reference.name.as_str()).collect();
356 let unmatched: Vec<&LemmaData> = overrides
357 .iter()
358 .filter(|o| {
359 o.reference
360 .segments
361 .first()
362 .map(|s| !matched_prefixes.contains(&s.as_str()))
363 .unwrap_or(true)
364 })
365 .copied()
366 .collect();
367 if !unmatched.is_empty() {
368 out.push('\n');
369 emit_group(&unmatched, out);
370 }
371}
372
373const UNLESS_LINE_PREFIX: &str = " unless ";
378
379#[inline]
381fn spec_line_len(line: &str) -> usize {
382 line.len()
383}
384
385fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
391 let expr_indent = " ";
392 let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
393 let mut out = String::new();
394 out.push_str("rule ");
395 out.push_str(&rule.name);
396 let body_single_line = !body.contains('\n');
397 let header_fits_on_one_line =
398 body_single_line && spec_line_len(&format!("rule {}: {}", rule.name, body)) <= max_cols;
399 if header_fits_on_one_line {
400 out.push_str(": ");
401 out.push_str(&body);
402 } else {
403 out.push_str(":\n");
404 out.push_str(expr_indent);
405 out.push_str(&body);
406 }
407
408 let pl = UNLESS_LINE_PREFIX.len();
409 let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
410 let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
411
412 let mut clauses: Vec<(String, String, bool)> = Vec::new();
413 for unless_clause in &rule.unless_clauses {
414 let condition = format_expr_wrapped(&unless_clause.condition, max_cols, " ", 10);
415 let result = format_expr_wrapped(&unless_clause.result, max_cols, " ", 10);
416 let multiline = condition.contains('\n') || result.contains('\n');
417 clauses.push((condition, result, multiline));
418 }
419
420 let mut singles: Vec<usize> = clauses
421 .iter()
422 .enumerate()
423 .filter(|(_, (c, r, m))| !*m && naive_single_len(c, r) <= max_cols)
424 .map(|(i, _)| i)
425 .collect();
426
427 loop {
428 if singles.is_empty() {
429 break;
430 }
431 let max_end = singles
432 .iter()
433 .map(|&i| pl + clauses[i].0.len())
434 .max()
435 .expect("BUG: singles non-empty");
436 let before = singles.len();
437 singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
438 if singles.len() == before {
439 break;
440 }
441 }
442
443 let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
444 const SPLIT_THEN_INDENT_SPACES: usize = 4;
445
446 for (i, (condition, result, multiline)) in clauses.iter().enumerate() {
447 if *multiline {
448 out.push_str("\n unless ");
449 out.push_str(condition);
450 out.push('\n');
451 out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
452 out.push_str("then ");
453 out.push_str(result);
454 continue;
455 }
456 if singles.contains(&i) {
457 let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
458 let gap = 1 + max_end.saturating_sub(pl + condition.len());
459 out.push('\n');
460 out.push_str(UNLESS_LINE_PREFIX);
461 out.push_str(condition);
462 out.push_str(&" ".repeat(gap));
463 out.push_str("then ");
464 out.push_str(result);
465 continue;
466 }
467 out.push_str("\n unless ");
468 out.push_str(condition);
469 out.push('\n');
470 out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
471 out.push_str("then ");
472 out.push_str(result);
473 }
474 out.push('\n');
475 out
476}
477
478fn indent_after_first_line(s: &str, indent: &str) -> String {
484 let mut first = true;
485 let mut out = String::new();
486 for line in s.lines() {
487 if first {
488 first = false;
489 out.push_str(line);
490 } else {
491 out.push('\n');
492 out.push_str(indent);
493 out.push_str(line);
494 }
495 }
496 if s.ends_with('\n') {
497 out.push('\n');
498 }
499 out
500}
501
502fn format_expr_wrapped(
507 expr: &Expression,
508 max_cols: usize,
509 indent: &str,
510 parent_prec: u8,
511) -> String {
512 let my_prec = expression_precedence(&expr.kind);
513
514 match &expr.kind {
515 ExpressionKind::Arithmetic(left, op, right) => {
516 let assoc = Some(arithmetic_associativity(op));
517 let left_inner = format_expr_wrapped(left.as_ref(), max_cols, indent, 10);
519 let right_inner = format_expr_wrapped(right.as_ref(), max_cols, indent, 10);
520 let left_str = if operand_needs_parentheses(
521 expression_precedence(&left.kind),
522 my_prec,
523 OperandSide::Left,
524 assoc,
525 ) {
526 format!("({})", left_inner)
527 } else {
528 left_inner
529 };
530 let right_str = if operand_needs_parentheses(
531 expression_precedence(&right.kind),
532 my_prec,
533 OperandSide::Right,
534 assoc,
535 ) {
536 format!("({})", right_inner)
537 } else {
538 right_inner
539 };
540 let single_line = format!("{} {} {}", left_str, op, right_str);
541 let body = if single_line.len() <= max_cols && !single_line.contains('\n') {
542 single_line
543 } else {
544 let continued_right = indent_after_first_line(&right_str, indent);
545 let continuation = format!("{}{} {}", indent, op, continued_right);
546 format!("{}\n{}", left_str, continuation)
547 };
548 if parent_prec < 10
549 && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
550 {
551 format!("({})", body)
552 } else {
553 body
554 }
555 }
556 _ => {
557 let s = expr.to_string();
558 if parent_prec < 10
559 && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
560 {
561 format!("({})", s)
562 } else {
563 s
564 }
565 }
566 }
567}
568
569#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::literals::DateGranularity;
577 use crate::parsing::ast::{
578 AsLemmaSource, BooleanValue, DateTimeValue, TimeValue, TimezoneValue, Value,
579 };
580 use rust_decimal::prelude::FromStr;
581 use rust_decimal::Decimal;
582
583 fn fmt_value(v: &Value) -> String {
585 format!("{}", AsLemmaSource(v))
586 }
587
588 #[test]
589 fn test_format_value_text_is_quoted() {
590 let v = Value::Text("light".to_string());
591 assert_eq!(fmt_value(&v), "\"light\"");
592 }
593
594 #[test]
595 fn test_format_value_text_escapes_quotes() {
596 let v = Value::Text("say \"hello\"".to_string());
597 assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
598 }
599
600 #[test]
601 fn test_format_value_number() {
602 let v = Value::Number(Decimal::from_str("42.50").unwrap());
603 assert_eq!(fmt_value(&v), "42.50");
604 }
605
606 #[test]
607 fn test_format_value_number_integer() {
608 let v = Value::Number(Decimal::from_str("100.00").unwrap());
609 assert_eq!(fmt_value(&v), "100");
610 }
611
612 #[test]
613 fn test_format_value_boolean() {
614 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
615 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
616 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
617 }
618
619 #[test]
620 fn test_format_value_measure() {
621 let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
622 assert_eq!(fmt_value(&v), "99.50 eur");
623 }
624
625 #[test]
626 fn test_format_value_duration_as_measure() {
627 let v = Value::NumberWithUnit(Decimal::from(40), "hour".to_string());
628 assert_eq!(fmt_value(&v), "40 hour");
629 }
630
631 #[test]
632 fn test_format_value_calendar() {
633 let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
634 assert_eq!(fmt_value(&v), "6 month");
635 }
636
637 #[test]
638 fn test_format_value_ratio_percent() {
639 let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
640 assert_eq!(fmt_value(&v), "10%");
641 }
642
643 #[test]
644 fn test_format_value_ratio_permille() {
645 let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
646 assert_eq!(fmt_value(&v), "5%%");
647 }
648
649 #[test]
650 fn test_format_value_number_with_unit_named() {
651 let v = Value::NumberWithUnit(
652 Decimal::from_str("500").unwrap(),
653 "basis_points".to_string(),
654 );
655 assert_eq!(fmt_value(&v), "500 basis_points");
656 }
657
658 #[test]
659 fn test_format_value_date_only() {
660 let v = Value::Date(DateTimeValue {
661 year: 2024,
662 month: 1,
663 day: 15,
664 hour: 0,
665 minute: 0,
666 second: 0,
667 microsecond: 0,
668 timezone: None,
669
670 granularity: DateGranularity::Full,
671 });
672 assert_eq!(fmt_value(&v), "2024-01-15");
673 }
674
675 #[test]
676 fn test_format_value_datetime_with_tz() {
677 let v = Value::Date(DateTimeValue {
678 year: 2024,
679 month: 1,
680 day: 15,
681 hour: 14,
682 minute: 30,
683 second: 0,
684 microsecond: 0,
685 timezone: Some(TimezoneValue {
686 offset_hours: 0,
687 offset_minutes: 0,
688 }),
689
690 granularity: DateGranularity::DateTime,
691 });
692 assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
693 }
694
695 #[test]
696 fn test_format_value_time() {
697 let v = Value::Time(TimeValue {
698 hour: 14,
699 minute: 30,
700 second: 45,
701 microsecond: 0,
702 timezone: None,
703 });
704 assert_eq!(fmt_value(&v), "14:30:45");
705 }
706
707 #[test]
708 fn test_format_source_preserves_date_granularity() {
709 let formatted = format_source(
710 "spec x 2026\n",
711 crate::parsing::source::SourceType::Volatile,
712 )
713 .expect("spec x 2026 should format");
714 assert!(
715 formatted.contains("spec x 2026\n"),
716 "year-only effective date must round-trip, got: {formatted}"
717 );
718 assert!(
719 !formatted.contains("2026-01-01"),
720 "year-only effective date must not expand, got: {formatted}"
721 );
722 let reformatted = format_source(&formatted, crate::parsing::source::SourceType::Volatile)
723 .expect("reformat");
724 assert_eq!(formatted, reformatted, "spec x 2026 must be idempotent");
725
726 let formatted = format_source(
727 "spec x 2026-03\n",
728 crate::parsing::source::SourceType::Volatile,
729 )
730 .expect("spec x 2026-03 should format");
731 assert!(
732 formatted.contains("spec x 2026-03\n"),
733 "year-month effective date must round-trip, got: {formatted}"
734 );
735
736 let formatted = format_source(
737 "spec x 2026-W34\n",
738 crate::parsing::source::SourceType::Volatile,
739 )
740 .expect("spec x 2026-W34 should format");
741 assert!(
742 formatted.contains("spec x 2026-W34\n"),
743 "iso week effective date must round-trip, got: {formatted}"
744 );
745
746 let source = "spec consumer\nuses finance 2026\n";
747 let formatted = format_source(source, crate::parsing::source::SourceType::Volatile)
748 .expect("uses with year should format");
749 assert!(
750 formatted.contains("uses finance 2026"),
751 "uses effective pin must preserve year-only date, got: {formatted}"
752 );
753 assert!(
754 !formatted.contains("2026-01-01"),
755 "uses effective pin must not expand year-only date, got: {formatted}"
756 );
757 }
758
759 #[test]
760 fn test_format_source_lowercases_logical_identifiers() {
761 let source = r#"spec Test
762data Price: number -> suggest 1
763rule Total: price
764"#;
765 let formatted =
766 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
767 assert!(formatted.contains("spec test"), "got: {formatted}");
768 assert!(formatted.contains("data price"), "got: {formatted}");
769 assert!(formatted.contains("rule total"), "got: {formatted}");
770 }
771
772 #[test]
773 fn test_format_source_round_trips_text() {
774 let source = r#"spec test
775
776data name: "Alice"
777
778rule greeting: "hello"
779"#;
780 let formatted =
781 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
782 assert!(formatted.contains("\"Alice\""), "data text must be quoted");
783 assert!(formatted.contains("\"hello\""), "rule text must be quoted");
784 }
785
786 #[test]
787 fn test_format_source_preserves_percent() {
788 let source = r#"spec test
789
790data rate: 10 percent
791
792rule tax: rate * 21%
793"#;
794 let formatted =
795 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
796 assert!(
797 formatted.contains("10%"),
798 "data percent must use shorthand %, got: {}",
799 formatted
800 );
801 }
802
803 #[test]
804 fn test_format_groups_data_preserving_order() {
805 let source = r#"spec test
808
809data income: number -> minimum 0
810data filing_status: filing_status_type -> suggest "single"
811data country: "NL"
812data deductions: number -> minimum 0
813data name: text
814
815rule total: income
816"#;
817 let formatted =
818 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
819 let data_section = formatted
820 .split("rule total")
821 .next()
822 .unwrap()
823 .split("spec test\n")
824 .nth(1)
825 .unwrap();
826 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
827 assert_eq!(lines[0], "data income: number");
829 assert_eq!(lines[1], " -> minimum 0");
830 assert_eq!(lines[2], "data filing_status: filing_status_type");
831 assert_eq!(lines[3], " -> suggest \"single\"");
832 assert_eq!(lines[4], "data country: \"NL\"");
833 assert_eq!(lines[5], "data deductions: number");
834 assert_eq!(lines[6], " -> minimum 0");
835 assert_eq!(lines[7], "data name: text");
836 }
837
838 #[test]
839 fn test_format_groups_spec_refs_with_overrides() {
840 let source = r#"spec test
841
842with retail.quantity: 5
843uses order wholesale
844uses order retail
845with wholesale.quantity: 100
846data base_price: 50
847
848rule total: base_price
849"#;
850 let formatted =
851 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
852 let data_section = formatted
853 .split("rule total")
854 .next()
855 .unwrap()
856 .split("spec test\n")
857 .nth(1)
858 .unwrap();
859 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
860 assert_eq!(lines[0], "uses order wholesale");
861 assert_eq!(lines[1], "with wholesale.quantity: 100");
862 assert_eq!(lines[2], "uses order retail");
863 assert_eq!(lines[3], "with retail.quantity: 5");
864 assert_eq!(lines[4], "data base_price: 50");
865 }
866
867 #[test]
868 fn test_format_groups_with_literals_under_each_uses() {
869 let source = r#"spec test
870
871uses x
872uses y
873
874with x.name: "Ben"
875with y.age: 15
876
877rule r: 1
878"#;
879 let formatted =
880 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
881 let data_section = formatted
882 .split("rule r")
883 .next()
884 .unwrap()
885 .split("spec test\n")
886 .nth(1)
887 .unwrap();
888 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
889 assert_eq!(lines[0], "uses x");
890 assert_eq!(lines[1], "with x.name: \"Ben\"");
891 assert_eq!(lines[2], "uses y");
892 assert_eq!(lines[3], "with y.age: 15");
893 }
894
895 #[test]
896 fn test_format_source_weather_clothing_text_quoted() {
897 let source = r#"spec weather_clothing
898
899data clothing_style: text
900 -> option "light"
901 -> option "warm"
902
903data temperature: number
904
905rule clothing_layer: "light"
906 unless temperature < 5 then "warm"
907"#;
908 let formatted =
909 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
910 assert!(
911 formatted.contains("\"light\""),
912 "text in rule must be quoted, got: {}",
913 formatted
914 );
915 assert!(
916 formatted.contains("\"warm\""),
917 "text in unless must be quoted, got: {}",
918 formatted
919 );
920 }
921
922 #[test]
928 fn test_format_text_option_round_trips() {
929 let source = r#"spec test
930
931data status: text
932 -> option "active"
933 -> option "inactive"
934
935data s: status
936
937rule out: s
938"#;
939 let formatted =
940 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
941 assert!(
942 formatted.contains("option \"active\""),
943 "text option must be quoted, got: {}",
944 formatted
945 );
946 assert!(
947 formatted.contains("option \"inactive\""),
948 "text option must be quoted, got: {}",
949 formatted
950 );
951 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
953 assert!(reparsed.is_ok(), "formatted output should re-parse");
954 }
955
956 #[test]
957 fn test_format_help_round_trips() {
958 let source = r#"spec test
959data quantity: number -> help "Number of items to order"
960rule total: quantity
961"#;
962 let formatted =
963 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
964 assert!(
965 formatted.contains("help \"Number of items to order\""),
966 "help must be quoted, got: {}",
967 formatted
968 );
969 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
971 assert!(reparsed.is_ok(), "formatted output should re-parse");
972 }
973
974 #[test]
975 fn test_format_measure_type_def_round_trips() {
976 let source = r#"spec test
977
978data money: measure
979 -> unit eur 1.00
980 -> unit usd 0.91
981 -> decimals 2
982 -> minimum 0
983
984data price: money
985
986rule total: price
987"#;
988 let formatted =
989 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
990 assert!(
991 formatted.contains("unit eur 1.00"),
992 "measure unit should not be quoted, got: {}",
993 formatted
994 );
995 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
997 assert!(
998 reparsed.is_ok(),
999 "formatted output should re-parse, got: {:?}",
1000 reparsed
1001 );
1002 }
1003
1004 #[test]
1005 fn test_format_expression_display_stable_round_trip() {
1006 let source = r#"spec test
1007data a: 1.00
1008rule r: a + 2.00 * 3
1009"#;
1010 let formatted =
1011 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1012 let again =
1013 format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1014 assert_eq!(
1015 formatted, again,
1016 "AST Display-based format must be idempotent under parse/format"
1017 );
1018 }
1019
1020 #[test]
1021 fn test_format_past_future_range_no_duplicate_in() {
1022 let source = r#"spec test
1023data start: date
1024data length: duration
1025rule valid: start in past length
1026rule window: past length
1027"#;
1028 let formatted =
1029 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1030 assert!(
1031 formatted.contains("rule valid: start in past length"),
1032 "RangeContainment+PastFutureRange must not emit duplicate 'in', got:\n{formatted}"
1033 );
1034 assert!(
1035 formatted.contains("rule window: past length"),
1036 "bare PastFutureRange must print 'past' not 'in past', got:\n{formatted}"
1037 );
1038 assert!(
1039 !formatted.contains("in in past"),
1040 "must not contain duplicate 'in', got:\n{formatted}"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_format_rule_default_on_same_line_when_fits() {
1046 let source = "spec test\nrule r: 1\n";
1047 let formatted =
1048 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1049 assert!(
1050 formatted.contains("rule r: 1\n"),
1051 "default expr should stay on rule line when under MAX_COLS, got:\n{formatted}"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_format_rule_unless_single_line_when_short() {
1057 let source = r#"spec test
1058data a: number
1059data b: boolean
1060
1061rule r: no
1062 unless a < 1 then yes
1063 unless b then yes
1064"#;
1065 let formatted =
1066 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1067 assert!(
1068 formatted.contains("unless a < 1 then yes")
1069 && formatted.contains("unless b then yes"),
1070 "unless stays on one line when under MAX_COLS, got:\n{formatted}"
1071 );
1072 }
1073}