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