1use crate::parsing::ast::{
8 arithmetic_associativity, expression_precedence, operand_needs_parentheses, AsLemmaSource,
9 Associativity, Constraint, DataValue, Expression, ExpressionKind, LemmaData, LemmaRule,
10 LemmaSpec, 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 if !spec.data.is_empty() {
128 out.push_str("\n\n");
129 } else {
130 out.push('\n');
131 }
132 for (index, rule) in spec.rules.iter().enumerate() {
133 if index > 0 {
134 out.push('\n');
135 }
136 let rule_text = format_rule(rule, max_cols);
137 for line in rule_text.lines() {
138 out.push_str(line);
139 out.push('\n');
140 }
141 }
142 }
143
144 out
145}
146
147const DATA_CONSTRAINT_INDENT: &str = " ";
153
154fn data_constraints_nonempty(constraints: &Option<Vec<Constraint>>) -> bool {
155 constraints.as_ref().is_some_and(|v| !v.is_empty())
156}
157
158fn data_value_has_arrow_constraints(value: &DataValue) -> bool {
159 match value {
160 DataValue::Definition { constraints, .. } => data_constraints_nonempty(constraints),
161 DataValue::Import { .. } => false,
162 }
163}
164
165fn format_with_rhs(rhs: &crate::parsing::ast::WithRhs) -> String {
166 match rhs {
167 crate::parsing::ast::WithRhs::Literal(v) => format!("{}", AsLemmaSource(v)),
168 crate::parsing::ast::WithRhs::Reference { target } => target.to_string(),
169 }
170}
171
172fn data_value_rhs_for_spec_body(value: &DataValue, continuation_prefix: &str) -> String {
173 match value {
174 DataValue::Definition {
175 base,
176 constraints,
177 value,
178 } if data_constraints_nonempty(constraints) => {
179 let cs = constraints
180 .as_ref()
181 .expect("BUG: constraints checked above");
182 let head: String = if base.is_none() {
183 match value {
184 Some(v) => format!("{}", AsLemmaSource(v)),
185 None => String::new(),
186 }
187 } else {
188 match base.as_ref() {
189 Some(b) => format!("{}", b),
190 None => String::new(),
191 }
192 };
193 let mut out = head;
194 for row in cs {
195 out.push('\n');
196 out.push_str(continuation_prefix);
197 out.push_str("-> ");
198 out.push_str(&crate::parsing::ast::format_constraint_as_source(
199 &row.command,
200 &row.args,
201 ));
202 }
203 out
204 }
205 DataValue::Definition { .. } => format!("{}", AsLemmaSource(value)),
206 DataValue::Import { .. } => unreachable!("BUG: format_data called on Import row"),
207 }
208}
209
210fn data_declaration_keyword(data: &LemmaData) -> &'static str {
211 match &data.value {
212 DataValue::Import { .. } => unreachable!("BUG: format_data called on Import row"),
213 DataValue::Definition { .. } => "data",
214 }
215}
216
217fn format_data(data: &LemmaData, line_prefix: &str) -> String {
218 let kw = data_declaration_keyword(data);
219 let ref_str = format!("{}", data.reference);
220 let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
221 let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
222 if let Some((first, rest)) = rhs.split_once('\n') {
223 format!("{kw} {}: {}\n{}", ref_str, first, rest)
224 } else {
225 format!("{kw} {}: {}", ref_str, rhs)
226 }
227}
228
229fn data_line_prefix_len_before_rhs(keyword: &str, ref_str: &str) -> usize {
231 keyword.len() + 1 + ref_str.len() + 2
232}
233
234fn data_is_simple_single_line(data: &LemmaData, line_prefix: &str) -> bool {
235 if data_value_has_arrow_constraints(&data.value) {
236 return false;
237 }
238 let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
239 let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
240 !rhs.contains('\n')
241}
242
243fn push_formatted_simple_data_line_padded(
244 out: &mut String,
245 data: &LemmaData,
246 line_prefix: &str,
247 target_prefix_len_before_rhs: usize,
248) {
249 let kw = data_declaration_keyword(data);
250 let ref_str = format!("{}", data.reference);
251 let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
252 let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
253 let base = data_line_prefix_len_before_rhs(kw, &ref_str);
254 let gap = 1 + target_prefix_len_before_rhs.saturating_sub(base);
255 out.push_str(line_prefix);
256 out.push_str(kw);
257 out.push(' ');
258 out.push_str(&ref_str);
259 out.push(':');
260 out.push_str(&" ".repeat(gap));
261 out.push_str(&rhs);
262}
263
264fn emit_data_row_group(rows: &[&LemmaData], line_prefix: &str, out: &mut String) {
265 let mut i = 0;
266 while i < rows.len() {
267 if data_is_simple_single_line(rows[i], line_prefix) {
268 let run_start = i;
269 i += 1;
270 while i < rows.len() && data_is_simple_single_line(rows[i], line_prefix) {
271 i += 1;
272 }
273 let run_end = i;
274 let target = (run_start..run_end)
275 .map(|k| {
276 let row = rows[k];
277 let kw = data_declaration_keyword(row);
278 let ref_str = format!("{}", row.reference);
279 data_line_prefix_len_before_rhs(kw, &ref_str)
280 })
281 .max()
282 .expect("BUG: non-empty run");
283 for row in rows[run_start..run_end].iter().copied() {
284 push_formatted_simple_data_line_padded(out, row, line_prefix, target);
285 out.push('\n');
286 }
287 } else {
288 let row = rows[i];
289 out.push_str(line_prefix);
290 out.push_str(&format_data(row, line_prefix));
291 out.push('\n');
292 if data_value_has_arrow_constraints(&row.value) && i + 1 < rows.len() {
293 out.push('\n');
294 }
295 i += 1;
296 }
297 }
298}
299
300fn format_import_header(data: &LemmaData) -> String {
301 let alias = &data.reference.name;
302 let DataValue::Import { spec_ref, .. } = &data.value else {
303 unreachable!("BUG: format_import_header called on non-Import data");
304 };
305 let spec_name = &spec_ref.name;
306 if alias == spec_name {
307 format!("uses {}", spec_ref)
308 } else {
309 format!("uses {}: {}", alias, spec_ref)
310 }
311}
312
313fn format_uses_block(data: &LemmaData, line_prefix: &str) -> String {
314 let mut out = format_import_header(data);
315 let DataValue::Import { bindings, .. } = &data.value else {
316 unreachable!("BUG: format_uses_block called on non-Import data");
317 };
318 for binding in bindings {
319 out.push('\n');
320 out.push_str(line_prefix);
321 out.push_str(DATA_CONSTRAINT_INDENT);
322 out.push_str("-> ");
323 out.push_str(&crate::parsing::ast::format_assignment_continuation(
324 "with",
325 &format!("{}", binding.path),
326 &format_with_rhs(&binding.rhs),
327 ));
328 }
329 out
330}
331
332fn format_sorted_data(data: &[LemmaData], out: &mut String, line_prefix: &str) {
337 let mut regular: Vec<&LemmaData> = Vec::new();
338 let mut imports: Vec<&LemmaData> = Vec::new();
339
340 for data in data {
341 if matches!(&data.value, DataValue::Import { .. }) {
342 imports.push(data);
343 } else {
344 regular.push(data);
345 }
346 }
347
348 let emit_group =
349 |rows: &[&LemmaData], out: &mut String| emit_data_row_group(rows, line_prefix, out);
350
351 if !imports.is_empty() {
352 out.push('\n');
353
354 for (i, row) in imports.iter().enumerate() {
355 if i > 0 {
356 out.push('\n');
357 }
358 out.push_str(line_prefix);
359 out.push_str(&format_uses_block(row, line_prefix));
360 out.push('\n');
361 }
362 }
363
364 if !regular.is_empty() {
365 out.push('\n');
366 emit_group(®ular, out);
367 }
368}
369
370const UNLESS_LINE_PREFIX: &str = " unless ";
375
376fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
382 let expr_indent = " ";
383 let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
384 let mut out = String::new();
385 out.push_str("rule ");
386 out.push_str(&rule.name);
387 out.push_str(":\n");
388 out.push_str(expr_indent);
389 out.push_str(&body);
390
391 let pl = UNLESS_LINE_PREFIX.len();
392 let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
393 let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
394 let unless_condition_budget = max_cols.saturating_sub(pl);
395
396 let mut clauses: Vec<(String, String)> = Vec::new();
397 for unless_clause in &rule.unless_clauses {
398 let condition = format_expr_wrapped(
399 &unless_clause.condition,
400 unless_condition_budget,
401 " ",
402 10,
403 );
404 let result = format_expr_wrapped(&unless_clause.result, max_cols, " ", 10);
405 clauses.push((condition, result));
406 }
407
408 let clause_needs_split_then = |condition: &str, result: &str| {
409 condition.contains('\n')
410 || result.contains('\n')
411 || naive_single_len(condition, result) > max_cols
412 };
413
414 let any_split = clauses.iter().any(|(c, r)| clause_needs_split_then(c, r));
415
416 const SPLIT_THEN_INDENT_SPACES: usize = 4;
417
418 if any_split {
419 for (condition, result) in &clauses {
420 out.push_str("\n unless ");
421 out.push_str(condition);
422 out.push('\n');
423 out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
424 out.push_str("then ");
425 out.push_str(result);
426 }
427 } else {
428 let mut singles: Vec<usize> = clauses
429 .iter()
430 .enumerate()
431 .filter(|(_, (c, r))| naive_single_len(c, r) <= max_cols)
432 .map(|(i, _)| i)
433 .collect();
434
435 loop {
436 if singles.is_empty() {
437 break;
438 }
439 let max_end = singles
440 .iter()
441 .map(|&i| pl + clauses[i].0.len())
442 .max()
443 .expect("BUG: singles non-empty");
444 let before = singles.len();
445 singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
446 if singles.len() == before {
447 break;
448 }
449 }
450
451 let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
452
453 for (i, (condition, result)) in clauses.iter().enumerate() {
454 if singles.contains(&i) {
455 let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
456 let gap = 1 + max_end.saturating_sub(pl + condition.len());
457 out.push('\n');
458 out.push_str(UNLESS_LINE_PREFIX);
459 out.push_str(condition);
460 out.push_str(&" ".repeat(gap));
461 out.push_str("then ");
462 out.push_str(result);
463 } else {
464 out.push_str("\n unless ");
465 out.push_str(condition);
466 out.push('\n');
467 out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
468 out.push_str("then ");
469 out.push_str(result);
470 }
471 }
472 }
473 out.push('\n');
474 out
475}
476
477fn indent_after_first_line(s: &str, indent: &str) -> String {
483 let mut first = true;
484 let mut out = String::new();
485 for line in s.lines() {
486 if first {
487 first = false;
488 out.push_str(line);
489 } else {
490 out.push('\n');
491 out.push_str(indent);
492 out.push_str(line);
493 }
494 }
495 if s.ends_with('\n') {
496 out.push('\n');
497 }
498 out
499}
500
501struct BinaryWrapContext<'a> {
502 max_cols: usize,
503 indent: &'a str,
504 parent_prec: u8,
505 my_prec: u8,
506 assoc: Option<Associativity>,
507}
508
509fn format_binary_expr_wrapped(
510 left: &Expression,
511 op: &str,
512 right: &Expression,
513 ctx: BinaryWrapContext<'_>,
514) -> String {
515 let BinaryWrapContext {
516 max_cols,
517 indent,
518 parent_prec,
519 my_prec,
520 assoc,
521 } = ctx;
522 let left_inner = format_expr_wrapped(left, max_cols, indent, 10);
523 let right_inner = format_expr_wrapped(right, max_cols, indent, 10);
524 let left_str = if operand_needs_parentheses(
525 expression_precedence(&left.kind),
526 my_prec,
527 OperandSide::Left,
528 assoc,
529 ) {
530 format!("({})", left_inner)
531 } else {
532 left_inner
533 };
534 let right_str = if operand_needs_parentheses(
535 expression_precedence(&right.kind),
536 my_prec,
537 OperandSide::Right,
538 assoc,
539 ) {
540 format!("({})", right_inner)
541 } else {
542 right_inner
543 };
544 let single_line = format!("{} {} {}", left_str, op, right_str);
545 let body = if single_line.len() <= max_cols && !single_line.contains('\n') {
546 single_line
547 } else {
548 let continued_right = indent_after_first_line(&right_str, indent);
549 let continuation = format!("{}{} {}", indent, op, continued_right);
550 format!("{}\n{}", left_str, continuation)
551 };
552 if parent_prec < 10 && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
553 {
554 format!("({})", body)
555 } else {
556 body
557 }
558}
559
560fn format_expr_wrapped(
565 expr: &Expression,
566 max_cols: usize,
567 indent: &str,
568 parent_prec: u8,
569) -> String {
570 let my_prec = expression_precedence(&expr.kind);
571
572 match &expr.kind {
573 ExpressionKind::Arithmetic(left, op, right) => format_binary_expr_wrapped(
574 left,
575 &op.to_string(),
576 right,
577 BinaryWrapContext {
578 max_cols,
579 indent,
580 parent_prec,
581 my_prec,
582 assoc: Some(arithmetic_associativity(op)),
583 },
584 ),
585 ExpressionKind::LogicalAnd(left, right) => format_binary_expr_wrapped(
586 left,
587 "and",
588 right,
589 BinaryWrapContext {
590 max_cols,
591 indent,
592 parent_prec,
593 my_prec,
594 assoc: Some(Associativity::Left),
595 },
596 ),
597 _ => {
598 let s = expr.to_string();
599 if parent_prec < 10
600 && operand_needs_parentheses(my_prec, parent_prec, OperandSide::Left, None)
601 {
602 format!("({})", s)
603 } else {
604 s
605 }
606 }
607 }
608}
609
610#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::literals::DateGranularity;
618 use crate::parsing::ast::{
619 AsLemmaSource, BooleanValue, DateTimeValue, TimeValue, TimezoneValue, Value,
620 };
621 use rust_decimal::prelude::FromStr;
622 use rust_decimal::Decimal;
623
624 fn fmt_value(v: &Value) -> String {
626 format!("{}", AsLemmaSource(v))
627 }
628
629 #[test]
630 fn test_format_value_text_is_quoted() {
631 let v = Value::Text("light".to_string());
632 assert_eq!(fmt_value(&v), "\"light\"");
633 }
634
635 #[test]
636 fn test_format_value_text_escapes_quotes() {
637 let v = Value::Text("say \"hello\"".to_string());
638 assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
639 }
640
641 #[test]
642 fn test_format_value_number() {
643 let v = Value::Number(Decimal::from_str("42.50").unwrap());
644 assert_eq!(fmt_value(&v), "42.50");
645 }
646
647 #[test]
648 fn test_format_value_number_integer() {
649 let v = Value::Number(Decimal::from_str("100.00").unwrap());
650 assert_eq!(fmt_value(&v), "100");
651 }
652
653 #[test]
654 fn test_format_value_boolean() {
655 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
656 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
657 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
658 }
659
660 #[test]
661 fn test_format_value_measure() {
662 let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
663 assert_eq!(fmt_value(&v), "99.50 eur");
664 }
665
666 #[test]
667 fn test_format_value_duration_as_measure() {
668 let v = Value::NumberWithUnit(Decimal::from(40), "hour".to_string());
669 assert_eq!(fmt_value(&v), "40 hour");
670 }
671
672 #[test]
673 fn test_format_value_calendar() {
674 let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
675 assert_eq!(fmt_value(&v), "6 month");
676 }
677
678 #[test]
679 fn test_format_value_ratio_percent() {
680 let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
681 assert_eq!(fmt_value(&v), "10%");
682 }
683
684 #[test]
685 fn test_format_value_ratio_permille() {
686 let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
687 assert_eq!(fmt_value(&v), "5%%");
688 }
689
690 #[test]
691 fn test_format_value_number_with_unit_named() {
692 let v = Value::NumberWithUnit(
693 Decimal::from_str("500").unwrap(),
694 "basis_points".to_string(),
695 );
696 assert_eq!(fmt_value(&v), "500 basis_points");
697 }
698
699 #[test]
700 fn test_format_value_date_only() {
701 let v = Value::Date(DateTimeValue {
702 year: 2024,
703 month: 1,
704 day: 15,
705 hour: 0,
706 minute: 0,
707 second: 0,
708 microsecond: 0,
709 timezone: None,
710
711 granularity: DateGranularity::Full,
712 });
713 assert_eq!(fmt_value(&v), "2024-01-15");
714 }
715
716 #[test]
717 fn test_format_value_datetime_with_tz() {
718 let v = Value::Date(DateTimeValue {
719 year: 2024,
720 month: 1,
721 day: 15,
722 hour: 14,
723 minute: 30,
724 second: 0,
725 microsecond: 0,
726 timezone: Some(TimezoneValue {
727 offset_hours: 0,
728 offset_minutes: 0,
729 }),
730
731 granularity: DateGranularity::DateTime,
732 });
733 assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
734 }
735
736 #[test]
737 fn test_format_value_time() {
738 let v = Value::Time(TimeValue {
739 hour: 14,
740 minute: 30,
741 second: 45,
742 microsecond: 0,
743 timezone: None,
744 });
745 assert_eq!(fmt_value(&v), "14:30:45");
746 }
747
748 #[test]
749 fn test_format_source_preserves_date_granularity() {
750 let formatted = format_source(
751 "spec x 2026\n",
752 crate::parsing::source::SourceType::Volatile,
753 )
754 .expect("spec x 2026 should format");
755 assert!(
756 formatted.contains("spec x 2026\n"),
757 "year-only effective date must round-trip, got: {formatted}"
758 );
759 assert!(
760 !formatted.contains("2026-01-01"),
761 "year-only effective date must not expand, got: {formatted}"
762 );
763 let reformatted = format_source(&formatted, crate::parsing::source::SourceType::Volatile)
764 .expect("reformat");
765 assert_eq!(formatted, reformatted, "spec x 2026 must be idempotent");
766
767 let formatted = format_source(
768 "spec x 2026-03\n",
769 crate::parsing::source::SourceType::Volatile,
770 )
771 .expect("spec x 2026-03 should format");
772 assert!(
773 formatted.contains("spec x 2026-03\n"),
774 "year-month effective date must round-trip, got: {formatted}"
775 );
776
777 let formatted = format_source(
778 "spec x 2026-W34\n",
779 crate::parsing::source::SourceType::Volatile,
780 )
781 .expect("spec x 2026-W34 should format");
782 assert!(
783 formatted.contains("spec x 2026-W34\n"),
784 "iso week effective date must round-trip, got: {formatted}"
785 );
786
787 let source = "spec consumer\nuses finance 2026\n";
788 let formatted = format_source(source, crate::parsing::source::SourceType::Volatile)
789 .expect("uses with year should format");
790 assert!(
791 formatted.contains("uses finance 2026"),
792 "uses effective pin must preserve year-only date, got: {formatted}"
793 );
794 assert!(
795 !formatted.contains("2026-01-01"),
796 "uses effective pin must not expand year-only date, got: {formatted}"
797 );
798 }
799
800 #[test]
801 fn test_format_source_lowercases_logical_identifiers() {
802 let source = r#"spec Test
803data Price: number -> suggest 1
804rule Total: price
805"#;
806 let formatted =
807 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
808 assert!(formatted.contains("spec test"), "got: {formatted}");
809 assert!(formatted.contains("data price"), "got: {formatted}");
810 assert!(formatted.contains("rule total"), "got: {formatted}");
811 }
812
813 #[test]
814 fn test_format_source_round_trips_text() {
815 let source = r#"spec test
816
817data name: "Alice"
818
819rule greeting: "hello"
820"#;
821 let formatted =
822 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
823 assert!(formatted.contains("\"Alice\""), "data text must be quoted");
824 assert!(formatted.contains("\"hello\""), "rule text must be quoted");
825 }
826
827 #[test]
828 fn test_format_source_preserves_percent() {
829 let source = r#"spec test
830
831data rate: 10 percent
832
833rule tax: rate * 21%
834"#;
835 let formatted =
836 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
837 assert!(
838 formatted.contains("10%"),
839 "data percent must use shorthand %, got: {}",
840 formatted
841 );
842 }
843
844 #[test]
845 fn test_format_groups_data_preserving_order() {
846 let source = r#"spec test
849
850data income: number -> minimum 0
851data filing_status: filing_status_type -> suggest "single"
852data country: "NL"
853data deductions: number -> minimum 0
854data name: text
855
856rule total: income
857"#;
858 let formatted =
859 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
860 let data_section = formatted
861 .split("rule total")
862 .next()
863 .unwrap()
864 .split("spec test\n")
865 .nth(1)
866 .unwrap();
867 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
868 assert_eq!(lines[0], "data income: number");
870 assert_eq!(lines[1], " -> minimum 0");
871 assert_eq!(lines[2], "data filing_status: filing_status_type");
872 assert_eq!(lines[3], " -> suggest \"single\"");
873 assert_eq!(lines[4], "data country: \"NL\"");
874 assert_eq!(lines[5], "data deductions: number");
875 assert_eq!(lines[6], " -> minimum 0");
876 assert_eq!(lines[7], "data name: text");
877 }
878
879 #[test]
880 fn test_format_groups_spec_refs_with_overrides() {
881 let source = r#"spec test
882
883uses order wholesale
884 -> with quantity: 100
885uses order retail
886 -> with quantity: 5
887data base_price: 50
888
889rule total: base_price
890"#;
891 let formatted =
892 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
893 let data_section = formatted
894 .split("rule total")
895 .next()
896 .unwrap()
897 .split("spec test\n")
898 .nth(1)
899 .unwrap();
900 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
901 assert_eq!(lines[0], "uses order wholesale");
902 assert_eq!(lines[1], " -> with quantity: 100");
903 assert_eq!(lines[2], "uses order retail");
904 assert_eq!(lines[3], " -> with quantity: 5");
905 assert_eq!(lines[4], "data base_price: 50");
906 }
907
908 #[test]
909 fn test_format_groups_with_literals_under_each_uses() {
910 let source = r#"spec test
911
912uses x
913 -> with name: "Ben"
914uses y
915 -> with age: 15
916
917rule r: 1
918"#;
919 let formatted =
920 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
921 let data_section = formatted
922 .split("rule r")
923 .next()
924 .unwrap()
925 .split("spec test\n")
926 .nth(1)
927 .unwrap();
928 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
929 assert_eq!(lines[0], "uses x");
930 assert_eq!(lines[1], " -> with name: \"Ben\"");
931 assert_eq!(lines[2], "uses y");
932 assert_eq!(lines[3], " -> with age: 15");
933 }
934
935 #[test]
936 fn test_format_source_weather_clothing_text_quoted() {
937 let source = r#"spec weather_clothing
938
939data clothing_style: text
940 -> option "light"
941 -> option "warm"
942
943data temperature: number
944
945rule clothing_layer: "light"
946 unless temperature < 5 then "warm"
947"#;
948 let formatted =
949 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
950 assert!(
951 formatted.contains("\"light\""),
952 "text in rule must be quoted, got: {}",
953 formatted
954 );
955 assert!(
956 formatted.contains("\"warm\""),
957 "text in unless must be quoted, got: {}",
958 formatted
959 );
960 }
961
962 #[test]
968 fn test_format_text_option_round_trips() {
969 let source = r#"spec test
970
971data status: text
972 -> option "active"
973 -> option "inactive"
974
975data s: status
976
977rule out: s
978"#;
979 let formatted =
980 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
981 assert!(
982 formatted.contains("option \"active\""),
983 "text option must be quoted, got: {}",
984 formatted
985 );
986 assert!(
987 formatted.contains("option \"inactive\""),
988 "text option must be quoted, got: {}",
989 formatted
990 );
991 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
993 assert!(reparsed.is_ok(), "formatted output should re-parse");
994 }
995
996 #[test]
997 fn test_format_help_round_trips() {
998 let source = r#"spec test
999data quantity: number -> help "Number of items to order"
1000rule total: quantity
1001"#;
1002 let formatted =
1003 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1004 assert!(
1005 formatted.contains("help \"Number of items to order\""),
1006 "help must be quoted, got: {}",
1007 formatted
1008 );
1009 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1011 assert!(reparsed.is_ok(), "formatted output should re-parse");
1012 }
1013
1014 #[test]
1015 fn test_format_measure_type_def_round_trips() {
1016 let source = r#"spec test
1017
1018data money: measure
1019 -> unit eur: 1.00
1020 -> unit usd: 0.91
1021 -> decimals 2
1022 -> minimum 0
1023
1024data price: money
1025
1026rule total: price
1027"#;
1028 let formatted =
1029 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1030 assert!(
1031 formatted.contains("unit eur: 1.00"),
1032 "measure unit should not be quoted, got: {}",
1033 formatted
1034 );
1035 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1037 assert!(
1038 reparsed.is_ok(),
1039 "formatted output should re-parse, got: {:?}",
1040 reparsed
1041 );
1042 }
1043
1044 #[test]
1045 fn format_deprecated_unit_space_emits_assignment_colon() {
1046 let source = r#"spec test
1047uses lemma units
1048
1049data money: measure
1050 -> unit eur: 1.00
1051
1052data rate: measure
1053 -> unit eur_per_hour: eur/hour
1054"#;
1055 let formatted =
1056 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1057 assert!(
1058 formatted.contains("unit eur: 1.00"),
1059 "formatter must emit assignment colon for unit, got: {}",
1060 formatted
1061 );
1062 assert!(
1063 formatted.contains("unit eur_per_hour: eur/hour"),
1064 "formatter must emit assignment colon for compound unit, got: {}",
1065 formatted
1066 );
1067 assert!(
1068 !formatted.contains("unit eur 1.00"),
1069 "formatter must not emit deprecated space unit syntax, got: {}",
1070 formatted
1071 );
1072 }
1073
1074 #[test]
1075 fn format_canonical_unit_colon_round_trips() {
1076 let source = r#"spec test
1077uses lemma units
1078
1079data money: measure
1080 -> unit eur: 1.00
1081
1082data rate: measure
1083 -> unit eur_per_hour: eur/hour
1084"#;
1085 let formatted =
1086 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1087 assert!(
1088 formatted.contains("unit eur: 1.00"),
1089 "canonical unit syntax must survive format, got: {}",
1090 formatted
1091 );
1092 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
1093 assert!(
1094 reparsed.is_ok(),
1095 "formatted canonical unit syntax should re-parse, got: {:?}",
1096 reparsed
1097 );
1098 }
1099
1100 #[test]
1101 fn test_format_expression_display_stable_round_trip() {
1102 let source = r#"spec test
1103data a: 1.00
1104rule r: a + 2.00 * 3
1105"#;
1106 let formatted =
1107 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1108 let again =
1109 format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1110 assert_eq!(
1111 formatted, again,
1112 "AST Display-based format must be idempotent under parse/format"
1113 );
1114 }
1115
1116 #[test]
1117 fn test_format_past_future_range_no_duplicate_in() {
1118 let source = r#"spec test
1119data start: date
1120data length: duration
1121rule valid: start in past length
1122rule window: past length
1123"#;
1124 let formatted =
1125 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1126 assert!(
1127 formatted.contains("rule valid:\n start in past length"),
1128 "RangeContainment+PastFutureRange must not emit duplicate 'in', got:\n{formatted}"
1129 );
1130 assert!(
1131 formatted.contains("rule window:\n past length"),
1132 "bare PastFutureRange must print 'past' not 'in past', got:\n{formatted}"
1133 );
1134 assert!(
1135 !formatted.contains("in in past"),
1136 "must not contain duplicate 'in', got:\n{formatted}"
1137 );
1138 }
1139
1140 #[test]
1141 fn test_format_rule_body_on_next_line() {
1142 let source = "spec test\nrule r: 1\n";
1143 let formatted =
1144 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1145 assert!(
1146 formatted.contains("rule r:\n 1\n"),
1147 "rule body must start on next line, got:\n{formatted}"
1148 );
1149 }
1150
1151 #[test]
1152 fn test_format_blank_line_between_data_and_rules() {
1153 let source = "spec test\ndata x: 1\nrule r: x\n";
1154 let formatted =
1155 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1156 assert!(
1157 formatted.contains("data x: 1\n\n\nrule r:\n"),
1158 "two blank lines required between data block and rules block, got:\n{formatted:?}"
1159 );
1160 }
1161
1162 #[test]
1163 fn test_format_rule_unless_single_line_when_short() {
1164 let source = r#"spec test
1165data a: number
1166data b: boolean
1167
1168rule r: no
1169 unless a < 1 then yes
1170 unless b then yes
1171"#;
1172 let formatted =
1173 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1174 assert!(
1175 formatted.contains("unless a < 1 then yes")
1176 && formatted.contains("unless b then yes"),
1177 "unless stays on one line when under MAX_COLS, got:\n{formatted}"
1178 );
1179 }
1180
1181 #[test]
1182 fn test_format_rule_unless_child_premium_applies_inline_aligned() {
1183 let source = r#"spec child_premium_applies
1184data child_age_years: number
1185data is_male: boolean
1186data child_has_own_children: boolean
1187data child_is_oldest_insured: boolean
1188
1189rule child_premium_applies:
1190 yes
1191 unless child_age_years >= 25 and is_male then no
1192 unless child_has_own_children then no
1193 unless child_is_oldest_insured then no
1194"#;
1195 let formatted =
1196 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1197 assert!(
1198 formatted.contains("unless child_age_years >= 25 and is_male then no")
1199 && formatted.contains("unless child_has_own_children then no")
1200 && formatted.contains("unless child_is_oldest_insured then no"),
1201 "all-single unless clauses align then, got:\n{formatted}"
1202 );
1203 let twice =
1204 format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1205 assert_eq!(formatted, twice);
1206 }
1207
1208 #[test]
1209 fn test_format_rule_unless_can_request_reinstatement_and_wrap() {
1210 let source = r#"spec can_request_reinstatement
1211data days_since_policy_stopped: number
1212data arrears_paid: boolean
1213data surrender_value_repaid: boolean
1214data all_insured_persons_alive: boolean
1215
1216rule can_request_reinstatement:
1217 no
1218 unless days_since_policy_stopped <= 365 and arrears_paid and surrender_value_repaid and all_insured_persons_alive then yes
1219"#;
1220 let formatted =
1221 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1222 assert!(
1223 formatted.contains(
1224 "unless days_since_policy_stopped <= 365\n and arrears_paid\n and surrender_value_repaid\n and all_insured_persons_alive\n then yes"
1225 ),
1226 "long and chain wraps one operand per line, got:\n{formatted}"
1227 );
1228 let twice =
1229 format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1230 assert_eq!(formatted, twice);
1231 }
1232
1233 #[test]
1234 fn test_format_rule_unless_foreign_transport_uniform_split_then() {
1235 let source = r#"spec foreign_transport_covered
1236data death_location: text
1237data trip_duration_months: number
1238data negative_travel_advisory_at_departure: boolean
1239data left_area_asap_after_advisory: boolean
1240
1241rule foreign_transport_covered:
1242 yes
1243 unless death_location is "abroad" then no
1244 unless death_location is "abroad" and trip_duration_months <= 2 then yes
1245 unless death_location is "abroad" and trip_duration_months <= 2 and negative_travel_advisory_at_departure then no
1246 unless death_location is "abroad" and trip_duration_months <= 2 and negative_travel_advisory_at_departure and left_area_asap_after_advisory then yes
1247"#;
1248 let formatted =
1249 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1250 assert!(
1251 formatted.contains("unless death_location is \"abroad\"\n then no"),
1252 "short sister uses split then when any clause needs it, got:\n{formatted}"
1253 );
1254 assert!(
1255 formatted.contains(
1256 "unless death_location is \"abroad\"\n and trip_duration_months <= 2\n then yes"
1257 ),
1258 "wrapped and chain with split then, got:\n{formatted}"
1259 );
1260 let twice =
1261 format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1262 assert_eq!(formatted, twice);
1263 }
1264
1265 #[test]
1266 fn test_format_rule_unless_child_auto_covered_service_only() {
1267 let source = r#"spec child_auto_covered_service_only
1268data days_since_birth: number
1269data birth_reported_within_60_days: boolean
1270
1271rule child_auto_covered_service_only:
1272 yes
1273 unless days_since_birth >= 60 and not birth_reported_within_60_days then no
1274"#;
1275 let formatted =
1276 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1277 assert!(
1278 formatted.contains(
1279 "unless days_since_birth >= 60\n and not birth_reported_within_60_days\n then no"
1280 ),
1281 "multiline unless condition with split then, got:\n{formatted}"
1282 );
1283 let twice =
1284 format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
1285 assert_eq!(formatted, twice);
1286 }
1287}