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