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, DateTimeValue, TimeValue, TimezoneValue, Value,
547 };
548 use rust_decimal::prelude::FromStr;
549 use rust_decimal::Decimal;
550
551 fn fmt_value(v: &Value) -> String {
553 format!("{}", AsLemmaSource(v))
554 }
555
556 #[test]
557 fn test_format_value_text_is_quoted() {
558 let v = Value::Text("light".to_string());
559 assert_eq!(fmt_value(&v), "\"light\"");
560 }
561
562 #[test]
563 fn test_format_value_text_escapes_quotes() {
564 let v = Value::Text("say \"hello\"".to_string());
565 assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
566 }
567
568 #[test]
569 fn test_format_value_number() {
570 let v = Value::Number(Decimal::from_str("42.50").unwrap());
571 assert_eq!(fmt_value(&v), "42.50");
572 }
573
574 #[test]
575 fn test_format_value_number_integer() {
576 let v = Value::Number(Decimal::from_str("100.00").unwrap());
577 assert_eq!(fmt_value(&v), "100");
578 }
579
580 #[test]
581 fn test_format_value_boolean() {
582 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
583 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
584 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
585 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Accept)), "accept");
586 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Reject)), "reject");
587 }
588
589 #[test]
590 fn test_format_value_quantity() {
591 let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
592 assert_eq!(fmt_value(&v), "99.50 eur");
593 }
594
595 #[test]
596 fn test_format_value_duration_as_quantity() {
597 let v = Value::NumberWithUnit(Decimal::from(40), "hours".to_string());
598 assert_eq!(fmt_value(&v), "40 hours");
599 }
600
601 #[test]
602 fn test_format_value_calendar() {
603 let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
604 assert_eq!(fmt_value(&v), "6 month");
605 }
606
607 #[test]
608 fn test_format_value_ratio_percent() {
609 let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
610 assert_eq!(fmt_value(&v), "10%");
611 }
612
613 #[test]
614 fn test_format_value_ratio_permille() {
615 let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
616 assert_eq!(fmt_value(&v), "5%%");
617 }
618
619 #[test]
620 fn test_format_value_number_with_unit_named() {
621 let v = Value::NumberWithUnit(
622 Decimal::from_str("500").unwrap(),
623 "basis_points".to_string(),
624 );
625 assert_eq!(fmt_value(&v), "500 basis_points");
626 }
627
628 #[test]
629 fn test_format_value_date_only() {
630 let v = Value::Date(DateTimeValue {
631 year: 2024,
632 month: 1,
633 day: 15,
634 hour: 0,
635 minute: 0,
636 second: 0,
637 microsecond: 0,
638 timezone: None,
639 });
640 assert_eq!(fmt_value(&v), "2024-01-15");
641 }
642
643 #[test]
644 fn test_format_value_datetime_with_tz() {
645 let v = Value::Date(DateTimeValue {
646 year: 2024,
647 month: 1,
648 day: 15,
649 hour: 14,
650 minute: 30,
651 second: 0,
652 microsecond: 0,
653 timezone: Some(TimezoneValue {
654 offset_hours: 0,
655 offset_minutes: 0,
656 }),
657 });
658 assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
659 }
660
661 #[test]
662 fn test_format_value_time() {
663 let v = Value::Time(TimeValue {
664 hour: 14,
665 minute: 30,
666 second: 45,
667 microsecond: 0,
668 timezone: None,
669 });
670 assert_eq!(fmt_value(&v), "14:30:45");
671 }
672
673 #[test]
674 fn test_format_source_lowercases_logical_identifiers() {
675 let source = r#"spec Test
676data Price: number -> default 1
677rule Total: price
678"#;
679 let formatted =
680 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
681 assert!(formatted.contains("spec test"), "got: {formatted}");
682 assert!(formatted.contains("data price"), "got: {formatted}");
683 assert!(formatted.contains("rule total"), "got: {formatted}");
684 }
685
686 #[test]
687 fn test_format_source_round_trips_text() {
688 let source = r#"spec test
689
690data name: "Alice"
691
692rule greeting: "hello"
693"#;
694 let formatted =
695 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
696 assert!(formatted.contains("\"Alice\""), "data text must be quoted");
697 assert!(formatted.contains("\"hello\""), "rule text must be quoted");
698 }
699
700 #[test]
701 fn test_format_source_preserves_percent() {
702 let source = r#"spec test
703
704data rate: 10 percent
705
706rule tax: rate * 21%
707"#;
708 let formatted =
709 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
710 assert!(
711 formatted.contains("10%"),
712 "data percent must use shorthand %, got: {}",
713 formatted
714 );
715 }
716
717 #[test]
718 fn test_format_groups_data_preserving_order() {
719 let source = r#"spec test
722
723data income: number -> minimum 0
724data filing_status: filing_status_type -> default "single"
725data country: "NL"
726data deductions: number -> minimum 0
727data name: text
728
729rule total: income
730"#;
731 let formatted =
732 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
733 let data_section = formatted
734 .split("rule total")
735 .next()
736 .unwrap()
737 .split("spec test\n")
738 .nth(1)
739 .unwrap();
740 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
741 assert_eq!(lines[0], "data income: number");
743 assert_eq!(lines[1], " -> minimum 0");
744 assert_eq!(lines[2], "data filing_status: filing_status_type");
745 assert_eq!(lines[3], " -> default \"single\"");
746 assert_eq!(lines[4], "data country: \"NL\"");
747 assert_eq!(lines[5], "data deductions: number");
748 assert_eq!(lines[6], " -> minimum 0");
749 assert_eq!(lines[7], "data name: text");
750 }
751
752 #[test]
753 fn test_format_groups_spec_refs_with_overrides() {
754 let source = r#"spec test
755
756with retail.quantity: 5
757uses order wholesale
758uses order retail
759with wholesale.quantity: 100
760data base_price: 50
761
762rule total: base_price
763"#;
764 let formatted =
765 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
766 let data_section = formatted
767 .split("rule total")
768 .next()
769 .unwrap()
770 .split("spec test\n")
771 .nth(1)
772 .unwrap();
773 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
774 assert_eq!(lines[0], "uses order wholesale");
775 assert_eq!(lines[1], "with wholesale.quantity: 100");
776 assert_eq!(lines[2], "uses order retail");
777 assert_eq!(lines[3], "with retail.quantity: 5");
778 assert_eq!(lines[4], "data base_price: 50");
779 }
780
781 #[test]
782 fn test_format_groups_with_literals_under_each_uses() {
783 let source = r#"spec test
784
785uses x
786uses y
787
788with x.name: "Ben"
789with y.age: 15
790
791rule r: 1
792"#;
793 let formatted =
794 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
795 let data_section = formatted
796 .split("rule r")
797 .next()
798 .unwrap()
799 .split("spec test\n")
800 .nth(1)
801 .unwrap();
802 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
803 assert_eq!(lines[0], "uses x");
804 assert_eq!(lines[1], "with x.name: \"Ben\"");
805 assert_eq!(lines[2], "uses y");
806 assert_eq!(lines[3], "with y.age: 15");
807 }
808
809 #[test]
810 fn test_format_source_weather_clothing_text_quoted() {
811 let source = r#"spec weather_clothing
812
813data clothing_style: text
814 -> option "light"
815 -> option "warm"
816
817data temperature: number
818
819rule clothing_layer: "light"
820 unless temperature < 5 then "warm"
821"#;
822 let formatted =
823 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
824 assert!(
825 formatted.contains("\"light\""),
826 "text in rule must be quoted, got: {}",
827 formatted
828 );
829 assert!(
830 formatted.contains("\"warm\""),
831 "text in unless must be quoted, got: {}",
832 formatted
833 );
834 }
835
836 #[test]
842 fn test_format_text_option_round_trips() {
843 let source = r#"spec test
844
845data status: text
846 -> option "active"
847 -> option "inactive"
848
849data s: status
850
851rule out: s
852"#;
853 let formatted =
854 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
855 assert!(
856 formatted.contains("option \"active\""),
857 "text option must be quoted, got: {}",
858 formatted
859 );
860 assert!(
861 formatted.contains("option \"inactive\""),
862 "text option must be quoted, got: {}",
863 formatted
864 );
865 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
867 assert!(reparsed.is_ok(), "formatted output should re-parse");
868 }
869
870 #[test]
871 fn test_format_help_round_trips() {
872 let source = r#"spec test
873data quantity: number -> help "Number of items to order"
874rule total: quantity
875"#;
876 let formatted =
877 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
878 assert!(
879 formatted.contains("help \"Number of items to order\""),
880 "help must be quoted, got: {}",
881 formatted
882 );
883 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
885 assert!(reparsed.is_ok(), "formatted output should re-parse");
886 }
887
888 #[test]
889 fn test_format_quantity_type_def_round_trips() {
890 let source = r#"spec test
891
892data money: quantity
893 -> unit eur 1.00
894 -> unit usd 0.91
895 -> decimals 2
896 -> minimum 0
897
898data price: money
899
900rule total: price
901"#;
902 let formatted =
903 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
904 assert!(
905 formatted.contains("unit eur 1.00"),
906 "quantity unit should not be quoted, got: {}",
907 formatted
908 );
909 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
911 assert!(
912 reparsed.is_ok(),
913 "formatted output should re-parse, got: {:?}",
914 reparsed
915 );
916 }
917
918 #[test]
919 fn test_format_expression_display_stable_round_trip() {
920 let source = r#"spec test
921data a: 1.00
922rule r: a + 2.00 * 3
923"#;
924 let formatted =
925 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
926 let again =
927 format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
928 assert_eq!(
929 formatted, again,
930 "AST Display-based format must be idempotent under parse/format"
931 );
932 }
933
934 #[test]
935 fn test_format_rule_default_on_same_line_when_fits() {
936 let source = "spec test\nrule r: 1\n";
937 let formatted =
938 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
939 assert!(
940 formatted.contains("rule r: 1\n"),
941 "default expr should stay on rule line when under MAX_COLS, got:\n{formatted}"
942 );
943 }
944
945 #[test]
946 fn test_format_rule_unless_single_line_when_short() {
947 let source = r#"spec test
948data a: number
949data b: boolean
950
951rule r: no
952 unless a < 1 then yes
953 unless b then yes
954"#;
955 let formatted =
956 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
957 assert!(
958 formatted.contains("unless a < 1 then yes")
959 && formatted.contains("unless b then yes"),
960 "unless stays on one line when under MAX_COLS, got:\n{formatted}"
961 );
962 }
963}