Skip to main content

rustledger_core/
display_context.rs

1//! Display context for formatting numbers with consistent precision.
2//!
3//! This module provides the [`DisplayContext`] type which tracks a frequency
4//! distribution of decimal places per currency, observed during parsing. The
5//! configured [`Precision`] policy then determines how that distribution is
6//! collapsed to a single per-currency precision for display.
7//!
8//! Default policy is [`Precision::MostCommon`] — the *mode* of the dp
9//! distribution. This matches Python `bean-query`'s default rendering and
10//! ensures that outliers (e.g. a single 28-decimal computed price annotation)
11//! don't inflate the display precision for an otherwise 2dp-dominant currency.
12//!
13//! [`Precision::Maximum`] selects the highest dp ever observed, which is what
14//! Python uses when rendering price tables. Callers opt in via
15//! [`DisplayContext::set_precision`].
16//!
17//! # Example
18//!
19//! ```
20//! use rustledger_core::DisplayContext;
21//! use rust_decimal_macros::dec;
22//!
23//! let mut ctx = DisplayContext::new();
24//!
25//! // Track samples for USD: tied 1×0dp + 1×2dp → tie-break favors larger.
26//! ctx.update(dec!(100), "USD");       // 0 dp
27//! ctx.update(dec!(50.25), "USD");     // 2 dp
28//! ctx.update(dec!(1.5), "EUR");       // 1 dp
29//!
30//! // Default policy (MostCommon) returns the mode of the per-currency dist.
31//! assert_eq!(ctx.get_precision("USD"), Some(2));
32//! assert_eq!(ctx.get_precision("EUR"), Some(1));
33//! assert_eq!(ctx.get_precision("GBP"), None); // Never seen
34//!
35//! // format() uses the policy's effective precision.
36//! assert_eq!(ctx.format(dec!(100), "USD"), "100.00");
37//! assert_eq!(ctx.format(dec!(50.25), "USD"), "50.25");
38//! assert_eq!(ctx.format(dec!(1.5), "EUR"), "1.5");
39//! ```
40
41use rust_decimal::{Decimal, MathematicalOps};
42use std::collections::{BTreeMap, HashMap, HashSet};
43
44/// Sentinel currency key for "naked-decimal" observations.
45///
46/// Used for values with no associated currency, e.g. BQL `Value::Number`
47/// results from `SUM(number)` or `cost_number` columns. Matches Python's
48/// `__default__` convention in `beancount.core.display_context`.
49pub const DEFAULT_CURRENCY: &str = "__default__";
50
51/// Per-currency frequency distribution of decimal-place counts.
52///
53/// Replaces the old "max-only" `u32` storage so that [`Precision::MostCommon`]
54/// can pick the *mode* of observed precisions (matching Python `bean-query`'s
55/// default), while [`Precision::Maximum`] still picks the historical max.
56///
57/// Uses `BTreeMap` so iteration order is deterministic and `mode()`'s
58/// tie-breaking matches Python's "largest dp wins on ties" rule (Python
59/// iterates sorted ascending with `>=`, which keeps the *last* equal-count
60/// entry — i.e. the largest dp).
61#[derive(Debug, Clone, Default)]
62struct Distribution {
63    hist: BTreeMap<u32, u32>,
64}
65
66impl Distribution {
67    fn update(&mut self, dp: u32) {
68        *self.hist.entry(dp).or_insert(0) += 1;
69    }
70
71    fn merge(&mut self, other: &Self) {
72        for (&dp, &count) in &other.hist {
73            *self.hist.entry(dp).or_insert(0) += count;
74        }
75    }
76
77    fn max(&self) -> Option<u32> {
78        self.hist.keys().next_back().copied()
79    }
80
81    /// Most-common dp. On ties, prefer the larger dp (matches
82    /// `beancount.core.distribution.Distribution.mode`, which iterates
83    /// sorted-ascending with `count >= max_count`).
84    fn mode(&self) -> Option<u32> {
85        let mut best: Option<(u32, u32)> = None; // (count, dp)
86        for (&dp, &count) in &self.hist {
87            // `>=` keeps the larger dp on ties because BTreeMap iterates ascending
88            if best.is_none_or(|(c, _)| count >= c) {
89                best = Some((count, dp));
90            }
91        }
92        best.map(|(_, dp)| dp)
93    }
94}
95
96/// Policy for resolving the per-currency display precision from the
97/// observed distribution.
98///
99/// Matches Python `beancount.core.display_context.Precision`:
100/// - [`MostCommon`](Self::MostCommon) returns the mode of the dp histogram.
101///   Used by `bean-query` for its result tables. Outliers (a single 28-decimal
102///   price annotation, a single integer-valued cost amid mostly 2dp postings)
103///   don't dominate.
104/// - [`Maximum`](Self::Maximum) returns the highest dp ever observed for the
105///   currency. Used by Python `display_context` when rendering prices, where
106///   preserving the highest-precision sample is the explicit goal.
107///
108/// Default is `MostCommon` to match `bean-query`'s default rendering of
109/// position/amount columns. See PR #985 follow-up and beanquery#275 for
110/// the upstream conversation.
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
112pub enum Precision {
113    /// Mode of the per-currency distribution (Python `MOST_COMMON`).
114    #[default]
115    MostCommon,
116    /// Maximum dp ever observed (Python `MAXIMUM`).
117    Maximum,
118}
119
120/// Display context for formatting numbers with consistent precision per currency.
121///
122/// Tracks a frequency distribution of decimal places per currency and exposes
123/// it via [`get_precision`](Self::get_precision) under the configured
124/// [`Precision`] policy. Default policy is [`Precision::MostCommon`] to match
125/// Python `bean-query`.
126///
127/// Fixed per-currency overrides (from `option "display_precision"`) always
128/// win over inferred precision regardless of the policy.
129#[derive(Debug, Clone, Default)]
130pub struct DisplayContext {
131    /// Per-currency observed decimal-place distributions.
132    distributions: HashMap<String, Distribution>,
133
134    /// Whether to render commas in numbers (from `option "render_commas"`).
135    render_commas: bool,
136
137    /// Fixed precision overrides (from `option "display_precision"`).
138    /// These take precedence over inferred precision under any policy.
139    fixed_precisions: HashMap<String, u32>,
140
141    /// Inference policy for [`DisplayContext::get_precision`]. Defaults
142    /// to [`Precision::MostCommon`] to match Python `bean-query`.
143    precision: Precision,
144}
145
146impl DisplayContext {
147    /// Create a new empty display context.
148    #[must_use]
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Update the display context with a number for a currency.
154    ///
155    /// Records the decimal precision (number of digits after the decimal
156    /// point) of `number` against `currency`'s histogram, so subsequent
157    /// `get_precision` calls reflect the new sample under the active
158    /// [`Precision`] policy.
159    pub fn update(&mut self, number: Decimal, currency: &str) {
160        let dp = Self::decimal_precision(number);
161        self.distributions
162            .entry(currency.to_string())
163            .or_default()
164            .update(dp);
165    }
166
167    /// Update the display context from another display context.
168    ///
169    /// - Inferred per-currency distributions: merge histograms (sum counts
170    ///   across both sides). This preserves frequency information so the
171    ///   merged context's mode reflects the union of samples — strictly more
172    ///   correct than the old "max of maxes" merge, and matches Python
173    ///   `display_context.DisplayContext.update_from`.
174    /// - Fixed per-currency overrides (`option "display_precision"`):
175    ///   propagated from `other` only when `self` has no fixed override for
176    ///   that currency (so a per-context override stays authoritative).
177    /// - `render_commas`: enabled if either side has it on (one-way
178    ///   "sticky on" merge — same rationale as before).
179    /// - `precision` policy: NOT propagated. The policy is a property of
180    ///   the consumer (e.g. BQL renderer vs price-display formatter), not
181    ///   the data, so it stays as set on `self`.
182    ///
183    /// The fixed-precision and `render_commas` merging matters when a column
184    /// context inherits from a ledger context for `Value::Number` rendering:
185    /// without it, the ledger's display options would silently fail to apply
186    /// to naked-decimal columns. See PR #961 follow-up.
187    pub fn update_from(&mut self, other: &Self) {
188        for (currency, dist) in &other.distributions {
189            self.distributions
190                .entry(currency.clone())
191                .or_default()
192                .merge(dist);
193        }
194        for (currency, precision) in &other.fixed_precisions {
195            self.fixed_precisions
196                .entry(currency.clone())
197                .or_insert(*precision);
198        }
199        if other.render_commas {
200            self.render_commas = true;
201        }
202    }
203
204    /// Set the inference policy for [`Self::get_precision`].
205    ///
206    /// Default is [`Precision::MostCommon`] to match Python `bean-query`.
207    /// Callers that need to preserve the highest-precision sample (e.g.
208    /// price-display formatters) can opt into [`Precision::Maximum`].
209    pub const fn set_precision(&mut self, precision: Precision) {
210        self.precision = precision;
211    }
212
213    /// Get the active inference policy.
214    #[must_use]
215    pub const fn precision(&self) -> Precision {
216        self.precision
217    }
218
219    /// Iterate the currencies that have observed dp samples or fixed
220    /// overrides, in deterministic-but-unspecified order.
221    ///
222    /// Skips the `__default__` sentinel — that bucket is for naked-decimal
223    /// columns (BQL `Value::Number`) and isn't a "real" currency from the
224    /// user's perspective.
225    pub fn currencies(&self) -> impl Iterator<Item = &str> {
226        let mut seen: HashSet<&str> = HashSet::new();
227        let mut out: Vec<&str> = Vec::new();
228        for currency in self
229            .distributions
230            .keys()
231            .chain(self.fixed_precisions.keys())
232            .map(String::as_str)
233        {
234            if currency != DEFAULT_CURRENCY && seen.insert(currency) {
235                out.push(currency);
236            }
237        }
238        out.sort_unstable();
239        out.into_iter()
240    }
241
242    /// Return the dp histogram for `currency` as ascending `(dp, count)`
243    /// pairs. Empty if the currency has no observed samples.
244    ///
245    /// Useful for diagnostic / debugging tooling
246    /// (e.g. `rledger doctor display-context`) that wants to show *why*
247    /// a particular precision was chosen.
248    #[must_use]
249    pub fn histogram(&self, currency: &str) -> Vec<(u32, u32)> {
250        self.distributions.get(currency).map_or_else(Vec::new, |d| {
251            d.hist.iter().map(|(&dp, &c)| (dp, c)).collect()
252        })
253    }
254
255    /// Look up the precision that *would* be returned under a specific
256    /// policy, without mutating `self`. Same semantics as
257    /// [`Self::get_precision`] but lets a single context be queried
258    /// under both policies (e.g. for diagnostic output that compares
259    /// `MostCommon` vs `Maximum`).
260    #[must_use]
261    pub fn precision_under(&self, currency: &str, policy: Precision) -> Option<u32> {
262        if let Some(&fixed) = self.fixed_precisions.get(currency) {
263            return Some(fixed);
264        }
265        let dist = self.distributions.get(currency)?;
266        match policy {
267            Precision::MostCommon => dist.mode(),
268            Precision::Maximum => dist.max(),
269        }
270    }
271
272    /// True if `currency` has a fixed-precision override
273    /// (from `option "display_precision"` or
274    /// [`Self::set_fixed_precision`]).
275    #[must_use]
276    pub fn has_fixed_precision(&self, currency: &str) -> bool {
277        self.fixed_precisions.contains_key(currency)
278    }
279
280    /// Set the `render_commas` flag.
281    pub const fn set_render_commas(&mut self, render_commas: bool) {
282        self.render_commas = render_commas;
283    }
284
285    /// Get the `render_commas` flag.
286    #[must_use]
287    pub const fn render_commas(&self) -> bool {
288        self.render_commas
289    }
290
291    /// Set a fixed precision for a currency (from `option "display_precision"`).
292    ///
293    /// Fixed precision takes precedence over inferred precision.
294    pub fn set_fixed_precision(&mut self, currency: &str, precision: u32) {
295        self.fixed_precisions
296            .insert(currency.to_string(), precision);
297    }
298
299    /// Get the precision for a currency.
300    ///
301    /// Returns the fixed precision if set; otherwise looks up the inferred
302    /// precision under the active [`Precision`] policy
303    /// ([`MostCommon`](Precision::MostCommon) by default — the mode of the
304    /// observed distribution; or [`Maximum`](Precision::Maximum) — the highest
305    /// observed dp). Returns `None` if the currency has never been seen.
306    #[must_use]
307    pub fn get_precision(&self, currency: &str) -> Option<u32> {
308        if let Some(&precision) = self.fixed_precisions.get(currency) {
309            return Some(precision);
310        }
311        let dist = self.distributions.get(currency)?;
312        match self.precision {
313            Precision::MostCommon => dist.mode(),
314            Precision::Maximum => dist.max(),
315        }
316    }
317
318    /// Get the default precision used when formatting a Decimal that has no
319    /// associated currency (e.g. the result of `SUM(number)` in BQL).
320    ///
321    /// Resolution order (matches the BQL renderer's expectations after
322    /// PR #986):
323    ///
324    /// 1. **`__default__` bucket** — if any naked-decimal observations have
325    ///    been recorded via `update(n, DEFAULT_CURRENCY)`, the bucket's
326    ///    effective precision wins. This is what BQL populates for
327    ///    `Value::Number` columns (matches Python `bean-query`'s per-column
328    ///    `DecimalRenderer`).
329    /// 2. **Max effective precision across every other currency** — fallback
330    ///    when no naked-decimal observations exist. Covers issue #954: a
331    ///    column of `Value::Number(0)` that came from an aggregate
332    ///    collapsing to literal zero still renders with the column's
333    ///    expected dp (e.g. `0.00` for a USD-only file).
334    /// 3. **Returns 0** if no currencies have been recorded at all.
335    ///
336    /// "Effective" precision means per-currency `fixed` overrides `inferred`
337    /// (same rule as [`Self::get_precision`]) and respects the active
338    /// [`Precision`] policy, so a fixed `display_precision` of 2 for USD
339    /// won't be overridden by an inferred 4-digit value.
340    #[must_use]
341    pub fn default_precision(&self) -> u32 {
342        // Prefer the `__default__` bucket if it has samples — this is what
343        // BQL renderers populate for naked-Decimal columns (`Value::Number`
344        // results from `SUM(number)`, `cost_number`, etc.). Matches Python
345        // `bean-query`'s `DecimalRenderer`, which tracks per-column dp
346        // independently of the per-currency dctx.
347        if let Some(dp) = self.get_precision(DEFAULT_CURRENCY) {
348            return dp;
349        }
350
351        // Fall back to max-of-effective-precisions across all known
352        // currencies. Used when no explicit naked-decimal observations
353        // were made (e.g. a query that returns aggregates with implicit
354        // 0 results — issue #954). `get_precision` handles fixed-vs-
355        // inferred priority and respects the active `Precision` policy.
356        let mut max_dp: u32 = 0;
357        let mut seen: HashSet<&str> = HashSet::new();
358        for currency in self
359            .fixed_precisions
360            .keys()
361            .chain(self.distributions.keys())
362            .map(String::as_str)
363        {
364            if seen.insert(currency)
365                && currency != DEFAULT_CURRENCY
366                && let Some(dp) = self.get_precision(currency)
367            {
368                max_dp = max_dp.max(dp);
369            }
370        }
371        max_dp
372    }
373
374    /// Quantize a number to the tracked precision for a currency.
375    ///
376    /// Mirrors Python's `Decimal.quantize`: the result has *exactly* the
377    /// target scale — rounding when the input has more dp, padding with
378    /// trailing zeros when the input has fewer. This matches what
379    /// `bean-query`'s `AmountRenderer` does: it quantizes via the ledger
380    /// dctx before populating the column dctx, so the column dctx sees
381    /// uniformly-padded values.
382    ///
383    /// If the currency has no tracked precision, returns the number
384    /// unchanged.
385    ///
386    /// Pre-fix this used `round_dp(dp)`, which only ROUNDS down — it
387    /// never PADS up. That meant a 2dp input under a 4dp target stayed
388    /// 2dp, the column dctx saw dp=2, and the output rendered 2dp instead
389    /// of bean-query's 4dp.
390    #[must_use]
391    pub fn quantize(&self, number: Decimal, currency: &str) -> Decimal {
392        if let Some(dp) = self.get_precision(currency) {
393            let mut rounded = number.round_dp(dp);
394            // round_dp can leave a smaller scale than `dp` (it only rounds
395            // *down* the dp count). rescale pads up to exactly `dp`.
396            rounded.rescale(dp);
397            rounded
398        } else {
399            number
400        }
401    }
402
403    /// Format a decimal number for a currency using the tracked precision.
404    ///
405    /// Render rules (matching bean-query's `AmountRenderer.format`):
406    /// - If the value's intrinsic scale exceeds the currency's tracked
407    ///   precision, render at the value's scale. Python's `decimal`
408    ///   carries scale through arithmetic and bean-query preserves it,
409    ///   so a `SUM(number) GROUP BY currency` that aggregates a
410    ///   `-805.50896` row and a `-396.50000` row renders as
411    ///   `-1202.00896` (scale=5), not `-1202.01` (rounded to USD's 2dp).
412    /// - If the value's scale is less than the tracked precision, pad
413    ///   with trailing zeros (`7.5 USD` → `7.50`). Preserves the
414    ///   #954 fix that stops `SUM(0.00) = 0` rendering as plain `0`.
415    /// - If the currency has no tracked precision, fall through to the
416    ///   value's natural rendering with trailing zeros stripped.
417    ///
418    /// The previous implementation always quantized to the tracked
419    /// precision via `round_dp(dp)`. That was correct for under-scale
420    /// padding but wrong for over-scale truncation — it lost
421    /// arithmetic precision that bean-query preserved (closes #1103).
422    #[must_use]
423    pub fn format(&self, number: Decimal, currency: &str) -> String {
424        let precision = self.get_precision(currency);
425
426        if let Some(dp) = precision {
427            // Render at max(value_scale, tracked_dp). When value_scale
428            // already meets or exceeds dp, `round_dp` is a no-op (it only
429            // rounds when scale > target). When value_scale is shorter,
430            // `ensure_decimal_places` pads to dp. So this branch covers
431            // both "preserve high precision" and "pad short precision"
432            // without losing either.
433            let effective_dp = number.scale().max(dp);
434            let rounded = number.round_dp(effective_dp);
435            let formatted = format!("{rounded}");
436            let formatted = Self::ensure_decimal_places(&formatted, effective_dp);
437            if self.render_commas {
438                Self::add_commas(&formatted)
439            } else {
440                formatted
441            }
442        } else {
443            // No tracked precision - use natural formatting
444            let formatted = number.normalize().to_string();
445            if self.render_commas {
446                Self::add_commas(&formatted)
447            } else {
448                formatted
449            }
450        }
451    }
452
453    /// Format an amount (number + currency) using the tracked precision.
454    ///
455    /// Unlike [`Self::format`] (which preserves over-scale arithmetic
456    /// precision to match Python `bean-query`'s `DecimalRenderer` for
457    /// scalar `Value::Number` results), this method always *quantizes* to
458    /// the currency's tracked dp — matching bean-query's `AmountRenderer`
459    /// for Amounts, Positions, and Inventory entries.
460    ///
461    /// Python uses two distinct renderers for the two semantic kinds of
462    /// output:
463    ///
464    /// - `DecimalRenderer` for naked decimals (preserves scale, since
465    ///   Python `decimal` carries scale through arithmetic).
466    /// - `AmountRenderer` for amount-typed values (uses the ledger's
467    ///   display context per-currency dp, which is the user-facing
468    ///   "how many decimal places does this currency render at" setting).
469    ///
470    /// Rust used to conflate the two through a single `format` call,
471    /// which is why #1103's fix (preserving scale in `format`) inadvertently
472    /// regressed the BQL compat suite by ~7pp on queries that produce
473    /// `Value::Inventory` — the position amounts inside the inventory now
474    /// render with raw arithmetic scale instead of the currency's display
475    /// dp. See #1112 for the regression analysis.
476    #[must_use]
477    pub fn format_amount(&self, number: Decimal, currency: &str) -> String {
478        format!("{} {}", self.format_quantized(number, currency), currency)
479    }
480
481    /// Format the number portion of an Amount/Position (no currency
482    /// suffix), quantized to the tracked dp.
483    ///
484    /// Used by the BQL `numberify` rendering path that strips the
485    /// currency from positions/inventories — same semantics as
486    /// [`Self::format_amount`] but without the trailing ` <CURRENCY>`.
487    #[must_use]
488    pub fn format_amount_number(&self, number: Decimal, currency: &str) -> String {
489        self.format_quantized(number, currency)
490    }
491
492    /// Internal: quantize `number` to `currency`'s tracked dp (rounding
493    /// and padding) and stringify. Falls back to natural representation
494    /// when the currency is untracked.
495    fn format_quantized(&self, number: Decimal, currency: &str) -> String {
496        let raw = match self.get_precision(currency) {
497            Some(dp) => {
498                let mut rounded = number.round_dp(dp);
499                // `round_dp` leaves a smaller scale than `dp` when the
500                // input had fewer dp; `rescale` pads with trailing zeros
501                // to exactly `dp`.
502                rounded.rescale(dp);
503                rounded.to_string()
504            }
505            None => number.normalize().to_string(),
506        };
507        if self.render_commas {
508            Self::add_commas(&raw)
509        } else {
510            raw
511        }
512    }
513
514    /// Format a Decimal that has no associated currency.
515    ///
516    /// Used by the BQL query renderer for `Value::Number` results —
517    /// bare Decimals produced by aggregates like `SUM(number)` or
518    /// columns like `cost_number`.
519    ///
520    /// Matches Python `bean-query`'s `DecimalRenderer.format`, which
521    /// uses the value's *natural* string representation (preserving the
522    /// scale baked into the Decimal) without imposing uniform precision
523    /// across rows. So `Value::Number(Decimal('0.00'))` renders `0.00`
524    /// (scale survives — covers issue #954) while `Value::Number(0)`
525    /// renders `0` (no artificial padding).
526    ///
527    /// When the value has scale 0 (no fractional part) but the context
528    /// has a `__default__`-bucket precision, we DO pad up to that
529    /// precision — this is the issue #954 path: an aggregate that
530    /// collapsed to literal zero (scale lost) still gets rendered with
531    /// the column's expected dp.
532    #[must_use]
533    pub fn format_default(&self, number: Decimal) -> String {
534        // Match Python `bean-query`'s `DecimalRenderer.format`: render
535        // each value at its intrinsic scale. No padding to a "column
536        // default precision" — that branch was added as a fix for
537        // #954 ("`SUM(0.00 + -0.00)` rendered as `0` instead of
538        // `0.00`"), but the real bug there was `n.normalize()` stripping
539        // the SUM result's scale to 0 *before* rendering. Once that
540        // normalize was removed, scale-2 SUMs naturally render as
541        // `0.00` via `to_string()` without any padding step. The padding
542        // overfit covered up the symptom but caused two new shapes of
543        // divergence:
544        //
545        // 1. Mixed-scale columns where a scale-0 cell renders next to
546        //    a scale-25 cell get the scale-0 value padded to 25dp
547        //    (`1000` → `1000.0000000000000000000000000`). Bean-query
548        //    renders the scale-0 cell as `1000`.
549        // 2. Literal `Decimal(0)` values rendered as `0.00` instead of
550        //    `0` even when no SUM aggregator was involved. Bean-query
551        //    renders `Decimal(0)` as `0`.
552        //
553        // Cap total significant digits at 28 to match Python's default
554        // `Decimal` context precision (`getcontext().prec`). rust_decimal's
555        // 96-bit mantissa can land at 29 sig figs from some divisions
556        // (e.g. `300 / 1.763 = 170.16449…` with 26 fractional + 3 integer
557        // = 29 digits, where Python clamps the same division at 25
558        // fractional digits = 28 total).
559        const PYTHON_DECIMAL_PRECISION: u32 = 28;
560        let capped = Self::cap_significant_digits(number, PYTHON_DECIMAL_PRECISION);
561        let formatted = capped.to_string();
562        if self.render_commas {
563            Self::add_commas(&formatted)
564        } else {
565            formatted
566        }
567    }
568
569    /// Round `number` to at most `max_sig` significant digits, matching
570    /// Python's `Decimal` context-precision-clamped arithmetic. No-op
571    /// when the value already fits; otherwise rounds half-even (Python's
572    /// `Decimal` default rounding mode).
573    ///
574    /// Handles both fractional and integer-only excess:
575    ///
576    /// - Fractional case (`new_scale > 0`): rounds via
577    ///   [`Decimal::round_dp_with_strategy`] which truncates trailing
578    ///   fractional digits.
579    /// - Integer-only case (`number.scale() < digits - max_sig`):
580    ///   `round_dp_with_strategy(0, …)` would leave the over-precise
581    ///   integer unchanged, since it can't go to negative scales. We
582    ///   scale by a power of ten, round to nearest integer, then
583    ///   restore the magnitude — same as Python's clamp on a 29-digit
584    ///   integer, which puts it in scientific form with a 28-digit
585    ///   mantissa. Caught by Copilot review on PR #1064.
586    fn cap_significant_digits(number: Decimal, max_sig: u32) -> Decimal {
587        // mantissa() returns the integer mantissa; its decimal length is
588        // the number of significant digits regardless of scale. Zero has
589        // zero significant digits by this convention — `ilog10` returns
590        // `None` and we fall through to the early-return below.
591        let mantissa_abs = number.mantissa().unsigned_abs();
592        let digits = mantissa_abs.checked_ilog10().map_or(0, |x| x + 1);
593        if digits <= max_sig {
594            return number;
595        }
596        let excess = digits - max_sig;
597        if excess <= number.scale() {
598            // Trimming only affects fractional digits — use the standard
599            // dp-based rounding directly.
600            return number.round_dp_with_strategy(
601                number.scale() - excess,
602                rust_decimal::RoundingStrategy::MidpointNearestEven,
603            );
604        }
605        // Excess exceeds the available fractional digits: we have to
606        // round integer-portion digits, which `round_dp_with_strategy`
607        // can't express (it doesn't support negative dp). Lift by a
608        // power of 10, round to nearest integer, drop back.
609        // `integer_excess` is always >= 1 here.
610        let integer_excess = excess - number.scale();
611        let Some(factor) = Decimal::TEN.checked_powu(u64::from(integer_excess)) else {
612            // `10^integer_excess` overflows when `integer_excess` is
613            // implausibly large (>28). The input must have been an
614            // already-overflowed Decimal; bail out with the original
615            // value rather than panicking.
616            return number;
617        };
618        let lifted = number / factor;
619        let rounded =
620            lifted.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointNearestEven);
621        rounded * factor
622    }
623
624    /// Get the decimal precision (number of digits after decimal point) of a number.
625    const fn decimal_precision(number: Decimal) -> u32 {
626        // scale() returns the number of decimal digits
627        number.scale()
628    }
629
630    /// Ensure a formatted number has exactly `dp` decimal places.
631    /// Adds trailing zeros if needed, or adds ".00..." if no decimal point.
632    fn ensure_decimal_places(s: &str, dp: u32) -> String {
633        if dp == 0 {
634            // No decimal places needed - remove any decimal point
635            return s.split('.').next().unwrap_or(s).to_string();
636        }
637
638        let dp = dp as usize;
639        if let Some(dot_pos) = s.find('.') {
640            let current_decimals = s.len() - dot_pos - 1;
641            if current_decimals >= dp {
642                // Already has enough or more decimals
643                s.to_string()
644            } else {
645                // Need to add trailing zeros
646                let zeros_needed = dp - current_decimals;
647                format!("{s}{}", "0".repeat(zeros_needed))
648            }
649        } else {
650            // No decimal point - add one with zeros
651            format!("{s}.{}", "0".repeat(dp))
652        }
653    }
654
655    /// Add thousand separators (commas) to a formatted number string.
656    fn add_commas(s: &str) -> String {
657        // Split on decimal point
658        let (integer_part, decimal_part) = match s.find('.') {
659            Some(pos) => (&s[..pos], Some(&s[pos..])),
660            None => (s, None),
661        };
662
663        // Handle negative sign
664        let (sign, digits) = if let Some(stripped) = integer_part.strip_prefix('-') {
665            ("-", stripped)
666        } else {
667            ("", integer_part)
668        };
669
670        // Add commas to integer part (from right to left)
671        let mut result = String::with_capacity(digits.len() + digits.len() / 3);
672        for (i, c) in digits.chars().rev().enumerate() {
673            if i > 0 && i % 3 == 0 {
674                result.push(',');
675            }
676            result.push(c);
677        }
678        let integer_with_commas: String = result.chars().rev().collect();
679
680        // Combine parts
681        match decimal_part {
682            Some(dec) => format!("{sign}{integer_with_commas}{dec}"),
683            None => format!("{sign}{integer_with_commas}"),
684        }
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use rust_decimal_macros::dec;
692
693    #[test]
694    fn test_update_and_get_precision_most_common_default() {
695        // Default policy is MostCommon (matches Python bean-query). With
696        // 2 integer-valued samples and 1 fractional, the mode is 0dp.
697        let mut ctx = DisplayContext::new();
698
699        ctx.update(dec!(100), "USD");
700        assert_eq!(ctx.get_precision("USD"), Some(0));
701
702        // Tied at 1×0dp + 1×2dp → tie-break favors larger dp = 2.
703        ctx.update(dec!(50.25), "USD");
704        assert_eq!(ctx.get_precision("USD"), Some(2));
705
706        // Now 2×0dp + 1×2dp → mode is 0dp (most common).
707        ctx.update(dec!(1), "USD");
708        assert_eq!(ctx.get_precision("USD"), Some(0));
709
710        // Unknown currency
711        assert_eq!(ctx.get_precision("EUR"), None);
712    }
713
714    #[test]
715    fn test_update_and_get_precision_maximum_policy() {
716        // Same samples as the MostCommon test, but with Maximum policy:
717        // the highest dp ever observed wins — preserves the historical
718        // behavior for callers that opt in.
719        let mut ctx = DisplayContext::new();
720        ctx.set_precision(Precision::Maximum);
721
722        ctx.update(dec!(100), "USD");
723        assert_eq!(ctx.get_precision("USD"), Some(0));
724
725        ctx.update(dec!(50.25), "USD");
726        assert_eq!(ctx.get_precision("USD"), Some(2));
727
728        // Adding more 0dp samples doesn't lower the max.
729        ctx.update(dec!(1), "USD");
730        assert_eq!(ctx.get_precision("USD"), Some(2));
731    }
732
733    #[test]
734    fn test_default_precision_prefers_default_bucket_over_max_of_modes() {
735        // When BQL renders a naked-Decimal column, it observes the column's
736        // actual values into the `__default__` bucket (matching Python
737        // bean-query's per-column DecimalRenderer). default_precision must
738        // prefer that bucket over the max-of-modes across other currencies
739        // — otherwise an unrelated currency with a higher mode (e.g. VBMPX
740        // at 3dp from `3.149 VBMPX` postings) would inflate the precision
741        // of a USD `cost_number` column.
742        let mut ctx = DisplayContext::new();
743        // Ledger context: USD has mode 2, VBMPX has mode 3.
744        for _ in 0..5 {
745            ctx.update(dec!(1.23), "USD");
746        }
747        for _ in 0..5 {
748            ctx.update(dec!(1.234), "VBMPX");
749        }
750        // Without naked-decimal observations, default_precision falls
751        // back to max-of-modes = 3 (VBMPX wins).
752        assert_eq!(ctx.default_precision(), 3);
753        // After observing two 2dp values into __default__, that bucket's
754        // mode (2) takes precedence regardless of VBMPX.
755        ctx.update(dec!(128.99), DEFAULT_CURRENCY);
756        ctx.update(dec!(131.73), DEFAULT_CURRENCY);
757        assert_eq!(ctx.default_precision(), 2);
758    }
759
760    #[test]
761    fn test_format_default_integer_column_stays_integer() {
762        // A naked-decimal column where every observed value has scale 0
763        // (e.g. an integer count column from a query like
764        // `SELECT account, SUM(units) WHERE units > 0`) should render
765        // each value as an integer, NOT pad to some fractional precision
766        // borrowed from an unrelated currency.
767        //
768        // Even though USD has 2dp inferred, the __default__ bucket's
769        // mode is 0, so format_default returns the value's natural
770        // string ("100", "5", etc.) — the scale==0 padding branch only
771        // fires when the resolved default_precision > 0. Here dp = 0
772        // so no padding.
773        let mut ctx = DisplayContext::new();
774        ctx.update(dec!(1.23), "USD"); // ledger USD has 2dp
775        // Column observes integer values into __default__:
776        for n in [dec!(100), dec!(5), dec!(42)] {
777            ctx.update(n, DEFAULT_CURRENCY);
778        }
779        // __default__ mode is 0 → no padding, natural rendering.
780        assert_eq!(ctx.format_default(dec!(100)), "100");
781        assert_eq!(ctx.format_default(dec!(5)), "5");
782        // A fractional value still prints at its natural scale (matches
783        // Python `DecimalRenderer` per-row formatting).
784        assert_eq!(ctx.format_default(dec!(7.5)), "7.5");
785    }
786
787    #[test]
788    fn test_default_precision_falls_back_when_default_bucket_empty() {
789        // Issue #954: a column of `Value::Number(0)` (e.g. SUM that
790        // collapsed to zero) has no naked-decimal observations to
791        // populate __default__. default_precision falls back to the
792        // max-of-modes so we still render `0.00` instead of `0`.
793        let mut ctx = DisplayContext::new();
794        for _ in 0..5 {
795            ctx.update(dec!(1.23), "USD");
796        }
797        // No __default__ observations.
798        assert_eq!(ctx.default_precision(), 2);
799    }
800
801    // ===== Diagnostic-API tests (currencies / histogram / precision_under) =====
802
803    #[test]
804    fn test_currencies_skips_default_sentinel() {
805        let mut ctx = DisplayContext::new();
806        ctx.update(dec!(1.23), "USD");
807        ctx.update(dec!(0.5), "EUR");
808        ctx.update(dec!(100), DEFAULT_CURRENCY); // sentinel — must be hidden
809        let cs: Vec<&str> = ctx.currencies().collect();
810        assert_eq!(cs, vec!["EUR", "USD"]); // sorted, no __default__
811    }
812
813    #[test]
814    fn test_currencies_includes_fixed_only_currencies() {
815        let mut ctx = DisplayContext::new();
816        // Only a fixed override, no observed samples.
817        ctx.set_fixed_precision("BTC", 8);
818        let cs: Vec<&str> = ctx.currencies().collect();
819        assert_eq!(cs, vec!["BTC"]);
820    }
821
822    #[test]
823    fn test_histogram_returns_ascending_pairs() {
824        let mut ctx = DisplayContext::new();
825        for _ in 0..5 {
826            ctx.update(dec!(1.23), "USD"); // 2dp × 5
827        }
828        for _ in 0..2 {
829            ctx.update(dec!(1.234), "USD"); // 3dp × 2
830        }
831        ctx.update(dec!(100), "USD"); // 0dp × 1
832        let h = ctx.histogram("USD");
833        // Ascending dp order, full counts preserved.
834        assert_eq!(h, vec![(0, 1), (2, 5), (3, 2)]);
835    }
836
837    #[test]
838    fn test_histogram_empty_for_unknown_currency() {
839        let ctx = DisplayContext::new();
840        assert!(ctx.histogram("XYZ").is_empty());
841    }
842
843    #[test]
844    fn test_precision_under_does_not_mutate_active_policy() {
845        let mut ctx = DisplayContext::new();
846        for _ in 0..5 {
847            ctx.update(dec!(100), "USD");
848        }
849        ctx.update(dec!(1.234), "USD");
850        // Active policy is MostCommon; mode = 0.
851        assert_eq!(ctx.get_precision("USD"), Some(0));
852        // Querying under Maximum returns 3 — without changing active.
853        assert_eq!(ctx.precision_under("USD", Precision::Maximum), Some(3));
854        // Active policy unchanged after the introspection call.
855        assert_eq!(ctx.precision(), Precision::MostCommon);
856        assert_eq!(ctx.get_precision("USD"), Some(0));
857    }
858
859    #[test]
860    fn test_precision_under_returns_zero_when_fixed_is_zero() {
861        // `set_fixed_precision(c, 0)` is a legitimate setting (forces a
862        // currency to render as integer). Both policies must return Some(0)
863        // — not None, not the inferred precision.
864        let mut ctx = DisplayContext::new();
865        ctx.update(dec!(1.234), "JPY"); // inferred mode = 3
866        ctx.set_fixed_precision("JPY", 0); // user wants integer JPY
867        assert_eq!(ctx.precision_under("JPY", Precision::MostCommon), Some(0));
868        assert_eq!(ctx.precision_under("JPY", Precision::Maximum), Some(0));
869        assert_eq!(ctx.get_precision("JPY"), Some(0));
870    }
871
872    #[test]
873    fn test_precision_under_respects_fixed_override() {
874        let mut ctx = DisplayContext::new();
875        ctx.update(dec!(1.234), "USD");
876        ctx.set_fixed_precision("USD", 2);
877        // Both policies see the fixed override, regardless.
878        assert_eq!(ctx.precision_under("USD", Precision::MostCommon), Some(2));
879        assert_eq!(ctx.precision_under("USD", Precision::Maximum), Some(2));
880    }
881
882    #[test]
883    fn test_has_fixed_precision() {
884        let mut ctx = DisplayContext::new();
885        ctx.update(dec!(1.23), "USD");
886        assert!(!ctx.has_fixed_precision("USD"));
887        ctx.set_fixed_precision("USD", 2);
888        assert!(ctx.has_fixed_precision("USD"));
889    }
890
891    #[test]
892    fn test_quantize_pads_scale_upward() {
893        // Pinned because `Decimal::round_dp(dp)` only rounds *down* — it
894        // doesn't pad scale upward. Pre-fix, quantize(150.67, "USD") with
895        // USD precision=4 returned 150.67 (scale 2), which broke the
896        // bean-query parity for column-level dist tracking.
897        let mut ctx = DisplayContext::new();
898        for _ in 0..10 {
899            ctx.update(dec!(0.0400), "USD"); // 10×4dp samples → mode=4
900        }
901        for _ in 0..3 {
902            ctx.update(dec!(150.67), "USD"); // 3×2dp samples
903        }
904        // Mode is 4 (ten 4dp samples win).
905        assert_eq!(ctx.get_precision("USD"), Some(4));
906        // Quantize must produce a Decimal with scale exactly 4, not 2.
907        let q = ctx.quantize(dec!(150.67), "USD");
908        assert_eq!(q.scale(), 4);
909        assert_eq!(q.to_string(), "150.6700");
910    }
911
912    #[test]
913    fn test_format_with_precision() {
914        let mut ctx = DisplayContext::new();
915        ctx.update(dec!(100), "USD");
916        ctx.update(dec!(50.25), "USD");
917
918        // 1×0dp + 1×2dp → mode tie-breaks to the larger (2dp), so format
919        // uses 2 fractional digits. (See test_mode_tie_break_favors_larger_dp.)
920        assert_eq!(ctx.format(dec!(100), "USD"), "100.00");
921        assert_eq!(ctx.format(dec!(50.25), "USD"), "50.25");
922        assert_eq!(ctx.format(dec!(7.5), "USD"), "7.50");
923    }
924
925    /// Issue #1103: when the value's intrinsic scale exceeds the
926    /// currency's tracked precision, render at the value's scale
927    /// rather than quantizing down. Matches bean-query: a
928    /// `SUM(number)` over a fixture with high-precision arithmetic
929    /// (cost-spec interpolation residuals, manual high-dp postings)
930    /// produces a Decimal whose scale we MUST preserve to align with
931    /// Python's `decimal` representation. The currency hint only ever
932    /// PADS UP from a shorter scale; it never rounds DOWN from a
933    /// longer one.
934    #[test]
935    fn test_format_preserves_value_scale_above_tracked_precision() {
936        let mut ctx = DisplayContext::new();
937        // USD tracked at 2dp (mode of two 2dp observations).
938        ctx.update(dec!(100.00), "USD");
939        ctx.update(dec!(50.25), "USD");
940        assert_eq!(ctx.get_precision("USD"), Some(2));
941
942        // Value scale > tracked dp → preserve value scale (no round-down).
943        assert_eq!(ctx.format(dec!(1.234), "USD"), "1.234");
944        assert_eq!(ctx.format(dec!(-1202.00896), "USD"), "-1202.00896");
945        assert_eq!(ctx.format(dec!(0.00000), "USD"), "0.00000");
946
947        // Value scale ≤ tracked dp → pad up (unchanged from #988 fix).
948        assert_eq!(ctx.format(dec!(7.5), "USD"), "7.50");
949        assert_eq!(ctx.format(dec!(0), "USD"), "0.00");
950    }
951
952    /// Pins the post-#1112 fix: `format` and `format_amount` must NOT share
953    /// rounding behavior.
954    ///
955    /// `format` (used for scalar `Value::Number`) preserves the Decimal's
956    /// arithmetic scale — matches Python `DecimalRenderer`. `format_amount`
957    /// (used for Amounts/Positions/Inventory) quantizes to the currency's
958    /// tracked dp — matches Python `AmountRenderer`. Conflating them is
959    /// what caused the 7pp BQL compat regression on main since #1106.
960    #[test]
961    fn test_format_vs_format_amount_split_semantics() {
962        let mut ctx = DisplayContext::new();
963        ctx.update(dec!(100.00), "USD");
964        ctx.update(dec!(50.25), "USD");
965        assert_eq!(ctx.get_precision("USD"), Some(2));
966
967        // `format`: scalar Number → preserve arithmetic scale (over and under).
968        assert_eq!(ctx.format(dec!(-1202.00896), "USD"), "-1202.00896");
969        assert_eq!(ctx.format(dec!(7.5), "USD"), "7.50");
970
971        // `format_amount`: Amount → quantize to tracked dp (over and under).
972        assert_eq!(ctx.format_amount(dec!(-1202.00896), "USD"), "-1202.01 USD");
973        assert_eq!(ctx.format_amount(dec!(7.5), "USD"), "7.50 USD");
974        // Cost-spec interpolation can produce 26-digit per-unit values; the
975        // Amount renderer must clamp those to the currency's display dp.
976        assert_eq!(
977            ctx.format_amount(dec!(170.16449234259784458309699376), "USD"),
978            "170.16 USD"
979        );
980
981        // `format_amount_number`: same quantize semantics, no currency suffix.
982        assert_eq!(
983            ctx.format_amount_number(dec!(-1202.00896), "USD"),
984            "-1202.01"
985        );
986        assert_eq!(ctx.format_amount_number(dec!(7.5), "USD"), "7.50");
987    }
988
989    /// Untracked currencies fall through to natural rendering in both
990    /// `format` and `format_amount`. Trailing zeros are stripped because
991    /// there's no display-precision target to pad against.
992    #[test]
993    fn test_format_amount_untracked_currency_uses_natural_scale() {
994        let ctx = DisplayContext::new();
995        // No prior `update` calls — get_precision("USD") returns None.
996        assert_eq!(ctx.format_amount(dec!(170.164), "USD"), "170.164 USD");
997        assert_eq!(ctx.format_amount(dec!(7.5), "USD"), "7.5 USD");
998        assert_eq!(ctx.format_amount(dec!(100), "USD"), "100 USD");
999    }
1000
1001    #[test]
1002    fn test_format_unknown_currency() {
1003        let ctx = DisplayContext::new();
1004
1005        // Unknown currency uses natural formatting
1006        assert_eq!(ctx.format(dec!(100), "EUR"), "100");
1007        assert_eq!(ctx.format(dec!(50.25), "EUR"), "50.25");
1008    }
1009
1010    #[test]
1011    fn test_fixed_precision_override() {
1012        let mut ctx = DisplayContext::new();
1013        ctx.update(dec!(100), "USD");
1014        ctx.update(dec!(50.25), "USD");
1015
1016        // Inferred precision is 2
1017        assert_eq!(ctx.get_precision("USD"), Some(2));
1018
1019        // Set fixed precision to 4
1020        ctx.set_fixed_precision("USD", 4);
1021        assert_eq!(ctx.get_precision("USD"), Some(4));
1022
1023        // Formatting uses fixed precision
1024        assert_eq!(ctx.format(dec!(100), "USD"), "100.0000");
1025    }
1026
1027    // ===== Precision policy tests =====
1028
1029    #[test]
1030    fn test_mode_picks_most_common_dp() {
1031        let mut ctx = DisplayContext::new();
1032        for _ in 0..5 {
1033            ctx.update(dec!(1.23), "USD"); // 2dp × 5
1034        }
1035        for _ in 0..2 {
1036            ctx.update(dec!(1.234), "USD"); // 3dp × 2
1037        }
1038        assert_eq!(ctx.get_precision("USD"), Some(2));
1039    }
1040
1041    #[test]
1042    fn test_mode_tie_break_favors_larger_dp() {
1043        // Pins Python's `Distribution.mode()` tie-break: when counts tie,
1044        // the LARGEST dp wins. Python iterates sorted-ascending with `>=`
1045        // (in beancount/core/distribution.py), keeping the last equal
1046        // entry. We match by iterating the BTreeMap ascending with `>=`.
1047        let mut ctx = DisplayContext::new();
1048        ctx.update(dec!(1.23), "USD"); // 2dp × 1
1049        ctx.update(dec!(1.234), "USD"); // 3dp × 1
1050        ctx.update(dec!(1.2345), "USD"); // 4dp × 1
1051        assert_eq!(ctx.get_precision("USD"), Some(4));
1052    }
1053
1054    #[test]
1055    fn test_mode_outlier_does_not_dominate() {
1056        // The bean-query parity case: 5x integer + 1x 28dp price annotation
1057        // → mode = 0dp, NOT 28. Pre-fix rledger returned 28 (the max);
1058        // post-fix returns 0 to match bean-query's MOST_COMMON default.
1059        let mut ctx = DisplayContext::new();
1060        for _ in 0..5 {
1061            ctx.update(dec!(100), "USD");
1062        }
1063        ctx.update(dec!(0.0000000000000000000000000001), "USD");
1064        assert_eq!(ctx.get_precision("USD"), Some(0));
1065    }
1066
1067    #[test]
1068    fn test_switching_to_maximum_returns_max() {
1069        let mut ctx = DisplayContext::new();
1070        for _ in 0..5 {
1071            ctx.update(dec!(100), "USD");
1072        }
1073        ctx.update(dec!(1.234567), "USD");
1074        // Default MostCommon: integer mode wins
1075        assert_eq!(ctx.get_precision("USD"), Some(0));
1076        // Switch policy to Maximum: the single 6dp sample wins
1077        ctx.set_precision(Precision::Maximum);
1078        assert_eq!(ctx.get_precision("USD"), Some(6));
1079        // Switch back: mode again
1080        ctx.set_precision(Precision::MostCommon);
1081        assert_eq!(ctx.get_precision("USD"), Some(0));
1082    }
1083
1084    #[test]
1085    fn test_fixed_precision_overrides_both_policies() {
1086        let mut ctx = DisplayContext::new();
1087        ctx.update(dec!(1.234), "USD");
1088        ctx.set_fixed_precision("USD", 2);
1089        assert_eq!(ctx.get_precision("USD"), Some(2));
1090        // Maximum policy still respects the fixed override
1091        ctx.set_precision(Precision::Maximum);
1092        assert_eq!(ctx.get_precision("USD"), Some(2));
1093    }
1094
1095    #[test]
1096    fn test_update_from_merges_distributions_not_just_max() {
1097        // Pre-fix: update_from took max(self.max, other.max) per currency,
1098        // collapsing distributions. Post-fix: merges histograms so the mode
1099        // reflects the union of frequencies. Without this, a column ctx
1100        // inheriting from a ledger ctx would only see the ledger's MAX
1101        // value, defeating the whole MostCommon design.
1102        let mut a = DisplayContext::new();
1103        for _ in 0..5 {
1104            a.update(dec!(1.23), "USD"); // 2dp × 5
1105        }
1106
1107        let mut b = DisplayContext::new();
1108        for _ in 0..10 {
1109            b.update(dec!(1.234), "USD"); // 3dp × 10
1110        }
1111
1112        a.update_from(&b);
1113        // After merge: 5×2dp + 10×3dp → mode = 3dp
1114        assert_eq!(a.get_precision("USD"), Some(3));
1115    }
1116
1117    #[test]
1118    fn test_update_from_is_not_idempotent_under_add_merge() {
1119        // Pin the semantics that triggered Copilot's review on PR #986:
1120        // since update_from now ADDS counts (not max-merges), calling it
1121        // multiple times multiplies the source's contribution. This is
1122        // why the BQL renderer must guard against repeated inheritance
1123        // per row (see crates/rustledger/src/cmd/query/output.rs).
1124        let mut src = DisplayContext::new();
1125        for _ in 0..10 {
1126            src.update(dec!(1.23), "USD"); // 2dp × 10
1127        }
1128
1129        let mut dst1 = DisplayContext::new();
1130        dst1.update_from(&src);
1131        // After 1 merge: 10×2dp.
1132        assert_eq!(dst1.histogram("USD"), vec![(2, 10)]);
1133
1134        let mut dst2 = DisplayContext::new();
1135        dst2.update_from(&src);
1136        dst2.update_from(&src);
1137        // After 2 merges: 20×2dp — counts compounded.
1138        assert_eq!(dst2.histogram("USD"), vec![(2, 20)]);
1139    }
1140
1141    #[test]
1142    fn test_update_from_does_not_propagate_precision_policy() {
1143        // Policy is a property of the consumer, not the data. A column ctx
1144        // that opted into Maximum shouldn't have its policy clobbered by
1145        // a ledger ctx that uses the MostCommon default.
1146        let mut ledger = DisplayContext::new();
1147        // ledger uses default MostCommon
1148        ledger.update(dec!(1.23), "USD");
1149
1150        let mut col = DisplayContext::new();
1151        col.set_precision(Precision::Maximum);
1152        col.update_from(&ledger);
1153
1154        assert_eq!(col.precision(), Precision::Maximum);
1155    }
1156
1157    #[test]
1158    fn test_render_commas() {
1159        let mut ctx = DisplayContext::new();
1160        ctx.set_render_commas(true);
1161        ctx.update(dec!(1234567.89), "USD");
1162
1163        assert_eq!(ctx.format(dec!(1234567.89), "USD"), "1,234,567.89");
1164        assert_eq!(ctx.format(dec!(1000), "USD"), "1,000.00");
1165    }
1166
1167    #[test]
1168    fn test_add_commas() {
1169        assert_eq!(DisplayContext::add_commas("1234567"), "1,234,567");
1170        assert_eq!(DisplayContext::add_commas("1234567.89"), "1,234,567.89");
1171        assert_eq!(DisplayContext::add_commas("-1234567.89"), "-1,234,567.89");
1172        assert_eq!(DisplayContext::add_commas("123"), "123");
1173        assert_eq!(DisplayContext::add_commas("1"), "1");
1174    }
1175
1176    #[test]
1177    fn test_update_from() {
1178        let mut ctx1 = DisplayContext::new();
1179        ctx1.update(dec!(100), "USD");
1180
1181        let mut ctx2 = DisplayContext::new();
1182        ctx2.update(dec!(50.25), "USD");
1183        ctx2.update(dec!(1.5), "EUR");
1184
1185        ctx1.update_from(&ctx2);
1186
1187        assert_eq!(ctx1.get_precision("USD"), Some(2));
1188        assert_eq!(ctx1.get_precision("EUR"), Some(1));
1189    }
1190
1191    #[test]
1192    fn test_update_from_propagates_fixed_precisions_and_render_commas() {
1193        // Copilot review on PR #961: previously update_from only merged
1194        // inferred precisions, so naked-decimal columns inheriting from a
1195        // ledger context with `option "display_precision"` would miss the
1196        // fixed overrides.
1197        let mut ledger = DisplayContext::new();
1198        ledger.update(dec!(1.234), "USD"); // inferred precision 3
1199        ledger.set_fixed_precision("USD", 2); // fixed override
1200        ledger.set_fixed_precision("BTC", 8);
1201        ledger.set_render_commas(true);
1202
1203        let mut col = DisplayContext::new();
1204        col.update_from(&ledger);
1205
1206        // Inferred precision distribution merged — under default
1207        // MostCommon policy, USD has only the single 3dp sample so
1208        // mode = 3.
1209        assert_eq!(
1210            col.distributions.get("USD").and_then(Distribution::mode),
1211            Some(3)
1212        );
1213        // Fixed overrides also propagated.
1214        assert_eq!(col.fixed_precisions.get("USD"), Some(&2));
1215        assert_eq!(col.fixed_precisions.get("BTC"), Some(&8));
1216        // get_precision still respects the fixed override.
1217        assert_eq!(col.get_precision("USD"), Some(2));
1218        assert_eq!(col.get_precision("BTC"), Some(8));
1219        // render_commas propagated.
1220        assert!(col.render_commas);
1221    }
1222
1223    #[test]
1224    fn test_update_from_preserves_self_fixed_overrides() {
1225        // If self already has a fixed override for a currency, update_from
1226        // shouldn't clobber it with the other's value. Self wins.
1227        let mut ledger = DisplayContext::new();
1228        ledger.set_fixed_precision("USD", 2);
1229
1230        let mut col = DisplayContext::new();
1231        col.set_fixed_precision("USD", 4); // self's override
1232        col.update_from(&ledger);
1233
1234        assert_eq!(col.fixed_precisions.get("USD"), Some(&4));
1235    }
1236
1237    #[test]
1238    fn test_default_precision_respects_fixed_override_lower_than_inferred() {
1239        // Copilot review on PR #961: if USD has inferred=4 but fixed=2,
1240        // the user said "render USD with 2 decimals" — default_precision
1241        // for naked Decimals must respect that, not fall back to the
1242        // inferred max (4).
1243        let mut ctx = DisplayContext::new();
1244        ctx.update(dec!(1.2345), "USD"); // inferred 4
1245        ctx.set_fixed_precision("USD", 2); // fixed override
1246
1247        // get_precision returns the effective precision (fixed wins).
1248        assert_eq!(ctx.get_precision("USD"), Some(2));
1249        // default_precision must use the same effective view, not raw max.
1250        assert_eq!(ctx.default_precision(), 2);
1251    }
1252
1253    #[test]
1254    fn test_default_precision_takes_max_across_currencies_with_overrides() {
1255        // EUR fixed=4 wins over USD fixed=2 → default = 4.
1256        let mut ctx = DisplayContext::new();
1257        ctx.set_fixed_precision("USD", 2);
1258        ctx.set_fixed_precision("EUR", 4);
1259
1260        assert_eq!(ctx.default_precision(), 4);
1261    }
1262
1263    #[test]
1264    fn test_format_amount() {
1265        let mut ctx = DisplayContext::new();
1266        ctx.update(dec!(50.25), "USD");
1267
1268        assert_eq!(ctx.format_amount(dec!(100), "USD"), "100.00 USD");
1269    }
1270
1271    #[test]
1272    fn test_default_precision_picks_max_across_currencies() {
1273        // Issue #954: bare Decimals (e.g. SUM(number) result) need a default
1274        // precision matching what bean-query uses — the max precision across
1275        // every known currency.
1276        let mut ctx = DisplayContext::new();
1277        ctx.update(dec!(1.23), "USD"); // precision 2
1278        ctx.update(dec!(1.2345), "EUR"); // precision 4
1279        ctx.update(dec!(0.5), "GBP"); // precision 1
1280
1281        assert_eq!(ctx.default_precision(), 4);
1282    }
1283
1284    #[test]
1285    fn test_default_precision_includes_fixed_overrides() {
1286        // Fixed precision (from `option "display_precision"`) should also
1287        // contribute to the max.
1288        let mut ctx = DisplayContext::new();
1289        ctx.update(dec!(1.23), "USD");
1290        ctx.set_fixed_precision("BTC", 8);
1291
1292        assert_eq!(ctx.default_precision(), 8);
1293    }
1294
1295    #[test]
1296    fn test_default_precision_empty_context_is_zero() {
1297        let ctx = DisplayContext::new();
1298        assert_eq!(ctx.default_precision(), 0);
1299    }
1300
1301    #[test]
1302    fn test_format_default_does_not_pad_scale_zero_to_column_precision() {
1303        // Inverted from the pre-fix `test_format_default_pads_to_max_precision`.
1304        //
1305        // Python `bean-query`'s `DecimalRenderer.format` calls
1306        // `str(value)` — no padding step. A `Decimal(0)` (scale 0)
1307        // renders as `"0"` regardless of what other cells in the
1308        // column look like; a `Decimal(0.0000)` renders as `"0.0000"`.
1309        //
1310        // We used to pad scale-0 values to the column's default
1311        // precision as an over-fit for #954, but that broke mixed-scale
1312        // columns (issue #1051's `cost-basis-fields` cases on fixtures
1313        // like `tests_test_inputs_missing_prices.beancount`, where a
1314        // scale-0 `cost_number=1000` was rendered as
1315        // `"1000.0000000000000000000000000"` because the column's other
1316        // row had a scale-25 cost from a `{{total}}`-form spec). The
1317        // #954 case (`SUM(0.00 + -0.00)`) still renders `"0.00"`
1318        // correctly because the aggregator preserves the inputs' max
1319        // scale — `to_string()` on the resulting `Decimal('0.00')` is
1320        // `"0.00"` without any padding.
1321        let mut ctx = DisplayContext::new();
1322        ctx.update(dec!(1.23), "USD");
1323        ctx.update(dec!(1.2345), "EUR");
1324        assert_eq!(ctx.format_default(dec!(0)), "0");
1325        assert_eq!(ctx.format_default(dec!(100)), "100");
1326    }
1327
1328    #[test]
1329    fn test_format_default_preserves_natural_scale_for_overprecise_values() {
1330        // Updated post-#985-follow-up: format_default no longer ROUNDS to
1331        // a uniform precision. Instead it preserves each value's natural
1332        // scale (matches Python `bean-query`'s DecimalRenderer, which
1333        // formats with `{value:<width}` — no precision specifier). That
1334        // means 1.235 prints as "1.235", NOT rounded to "1.24".
1335        let mut ctx = DisplayContext::new();
1336        ctx.update(dec!(1.23), "USD");
1337        assert_eq!(ctx.format_default(dec!(1.235)), "1.235");
1338    }
1339
1340    #[test]
1341    fn test_format_default_empty_context_natural() {
1342        let ctx = DisplayContext::new();
1343        // No tracked precision → integer-like rendering (no padding,
1344        // no rounding, value's natural scale).
1345        assert_eq!(ctx.format_default(dec!(42)), "42");
1346        // Fractional values keep their natural scale.
1347        assert_eq!(ctx.format_default(dec!(1.5)), "1.5");
1348    }
1349
1350    #[test]
1351    fn test_format_default_renders_commas() {
1352        let mut ctx = DisplayContext::new();
1353        ctx.update(dec!(1.23), "USD");
1354        ctx.set_render_commas(true);
1355
1356        assert_eq!(ctx.format_default(dec!(1234567.89)), "1,234,567.89");
1357    }
1358
1359    /// Issue #1051 example 4: `rust_decimal`'s 96-bit mantissa can land
1360    /// at 29 sig figs from divisions like `300 / 1.763`, where Python's
1361    /// default `Decimal` context (`getcontext().prec = 28`) clamps the
1362    /// same operation at 28. Without the cap in `format_default`, BQL's
1363    /// `cost_number` rendering would show 29 digits where bean-query
1364    /// shows 28, surfacing as a `cost-basis-fields` mismatch on every
1365    /// fixture with computed (`{{total}}`-form) cost specs.
1366    #[test]
1367    fn test_format_default_caps_significant_digits_at_28() {
1368        let ctx = DisplayContext::new();
1369        // 300 / 1.763 in rust_decimal lands at 29 sig figs:
1370        // 170.16449234259784458309699376 (3 integer + 26 fractional).
1371        let v = Decimal::from_str_exact("170.16449234259784458309699376").unwrap();
1372        assert_eq!(v.scale(), 26, "test setup: input has scale 26");
1373        // After capping to 28 sig figs total, the fractional scale drops
1374        // by 1 to 25 — matching Python's `Decimal('300') / Decimal('1.763')
1375        // = Decimal('170.1644923425978445830969938')`.
1376        assert_eq!(
1377            ctx.format_default(v),
1378            "170.1644923425978445830969938",
1379            "should cap at 28 sig figs (3 integer + 25 fractional)"
1380        );
1381    }
1382
1383    #[test]
1384    fn test_format_default_28_digit_or_fewer_passes_through_unchanged() {
1385        let ctx = DisplayContext::new();
1386        // Fits within 28 — no rounding. Don't accidentally re-quantize
1387        // values that are already at the right precision.
1388        assert_eq!(ctx.format_default(dec!(170.16449)), "170.16449");
1389        // Edge case: exactly 28 digits.
1390        let v = Decimal::from_str_exact("1.234567890123456789012345678").unwrap();
1391        assert_eq!(v.scale(), 27);
1392        assert_eq!(
1393            ctx.format_default(v),
1394            "1.234567890123456789012345678",
1395            "value at exactly 28 sig figs must pass through unchanged"
1396        );
1397    }
1398
1399    #[test]
1400    fn test_format_default_cap_preserves_sign_and_integer_part() {
1401        let ctx = DisplayContext::new();
1402        // Negative value > 28 sig figs: sign and integer part survive
1403        // the rescale; only fractional digits get truncated.
1404        let v = Decimal::from_str_exact("-1234.5678901234567890123456789").unwrap();
1405        // mantissa has 29 digits; capping to 28 drops the last fractional digit.
1406        assert_eq!(
1407            ctx.format_default(v),
1408            "-1234.567890123456789012345679",
1409            "negative + integer part preserved; fractional rounded half-even"
1410        );
1411    }
1412
1413    /// Integer-only excess: a 29-digit scale-0 Decimal must actually
1414    /// round (to nearest 10), not pass through unchanged. Pre-fix
1415    /// `cap_significant_digits` did `saturating_sub` on the scale
1416    /// which clamped to 0, and `round_dp_with_strategy(0, …)` left
1417    /// the integer alone — contradicting the doc comment. Caught by
1418    /// Copilot review on PR #1064.
1419    #[test]
1420    fn test_format_default_caps_integer_only_excess() {
1421        let ctx = DisplayContext::new();
1422        // 29 digits, scale 0. Cap to 28 → round to nearest 10.
1423        // 12345678901234567890123456789 / 10 = 1234567890123456789012345678.9
1424        // rounded half-even at 0dp = 1234567890123456789012345679
1425        // × 10 = 12345678901234567890123456790
1426        let v = Decimal::from_str_exact("12345678901234567890123456789").unwrap();
1427        assert_eq!(v.scale(), 0);
1428        assert_eq!(
1429            ctx.format_default(v),
1430            "12345678901234567890123456790",
1431            "29-digit integer must round to nearest 10 (28 sig figs), \
1432             trailing 0 marks the rounded position"
1433        );
1434    }
1435
1436    /// Zero values render at their intrinsic scale and skip the
1437    /// significant-digit cap (since `mantissa()` is 0). Guards against
1438    /// `checked_ilog10(0) → None` regressing into an off-by-one or
1439    /// accidental cap. Together with
1440    /// `test_format_default_does_not_pad_scale_zero_to_column_precision`
1441    /// this locks in bean-query parity for both `Decimal(0)` and
1442    /// `Decimal(0.00)` shapes.
1443    #[test]
1444    fn test_format_default_zero_preserves_intrinsic_scale() {
1445        let ctx = DisplayContext::new();
1446        assert_eq!(ctx.format_default(dec!(0)), "0", "Decimal(0) → \"0\"");
1447        assert_eq!(
1448            ctx.format_default(dec!(0.00)),
1449            "0.00",
1450            "Decimal(0.00) → \"0.00\" — the SUM-of-scale-2-zeros case from #954"
1451        );
1452        assert_eq!(
1453            ctx.format_default(dec!(-0.0000)),
1454            "0.0000",
1455            "Decimal(-0.0000) — rust_decimal canonicalizes negative zero"
1456        );
1457    }
1458}