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