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::Fill(_) => 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::Fill(crate::parsing::ast::FillRhs::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::Fill(_) => "fill",
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 let has_overrides = |row: &LemmaData| -> bool {
328 let ref_name = &row.reference.name;
329 overrides.iter().any(|o| {
330 o.reference.segments.first().map(|s| s.as_str()) == Some(ref_name.as_str())
331 })
332 };
333
334 let is_bare = |row: &LemmaData| -> bool {
335 if let DataValue::Import(sr) = &row.value {
336 let last = sr.name.rsplit('/').next().unwrap_or(&sr.name);
337 row.reference.name == last && sr.effective.is_none() && !has_overrides(row)
338 } else {
339 false
340 }
341 };
342
343 let mut i = 0;
344 while i < imports.len() {
345 if i > 0 {
346 out.push('\n');
347 }
348 if is_bare(imports[i]) {
349 let mut group_names = Vec::new();
350 while i < imports.len() && is_bare(imports[i]) {
351 if let DataValue::Import(sr) = &imports[i].value {
352 group_names.push(sr.to_string());
353 }
354 i += 1;
355 }
356 if group_names.len() == 1 {
357 out.push_str(line_prefix);
358 out.push_str(&format!("uses {}", group_names[0]));
359 } else {
360 out.push_str(line_prefix);
361 out.push_str(&format!("uses {}", group_names.join(", ")));
362 }
363 out.push('\n');
364 } else {
365 let row = imports[i];
366 out.push_str(line_prefix);
367 out.push_str(&format_import_row(row));
368 out.push('\n');
369 let ref_name = &row.reference.name;
370 let binding_overrides: Vec<&LemmaData> = overrides
371 .iter()
372 .filter(|o| {
373 o.reference.segments.first().map(|s| s.as_str()) == Some(ref_name.as_str())
374 })
375 .copied()
376 .collect();
377 if !binding_overrides.is_empty() {
378 emit_data_row_group(&binding_overrides, line_prefix, out);
379 }
380 i += 1;
381 }
382 }
383 }
384
385 if !regular.is_empty() {
386 out.push('\n');
387 emit_group(®ular, out);
388 }
389
390 let matched_prefixes: Vec<&str> = imports.iter().map(|f| f.reference.name.as_str()).collect();
391 let unmatched: Vec<&LemmaData> = overrides
392 .iter()
393 .filter(|o| {
394 o.reference
395 .segments
396 .first()
397 .map(|s| !matched_prefixes.contains(&s.as_str()))
398 .unwrap_or(true)
399 })
400 .copied()
401 .collect();
402 if !unmatched.is_empty() {
403 out.push('\n');
404 emit_group(&unmatched, out);
405 }
406}
407
408const UNLESS_LINE_PREFIX: &str = " unless ";
413
414#[inline]
416fn spec_line_len(line: &str) -> usize {
417 line.len()
418}
419
420fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
426 let expr_indent = " ";
427 let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
428 let mut out = String::new();
429 out.push_str("rule ");
430 out.push_str(&rule.name);
431 let body_single_line = !body.contains('\n');
432 let header_fits_on_one_line =
433 body_single_line && spec_line_len(&format!("rule {}: {}", rule.name, body)) <= max_cols;
434 if header_fits_on_one_line {
435 out.push_str(": ");
436 out.push_str(&body);
437 } else {
438 out.push_str(":\n");
439 out.push_str(expr_indent);
440 out.push_str(&body);
441 }
442
443 let pl = UNLESS_LINE_PREFIX.len();
444 let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
445 let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
446
447 let mut clauses: Vec<(String, String, bool)> = Vec::new();
448 for unless_clause in &rule.unless_clauses {
449 let condition = format_expr_wrapped(&unless_clause.condition, max_cols, " ", 10);
450 let result = format_expr_wrapped(&unless_clause.result, max_cols, " ", 10);
451 let multiline = condition.contains('\n') || result.contains('\n');
452 clauses.push((condition, result, multiline));
453 }
454
455 let mut singles: Vec<usize> = clauses
456 .iter()
457 .enumerate()
458 .filter(|(_, (c, r, m))| !*m && naive_single_len(c, r) <= max_cols)
459 .map(|(i, _)| i)
460 .collect();
461
462 loop {
463 if singles.is_empty() {
464 break;
465 }
466 let max_end = singles
467 .iter()
468 .map(|&i| pl + clauses[i].0.len())
469 .max()
470 .expect("BUG: singles non-empty");
471 let before = singles.len();
472 singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
473 if singles.len() == before {
474 break;
475 }
476 }
477
478 let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
479 const SPLIT_THEN_INDENT_SPACES: usize = 4;
480
481 for (i, (condition, result, multiline)) in clauses.iter().enumerate() {
482 if *multiline {
483 out.push_str("\n unless ");
484 out.push_str(condition);
485 out.push('\n');
486 out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
487 out.push_str("then ");
488 out.push_str(result);
489 continue;
490 }
491 if singles.contains(&i) {
492 let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
493 let gap = 1 + max_end.saturating_sub(pl + condition.len());
494 out.push('\n');
495 out.push_str(UNLESS_LINE_PREFIX);
496 out.push_str(condition);
497 out.push_str(&" ".repeat(gap));
498 out.push_str("then ");
499 out.push_str(result);
500 continue;
501 }
502 out.push_str("\n unless ");
503 out.push_str(condition);
504 out.push('\n');
505 out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
506 out.push_str("then ");
507 out.push_str(result);
508 }
509 out.push('\n');
510 out
511}
512
513fn indent_after_first_line(s: &str, indent: &str) -> String {
519 let mut first = true;
520 let mut out = String::new();
521 for line in s.lines() {
522 if first {
523 first = false;
524 out.push_str(line);
525 } else {
526 out.push('\n');
527 out.push_str(indent);
528 out.push_str(line);
529 }
530 }
531 if s.ends_with('\n') {
532 out.push('\n');
533 }
534 out
535}
536
537fn format_expr_wrapped(
540 expr: &Expression,
541 max_cols: usize,
542 indent: &str,
543 parent_prec: u8,
544) -> String {
545 let my_prec = expression_precedence(&expr.kind);
546
547 let wrap_in_parens = |s: String| {
548 if parent_prec < 10 && my_prec < parent_prec {
549 format!("({})", s)
550 } else {
551 s
552 }
553 };
554
555 match &expr.kind {
556 ExpressionKind::Arithmetic(left, op, right) => {
557 let left_str = format_expr_wrapped(left.as_ref(), max_cols, indent, my_prec);
558 let right_str = format_expr_wrapped(right.as_ref(), max_cols, indent, my_prec);
559 let single_line = format!("{} {} {}", left_str, op, right_str);
560 if single_line.len() <= max_cols && !single_line.contains('\n') {
561 return wrap_in_parens(single_line);
562 }
563 let continued_right = indent_after_first_line(&right_str, indent);
564 let continuation = format!("{}{} {}", indent, op, continued_right);
565 let multi_line = format!("{}\n{}", left_str, continuation);
566 wrap_in_parens(multi_line)
567 }
568 _ => {
569 let s = expr.to_string();
570 wrap_in_parens(s)
571 }
572 }
573}
574
575#[cfg(test)]
580mod tests {
581 use super::*;
582 use crate::parsing::ast::{
583 AsLemmaSource, BooleanValue, DateTimeValue, TimeValue, TimezoneValue, Value,
584 };
585 use rust_decimal::prelude::FromStr;
586 use rust_decimal::Decimal;
587
588 fn fmt_value(v: &Value) -> String {
590 format!("{}", AsLemmaSource(v))
591 }
592
593 #[test]
594 fn test_format_value_text_is_quoted() {
595 let v = Value::Text("light".to_string());
596 assert_eq!(fmt_value(&v), "\"light\"");
597 }
598
599 #[test]
600 fn test_format_value_text_escapes_quotes() {
601 let v = Value::Text("say \"hello\"".to_string());
602 assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
603 }
604
605 #[test]
606 fn test_format_value_number() {
607 let v = Value::Number(Decimal::from_str("42.50").unwrap());
608 assert_eq!(fmt_value(&v), "42.50");
609 }
610
611 #[test]
612 fn test_format_value_number_integer() {
613 let v = Value::Number(Decimal::from_str("100.00").unwrap());
614 assert_eq!(fmt_value(&v), "100");
615 }
616
617 #[test]
618 fn test_format_value_boolean() {
619 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
620 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
621 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
622 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Accept)), "accept");
623 assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Reject)), "reject");
624 }
625
626 #[test]
627 fn test_format_value_quantity() {
628 let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
629 assert_eq!(fmt_value(&v), "99.50 eur");
630 }
631
632 #[test]
633 fn test_format_value_duration_as_quantity() {
634 let v = Value::NumberWithUnit(Decimal::from(40), "hours".to_string());
635 assert_eq!(fmt_value(&v), "40 hours");
636 }
637
638 #[test]
639 fn test_format_value_calendar() {
640 let v = Value::Calendar(Decimal::from(6), crate::literals::CalendarUnit::Month);
641 assert_eq!(fmt_value(&v), "6 months");
642 }
643
644 #[test]
645 fn test_format_value_ratio_percent() {
646 let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
647 assert_eq!(fmt_value(&v), "10%");
648 }
649
650 #[test]
651 fn test_format_value_ratio_permille() {
652 let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
653 assert_eq!(fmt_value(&v), "5%%");
654 }
655
656 #[test]
657 fn test_format_value_number_with_unit_named() {
658 let v = Value::NumberWithUnit(
659 Decimal::from_str("500").unwrap(),
660 "basis_points".to_string(),
661 );
662 assert_eq!(fmt_value(&v), "500 basis_points");
663 }
664
665 #[test]
666 fn test_format_value_date_only() {
667 let v = Value::Date(DateTimeValue {
668 year: 2024,
669 month: 1,
670 day: 15,
671 hour: 0,
672 minute: 0,
673 second: 0,
674 microsecond: 0,
675 timezone: None,
676 });
677 assert_eq!(fmt_value(&v), "2024-01-15");
678 }
679
680 #[test]
681 fn test_format_value_datetime_with_tz() {
682 let v = Value::Date(DateTimeValue {
683 year: 2024,
684 month: 1,
685 day: 15,
686 hour: 14,
687 minute: 30,
688 second: 0,
689 microsecond: 0,
690 timezone: Some(TimezoneValue {
691 offset_hours: 0,
692 offset_minutes: 0,
693 }),
694 });
695 assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
696 }
697
698 #[test]
699 fn test_format_value_time() {
700 let v = Value::Time(TimeValue {
701 hour: 14,
702 minute: 30,
703 second: 45,
704 microsecond: 0,
705 timezone: None,
706 });
707 assert_eq!(fmt_value(&v), "14:30:45");
708 }
709
710 #[test]
711 fn test_format_source_lowercases_logical_identifiers() {
712 let source = r#"spec Test
713data Price: number -> default 1
714rule Total: price
715"#;
716 let formatted =
717 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
718 assert!(formatted.contains("spec test"), "got: {formatted}");
719 assert!(formatted.contains("data price"), "got: {formatted}");
720 assert!(formatted.contains("rule total"), "got: {formatted}");
721 }
722
723 #[test]
724 fn test_format_source_round_trips_text() {
725 let source = r#"spec test
726
727data name: "Alice"
728
729rule greeting: "hello"
730"#;
731 let formatted =
732 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
733 assert!(formatted.contains("\"Alice\""), "data text must be quoted");
734 assert!(formatted.contains("\"hello\""), "rule text must be quoted");
735 }
736
737 #[test]
738 fn test_format_source_preserves_percent() {
739 let source = r#"spec test
740
741data rate: 10 percent
742
743rule tax: rate * 21%
744"#;
745 let formatted =
746 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
747 assert!(
748 formatted.contains("10%"),
749 "data percent must use shorthand %, got: {}",
750 formatted
751 );
752 }
753
754 #[test]
755 fn test_format_groups_data_preserving_order() {
756 let source = r#"spec test
759
760data income: number -> minimum 0
761data filing_status: filing_status_type -> default "single"
762data country: "NL"
763data deductions: number -> minimum 0
764data name: text
765
766rule total: income
767"#;
768 let formatted =
769 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
770 let data_section = formatted
771 .split("rule total")
772 .next()
773 .unwrap()
774 .split("spec test\n")
775 .nth(1)
776 .unwrap();
777 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
778 assert_eq!(lines[0], "data income: number");
780 assert_eq!(lines[1], " -> minimum 0");
781 assert_eq!(lines[2], "data filing_status: filing_status_type");
782 assert_eq!(lines[3], " -> default \"single\"");
783 assert_eq!(lines[4], "data country: \"NL\"");
784 assert_eq!(lines[5], "data deductions: number");
785 assert_eq!(lines[6], " -> minimum 0");
786 assert_eq!(lines[7], "data name: text");
787 }
788
789 #[test]
790 fn test_format_groups_spec_refs_with_overrides() {
791 let source = r#"spec test
792
793fill retail.quantity: 5
794uses order wholesale
795uses order retail
796fill wholesale.quantity: 100
797data base_price: 50
798
799rule total: base_price
800"#;
801 let formatted =
802 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
803 let data_section = formatted
804 .split("rule total")
805 .next()
806 .unwrap()
807 .split("spec test\n")
808 .nth(1)
809 .unwrap();
810 let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
811 assert_eq!(lines[0], "uses order wholesale");
812 assert_eq!(lines[1], "fill wholesale.quantity: 100");
813 assert_eq!(lines[2], "uses order retail");
814 assert_eq!(lines[3], "fill retail.quantity: 5");
815 assert_eq!(lines[4], "data base_price: 50");
816 }
817
818 #[test]
819 fn test_format_source_weather_clothing_text_quoted() {
820 let source = r#"spec weather_clothing
821
822data clothing_style: text
823 -> option "light"
824 -> option "warm"
825
826data temperature: number
827
828rule clothing_layer: "light"
829 unless temperature < 5 then "warm"
830"#;
831 let formatted =
832 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
833 assert!(
834 formatted.contains("\"light\""),
835 "text in rule must be quoted, got: {}",
836 formatted
837 );
838 assert!(
839 formatted.contains("\"warm\""),
840 "text in unless must be quoted, got: {}",
841 formatted
842 );
843 }
844
845 #[test]
851 fn test_format_text_option_round_trips() {
852 let source = r#"spec test
853
854data status: text
855 -> option "active"
856 -> option "inactive"
857
858data s: status
859
860rule out: s
861"#;
862 let formatted =
863 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
864 assert!(
865 formatted.contains("option \"active\""),
866 "text option must be quoted, got: {}",
867 formatted
868 );
869 assert!(
870 formatted.contains("option \"inactive\""),
871 "text option must be quoted, got: {}",
872 formatted
873 );
874 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
876 assert!(reparsed.is_ok(), "formatted output should re-parse");
877 }
878
879 #[test]
880 fn test_format_help_round_trips() {
881 let source = r#"spec test
882data quantity: number -> help "Number of items to order"
883rule total: quantity
884"#;
885 let formatted =
886 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
887 assert!(
888 formatted.contains("help \"Number of items to order\""),
889 "help must be quoted, got: {}",
890 formatted
891 );
892 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
894 assert!(reparsed.is_ok(), "formatted output should re-parse");
895 }
896
897 #[test]
898 fn test_format_quantity_type_def_round_trips() {
899 let source = r#"spec test
900
901data money: quantity
902 -> unit eur 1.00
903 -> unit usd 0.91
904 -> decimals 2
905 -> minimum 0
906
907data price: money
908
909rule total: price
910"#;
911 let formatted =
912 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
913 assert!(
914 formatted.contains("unit eur 1.00"),
915 "quantity unit should not be quoted, got: {}",
916 formatted
917 );
918 let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
920 assert!(
921 reparsed.is_ok(),
922 "formatted output should re-parse, got: {:?}",
923 reparsed
924 );
925 }
926
927 #[test]
928 fn test_format_expression_display_stable_round_trip() {
929 let source = r#"spec test
930data a: 1.00
931rule r: a + 2.00 * 3
932"#;
933 let formatted =
934 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
935 let again =
936 format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
937 assert_eq!(
938 formatted, again,
939 "AST Display-based format must be idempotent under parse/format"
940 );
941 }
942
943 #[test]
944 fn test_format_rule_default_on_same_line_when_fits() {
945 let source = "spec test\nrule r: 1\n";
946 let formatted =
947 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
948 assert!(
949 formatted.contains("rule r: 1\n"),
950 "default expr should stay on rule line when under MAX_COLS, got:\n{formatted}"
951 );
952 }
953
954 #[test]
955 fn test_format_rule_unless_single_line_when_short() {
956 let source = r#"spec test
957data a: number
958data b: boolean
959
960rule r: no
961 unless a < 1 then yes
962 unless b then yes
963"#;
964 let formatted =
965 format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
966 assert!(
967 formatted.contains("unless a < 1 then yes")
968 && formatted.contains("unless b then yes"),
969 "unless stays on one line when under MAX_COLS, got:\n{formatted}"
970 );
971 }
972}