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::With(_) => false,
156        _ => false,
157    }
158}
159
160fn data_value_rhs_for_spec_body(value: &DataValue, continuation_prefix: &str) -> String {
161    match value {
162        DataValue::Definition {
163            base,
164            constraints,
165            value,
166        } if data_constraints_nonempty(constraints) => {
167            let cs = constraints
168                .as_ref()
169                .expect("BUG: constraints checked above");
170            let head: String = if base.is_none() {
171                match value {
172                    Some(v) => format!("{}", AsLemmaSource(v)),
173                    None => String::new(),
174                }
175            } else {
176                match base.as_ref() {
177                    Some(b) => format!("{}", b),
178                    None => String::new(),
179                }
180            };
181            let mut out = head;
182            for (cmd, args) in cs {
183                out.push('\n');
184                out.push_str(continuation_prefix);
185                out.push_str("-> ");
186                out.push_str(&crate::parsing::ast::format_constraint_as_source(cmd, args));
187            }
188            out
189        }
190        DataValue::With(crate::parsing::ast::WithRhs::Reference { target }) => target.to_string(),
191        _ => format!("{}", AsLemmaSource(value)),
192    }
193}
194
195fn data_declaration_keyword(data: &LemmaData) -> &'static str {
196    match &data.value {
197        DataValue::Import(_) => unreachable!("BUG: format_data called on Import row"),
198        DataValue::With(_) => "with",
199        DataValue::Definition { .. } => "data",
200    }
201}
202
203fn format_data(data: &LemmaData, line_prefix: &str) -> String {
204    let kw = data_declaration_keyword(data);
205    let ref_str = format!("{}", data.reference);
206    let continuation = format!("{line_prefix}{DATA_CONSTRAINT_INDENT}");
207    let rhs = data_value_rhs_for_spec_body(&data.value, &continuation);
208    if let Some((first, rest)) = rhs.split_once('\n') {
209        format!("{kw} {}: {}\n{}", ref_str, first, rest)
210    } else {
211        format!("{kw} {}: {}", ref_str, rhs)
212    }
213}
214
215/// Byte length from start of `data ` or `with ` 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        for (i, row) in imports.iter().enumerate() {
328            if i > 0 {
329                out.push('\n');
330            }
331            out.push_str(line_prefix);
332            out.push_str(&format_import_row(row));
333            out.push('\n');
334            let ref_name = &row.reference.name;
335            let binding_overrides: Vec<&LemmaData> = overrides
336                .iter()
337                .filter(|o| {
338                    o.reference.segments.first().map(|s| s.as_str()) == Some(ref_name.as_str())
339                })
340                .copied()
341                .collect();
342            if !binding_overrides.is_empty() {
343                emit_data_row_group(&binding_overrides, line_prefix, out);
344            }
345        }
346    }
347
348    if !regular.is_empty() {
349        out.push('\n');
350        emit_group(&regular, out);
351    }
352
353    let matched_prefixes: Vec<&str> = imports.iter().map(|f| f.reference.name.as_str()).collect();
354    let unmatched: Vec<&LemmaData> = overrides
355        .iter()
356        .filter(|o| {
357            o.reference
358                .segments
359                .first()
360                .map(|s| !matched_prefixes.contains(&s.as_str()))
361                .unwrap_or(true)
362        })
363        .copied()
364        .collect();
365    if !unmatched.is_empty() {
366        out.push('\n');
367        emit_group(&unmatched, out);
368    }
369}
370
371// =============================================================================
372// Rules
373// =============================================================================
374
375const UNLESS_LINE_PREFIX: &str = "  unless ";
376
377/// Logical line length for `max_cols` checks (no extra spec-level indent).
378#[inline]
379fn spec_line_len(line: &str) -> usize {
380    line.len()
381}
382
383/// Default expression stays on the `rule name:` line when it fits under `max_cols`.
384///
385/// Single-line `unless … then …` clauses align `then` when every such line still fits under
386/// `max_cols` after alignment. Any clause that splits across lines (expression wraps, or one line
387/// would exceed `max_cols`) uses a fixed `then` indent — no column alignment with shorter sisters.
388fn format_rule(rule: &LemmaRule, max_cols: usize) -> String {
389    let expr_indent = "  ";
390    let body = format_expr_wrapped(&rule.expression, max_cols, expr_indent, 10);
391    let mut out = String::new();
392    out.push_str("rule ");
393    out.push_str(&rule.name);
394    let body_single_line = !body.contains('\n');
395    let header_fits_on_one_line =
396        body_single_line && spec_line_len(&format!("rule {}: {}", rule.name, body)) <= max_cols;
397    if header_fits_on_one_line {
398        out.push_str(": ");
399        out.push_str(&body);
400    } else {
401        out.push_str(":\n");
402        out.push_str(expr_indent);
403        out.push_str(&body);
404    }
405
406    let pl = UNLESS_LINE_PREFIX.len();
407    let naive_single_len = |cond: &str, res: &str| pl + cond.len() + 6 + res.len();
408    let aligned_single_len = |res: &str, max_end: usize| max_end + 6 + res.len();
409
410    let mut clauses: Vec<(String, String, bool)> = Vec::new();
411    for unless_clause in &rule.unless_clauses {
412        let condition = format_expr_wrapped(&unless_clause.condition, max_cols, "    ", 10);
413        let result = format_expr_wrapped(&unless_clause.result, max_cols, "    ", 10);
414        let multiline = condition.contains('\n') || result.contains('\n');
415        clauses.push((condition, result, multiline));
416    }
417
418    let mut singles: Vec<usize> = clauses
419        .iter()
420        .enumerate()
421        .filter(|(_, (c, r, m))| !*m && naive_single_len(c, r) <= max_cols)
422        .map(|(i, _)| i)
423        .collect();
424
425    loop {
426        if singles.is_empty() {
427            break;
428        }
429        let max_end = singles
430            .iter()
431            .map(|&i| pl + clauses[i].0.len())
432            .max()
433            .expect("BUG: singles non-empty");
434        let before = singles.len();
435        singles.retain(|&i| aligned_single_len(&clauses[i].1, max_end) <= max_cols);
436        if singles.len() == before {
437            break;
438        }
439    }
440
441    let align_max_end = singles.iter().map(|&i| pl + clauses[i].0.len()).max();
442    const SPLIT_THEN_INDENT_SPACES: usize = 4;
443
444    for (i, (condition, result, multiline)) in clauses.iter().enumerate() {
445        if *multiline {
446            out.push_str("\n  unless ");
447            out.push_str(condition);
448            out.push('\n');
449            out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
450            out.push_str("then ");
451            out.push_str(result);
452            continue;
453        }
454        if singles.contains(&i) {
455            let max_end = align_max_end.expect("BUG: singles.contains but align_max_end empty");
456            let gap = 1 + max_end.saturating_sub(pl + condition.len());
457            out.push('\n');
458            out.push_str(UNLESS_LINE_PREFIX);
459            out.push_str(condition);
460            out.push_str(&" ".repeat(gap));
461            out.push_str("then ");
462            out.push_str(result);
463            continue;
464        }
465        out.push_str("\n  unless ");
466        out.push_str(condition);
467        out.push('\n');
468        out.push_str(&" ".repeat(SPLIT_THEN_INDENT_SPACES));
469        out.push_str("then ");
470        out.push_str(result);
471    }
472    out.push('\n');
473    out
474}
475
476// =============================================================================
477// Expression wrapping (soft line breaking at max_cols)
478// =============================================================================
479
480/// Indent every line after the first by `indent`.
481fn indent_after_first_line(s: &str, indent: &str) -> String {
482    let mut first = true;
483    let mut out = String::new();
484    for line in s.lines() {
485        if first {
486            first = false;
487            out.push_str(line);
488        } else {
489            out.push('\n');
490            out.push_str(indent);
491            out.push_str(line);
492        }
493    }
494    if s.ends_with('\n') {
495        out.push('\n');
496    }
497    out
498}
499
500/// Format an expression with optional wrapping at arithmetic operators when over max_cols.
501/// `parent_prec` is used to add parentheses when needed (pass 10 for top level).
502fn format_expr_wrapped(
503    expr: &Expression,
504    max_cols: usize,
505    indent: &str,
506    parent_prec: u8,
507) -> String {
508    let my_prec = expression_precedence(&expr.kind);
509
510    let wrap_in_parens = |s: String| {
511        if parent_prec < 10 && my_prec < parent_prec {
512            format!("({})", s)
513        } else {
514            s
515        }
516    };
517
518    match &expr.kind {
519        ExpressionKind::Arithmetic(left, op, right) => {
520            let left_str = format_expr_wrapped(left.as_ref(), max_cols, indent, my_prec);
521            let right_str = format_expr_wrapped(right.as_ref(), max_cols, indent, my_prec);
522            let single_line = format!("{} {} {}", left_str, op, right_str);
523            if single_line.len() <= max_cols && !single_line.contains('\n') {
524                return wrap_in_parens(single_line);
525            }
526            let continued_right = indent_after_first_line(&right_str, indent);
527            let continuation = format!("{}{} {}", indent, op, continued_right);
528            let multi_line = format!("{}\n{}", left_str, continuation);
529            wrap_in_parens(multi_line)
530        }
531        _ => {
532            let s = expr.to_string();
533            wrap_in_parens(s)
534        }
535    }
536}
537
538// =============================================================================
539// Tests
540// =============================================================================
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::parsing::ast::{
546        AsLemmaSource, BooleanValue, DateGranularity, DateTimeValue, TimeValue, TimezoneValue,
547        Value,
548    };
549    use rust_decimal::prelude::FromStr;
550    use rust_decimal::Decimal;
551
552    /// Helper: format a Value as canonical Lemma source via AsLemmaSource.
553    fn fmt_value(v: &Value) -> String {
554        format!("{}", AsLemmaSource(v))
555    }
556
557    #[test]
558    fn test_format_value_text_is_quoted() {
559        let v = Value::Text("light".to_string());
560        assert_eq!(fmt_value(&v), "\"light\"");
561    }
562
563    #[test]
564    fn test_format_value_text_escapes_quotes() {
565        let v = Value::Text("say \"hello\"".to_string());
566        assert_eq!(fmt_value(&v), "\"say \\\"hello\\\"\"");
567    }
568
569    #[test]
570    fn test_format_value_number() {
571        let v = Value::Number(Decimal::from_str("42.50").unwrap());
572        assert_eq!(fmt_value(&v), "42.50");
573    }
574
575    #[test]
576    fn test_format_value_number_integer() {
577        let v = Value::Number(Decimal::from_str("100.00").unwrap());
578        assert_eq!(fmt_value(&v), "100");
579    }
580
581    #[test]
582    fn test_format_value_boolean() {
583        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::True)), "true");
584        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Yes)), "yes");
585        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::No)), "no");
586        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Accept)), "accept");
587        assert_eq!(fmt_value(&Value::Boolean(BooleanValue::Reject)), "reject");
588    }
589
590    #[test]
591    fn test_format_value_quantity() {
592        let v = Value::NumberWithUnit(Decimal::from_str("99.50").unwrap(), "eur".to_string());
593        assert_eq!(fmt_value(&v), "99.50 eur");
594    }
595
596    #[test]
597    fn test_format_value_duration_as_quantity() {
598        let v = Value::NumberWithUnit(Decimal::from(40), "hours".to_string());
599        assert_eq!(fmt_value(&v), "40 hours");
600    }
601
602    #[test]
603    fn test_format_value_calendar() {
604        let v = Value::NumberWithUnit(Decimal::from(6), "month".to_string());
605        assert_eq!(fmt_value(&v), "6 month");
606    }
607
608    #[test]
609    fn test_format_value_ratio_percent() {
610        let v = Value::NumberWithUnit(Decimal::from_str("10").unwrap(), "percent".to_string());
611        assert_eq!(fmt_value(&v), "10%");
612    }
613
614    #[test]
615    fn test_format_value_ratio_permille() {
616        let v = Value::NumberWithUnit(Decimal::from_str("5").unwrap(), "permille".to_string());
617        assert_eq!(fmt_value(&v), "5%%");
618    }
619
620    #[test]
621    fn test_format_value_number_with_unit_named() {
622        let v = Value::NumberWithUnit(
623            Decimal::from_str("500").unwrap(),
624            "basis_points".to_string(),
625        );
626        assert_eq!(fmt_value(&v), "500 basis_points");
627    }
628
629    #[test]
630    fn test_format_value_date_only() {
631        let v = Value::Date(DateTimeValue {
632            year: 2024,
633            month: 1,
634            day: 15,
635            hour: 0,
636            minute: 0,
637            second: 0,
638            microsecond: 0,
639            timezone: None,
640
641            granularity: DateGranularity::Full,
642        });
643        assert_eq!(fmt_value(&v), "2024-01-15");
644    }
645
646    #[test]
647    fn test_format_value_datetime_with_tz() {
648        let v = Value::Date(DateTimeValue {
649            year: 2024,
650            month: 1,
651            day: 15,
652            hour: 14,
653            minute: 30,
654            second: 0,
655            microsecond: 0,
656            timezone: Some(TimezoneValue {
657                offset_hours: 0,
658                offset_minutes: 0,
659            }),
660
661            granularity: DateGranularity::DateTime,
662        });
663        assert_eq!(fmt_value(&v), "2024-01-15T14:30:00Z");
664    }
665
666    #[test]
667    fn test_format_value_time() {
668        let v = Value::Time(TimeValue {
669            hour: 14,
670            minute: 30,
671            second: 45,
672            microsecond: 0,
673            timezone: None,
674        });
675        assert_eq!(fmt_value(&v), "14:30:45");
676    }
677
678    #[test]
679    fn test_format_source_preserves_date_granularity() {
680        let formatted = format_source(
681            "spec x 2026\n",
682            crate::parsing::source::SourceType::Volatile,
683        )
684        .expect("spec x 2026 should format");
685        assert!(
686            formatted.contains("spec x 2026\n"),
687            "year-only effective date must round-trip, got: {formatted}"
688        );
689        assert!(
690            !formatted.contains("2026-01-01"),
691            "year-only effective date must not expand, got: {formatted}"
692        );
693        let reformatted = format_source(&formatted, crate::parsing::source::SourceType::Volatile)
694            .expect("reformat");
695        assert_eq!(formatted, reformatted, "spec x 2026 must be idempotent");
696
697        let formatted = format_source(
698            "spec x 2026-03\n",
699            crate::parsing::source::SourceType::Volatile,
700        )
701        .expect("spec x 2026-03 should format");
702        assert!(
703            formatted.contains("spec x 2026-03\n"),
704            "year-month effective date must round-trip, got: {formatted}"
705        );
706
707        let formatted = format_source(
708            "spec x 2026-W34\n",
709            crate::parsing::source::SourceType::Volatile,
710        )
711        .expect("spec x 2026-W34 should format");
712        assert!(
713            formatted.contains("spec x 2026-W34\n"),
714            "iso week effective date must round-trip, got: {formatted}"
715        );
716
717        let source = "spec consumer\nuses finance 2026\n";
718        let formatted = format_source(source, crate::parsing::source::SourceType::Volatile)
719            .expect("uses with year should format");
720        assert!(
721            formatted.contains("uses finance 2026"),
722            "uses effective pin must preserve year-only date, got: {formatted}"
723        );
724        assert!(
725            !formatted.contains("2026-01-01"),
726            "uses effective pin must not expand year-only date, got: {formatted}"
727        );
728    }
729
730    #[test]
731    fn test_format_source_lowercases_logical_identifiers() {
732        let source = r#"spec Test
733data Price: number -> default 1
734rule Total: price
735"#;
736        let formatted =
737            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
738        assert!(formatted.contains("spec test"), "got: {formatted}");
739        assert!(formatted.contains("data price"), "got: {formatted}");
740        assert!(formatted.contains("rule total"), "got: {formatted}");
741    }
742
743    #[test]
744    fn test_format_source_round_trips_text() {
745        let source = r#"spec test
746
747data name: "Alice"
748
749rule greeting: "hello"
750"#;
751        let formatted =
752            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
753        assert!(formatted.contains("\"Alice\""), "data text must be quoted");
754        assert!(formatted.contains("\"hello\""), "rule text must be quoted");
755    }
756
757    #[test]
758    fn test_format_source_preserves_percent() {
759        let source = r#"spec test
760
761data rate: 10 percent
762
763rule tax: rate * 21%
764"#;
765        let formatted =
766            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
767        assert!(
768            formatted.contains("10%"),
769            "data percent must use shorthand %, got: {}",
770            formatted
771        );
772    }
773
774    #[test]
775    fn test_format_groups_data_preserving_order() {
776        // Data are deliberately mixed: the formatter keeps all regular data together
777        // in original order, aligned
778        let source = r#"spec test
779
780data income: number -> minimum 0
781data filing_status: filing_status_type -> default "single"
782data country: "NL"
783data deductions: number -> minimum 0
784data name: text
785
786rule total: income
787"#;
788        let formatted =
789            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
790        let data_section = formatted
791            .split("rule total")
792            .next()
793            .unwrap()
794            .split("spec test\n")
795            .nth(1)
796            .unwrap();
797        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
798        // Constrained rows: one blank line after each when more `data` follows.
799        assert_eq!(lines[0], "data income: number");
800        assert_eq!(lines[1], "  -> minimum 0");
801        assert_eq!(lines[2], "data filing_status: filing_status_type");
802        assert_eq!(lines[3], "  -> default \"single\"");
803        assert_eq!(lines[4], "data country: \"NL\"");
804        assert_eq!(lines[5], "data deductions: number");
805        assert_eq!(lines[6], "  -> minimum 0");
806        assert_eq!(lines[7], "data name: text");
807    }
808
809    #[test]
810    fn test_format_groups_spec_refs_with_overrides() {
811        let source = r#"spec test
812
813with retail.quantity: 5
814uses order wholesale
815uses order retail
816with wholesale.quantity: 100
817data base_price: 50
818
819rule total: base_price
820"#;
821        let formatted =
822            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
823        let data_section = formatted
824            .split("rule total")
825            .next()
826            .unwrap()
827            .split("spec test\n")
828            .nth(1)
829            .unwrap();
830        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
831        assert_eq!(lines[0], "uses order wholesale");
832        assert_eq!(lines[1], "with wholesale.quantity: 100");
833        assert_eq!(lines[2], "uses order retail");
834        assert_eq!(lines[3], "with retail.quantity: 5");
835        assert_eq!(lines[4], "data base_price: 50");
836    }
837
838    #[test]
839    fn test_format_groups_with_literals_under_each_uses() {
840        let source = r#"spec test
841
842uses x
843uses y
844
845with x.name: "Ben"
846with y.age: 15
847
848rule r: 1
849"#;
850        let formatted =
851            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
852        let data_section = formatted
853            .split("rule r")
854            .next()
855            .unwrap()
856            .split("spec test\n")
857            .nth(1)
858            .unwrap();
859        let lines: Vec<&str> = data_section.lines().filter(|l| !l.is_empty()).collect();
860        assert_eq!(lines[0], "uses x");
861        assert_eq!(lines[1], "with x.name: \"Ben\"");
862        assert_eq!(lines[2], "uses y");
863        assert_eq!(lines[3], "with y.age: 15");
864    }
865
866    #[test]
867    fn test_format_source_weather_clothing_text_quoted() {
868        let source = r#"spec weather_clothing
869
870data clothing_style: text
871  -> option "light"
872  -> option "warm"
873
874data temperature: number
875
876rule clothing_layer: "light"
877  unless temperature < 5 then "warm"
878"#;
879        let formatted =
880            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
881        assert!(
882            formatted.contains("\"light\""),
883            "text in rule must be quoted, got: {}",
884            formatted
885        );
886        assert!(
887            formatted.contains("\"warm\""),
888            "text in unless must be quoted, got: {}",
889            formatted
890        );
891    }
892
893    // NOTE: Default value type validation (e.g. rejecting "10 $$" as a number
894    // default) is tested at the planning level in engine.rs, not here. The
895    // formatter only parses — it does not validate types. Planning catches
896    // invalid defaults for both primitives and named types.
897
898    #[test]
899    fn test_format_text_option_round_trips() {
900        let source = r#"spec test
901
902data status: text
903  -> option "active"
904  -> option "inactive"
905
906data s: status
907
908rule out: s
909"#;
910        let formatted =
911            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
912        assert!(
913            formatted.contains("option \"active\""),
914            "text option must be quoted, got: {}",
915            formatted
916        );
917        assert!(
918            formatted.contains("option \"inactive\""),
919            "text option must be quoted, got: {}",
920            formatted
921        );
922        // Round-trip
923        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
924        assert!(reparsed.is_ok(), "formatted output should re-parse");
925    }
926
927    #[test]
928    fn test_format_help_round_trips() {
929        let source = r#"spec test
930data quantity: number -> help "Number of items to order"
931rule total: quantity
932"#;
933        let formatted =
934            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
935        assert!(
936            formatted.contains("help \"Number of items to order\""),
937            "help must be quoted, got: {}",
938            formatted
939        );
940        // Round-trip
941        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
942        assert!(reparsed.is_ok(), "formatted output should re-parse");
943    }
944
945    #[test]
946    fn test_format_quantity_type_def_round_trips() {
947        let source = r#"spec test
948
949data money: quantity
950  -> unit eur 1.00
951  -> unit usd 0.91
952  -> decimals 2
953  -> minimum 0
954
955data price: money
956
957rule total: price
958"#;
959        let formatted =
960            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
961        assert!(
962            formatted.contains("unit eur 1.00"),
963            "quantity unit should not be quoted, got: {}",
964            formatted
965        );
966        // Round-trip
967        let reparsed = format_source(&formatted, crate::parsing::source::SourceType::Volatile);
968        assert!(
969            reparsed.is_ok(),
970            "formatted output should re-parse, got: {:?}",
971            reparsed
972        );
973    }
974
975    #[test]
976    fn test_format_expression_display_stable_round_trip() {
977        let source = r#"spec test
978data a: 1.00
979rule r: a + 2.00 * 3
980"#;
981        let formatted =
982            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
983        let again =
984            format_source(&formatted, crate::parsing::source::SourceType::Volatile).unwrap();
985        assert_eq!(
986            formatted, again,
987            "AST Display-based format must be idempotent under parse/format"
988        );
989    }
990
991    #[test]
992    fn test_format_rule_default_on_same_line_when_fits() {
993        let source = "spec test\nrule r: 1\n";
994        let formatted =
995            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
996        assert!(
997            formatted.contains("rule r: 1\n"),
998            "default expr should stay on rule line when under MAX_COLS, got:\n{formatted}"
999        );
1000    }
1001
1002    #[test]
1003    fn test_format_rule_unless_single_line_when_short() {
1004        let source = r#"spec test
1005data a: number
1006data b: boolean
1007
1008rule r: no
1009  unless a < 1 then yes
1010  unless b then yes
1011"#;
1012        let formatted =
1013            format_source(source, crate::parsing::source::SourceType::Volatile).unwrap();
1014        assert!(
1015            formatted.contains("unless a < 1 then yes")
1016                && formatted.contains("unless b     then yes"),
1017            "unless stays on one line when under MAX_COLS, got:\n{formatted}"
1018        );
1019    }
1020}