Skip to main content

lemma/formatting/
mod.rs

1//! Lemma source code formatting.
2//!
3//! Formats parsed specs into canonical Lemma source text. Uses `AsLemmaSource`
4//! and `Expression::Display` for syntax; this module handles layout only.
5//! Canonical source includes ASCII-lowercase logical identifier names.
6
7use crate::parsing::ast::{
8    expression_precedence, AsLemmaSource, Constraint, DataValue, Expression, ExpressionKind,
9    LemmaData, LemmaRule, LemmaSpec,
10};
11use crate::{parse, Error, ParseResult, ResourceLimits};
12
13/// Soft line length limit. Longer lines may be wrapped (unless clauses, expressions).
14/// Data and other constructs are not broken if they exceed this.
15/// 56 has been chosen to fit on an average mobile screen with an 11pt font.
16pub const MAX_COLS: usize = 56;
17
18// =============================================================================
19// Public entry points
20// =============================================================================
21
22/// Format a sequence of parsed specs into canonical Lemma source.
23///
24/// specs are separated by two blank lines.
25/// The result ends with a single newline.
26#[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/// Like [`format_specs`] for borrowed specs (e.g. from [`Arc<LemmaSpec>`](crate::parsing::ast::LemmaSpec)).
33#[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/// Format a [`ParseResult`] (repository groups + specs) into canonical Lemma source.
49#[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
80/// Parse a source string and format it to canonical Lemma source.
81///
82/// Returns an error if the source does not parse.
83pub 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
92// =============================================================================
93// Spec
94// =============================================================================
95
96pub(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
141// =============================================================================
142// Data
143// =============================================================================
144
145/// Two spaces after `line_prefix` for each `-> ...` constraint line under `data ...: ...`.
146const 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
215/// Byte length from start of `data ` or `fill ` through the single space after `:` (same layout as [`format_data`]).
216fn 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
301/// Group data into sections separated by blank lines:
302///
303/// 1. Imports (`uses`), each followed by their literal bindings — original order within this block
304/// 2. Regular data (literals, type declarations, references) — original order
305/// 3. Qualified overrides that did not attach to any import — original order
306fn 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(&regular, 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
408// =============================================================================
409// Rules
410// =============================================================================
411
412const UNLESS_LINE_PREFIX: &str = "  unless ";
413
414/// Logical line length for `max_cols` checks (no extra spec-level indent).
415#[inline]
416fn spec_line_len(line: &str) -> usize {
417    line.len()
418}
419
420/// Default expression stays on the `rule name:` line when it fits under `max_cols`.
421///
422/// Single-line `unless … then …` clauses align `then` when every such line still fits under
423/// `max_cols` after alignment. Any clause that splits across lines (expression wraps, or one line
424/// would exceed `max_cols`) uses a fixed `then` indent — no column alignment with shorter sisters.
425fn 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
513// =============================================================================
514// Expression wrapping (soft line breaking at max_cols)
515// =============================================================================
516
517/// Indent every line after the first by `indent`.
518fn 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
537/// Format an expression with optional wrapping at arithmetic operators when over max_cols.
538/// `parent_prec` is used to add parentheses when needed (pass 10 for top level).
539fn 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// =============================================================================
576// Tests
577// =============================================================================
578
579#[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    /// Helper: format a Value as canonical Lemma source via AsLemmaSource.
589    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        // Data are deliberately mixed: the formatter keeps all regular data together
757        // in original order, aligned
758        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        // Constrained rows: one blank line after each when more `data` follows.
779        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    // NOTE: Default value type validation (e.g. rejecting "10 $$" as a number
846    // default) is tested at the planning level in engine.rs, not here. The
847    // formatter only parses — it does not validate types. Planning catches
848    // invalid defaults for both primitives and named types.
849
850    #[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        // Round-trip
875        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        // Round-trip
893        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        // Round-trip
919        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}