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