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