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 crate::Directive;
42use rust_decimal::{Decimal, MathematicalOps};
43use std::collections::{BTreeMap, HashMap, HashSet};
44
45/// Sentinel currency key for "naked-decimal" observations.
46///
47/// Used for values with no associated currency, e.g. BQL `Value::Number`
48/// results from `SUM(number)` or `cost_number` columns. Matches Python's
49/// `__default__` convention in `beancount.core.display_context`.
50pub const DEFAULT_CURRENCY: &str = "__default__";
51
52/// Per-currency frequency distribution of decimal-place counts.
53///
54/// Replaces the old "max-only" `u32` storage so that [`Precision::MostCommon`]
55/// can pick the *mode* of observed precisions (matching Python `bean-query`'s
56/// default), while [`Precision::Maximum`] still picks the historical max.
57///
58/// Uses `BTreeMap` so iteration order is deterministic and `mode()`'s
59/// tie-breaking matches Python's "largest dp wins on ties" rule (Python
60/// iterates sorted ascending with `>=`, which keeps the *last* equal-count
61/// entry — i.e. the largest dp).
62#[derive(Debug, Clone, Default)]
63struct Distribution {
64    hist: BTreeMap<u32, u32>,
65}
66
67impl Distribution {
68    fn update(&mut self, dp: u32) {
69        *self.hist.entry(dp).or_insert(0) += 1;
70    }
71
72    fn merge(&mut self, other: &Self) {
73        for (&dp, &count) in &other.hist {
74            *self.hist.entry(dp).or_insert(0) += count;
75        }
76    }
77
78    fn max(&self) -> Option<u32> {
79        self.hist.keys().next_back().copied()
80    }
81
82    /// Most-common dp. On ties, prefer the larger dp (matches
83    /// `beancount.core.distribution.Distribution.mode`, which iterates
84    /// sorted-ascending with `count >= max_count`).
85    fn mode(&self) -> Option<u32> {
86        let mut best: Option<(u32, u32)> = None; // (count, dp)
87        for (&dp, &count) in &self.hist {
88            // `>=` keeps the larger dp on ties because BTreeMap iterates ascending
89            if best.is_none_or(|(c, _)| count >= c) {
90                best = Some((count, dp));
91            }
92        }
93        best.map(|(_, dp)| dp)
94    }
95}
96
97/// Policy for resolving the per-currency display precision from the
98/// observed distribution.
99///
100/// Matches Python `beancount.core.display_context.Precision`:
101/// - [`MostCommon`](Self::MostCommon) returns the mode of the dp histogram.
102///   Used by `bean-query` for its result tables. Outliers (a single 28-decimal
103///   price annotation, a single integer-valued cost amid mostly 2dp postings)
104///   don't dominate.
105/// - [`Maximum`](Self::Maximum) returns the highest dp ever observed for the
106///   currency. Used by Python `display_context` when rendering prices, where
107///   preserving the highest-precision sample is the explicit goal.
108///
109/// Default is `MostCommon` to match `bean-query`'s default rendering of
110/// position/amount columns. See PR #985 follow-up and beanquery#275 for
111/// the upstream conversation.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub enum Precision {
114    /// Mode of the per-currency distribution (Python `MOST_COMMON`).
115    #[default]
116    MostCommon,
117    /// Maximum dp ever observed (Python `MAXIMUM`).
118    Maximum,
119}
120
121/// What kind of consumer an output surface is written for.
122///
123/// Decides whether thousands separators appear. The question is not "will a
124/// person read this" but **does the consumer have a grammar**:
125///
126/// - **machine interchange** (CSV, JSON) does not. A separator forces the
127///   field to be quoted and is then rejected by ordinary decimal parsers —
128///   `Decimal(field)` breaks (issue #1892). Suppressed unconditionally, and
129///   that suppression outranks any ledger or per-commodity declaration.
130/// - **ledger text** (`format`, `query --format beancount`) does. Grouped
131///   numerals are part of Beancount syntax, so every conforming reader must
132///   accept them; the parser, not the file, is the machine boundary. Honors
133///   the ledger's declaration (#1896).
134/// - **rendered tables** read by a person likewise honor it.
135///
136/// This exists so the rule is stated ONCE. It was previously re-derived per
137/// writer, and the surfaces had already drifted apart: `query --format
138/// beancount` emitted separators into ledger text while `format` stripped
139/// them, and CSV emitted them while JSON did not.
140///
141/// `LedgerText` initially suppressed them, on the premise that ledger text has
142/// one canonical on-disk form. #1896 abandoned that premise deliberately —
143/// `rledger format --ledger` now GROUPS, because a ledger that asks for
144/// separators should get them in the file it owns — which put the two ledger
145/// text producers back in disagreement until this arm followed. Beancount
146/// itself groups here: `grammar.py` calls `dcontext.set_commas(options
147/// ["render_commas"])` while parsing, and `printer.py` renders through that
148/// same context.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum OutputSurface {
151    /// A rendered table or report read by a person.
152    Human,
153    /// CSV/JSON consumed by another program.
154    Machine,
155    /// Beancount source text.
156    LedgerText,
157}
158
159impl OutputSurface {
160    /// Whether this surface renders thousands separators when the ledger asks
161    /// for them.
162    ///
163    /// Only [`Self::Machine`] refuses, because only its consumers lack a
164    /// grammar that admits the separator.
165    #[must_use]
166    pub const fn renders_thousands_separators(self) -> bool {
167        match self {
168            Self::Human | Self::LedgerText => true,
169            Self::Machine => false,
170        }
171    }
172}
173
174/// Display context for formatting numbers with consistent precision per currency.
175///
176/// Tracks a frequency distribution of decimal places per currency and exposes
177/// it via [`get_precision`](Self::get_precision) under the configured
178/// [`Precision`] policy. Default policy is [`Precision::MostCommon`] to match
179/// Python `bean-query`.
180///
181/// Fixed per-currency overrides (from `option "display_precision"`) always
182/// win over inferred precision regardless of the policy.
183#[derive(Debug, Clone, Default)]
184pub struct DisplayContext {
185    /// Per-currency observed decimal-place distributions.
186    distributions: HashMap<String, Distribution>,
187
188    /// Whether to render commas in numbers (from `option "render_commas"`).
189    ///
190    /// The LEDGER-WIDE default. A commodity may override it — see
191    /// [`Self::render_commas_for`] — so a 4000:1 currency can be grouped
192    /// without also grouping two-digit USD amounts.
193    render_commas: bool,
194    /// Per-commodity overrides of [`Self::render_commas`], from
195    /// `render_commas:` metadata on a `commodity` directive.
196    ///
197    /// Mirrors `fixed_precisions`: grouping and precision are both per-currency
198    /// display style, resolved by the same three tiers (inference / global
199    /// option / commodity metadata). Grouping had only the global tier, which
200    /// is the asymmetry this closes.
201    group_overrides: rustc_hash::FxHashMap<String, bool>,
202
203    /// Fixed precision overrides (from `option "display_precision"`).
204    /// These take precedence over inferred precision under any policy.
205    fixed_precisions: HashMap<String, u32>,
206
207    /// Inference policy for [`DisplayContext::get_precision`]. Defaults
208    /// to [`Precision::MostCommon`] to match Python `bean-query`.
209    precision: Precision,
210}
211
212impl DisplayContext {
213    /// Create a new empty display context.
214    #[must_use]
215    pub fn new() -> Self {
216        Self::default()
217    }
218
219    /// Update the display context with a number for a currency.
220    ///
221    /// Records the decimal precision (number of digits after the decimal
222    /// point) of `number` against `currency`'s histogram, so subsequent
223    /// `get_precision` calls reflect the new sample under the active
224    /// [`Precision`] policy.
225    pub fn update(&mut self, number: Decimal, currency: &str) {
226        let dp = Self::decimal_precision(number);
227        self.distributions
228            .entry(currency.to_string())
229            .or_default()
230            .update(dp);
231    }
232
233    /// Update the display context from another display context.
234    ///
235    /// - Inferred per-currency distributions: merge histograms (sum counts
236    ///   across both sides). This preserves frequency information so the
237    ///   merged context's mode reflects the union of samples — strictly more
238    ///   correct than the old "max of maxes" merge, and matches Python
239    ///   `display_context.DisplayContext.update_from`.
240    /// - Fixed per-currency overrides (`option "display_precision"`):
241    ///   propagated from `other` only when `self` has no fixed override for
242    ///   that currency (so a per-context override stays authoritative).
243    /// - `render_commas`: enabled if either side has it on (one-way
244    ///   "sticky on" merge — same rationale as before).
245    /// - `precision` policy: NOT propagated. The policy is a property of
246    ///   the consumer (e.g. BQL renderer vs price-display formatter), not
247    ///   the data, so it stays as set on `self`.
248    ///
249    /// The fixed-precision and `render_commas` merging matters when a column
250    /// context inherits from a ledger context for `Value::Number` rendering:
251    /// without it, the ledger's display options would silently fail to apply
252    /// to naked-decimal columns. See PR #961 follow-up.
253    pub fn update_from(&mut self, other: &Self) {
254        for (currency, dist) in &other.distributions {
255            self.distributions
256                .entry(currency.clone())
257                .or_default()
258                .merge(dist);
259        }
260        for (currency, precision) in &other.fixed_precisions {
261            self.fixed_precisions
262                .entry(currency.clone())
263                .or_insert(*precision);
264        }
265        if other.render_commas {
266            self.render_commas = true;
267        }
268        // Per-commodity grouping declarations travel with the flag they
269        // override. Merging the ledger-wide bit alone silently downgraded a
270        // commodity's own `render_commas:` to the global default (#1896).
271        for (currency, render) in &other.group_overrides {
272            self.group_overrides
273                .entry(currency.clone())
274                .or_insert(*render);
275        }
276    }
277
278    /// Adopt `other`'s grouping policy wholesale: the ledger-wide flag and
279    /// every per-commodity override.
280    ///
281    /// Distinct from [`Self::update_from`], which merges precision *facts*
282    /// inferred from data and is one-way for grouping. Separator rendering is
283    /// presentation policy for a whole table, so a derived context — the query
284    /// writer's per-column contexts, say — must take it entire rather than
285    /// reconstruct it. Reconstructing it is exactly how `SUM(number)` came to
286    /// disagree with `SUM(position)` in one query (#1892), and how a
287    /// commodity's own declaration came to be dropped (#1896).
288    pub fn adopt_grouping_from(&mut self, other: &Self) {
289        self.render_commas = other.render_commas;
290        self.group_overrides.clone_from(&other.group_overrides);
291    }
292
293    /// Set the inference policy for [`Self::get_precision`].
294    ///
295    /// Default is [`Precision::MostCommon`] to match Python `bean-query`.
296    /// Callers that need to preserve the highest-precision sample (e.g.
297    /// price-display formatters) can opt into [`Precision::Maximum`].
298    pub const fn set_precision(&mut self, precision: Precision) {
299        self.precision = precision;
300    }
301
302    /// Get the active inference policy.
303    #[must_use]
304    pub const fn precision(&self) -> Precision {
305        self.precision
306    }
307
308    /// Iterate the currencies that have observed dp samples or fixed
309    /// overrides, in deterministic-but-unspecified order.
310    ///
311    /// Skips the `__default__` sentinel — that bucket is for naked-decimal
312    /// columns (BQL `Value::Number`) and isn't a "real" currency from the
313    /// user's perspective.
314    pub fn currencies(&self) -> impl Iterator<Item = &str> {
315        let mut seen: HashSet<&str> = HashSet::new();
316        let mut out: Vec<&str> = Vec::new();
317        for currency in self
318            .distributions
319            .keys()
320            .chain(self.fixed_precisions.keys())
321            .map(String::as_str)
322        {
323            if currency != DEFAULT_CURRENCY && seen.insert(currency) {
324                out.push(currency);
325            }
326        }
327        out.sort_unstable();
328        out.into_iter()
329    }
330
331    /// Export every currency's RESOLVED precision (fixed override if
332    /// set, else the inferred precision under the active policy) as a
333    /// wire-friendly list, sorted by currency. This is what crosses the
334    /// FFI boundary as `ledger-options.display-precision` (#1766): the
335    /// same per-currency answer [`Self::get_precision`] would give, so
336    /// an embedder consuming the list renders with this context's
337    /// precision decisions without re-deriving inference (rustfava's
338    /// loader keeps a Python re-derivation only as a fallback for
339    /// engines that predate this field). Skips the `__default__`
340    /// naked-decimal bucket (see [`Self::currencies`]).
341    #[must_use]
342    pub fn resolved_precisions(&self) -> Vec<(String, u32)> {
343        self.currencies()
344            .filter_map(|c| self.get_precision(c).map(|p| (c.to_string(), p)))
345            .collect()
346    }
347
348    /// Return the dp histogram for `currency` as ascending `(dp, count)`
349    /// pairs. Empty if the currency has no observed samples.
350    ///
351    /// Useful for diagnostic / debugging tooling
352    /// (e.g. `rledger doctor display-context`) that wants to show *why*
353    /// a particular precision was chosen.
354    #[must_use]
355    pub fn histogram(&self, currency: &str) -> Vec<(u32, u32)> {
356        self.distributions.get(currency).map_or_else(Vec::new, |d| {
357            d.hist.iter().map(|(&dp, &c)| (dp, c)).collect()
358        })
359    }
360
361    /// Look up the precision that *would* be returned under a specific
362    /// policy, without mutating `self`. Same semantics as
363    /// [`Self::get_precision`] but lets a single context be queried
364    /// under both policies (e.g. for diagnostic output that compares
365    /// `MostCommon` vs `Maximum`).
366    #[must_use]
367    pub fn precision_under(&self, currency: &str, policy: Precision) -> Option<u32> {
368        if let Some(&fixed) = self.fixed_precisions.get(currency) {
369            return Some(fixed);
370        }
371        let dist = self.distributions.get(currency)?;
372        match policy {
373            Precision::MostCommon => dist.mode(),
374            Precision::Maximum => dist.max(),
375        }
376    }
377
378    /// True if `currency` has a fixed-precision override
379    /// (from `option "display_precision"` or
380    /// [`Self::set_fixed_precision`]).
381    #[must_use]
382    pub fn has_fixed_precision(&self, currency: &str) -> bool {
383        self.fixed_precisions.contains_key(currency)
384    }
385
386    /// This context, adjusted for the surface it will be written to.
387    ///
388    /// Only `render_commas` is affected — precision is a property of the data
389    /// and is identical on every surface. See [`OutputSurface`] for why the
390    /// distinction exists.
391    ///
392    /// Borrows unless the flag actually has to change, so the common cases
393    /// cost nothing: a ledger where nothing groups (almost all of them) and
394    /// any human-facing surface both borrow. Only suppressing separators for a
395    /// machine or ledger-text surface clones, and that clone carries the
396    /// per-currency histograms — worth avoiding on a REPL's hot path, and
397    /// wasted entirely on the JSON writer, which ignores the context.
398    ///
399    /// The borrow test is [`Self::renders_any_commas`], not the ledger-wide
400    /// flag: a commodity may declare `render_commas: TRUE` while the ledger
401    /// default is off, and borrowing on the strength of the global flag alone
402    /// would leak that commodity's separators onto a machine surface.
403    #[must_use]
404    pub fn for_surface(&self, surface: OutputSurface) -> std::borrow::Cow<'_, Self> {
405        if !self.renders_any_commas() || surface.renders_thousands_separators() {
406            return std::borrow::Cow::Borrowed(self);
407        }
408        let mut ctx = self.clone();
409        ctx.render_commas = false;
410        // Suppression is absolute: a per-commodity opt-in must not survive
411        // onto a surface whose consumer has no grammar for separators.
412        ctx.group_overrides.clear();
413        std::borrow::Cow::Owned(ctx)
414    }
415
416    /// Set the `render_commas` flag.
417    pub const fn set_render_commas(&mut self, render_commas: bool) {
418        self.render_commas = render_commas;
419    }
420
421    /// Declare whether `currency` renders thousands separators, overriding the
422    /// ledger-wide [`Self::set_render_commas`].
423    pub fn set_render_commas_for(&mut self, currency: &str, render: bool) {
424        self.group_overrides.insert(currency.to_string(), render);
425    }
426
427    /// Whether `currency` renders thousands separators: its own declaration if
428    /// it has one, else the ledger-wide default.
429    ///
430    /// Numerals with no currency in scope — metadata values, `custom`
431    /// directive values — have nothing to look up and take the default.
432    #[must_use]
433    pub fn render_commas_for(&self, currency: &str) -> bool {
434        self.group_overrides
435            .get(currency)
436            .copied()
437            .unwrap_or(self.render_commas)
438    }
439
440    /// Whether ANY currency renders separators.
441    ///
442    /// Lets a caller skip the per-numeral lookup entirely for the overwhelming
443    /// majority of ledgers, which declare nothing.
444    #[must_use]
445    pub fn renders_any_commas(&self) -> bool {
446        self.render_commas || self.group_overrides.values().any(|v| *v)
447    }
448
449    /// Get the `render_commas` flag.
450    #[must_use]
451    pub const fn render_commas(&self) -> bool {
452        self.render_commas
453    }
454
455    /// Set a fixed precision for a currency (from `option "display_precision"`).
456    ///
457    /// Fixed precision takes precedence over inferred precision.
458    pub fn set_fixed_precision(&mut self, currency: &str, precision: u32) {
459        self.fixed_precisions
460            .insert(currency.to_string(), precision);
461    }
462
463    /// Build a display context from a set of directives plus fixed
464    /// per-currency overrides. This is THE canonical builder — the loader
465    /// calls it for every load, and the FFI component's `session.format`
466    /// calls it over the held entries (#1766) — so the sampling rules
467    /// below stay in one place.
468    ///
469    /// Four stages, in precedence order (later wins):
470    /// 1. Scan every directive's amounts to infer per-currency dp
471    ///    distributions (posting units, cost specs, price annotations,
472    ///    balance amounts + tolerances, price directives).
473    /// 2. Apply `fixed_precisions` (from `option "display_precision"`).
474    /// 3. Apply per-commodity `precision: N` metadata (issue #991), AFTER
475    ///    the options so a commodity-level declaration wins over the
476    ///    global option. Multi-declaration of the same currency is
477    ///    last-wins (matches typical option-stacking semantics). Invalid
478    ///    values are silently skipped here — `rustledger-validate`
479    ///    surfaces them as `InvalidPrecisionMetadata` warnings (E5003) so
480    ///    users see the problem without breaking loading.
481    ///
482    /// 4. Apply `render_commas`, the ledger-wide grouping flag, plus the
483    ///    per-commodity `render_commas: TRUE|FALSE` declarations picked up in
484    ///    the same walk as stage 3. Grouping resolves by the same tiers as
485    ///    precision — see [`Self::render_commas_for`].
486    ///
487    /// The iterator must be cheaply cloneable (e.g. a slice iter or a
488    /// `map` over one) because the directives are walked twice (amount
489    /// scan, then commodity metadata).
490    ///
491    /// `render_commas` is a PARAMETER rather than something callers apply
492    /// afterwards with [`Self::set_render_commas`]. It used to be the latter,
493    /// and both production callers carried their own copy of
494    /// `from_directives(..)` + `set_render_commas(..)`; deleting the second
495    /// line from the FFI copy passed the entire test suite. Requiring it here
496    /// makes that omission a compile error (#1902 Phase 2).
497    ///
498    /// [`Self::set_render_commas`] remains for contexts built some other way —
499    /// a derived or per-column context, which is not a ledger load.
500    pub fn from_directives<'a, I>(
501        directives: I,
502        fixed_precisions: impl IntoIterator<Item = (&'a str, u32)>,
503        render_commas: bool,
504    ) -> Self
505    where
506        I: IntoIterator<Item = &'a Directive>,
507        I::IntoIter: Clone,
508    {
509        let directives = directives.into_iter();
510        let mut ctx = Self::new();
511
512        // Stage 1: scan directives for amounts to infer precision.
513        for directive in directives.clone() {
514            match directive {
515                Directive::Transaction(txn) => {
516                    for posting in &txn.postings {
517                        // Units (IncompleteAmount)
518                        if let Some(ref units) = posting.units
519                            && let (Some(number), Some(currency)) =
520                                (units.number(), units.currency())
521                        {
522                            ctx.update(number, currency);
523                        }
524                        // Cost (CostSpec) — feed the user-written amount to
525                        // the display-context inference. Prefer `total()`
526                        // over `per_unit()` so that for `PerUnitFromTotal`
527                        // we sample the user's literal `{{ total }}` rather
528                        // than the booker-derived per-unit (which has been
529                        // divided by |units| and typically carries far more
530                        // trailing precision than the source spec).
531                        if let Some(ref cost) = posting.cost
532                            && let (Some(number), Some(currency)) = (
533                                cost.number.map(|cn| {
534                                    cn.total().or_else(|| cn.per_unit()).unwrap_or_default()
535                                }),
536                                &cost.currency,
537                            )
538                        {
539                            ctx.update(number, currency.as_str());
540                        }
541                        // Price annotations: included so the per-currency dist
542                        // sees them, matching Python beancount's DisplayContext
543                        // population. With the default `Precision::MostCommon`
544                        // policy (introduced for bean-query parity), high-
545                        // precision computed exchange rates are naturally
546                        // ignored by the mode — they're a small minority next
547                        // to mainstream postings. Pre-fix (under MAX policy)
548                        // they were excluded to avoid inflating display
549                        // precision; that exclusion is no longer needed.
550                        if let Some(ref price) = posting.price
551                            && let Some(amount) = price.amount()
552                        {
553                            ctx.update(amount.number, amount.currency.as_str());
554                        }
555                    }
556                }
557                Directive::Balance(bal) => {
558                    ctx.update(bal.amount.number, bal.amount.currency.as_str());
559                    if let Some(tol) = bal.tolerance {
560                        ctx.update(tol, bal.amount.currency.as_str());
561                    }
562                }
563                Directive::Price(p) => {
564                    // Same rationale as posting price annotations above —
565                    // included now that MostCommon is the default. The single
566                    // 28dp computed-rate price won't shift the mode for a
567                    // currency with hundreds of mainstream postings.
568                    ctx.update(p.amount.number, p.amount.currency.as_str());
569                }
570                // A `custom` directive can carry amounts (Fava's
571                // `custom "budget" Expenses:Food "monthly" 400.00 USD`), but they
572                // deliberately do NOT inform display precision.
573                //
574                // They were tried as a source and are not one: the decimal count
575                // a user writes in a budget line is a stylistic choice about the
576                // DECLARATION, while the figure a budget report prints is
577                // pro-rated and a repeating decimal by construction. Taking the
578                // declared scale rounded `0.5 BTC` accrued over 14/31 of a month
579                // to `0.2` against a true 0.22580645 — 12% low — and taking an
580                // integer `1 BTC` pinned the currency to 0 dp. A consumer that
581                // needs to render a currency this context has never seen should
582                // choose its own precision (the budget report rounds and
583                // normalizes), rather than inferring one from metadata.
584                Directive::Custom(_)
585                | Directive::Pad(_)
586                | Directive::Open(_)
587                | Directive::Close(_)
588                | Directive::Commodity(_)
589                | Directive::Event(_)
590                | Directive::Query(_)
591                | Directive::Note(_)
592                | Directive::Document(_) => {}
593            }
594        }
595
596        // Stage 2: fixed precisions from options (override inferred values).
597        for (currency, precision) in fixed_precisions {
598            ctx.set_fixed_precision(currency, precision);
599        }
600
601        // Stage 2b: the ledger-wide grouping flag.
602        //
603        // A PARAMETER rather than a post-construction setter on purpose. Both
604        // production callers — the loader's `build_display_context` and the FFI
605        // component's `session.format` — used to call `set_render_commas`
606        // immediately after this, as two independent copies of the same
607        // six-line recipe with nothing asserting they agreed. Deleting the call
608        // from the FFI copy passed the entire workspace test suite. Threading it
609        // through the signature makes forgetting it a compile error instead.
610        ctx.set_render_commas(render_commas);
611
612        // Stage 3: per-commodity `precision: N` metadata (see doc above).
613        for directive in directives {
614            if let Directive::Commodity(comm) = directive
615                && let Some(value) = comm.meta.get("precision")
616                && let Ok(precision) = crate::parse_precision_meta(value)
617            {
618                ctx.set_fixed_precision(comm.currency.as_str(), precision);
619            }
620            // Same tier, same walk: per-commodity `render_commas: TRUE|FALSE`.
621            // Deliberately the same mechanism as `precision:` — beancount
622            // ignores metadata keys it does not know, so a ledger carrying this
623            // still round-trips through beancount and fava untouched.
624            if let Directive::Commodity(comm) = directive
625                && let Some(value) = comm.meta.get("render_commas")
626                && let Some(render) = crate::meta_value_as_bool(value)
627            {
628                ctx.set_render_commas_for(comm.currency.as_str(), render);
629            }
630        }
631
632        ctx
633    }
634
635    /// Get the precision for a currency.
636    ///
637    /// Returns the fixed precision if set; otherwise looks up the inferred
638    /// precision under the active [`Precision`] policy
639    /// ([`MostCommon`](Precision::MostCommon) by default — the mode of the
640    /// observed distribution; or [`Maximum`](Precision::Maximum) — the highest
641    /// observed dp). Returns `None` if the currency has never been seen.
642    #[must_use]
643    pub fn get_precision(&self, currency: &str) -> Option<u32> {
644        if let Some(&precision) = self.fixed_precisions.get(currency) {
645            return Some(precision);
646        }
647        let dist = self.distributions.get(currency)?;
648        match self.precision {
649            Precision::MostCommon => dist.mode(),
650            Precision::Maximum => dist.max(),
651        }
652    }
653
654    /// Get the default precision used when formatting a Decimal that has no
655    /// associated currency (e.g. the result of `SUM(number)` in BQL).
656    ///
657    /// Resolution order (matches the BQL renderer's expectations after
658    /// PR #986):
659    ///
660    /// 1. **`__default__` bucket** — if any naked-decimal observations have
661    ///    been recorded via `update(n, DEFAULT_CURRENCY)`, the bucket's
662    ///    effective precision wins. This is what BQL populates for
663    ///    `Value::Number` columns (matches Python `bean-query`'s per-column
664    ///    `DecimalRenderer`).
665    /// 2. **Max effective precision across every other currency** — fallback
666    ///    when no naked-decimal observations exist. Covers issue #954: a
667    ///    column of `Value::Number(0)` that came from an aggregate
668    ///    collapsing to literal zero still renders with the column's
669    ///    expected dp (e.g. `0.00` for a USD-only file).
670    /// 3. **Returns 0** if no currencies have been recorded at all.
671    ///
672    /// "Effective" precision means per-currency `fixed` overrides `inferred`
673    /// (same rule as [`Self::get_precision`]) and respects the active
674    /// [`Precision`] policy, so a fixed `display_precision` of 2 for USD
675    /// won't be overridden by an inferred 4-digit value.
676    #[must_use]
677    pub fn default_precision(&self) -> u32 {
678        // Prefer the `__default__` bucket if it has samples — this is what
679        // BQL renderers populate for naked-Decimal columns (`Value::Number`
680        // results from `SUM(number)`, `cost_number`, etc.). Matches Python
681        // `bean-query`'s `DecimalRenderer`, which tracks per-column dp
682        // independently of the per-currency dctx.
683        if let Some(dp) = self.get_precision(DEFAULT_CURRENCY) {
684            return dp;
685        }
686
687        // Fall back to max-of-effective-precisions across all known
688        // currencies. Used when no explicit naked-decimal observations
689        // were made (e.g. a query that returns aggregates with implicit
690        // 0 results — issue #954). `get_precision` handles fixed-vs-
691        // inferred priority and respects the active `Precision` policy.
692        let mut max_dp: u32 = 0;
693        let mut seen: HashSet<&str> = HashSet::new();
694        for currency in self
695            .fixed_precisions
696            .keys()
697            .chain(self.distributions.keys())
698            .map(String::as_str)
699        {
700            if seen.insert(currency)
701                && currency != DEFAULT_CURRENCY
702                && let Some(dp) = self.get_precision(currency)
703            {
704                max_dp = max_dp.max(dp);
705            }
706        }
707        max_dp
708    }
709
710    /// Quantize a number to the tracked precision for a currency.
711    ///
712    /// Mirrors Python's `Decimal.quantize`: the result has *exactly* the
713    /// target scale — rounding when the input has more dp, padding with
714    /// trailing zeros when the input has fewer. This matches what
715    /// `bean-query`'s `AmountRenderer` does: it quantizes via the ledger
716    /// dctx before populating the column dctx, so the column dctx sees
717    /// uniformly-padded values.
718    ///
719    /// If the currency has no tracked precision, returns the number
720    /// unchanged.
721    ///
722    /// Pre-fix this used `round_dp(dp)`, which only ROUNDS down — it
723    /// never PADS up. That meant a 2dp input under a 4dp target stayed
724    /// 2dp, the column dctx saw dp=2, and the output rendered 2dp instead
725    /// of bean-query's 4dp.
726    #[must_use]
727    pub fn quantize(&self, number: Decimal, currency: &str) -> Decimal {
728        if let Some(dp) = self.get_precision(currency) {
729            // `round_dp_python` pads to exactly `dp` (round_dp only ever
730            // reduces the scale) AND keeps the sign when a small negative
731            // rounds away — `-0.00495` at 2dp is `-0.00`, as bean-query
732            // renders it, not the unsigned `0.00` that `round_dp` returns.
733            // That sign is the only thing left telling the reader the balance
734            // is negative rather than flat.
735            crate::decimal::round_dp_python(number, dp)
736        } else {
737            number
738        }
739    }
740
741    /// Format a decimal number for a currency using the tracked precision.
742    ///
743    /// Render rules (matching bean-query's `AmountRenderer.format`):
744    /// - If the value's intrinsic scale exceeds the currency's tracked
745    ///   precision, render at the value's scale. Python's `decimal`
746    ///   carries scale through arithmetic and bean-query preserves it,
747    ///   so a `SUM(number) GROUP BY currency` that aggregates a
748    ///   `-805.50896` row and a `-396.50000` row renders as
749    ///   `-1202.00896` (scale=5), not `-1202.01` (rounded to USD's 2dp).
750    /// - If the value's scale is less than the tracked precision, pad
751    ///   with trailing zeros (`7.5 USD` → `7.50`). Preserves the
752    ///   #954 fix that stops `SUM(0.00) = 0` rendering as plain `0`.
753    /// - If the currency has no tracked precision, fall through to the
754    ///   value's natural rendering with trailing zeros stripped.
755    ///
756    /// The previous implementation always quantized to the tracked
757    /// precision via `round_dp(dp)`. That was correct for under-scale
758    /// padding but wrong for over-scale truncation — it lost
759    /// arithmetic precision that bean-query preserved (closes #1103).
760    #[must_use]
761    pub fn format(&self, number: Decimal, currency: &str) -> String {
762        let precision = self.get_precision(currency);
763
764        if let Some(dp) = precision {
765            // Render at max(value_scale, tracked_dp). When value_scale
766            // already meets or exceeds dp, `round_dp` is a no-op (it only
767            // rounds when scale > target). When value_scale is shorter,
768            // `ensure_decimal_places` pads to dp. So this branch covers
769            // both "preserve high precision" and "pad short precision"
770            // without losing either.
771            let effective_dp = number.scale().max(dp);
772            let rounded = number.round_dp(effective_dp);
773            let formatted = format!("{rounded}");
774            let formatted = Self::ensure_decimal_places(&formatted, effective_dp);
775            if self.render_commas_for(currency) {
776                Self::add_commas(&formatted)
777            } else {
778                formatted
779            }
780        } else {
781            // No tracked precision - use natural formatting
782            let formatted = number.normalize().to_string();
783            if self.render_commas_for(currency) {
784                Self::add_commas(&formatted)
785            } else {
786                formatted
787            }
788        }
789    }
790
791    /// Ledger-text variant of [`Self::format`] (#1766): pads a TRACKED
792    /// currency's value to the tracked precision (never rounding an
793    /// over-precise value away), and returns an UNTRACKED currency's
794    /// value at its own scale, byte-faithful — no `normalize()`
795    /// trailing-zero stripping, which would silently widen a balance
796    /// assertion's implicit tolerance. Never emits thousands
797    /// separators regardless of `render_commas`: canonical ledger text
798    /// carries none (separators stay a report/query display concern).
799    #[must_use]
800    pub fn format_plain(&self, number: Decimal, currency: &str) -> String {
801        match self.get_precision(currency) {
802            Some(dp) => {
803                let effective_dp = number.scale().max(dp);
804                let rounded = number.round_dp(effective_dp);
805                let formatted = format!("{rounded}");
806                Self::ensure_decimal_places(&formatted, effective_dp)
807            }
808            None => number.to_string(),
809        }
810    }
811
812    /// Format an amount (number + currency) using the tracked precision.
813    ///
814    /// Unlike [`Self::format`] (which preserves over-scale arithmetic
815    /// precision to match Python `bean-query`'s `DecimalRenderer` for
816    /// scalar `Value::Number` results), this method always *quantizes* to
817    /// the currency's tracked dp — matching bean-query's `AmountRenderer`
818    /// for Amounts, Positions, and Inventory entries.
819    ///
820    /// Python uses two distinct renderers for the two semantic kinds of
821    /// output:
822    ///
823    /// - `DecimalRenderer` for naked decimals (preserves scale, since
824    ///   Python `decimal` carries scale through arithmetic).
825    /// - `AmountRenderer` for amount-typed values (uses the ledger's
826    ///   display context per-currency dp, which is the user-facing
827    ///   "how many decimal places does this currency render at" setting).
828    ///
829    /// Rust used to conflate the two through a single `format` call,
830    /// which is why #1103's fix (preserving scale in `format`) inadvertently
831    /// regressed the BQL compat suite by ~7pp on queries that produce
832    /// `Value::Inventory` — the position amounts inside the inventory now
833    /// render with raw arithmetic scale instead of the currency's display
834    /// dp. See #1112 for the regression analysis.
835    #[must_use]
836    pub fn format_amount(&self, number: Decimal, currency: &str) -> String {
837        format!("{} {}", self.format_quantized(number, currency), currency)
838    }
839
840    /// Format the number portion of an Amount/Position (no currency
841    /// suffix), quantized to the tracked dp.
842    ///
843    /// Used by the BQL `numberify` rendering path that strips the
844    /// currency from positions/inventories — same semantics as
845    /// [`Self::format_amount`] but without the trailing ` <CURRENCY>`.
846    #[must_use]
847    pub fn format_amount_number(&self, number: Decimal, currency: &str) -> String {
848        self.format_quantized(number, currency)
849    }
850
851    /// Internal: quantize `number` to `currency`'s tracked dp (rounding
852    /// and padding) and stringify. Falls back to natural representation
853    /// when the currency is untracked.
854    fn format_quantized(&self, number: Decimal, currency: &str) -> String {
855        let raw = match self.get_precision(currency) {
856            // Same rounding as [`Self::quantize`], via the shared
857            // `round_dp_python` — this used to be an inline copy of the
858            // round_dp+rescale pair, and the copy is what made the sign fix
859            // for `-0.00495 -> -0.00` land on `quantize` while every rendered
860            // Amount kept going through this one unchanged.
861            Some(dp) => crate::decimal::round_dp_python(number, dp).to_string(),
862            None => number.normalize().to_string(),
863        };
864        if self.render_commas_for(currency) {
865            Self::add_commas(&raw)
866        } else {
867            raw
868        }
869    }
870
871    /// Format a Decimal that has no associated currency.
872    ///
873    /// Used by the BQL query renderer for `Value::Number` results —
874    /// bare Decimals produced by aggregates like `SUM(number)` or
875    /// columns like `cost_number`.
876    ///
877    /// Matches Python `bean-query`'s `DecimalRenderer.format`, which
878    /// uses the value's *natural* string representation (preserving the
879    /// scale baked into the Decimal) without imposing uniform precision
880    /// across rows. So `Value::Number(Decimal('0.00'))` renders `0.00`
881    /// (scale survives — covers issue #954) while `Value::Number(0)`
882    /// renders `0` (no artificial padding).
883    ///
884    /// When the value has scale 0 (no fractional part) but the context
885    /// has a `__default__`-bucket precision, we DO pad up to that
886    /// precision — this is the issue #954 path: an aggregate that
887    /// collapsed to literal zero (scale lost) still gets rendered with
888    /// the column's expected dp.
889    #[must_use]
890    pub fn format_default(&self, number: Decimal) -> String {
891        // Match Python `bean-query`'s `DecimalRenderer.format`: render
892        // each value at its intrinsic scale. No padding to a "column
893        // default precision" — that branch was added as a fix for
894        // #954 ("`SUM(0.00 + -0.00)` rendered as `0` instead of
895        // `0.00`"), but the real bug there was `n.normalize()` stripping
896        // the SUM result's scale to 0 *before* rendering. Once that
897        // normalize was removed, scale-2 SUMs naturally render as
898        // `0.00` via `to_string()` without any padding step. The padding
899        // overfit covered up the symptom but caused two new shapes of
900        // divergence:
901        //
902        // 1. Mixed-scale columns where a scale-0 cell renders next to
903        //    a scale-25 cell get the scale-0 value padded to 25dp
904        //    (`1000` → `1000.0000000000000000000000000`). Bean-query
905        //    renders the scale-0 cell as `1000`.
906        // 2. Literal `Decimal(0)` values rendered as `0.00` instead of
907        //    `0` even when no SUM aggregator was involved. Bean-query
908        //    renders `Decimal(0)` as `0`.
909        //
910        // Cap total significant digits at 28 to match Python's default
911        // `Decimal` context precision (`getcontext().prec`). rust_decimal's
912        // 96-bit mantissa can land at 29 sig figs from some divisions
913        // (e.g. `300 / 1.763 = 170.16449…` with 26 fractional + 3 integer
914        // = 29 digits, where Python clamps the same division at 25
915        // fractional digits = 28 total).
916        const PYTHON_DECIMAL_PRECISION: u32 = 28;
917        let capped = Self::cap_significant_digits(number, PYTHON_DECIMAL_PRECISION);
918        let formatted = Self::to_scientific_string(capped);
919        // No thousands separators on the exponential form. `add_commas`
920        // groups from the right of the integer part, and an exponent has no
921        // decimal point to shield it — `0E-14` came back `0E,-14` and `1E-7`
922        // came back `1,E-7`. A scientific mantissa is one digit before the
923        // point by construction, so there is never a group to insert anyway.
924        // Copilot's catch on #2053.
925        if self.render_commas && !formatted.contains('E') {
926            Self::add_commas(&formatted)
927        } else {
928            formatted
929        }
930    }
931
932    /// Render a `Decimal` the way Python's `Decimal.__str__` does.
933    ///
934    /// The spec's `to-scientific-string` switches to exponential notation
935    /// when the ADJUSTED exponent is below -6, and uses plain notation
936    /// otherwise:
937    ///
938    /// ```text
939    ///   0.000001   adj  -6  ->  0.000001
940    ///   0.0000001  adj  -7  ->  1E-7
941    ///   0E-14      adj -14  ->  0E-14
942    /// ```
943    ///
944    /// `rust_decimal`'s `Display` is always plain, so a naked Decimal column
945    /// diverged from bean-query on anything that far below zero: a zero at
946    /// scale 14 rendered `0.00000000000000` against bean-query's `0E-14`.
947    ///
948    /// The adjusted exponent is `-scale + digits - 1`, and Python counts a
949    /// zero coefficient as one digit — which is why a plain `0` (scale 0)
950    /// stays `0` while `0E-14` goes exponential.
951    ///
952    /// Only the naked-Decimal path uses this. Amount cells keep their
953    /// per-currency rendering, which bean-query also prints plainly.
954    fn to_scientific_string(number: Decimal) -> String {
955        let scale = i64::from(number.scale());
956        let mantissa = number.mantissa().unsigned_abs();
957        let digits = if mantissa == 0 {
958            1
959        } else {
960            i64::from(mantissa.ilog10()) + 1
961        };
962        // Spec: `adjusted = exponent + digits - 1`, with `exponent = -scale`.
963        let adjusted = digits - scale - 1;
964        if adjusted > -7 {
965            return number.to_string();
966        }
967
968        let sign = if number.is_sign_negative() { "-" } else { "" };
969        if mantissa == 0 {
970            // Python prints a zero's exponent as its own exponent.
971            return format!("{sign}0E-{scale}");
972        }
973
974        // Strip trailing zeros into the exponent, then place the decimal
975        // point after the first significant digit.
976        let all = mantissa.to_string();
977        let significant = all.trim_end_matches('0');
978        let trailing = (all.len() - significant.len()) as i64;
979        let exponent = -scale + trailing + (significant.len() as i64 - 1);
980        let coefficient = if significant.len() == 1 {
981            significant.to_string()
982        } else {
983            format!("{}.{}", &significant[..1], &significant[1..])
984        };
985        let exp_sign = if exponent < 0 { "-" } else { "+" };
986        format!("{sign}{coefficient}E{exp_sign}{}", exponent.abs())
987    }
988
989    /// Round `number` to at most `max_sig` significant digits, matching
990    /// Python's `Decimal` context-precision-clamped arithmetic. No-op
991    /// when the value already fits; otherwise rounds half-even (Python's
992    /// `Decimal` default rounding mode).
993    ///
994    /// Handles both fractional and integer-only excess:
995    ///
996    /// - Fractional case (`new_scale > 0`): rounds via
997    ///   [`Decimal::round_dp_with_strategy`] which truncates trailing
998    ///   fractional digits.
999    /// - Integer-only case (`number.scale() < digits - max_sig`):
1000    ///   `round_dp_with_strategy(0, …)` would leave the over-precise
1001    ///   integer unchanged, since it can't go to negative scales. We
1002    ///   scale by a power of ten, round to nearest integer, then
1003    ///   restore the magnitude — same as Python's clamp on a 29-digit
1004    ///   integer, which puts it in scientific form with a 28-digit
1005    ///   mantissa. Caught by Copilot review on PR #1064.
1006    fn cap_significant_digits(number: Decimal, max_sig: u32) -> Decimal {
1007        // mantissa() returns the integer mantissa; its decimal length is
1008        // the number of significant digits regardless of scale. Zero has
1009        // zero significant digits by this convention — `ilog10` returns
1010        // `None` and we fall through to the early-return below.
1011        let mantissa_abs = number.mantissa().unsigned_abs();
1012        let digits = mantissa_abs.checked_ilog10().map_or(0, |x| x + 1);
1013        if digits <= max_sig {
1014            return number;
1015        }
1016        let excess = digits - max_sig;
1017        if excess <= number.scale() {
1018            // Trimming only affects fractional digits — use the standard
1019            // dp-based rounding directly.
1020            return number.round_dp_with_strategy(
1021                number.scale() - excess,
1022                rust_decimal::RoundingStrategy::MidpointNearestEven,
1023            );
1024        }
1025        // Excess exceeds the available fractional digits: we have to
1026        // round integer-portion digits, which `round_dp_with_strategy`
1027        // can't express (it doesn't support negative dp). Lift by a
1028        // power of 10, round to nearest integer, drop back.
1029        // `integer_excess` is always >= 1 here.
1030        let integer_excess = excess - number.scale();
1031        let Some(factor) = Decimal::TEN.checked_powu(u64::from(integer_excess)) else {
1032            // `10^integer_excess` overflows when `integer_excess` is
1033            // implausibly large (>28). The input must have been an
1034            // already-overflowed Decimal; bail out with the original
1035            // value rather than panicking.
1036            return number;
1037        };
1038        let lifted = number / factor;
1039        let rounded =
1040            lifted.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointNearestEven);
1041        rounded * factor
1042    }
1043
1044    /// Get the decimal precision (number of digits after decimal point) of a number.
1045    const fn decimal_precision(number: Decimal) -> u32 {
1046        // scale() returns the number of decimal digits
1047        number.scale()
1048    }
1049
1050    /// Ensure a formatted number has exactly `dp` decimal places.
1051    /// Adds trailing zeros if needed, or adds ".00..." if no decimal point.
1052    fn ensure_decimal_places(s: &str, dp: u32) -> String {
1053        if dp == 0 {
1054            // No decimal places needed - remove any decimal point
1055            return s.split('.').next().unwrap_or(s).to_string();
1056        }
1057
1058        let dp = dp as usize;
1059        if let Some(dot_pos) = s.find('.') {
1060            let current_decimals = s.len() - dot_pos - 1;
1061            if current_decimals >= dp {
1062                // Already has enough or more decimals
1063                s.to_string()
1064            } else {
1065                // Need to add trailing zeros
1066                let zeros_needed = dp - current_decimals;
1067                format!("{s}{}", "0".repeat(zeros_needed))
1068            }
1069        } else {
1070            // No decimal point - add one with zeros
1071            format!("{s}.{}", "0".repeat(dp))
1072        }
1073    }
1074
1075    /// Add thousand separators (commas) to a formatted number string.
1076    fn add_commas(s: &str) -> String {
1077        // Split on decimal point
1078        let (integer_part, decimal_part) = match s.find('.') {
1079            Some(pos) => (&s[..pos], Some(&s[pos..])),
1080            None => (s, None),
1081        };
1082
1083        // Handle negative sign
1084        let (sign, digits) = if let Some(stripped) = integer_part.strip_prefix('-') {
1085            ("-", stripped)
1086        } else {
1087            ("", integer_part)
1088        };
1089
1090        // Add commas to integer part (from right to left)
1091        let mut result = String::with_capacity(digits.len() + digits.len() / 3);
1092        for (i, c) in digits.chars().rev().enumerate() {
1093            if i > 0 && i % 3 == 0 {
1094                result.push(',');
1095            }
1096            result.push(c);
1097        }
1098        let integer_with_commas: String = result.chars().rev().collect();
1099
1100        // Combine parts
1101        match decimal_part {
1102            Some(dec) => format!("{sign}{integer_with_commas}{dec}"),
1103            None => format!("{sign}{integer_with_commas}"),
1104        }
1105    }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use rust_decimal_macros::dec;
1112
1113    #[test]
1114    fn test_update_and_get_precision_most_common_default() {
1115        // Default policy is MostCommon (matches Python bean-query). With
1116        // 2 integer-valued samples and 1 fractional, the mode is 0dp.
1117        let mut ctx = DisplayContext::new();
1118
1119        ctx.update(dec!(100), "USD");
1120        assert_eq!(ctx.get_precision("USD"), Some(0));
1121
1122        // Tied at 1×0dp + 1×2dp → tie-break favors larger dp = 2.
1123        ctx.update(dec!(50.25), "USD");
1124        assert_eq!(ctx.get_precision("USD"), Some(2));
1125
1126        // Now 2×0dp + 1×2dp → mode is 0dp (most common).
1127        ctx.update(dec!(1), "USD");
1128        assert_eq!(ctx.get_precision("USD"), Some(0));
1129
1130        // Unknown currency
1131        assert_eq!(ctx.get_precision("EUR"), None);
1132    }
1133
1134    #[test]
1135    fn test_update_and_get_precision_maximum_policy() {
1136        // Same samples as the MostCommon test, but with Maximum policy:
1137        // the highest dp ever observed wins — preserves the historical
1138        // behavior for callers that opt in.
1139        let mut ctx = DisplayContext::new();
1140        ctx.set_precision(Precision::Maximum);
1141
1142        ctx.update(dec!(100), "USD");
1143        assert_eq!(ctx.get_precision("USD"), Some(0));
1144
1145        ctx.update(dec!(50.25), "USD");
1146        assert_eq!(ctx.get_precision("USD"), Some(2));
1147
1148        // Adding more 0dp samples doesn't lower the max.
1149        ctx.update(dec!(1), "USD");
1150        assert_eq!(ctx.get_precision("USD"), Some(2));
1151    }
1152
1153    #[test]
1154    fn test_default_precision_prefers_default_bucket_over_max_of_modes() {
1155        // When BQL renders a naked-Decimal column, it observes the column's
1156        // actual values into the `__default__` bucket (matching Python
1157        // bean-query's per-column DecimalRenderer). default_precision must
1158        // prefer that bucket over the max-of-modes across other currencies
1159        // — otherwise an unrelated currency with a higher mode (e.g. VBMPX
1160        // at 3dp from `3.149 VBMPX` postings) would inflate the precision
1161        // of a USD `cost_number` column.
1162        let mut ctx = DisplayContext::new();
1163        // Ledger context: USD has mode 2, VBMPX has mode 3.
1164        for _ in 0..5 {
1165            ctx.update(dec!(1.23), "USD");
1166        }
1167        for _ in 0..5 {
1168            ctx.update(dec!(1.234), "VBMPX");
1169        }
1170        // Without naked-decimal observations, default_precision falls
1171        // back to max-of-modes = 3 (VBMPX wins).
1172        assert_eq!(ctx.default_precision(), 3);
1173        // After observing two 2dp values into __default__, that bucket's
1174        // mode (2) takes precedence regardless of VBMPX.
1175        ctx.update(dec!(128.99), DEFAULT_CURRENCY);
1176        ctx.update(dec!(131.73), DEFAULT_CURRENCY);
1177        assert_eq!(ctx.default_precision(), 2);
1178    }
1179
1180    #[test]
1181    fn test_format_default_integer_column_stays_integer() {
1182        // A naked-decimal column where every observed value has scale 0
1183        // (e.g. an integer count column from a query like
1184        // `SELECT account, SUM(units) WHERE units > 0`) should render
1185        // each value as an integer, NOT pad to some fractional precision
1186        // borrowed from an unrelated currency.
1187        //
1188        // Even though USD has 2dp inferred, the __default__ bucket's
1189        // mode is 0, so format_default returns the value's natural
1190        // string ("100", "5", etc.) — the scale==0 padding branch only
1191        // fires when the resolved default_precision > 0. Here dp = 0
1192        // so no padding.
1193        let mut ctx = DisplayContext::new();
1194        ctx.update(dec!(1.23), "USD"); // ledger USD has 2dp
1195        // Column observes integer values into __default__:
1196        for n in [dec!(100), dec!(5), dec!(42)] {
1197            ctx.update(n, DEFAULT_CURRENCY);
1198        }
1199        // __default__ mode is 0 → no padding, natural rendering.
1200        assert_eq!(ctx.format_default(dec!(100)), "100");
1201        assert_eq!(ctx.format_default(dec!(5)), "5");
1202        // A fractional value still prints at its natural scale (matches
1203        // Python `DecimalRenderer` per-row formatting).
1204        assert_eq!(ctx.format_default(dec!(7.5)), "7.5");
1205    }
1206
1207    #[test]
1208    fn test_default_precision_falls_back_when_default_bucket_empty() {
1209        // Issue #954: a column of `Value::Number(0)` (e.g. SUM that
1210        // collapsed to zero) has no naked-decimal observations to
1211        // populate __default__. default_precision falls back to the
1212        // max-of-modes so we still render `0.00` instead of `0`.
1213        let mut ctx = DisplayContext::new();
1214        for _ in 0..5 {
1215            ctx.update(dec!(1.23), "USD");
1216        }
1217        // No __default__ observations.
1218        assert_eq!(ctx.default_precision(), 2);
1219    }
1220
1221    // ===== Diagnostic-API tests (currencies / histogram / precision_under) =====
1222
1223    #[test]
1224    fn test_currencies_skips_default_sentinel() {
1225        let mut ctx = DisplayContext::new();
1226        ctx.update(dec!(1.23), "USD");
1227        ctx.update(dec!(0.5), "EUR");
1228        ctx.update(dec!(100), DEFAULT_CURRENCY); // sentinel — must be hidden
1229        let cs: Vec<&str> = ctx.currencies().collect();
1230        assert_eq!(cs, vec!["EUR", "USD"]); // sorted, no __default__
1231    }
1232
1233    #[test]
1234    fn test_currencies_includes_fixed_only_currencies() {
1235        let mut ctx = DisplayContext::new();
1236        // Only a fixed override, no observed samples.
1237        ctx.set_fixed_precision("BTC", 8);
1238        let cs: Vec<&str> = ctx.currencies().collect();
1239        assert_eq!(cs, vec!["BTC"]);
1240    }
1241
1242    #[test]
1243    fn test_histogram_returns_ascending_pairs() {
1244        let mut ctx = DisplayContext::new();
1245        for _ in 0..5 {
1246            ctx.update(dec!(1.23), "USD"); // 2dp × 5
1247        }
1248        for _ in 0..2 {
1249            ctx.update(dec!(1.234), "USD"); // 3dp × 2
1250        }
1251        ctx.update(dec!(100), "USD"); // 0dp × 1
1252        let h = ctx.histogram("USD");
1253        // Ascending dp order, full counts preserved.
1254        assert_eq!(h, vec![(0, 1), (2, 5), (3, 2)]);
1255    }
1256
1257    #[test]
1258    fn test_histogram_empty_for_unknown_currency() {
1259        let ctx = DisplayContext::new();
1260        assert!(ctx.histogram("XYZ").is_empty());
1261    }
1262
1263    #[test]
1264    fn test_precision_under_does_not_mutate_active_policy() {
1265        let mut ctx = DisplayContext::new();
1266        for _ in 0..5 {
1267            ctx.update(dec!(100), "USD");
1268        }
1269        ctx.update(dec!(1.234), "USD");
1270        // Active policy is MostCommon; mode = 0.
1271        assert_eq!(ctx.get_precision("USD"), Some(0));
1272        // Querying under Maximum returns 3 — without changing active.
1273        assert_eq!(ctx.precision_under("USD", Precision::Maximum), Some(3));
1274        // Active policy unchanged after the introspection call.
1275        assert_eq!(ctx.precision(), Precision::MostCommon);
1276        assert_eq!(ctx.get_precision("USD"), Some(0));
1277    }
1278
1279    #[test]
1280    fn test_precision_under_returns_zero_when_fixed_is_zero() {
1281        // `set_fixed_precision(c, 0)` is a legitimate setting (forces a
1282        // currency to render as integer). Both policies must return Some(0)
1283        // — not None, not the inferred precision.
1284        let mut ctx = DisplayContext::new();
1285        ctx.update(dec!(1.234), "JPY"); // inferred mode = 3
1286        ctx.set_fixed_precision("JPY", 0); // user wants integer JPY
1287        assert_eq!(ctx.precision_under("JPY", Precision::MostCommon), Some(0));
1288        assert_eq!(ctx.precision_under("JPY", Precision::Maximum), Some(0));
1289        assert_eq!(ctx.get_precision("JPY"), Some(0));
1290    }
1291
1292    #[test]
1293    fn test_precision_under_respects_fixed_override() {
1294        let mut ctx = DisplayContext::new();
1295        ctx.update(dec!(1.234), "USD");
1296        ctx.set_fixed_precision("USD", 2);
1297        // Both policies see the fixed override, regardless.
1298        assert_eq!(ctx.precision_under("USD", Precision::MostCommon), Some(2));
1299        assert_eq!(ctx.precision_under("USD", Precision::Maximum), Some(2));
1300    }
1301
1302    #[test]
1303    fn test_has_fixed_precision() {
1304        let mut ctx = DisplayContext::new();
1305        ctx.update(dec!(1.23), "USD");
1306        assert!(!ctx.has_fixed_precision("USD"));
1307        ctx.set_fixed_precision("USD", 2);
1308        assert!(ctx.has_fixed_precision("USD"));
1309    }
1310
1311    #[test]
1312    fn test_quantize_pads_scale_upward() {
1313        // Pinned because `Decimal::round_dp(dp)` only rounds *down* — it
1314        // doesn't pad scale upward. Pre-fix, quantize(150.67, "USD") with
1315        // USD precision=4 returned 150.67 (scale 2), which broke the
1316        // bean-query parity for column-level dist tracking.
1317        let mut ctx = DisplayContext::new();
1318        for _ in 0..10 {
1319            ctx.update(dec!(0.0400), "USD"); // 10×4dp samples → mode=4
1320        }
1321        for _ in 0..3 {
1322            ctx.update(dec!(150.67), "USD"); // 3×2dp samples
1323        }
1324        // Mode is 4 (ten 4dp samples win).
1325        assert_eq!(ctx.get_precision("USD"), Some(4));
1326        // Quantize must produce a Decimal with scale exactly 4, not 2.
1327        let q = ctx.quantize(dec!(150.67), "USD");
1328        assert_eq!(q.scale(), 4);
1329        assert_eq!(q.to_string(), "150.6700");
1330    }
1331
1332    #[test]
1333    fn test_format_with_precision() {
1334        let mut ctx = DisplayContext::new();
1335        ctx.update(dec!(100), "USD");
1336        ctx.update(dec!(50.25), "USD");
1337
1338        // 1×0dp + 1×2dp → mode tie-breaks to the larger (2dp), so format
1339        // uses 2 fractional digits. (See test_mode_tie_break_favors_larger_dp.)
1340        assert_eq!(ctx.format(dec!(100), "USD"), "100.00");
1341        assert_eq!(ctx.format(dec!(50.25), "USD"), "50.25");
1342        assert_eq!(ctx.format(dec!(7.5), "USD"), "7.50");
1343    }
1344
1345    /// Issue #1103: when the value's intrinsic scale exceeds the
1346    /// currency's tracked precision, render at the value's scale
1347    /// rather than quantizing down. Matches bean-query: a
1348    /// `SUM(number)` over a fixture with high-precision arithmetic
1349    /// (cost-spec interpolation residuals, manual high-dp postings)
1350    /// produces a Decimal whose scale we MUST preserve to align with
1351    /// Python's `decimal` representation. The currency hint only ever
1352    /// PADS UP from a shorter scale; it never rounds DOWN from a
1353    /// longer one.
1354    #[test]
1355    fn test_format_preserves_value_scale_above_tracked_precision() {
1356        let mut ctx = DisplayContext::new();
1357        // USD tracked at 2dp (mode of two 2dp observations).
1358        ctx.update(dec!(100.00), "USD");
1359        ctx.update(dec!(50.25), "USD");
1360        assert_eq!(ctx.get_precision("USD"), Some(2));
1361
1362        // Value scale > tracked dp → preserve value scale (no round-down).
1363        assert_eq!(ctx.format(dec!(1.234), "USD"), "1.234");
1364        assert_eq!(ctx.format(dec!(-1202.00896), "USD"), "-1202.00896");
1365        assert_eq!(ctx.format(dec!(0.00000), "USD"), "0.00000");
1366
1367        // Value scale ≤ tracked dp → pad up (unchanged from #988 fix).
1368        assert_eq!(ctx.format(dec!(7.5), "USD"), "7.50");
1369        assert_eq!(ctx.format(dec!(0), "USD"), "0.00");
1370    }
1371
1372    /// Pins the post-#1112 fix: `format` and `format_amount` must NOT share
1373    /// rounding behavior.
1374    ///
1375    /// `format` (used for scalar `Value::Number`) preserves the Decimal's
1376    /// arithmetic scale — matches Python `DecimalRenderer`. `format_amount`
1377    /// (used for Amounts/Positions/Inventory) quantizes to the currency's
1378    /// tracked dp — matches Python `AmountRenderer`. Conflating them is
1379    /// what caused the 7pp BQL compat regression on main since #1106.
1380    #[test]
1381    fn test_format_vs_format_amount_split_semantics() {
1382        let mut ctx = DisplayContext::new();
1383        ctx.update(dec!(100.00), "USD");
1384        ctx.update(dec!(50.25), "USD");
1385        assert_eq!(ctx.get_precision("USD"), Some(2));
1386
1387        // `format`: scalar Number → preserve arithmetic scale (over and under).
1388        assert_eq!(ctx.format(dec!(-1202.00896), "USD"), "-1202.00896");
1389        assert_eq!(ctx.format(dec!(7.5), "USD"), "7.50");
1390
1391        // `format_amount`: Amount → quantize to tracked dp (over and under).
1392        assert_eq!(ctx.format_amount(dec!(-1202.00896), "USD"), "-1202.01 USD");
1393        assert_eq!(ctx.format_amount(dec!(7.5), "USD"), "7.50 USD");
1394        // Cost-spec interpolation can produce 26-digit per-unit values; the
1395        // Amount renderer must clamp those to the currency's display dp.
1396        assert_eq!(
1397            ctx.format_amount(dec!(170.16449234259784458309699376), "USD"),
1398            "170.16 USD"
1399        );
1400
1401        // `format_amount_number`: same quantize semantics, no currency suffix.
1402        assert_eq!(
1403            ctx.format_amount_number(dec!(-1202.00896), "USD"),
1404            "-1202.01"
1405        );
1406        assert_eq!(ctx.format_amount_number(dec!(7.5), "USD"), "7.50");
1407    }
1408
1409    /// Untracked currencies fall through to natural rendering in both
1410    /// `format` and `format_amount`. Trailing zeros are stripped because
1411    /// there's no display-precision target to pad against.
1412    #[test]
1413    fn test_format_amount_untracked_currency_uses_natural_scale() {
1414        let ctx = DisplayContext::new();
1415        // No prior `update` calls — get_precision("USD") returns None.
1416        assert_eq!(ctx.format_amount(dec!(170.164), "USD"), "170.164 USD");
1417        assert_eq!(ctx.format_amount(dec!(7.5), "USD"), "7.5 USD");
1418        assert_eq!(ctx.format_amount(dec!(100), "USD"), "100 USD");
1419    }
1420
1421    #[test]
1422    fn test_format_unknown_currency() {
1423        let ctx = DisplayContext::new();
1424
1425        // Unknown currency uses natural formatting
1426        assert_eq!(ctx.format(dec!(100), "EUR"), "100");
1427        assert_eq!(ctx.format(dec!(50.25), "EUR"), "50.25");
1428    }
1429
1430    #[test]
1431    fn test_fixed_precision_override() {
1432        let mut ctx = DisplayContext::new();
1433        ctx.update(dec!(100), "USD");
1434        ctx.update(dec!(50.25), "USD");
1435
1436        // Inferred precision is 2
1437        assert_eq!(ctx.get_precision("USD"), Some(2));
1438
1439        // Set fixed precision to 4
1440        ctx.set_fixed_precision("USD", 4);
1441        assert_eq!(ctx.get_precision("USD"), Some(4));
1442
1443        // Formatting uses fixed precision
1444        assert_eq!(ctx.format(dec!(100), "USD"), "100.0000");
1445    }
1446
1447    // ===== Precision policy tests =====
1448
1449    #[test]
1450    fn test_mode_picks_most_common_dp() {
1451        let mut ctx = DisplayContext::new();
1452        for _ in 0..5 {
1453            ctx.update(dec!(1.23), "USD"); // 2dp × 5
1454        }
1455        for _ in 0..2 {
1456            ctx.update(dec!(1.234), "USD"); // 3dp × 2
1457        }
1458        assert_eq!(ctx.get_precision("USD"), Some(2));
1459    }
1460
1461    #[test]
1462    fn test_mode_tie_break_favors_larger_dp() {
1463        // Pins Python's `Distribution.mode()` tie-break: when counts tie,
1464        // the LARGEST dp wins. Python iterates sorted-ascending with `>=`
1465        // (in beancount/core/distribution.py), keeping the last equal
1466        // entry. We match by iterating the BTreeMap ascending with `>=`.
1467        let mut ctx = DisplayContext::new();
1468        ctx.update(dec!(1.23), "USD"); // 2dp × 1
1469        ctx.update(dec!(1.234), "USD"); // 3dp × 1
1470        ctx.update(dec!(1.2345), "USD"); // 4dp × 1
1471        assert_eq!(ctx.get_precision("USD"), Some(4));
1472    }
1473
1474    #[test]
1475    fn test_mode_outlier_does_not_dominate() {
1476        // The bean-query parity case: 5x integer + 1x 28dp price annotation
1477        // → mode = 0dp, NOT 28. Pre-fix rledger returned 28 (the max);
1478        // post-fix returns 0 to match bean-query's MOST_COMMON default.
1479        let mut ctx = DisplayContext::new();
1480        for _ in 0..5 {
1481            ctx.update(dec!(100), "USD");
1482        }
1483        ctx.update(dec!(0.0000000000000000000000000001), "USD");
1484        assert_eq!(ctx.get_precision("USD"), Some(0));
1485    }
1486
1487    #[test]
1488    fn test_switching_to_maximum_returns_max() {
1489        let mut ctx = DisplayContext::new();
1490        for _ in 0..5 {
1491            ctx.update(dec!(100), "USD");
1492        }
1493        ctx.update(dec!(1.234567), "USD");
1494        // Default MostCommon: integer mode wins
1495        assert_eq!(ctx.get_precision("USD"), Some(0));
1496        // Switch policy to Maximum: the single 6dp sample wins
1497        ctx.set_precision(Precision::Maximum);
1498        assert_eq!(ctx.get_precision("USD"), Some(6));
1499        // Switch back: mode again
1500        ctx.set_precision(Precision::MostCommon);
1501        assert_eq!(ctx.get_precision("USD"), Some(0));
1502    }
1503
1504    #[test]
1505    fn test_fixed_precision_overrides_both_policies() {
1506        let mut ctx = DisplayContext::new();
1507        ctx.update(dec!(1.234), "USD");
1508        ctx.set_fixed_precision("USD", 2);
1509        assert_eq!(ctx.get_precision("USD"), Some(2));
1510        // Maximum policy still respects the fixed override
1511        ctx.set_precision(Precision::Maximum);
1512        assert_eq!(ctx.get_precision("USD"), Some(2));
1513    }
1514
1515    #[test]
1516    fn test_update_from_merges_distributions_not_just_max() {
1517        // Pre-fix: update_from took max(self.max, other.max) per currency,
1518        // collapsing distributions. Post-fix: merges histograms so the mode
1519        // reflects the union of frequencies. Without this, a column ctx
1520        // inheriting from a ledger ctx would only see the ledger's MAX
1521        // value, defeating the whole MostCommon design.
1522        let mut a = DisplayContext::new();
1523        for _ in 0..5 {
1524            a.update(dec!(1.23), "USD"); // 2dp × 5
1525        }
1526
1527        let mut b = DisplayContext::new();
1528        for _ in 0..10 {
1529            b.update(dec!(1.234), "USD"); // 3dp × 10
1530        }
1531
1532        a.update_from(&b);
1533        // After merge: 5×2dp + 10×3dp → mode = 3dp
1534        assert_eq!(a.get_precision("USD"), Some(3));
1535    }
1536
1537    #[test]
1538    fn test_update_from_is_not_idempotent_under_add_merge() {
1539        // Pin the semantics that triggered Copilot's review on PR #986:
1540        // since update_from now ADDS counts (not max-merges), calling it
1541        // multiple times multiplies the source's contribution. This is
1542        // why the BQL renderer must guard against repeated inheritance
1543        // per row (see crates/rustledger/src/cmd/query/output.rs).
1544        let mut src = DisplayContext::new();
1545        for _ in 0..10 {
1546            src.update(dec!(1.23), "USD"); // 2dp × 10
1547        }
1548
1549        let mut dst1 = DisplayContext::new();
1550        dst1.update_from(&src);
1551        // After 1 merge: 10×2dp.
1552        assert_eq!(dst1.histogram("USD"), vec![(2, 10)]);
1553
1554        let mut dst2 = DisplayContext::new();
1555        dst2.update_from(&src);
1556        dst2.update_from(&src);
1557        // After 2 merges: 20×2dp — counts compounded.
1558        assert_eq!(dst2.histogram("USD"), vec![(2, 20)]);
1559    }
1560
1561    #[test]
1562    fn test_update_from_does_not_propagate_precision_policy() {
1563        // Policy is a property of the consumer, not the data. A column ctx
1564        // that opted into Maximum shouldn't have its policy clobbered by
1565        // a ledger ctx that uses the MostCommon default.
1566        let mut ledger = DisplayContext::new();
1567        // ledger uses default MostCommon
1568        ledger.update(dec!(1.23), "USD");
1569
1570        let mut col = DisplayContext::new();
1571        col.set_precision(Precision::Maximum);
1572        col.update_from(&ledger);
1573
1574        assert_eq!(col.precision(), Precision::Maximum);
1575    }
1576
1577    #[test]
1578    fn test_render_commas() {
1579        let mut ctx = DisplayContext::new();
1580        ctx.set_render_commas(true);
1581        ctx.update(dec!(1234567.89), "USD");
1582
1583        assert_eq!(ctx.format(dec!(1234567.89), "USD"), "1,234,567.89");
1584        assert_eq!(ctx.format(dec!(1000), "USD"), "1,000.00");
1585    }
1586
1587    #[test]
1588    fn test_add_commas() {
1589        assert_eq!(DisplayContext::add_commas("1234567"), "1,234,567");
1590        assert_eq!(DisplayContext::add_commas("1234567.89"), "1,234,567.89");
1591        assert_eq!(DisplayContext::add_commas("-1234567.89"), "-1,234,567.89");
1592        assert_eq!(DisplayContext::add_commas("123"), "123");
1593        assert_eq!(DisplayContext::add_commas("1"), "1");
1594    }
1595
1596    #[test]
1597    fn test_update_from() {
1598        let mut ctx1 = DisplayContext::new();
1599        ctx1.update(dec!(100), "USD");
1600
1601        let mut ctx2 = DisplayContext::new();
1602        ctx2.update(dec!(50.25), "USD");
1603        ctx2.update(dec!(1.5), "EUR");
1604
1605        ctx1.update_from(&ctx2);
1606
1607        assert_eq!(ctx1.get_precision("USD"), Some(2));
1608        assert_eq!(ctx1.get_precision("EUR"), Some(1));
1609    }
1610
1611    #[test]
1612    fn test_update_from_propagates_fixed_precisions_and_render_commas() {
1613        // Copilot review on PR #961: previously update_from only merged
1614        // inferred precisions, so naked-decimal columns inheriting from a
1615        // ledger context with `option "display_precision"` would miss the
1616        // fixed overrides.
1617        let mut ledger = DisplayContext::new();
1618        ledger.update(dec!(1.234), "USD"); // inferred precision 3
1619        ledger.set_fixed_precision("USD", 2); // fixed override
1620        ledger.set_fixed_precision("BTC", 8);
1621        ledger.set_render_commas(true);
1622
1623        let mut col = DisplayContext::new();
1624        col.update_from(&ledger);
1625
1626        // Inferred precision distribution merged — under default
1627        // MostCommon policy, USD has only the single 3dp sample so
1628        // mode = 3.
1629        assert_eq!(
1630            col.distributions.get("USD").and_then(Distribution::mode),
1631            Some(3)
1632        );
1633        // Fixed overrides also propagated.
1634        assert_eq!(col.fixed_precisions.get("USD"), Some(&2));
1635        assert_eq!(col.fixed_precisions.get("BTC"), Some(&8));
1636        // get_precision still respects the fixed override.
1637        assert_eq!(col.get_precision("USD"), Some(2));
1638        assert_eq!(col.get_precision("BTC"), Some(8));
1639        // render_commas propagated.
1640        assert!(col.render_commas);
1641    }
1642
1643    #[test]
1644    fn test_update_from_preserves_self_fixed_overrides() {
1645        // If self already has a fixed override for a currency, update_from
1646        // shouldn't clobber it with the other's value. Self wins.
1647        let mut ledger = DisplayContext::new();
1648        ledger.set_fixed_precision("USD", 2);
1649
1650        let mut col = DisplayContext::new();
1651        col.set_fixed_precision("USD", 4); // self's override
1652        col.update_from(&ledger);
1653
1654        assert_eq!(col.fixed_precisions.get("USD"), Some(&4));
1655    }
1656
1657    #[test]
1658    fn test_default_precision_respects_fixed_override_lower_than_inferred() {
1659        // Copilot review on PR #961: if USD has inferred=4 but fixed=2,
1660        // the user said "render USD with 2 decimals" — default_precision
1661        // for naked Decimals must respect that, not fall back to the
1662        // inferred max (4).
1663        let mut ctx = DisplayContext::new();
1664        ctx.update(dec!(1.2345), "USD"); // inferred 4
1665        ctx.set_fixed_precision("USD", 2); // fixed override
1666
1667        // get_precision returns the effective precision (fixed wins).
1668        assert_eq!(ctx.get_precision("USD"), Some(2));
1669        // default_precision must use the same effective view, not raw max.
1670        assert_eq!(ctx.default_precision(), 2);
1671    }
1672
1673    #[test]
1674    fn test_default_precision_takes_max_across_currencies_with_overrides() {
1675        // EUR fixed=4 wins over USD fixed=2 → default = 4.
1676        let mut ctx = DisplayContext::new();
1677        ctx.set_fixed_precision("USD", 2);
1678        ctx.set_fixed_precision("EUR", 4);
1679
1680        assert_eq!(ctx.default_precision(), 4);
1681    }
1682
1683    #[test]
1684    fn test_format_amount() {
1685        let mut ctx = DisplayContext::new();
1686        ctx.update(dec!(50.25), "USD");
1687
1688        assert_eq!(ctx.format_amount(dec!(100), "USD"), "100.00 USD");
1689    }
1690
1691    #[test]
1692    fn test_default_precision_picks_max_across_currencies() {
1693        // Issue #954: bare Decimals (e.g. SUM(number) result) need a default
1694        // precision matching what bean-query uses — the max precision across
1695        // every known currency.
1696        let mut ctx = DisplayContext::new();
1697        ctx.update(dec!(1.23), "USD"); // precision 2
1698        ctx.update(dec!(1.2345), "EUR"); // precision 4
1699        ctx.update(dec!(0.5), "GBP"); // precision 1
1700
1701        assert_eq!(ctx.default_precision(), 4);
1702    }
1703
1704    #[test]
1705    fn test_default_precision_includes_fixed_overrides() {
1706        // Fixed precision (from `option "display_precision"`) should also
1707        // contribute to the max.
1708        let mut ctx = DisplayContext::new();
1709        ctx.update(dec!(1.23), "USD");
1710        ctx.set_fixed_precision("BTC", 8);
1711
1712        assert_eq!(ctx.default_precision(), 8);
1713    }
1714
1715    #[test]
1716    fn test_default_precision_empty_context_is_zero() {
1717        let ctx = DisplayContext::new();
1718        assert_eq!(ctx.default_precision(), 0);
1719    }
1720
1721    #[test]
1722    fn test_format_default_does_not_pad_scale_zero_to_column_precision() {
1723        // Inverted from the pre-fix `test_format_default_pads_to_max_precision`.
1724        //
1725        // Python `bean-query`'s `DecimalRenderer.format` calls
1726        // `str(value)` — no padding step. A `Decimal(0)` (scale 0)
1727        // renders as `"0"` regardless of what other cells in the
1728        // column look like; a `Decimal(0.0000)` renders as `"0.0000"`.
1729        //
1730        // We used to pad scale-0 values to the column's default
1731        // precision as an over-fit for #954, but that broke mixed-scale
1732        // columns (issue #1051's `cost-basis-fields` cases on fixtures
1733        // like `tests_test_inputs_missing_prices.beancount`, where a
1734        // scale-0 `cost_number=1000` was rendered as
1735        // `"1000.0000000000000000000000000"` because the column's other
1736        // row had a scale-25 cost from a `{{total}}`-form spec). The
1737        // #954 case (`SUM(0.00 + -0.00)`) still renders `"0.00"`
1738        // correctly because the aggregator preserves the inputs' max
1739        // scale — `to_string()` on the resulting `Decimal('0.00')` is
1740        // `"0.00"` without any padding.
1741        let mut ctx = DisplayContext::new();
1742        ctx.update(dec!(1.23), "USD");
1743        ctx.update(dec!(1.2345), "EUR");
1744        assert_eq!(ctx.format_default(dec!(0)), "0");
1745        assert_eq!(ctx.format_default(dec!(100)), "100");
1746    }
1747
1748    #[test]
1749    fn test_format_default_preserves_natural_scale_for_overprecise_values() {
1750        // Updated post-#985-follow-up: format_default no longer ROUNDS to
1751        // a uniform precision. Instead it preserves each value's natural
1752        // scale (matches Python `bean-query`'s DecimalRenderer, which
1753        // formats with `{value:<width}` — no precision specifier). That
1754        // means 1.235 prints as "1.235", NOT rounded to "1.24".
1755        let mut ctx = DisplayContext::new();
1756        ctx.update(dec!(1.23), "USD");
1757        assert_eq!(ctx.format_default(dec!(1.235)), "1.235");
1758    }
1759
1760    #[test]
1761    fn test_format_default_empty_context_natural() {
1762        let ctx = DisplayContext::new();
1763        // No tracked precision → integer-like rendering (no padding,
1764        // no rounding, value's natural scale).
1765        assert_eq!(ctx.format_default(dec!(42)), "42");
1766        // Fractional values keep their natural scale.
1767        assert_eq!(ctx.format_default(dec!(1.5)), "1.5");
1768    }
1769
1770    #[test]
1771    fn test_format_default_renders_commas() {
1772        let mut ctx = DisplayContext::new();
1773        ctx.update(dec!(1.23), "USD");
1774        ctx.set_render_commas(true);
1775
1776        assert_eq!(ctx.format_default(dec!(1234567.89)), "1,234,567.89");
1777    }
1778
1779    /// Issue #1051 example 4: `rust_decimal`'s 96-bit mantissa can land
1780    /// at 29 sig figs from divisions like `300 / 1.763`, where Python's
1781    /// default `Decimal` context (`getcontext().prec = 28`) clamps the
1782    /// same operation at 28. Without the cap in `format_default`, BQL's
1783    /// `cost_number` rendering would show 29 digits where bean-query
1784    /// shows 28, surfacing as a `cost-basis-fields` mismatch on every
1785    /// fixture with computed (`{{total}}`-form) cost specs.
1786    #[test]
1787    fn test_format_default_caps_significant_digits_at_28() {
1788        let ctx = DisplayContext::new();
1789        // 300 / 1.763 in rust_decimal lands at 29 sig figs:
1790        // 170.16449234259784458309699376 (3 integer + 26 fractional).
1791        let v = Decimal::from_str_exact("170.16449234259784458309699376").unwrap();
1792        assert_eq!(v.scale(), 26, "test setup: input has scale 26");
1793        // After capping to 28 sig figs total, the fractional scale drops
1794        // by 1 to 25 — matching Python's `Decimal('300') / Decimal('1.763')
1795        // = Decimal('170.1644923425978445830969938')`.
1796        assert_eq!(
1797            ctx.format_default(v),
1798            "170.1644923425978445830969938",
1799            "should cap at 28 sig figs (3 integer + 25 fractional)"
1800        );
1801    }
1802
1803    #[test]
1804    fn test_format_default_28_digit_or_fewer_passes_through_unchanged() {
1805        let ctx = DisplayContext::new();
1806        // Fits within 28 — no rounding. Don't accidentally re-quantize
1807        // values that are already at the right precision.
1808        assert_eq!(ctx.format_default(dec!(170.16449)), "170.16449");
1809        // Edge case: exactly 28 digits.
1810        let v = Decimal::from_str_exact("1.234567890123456789012345678").unwrap();
1811        assert_eq!(v.scale(), 27);
1812        assert_eq!(
1813            ctx.format_default(v),
1814            "1.234567890123456789012345678",
1815            "value at exactly 28 sig figs must pass through unchanged"
1816        );
1817    }
1818
1819    #[test]
1820    fn test_format_default_cap_preserves_sign_and_integer_part() {
1821        let ctx = DisplayContext::new();
1822        // Negative value > 28 sig figs: sign and integer part survive
1823        // the rescale; only fractional digits get truncated.
1824        let v = Decimal::from_str_exact("-1234.5678901234567890123456789").unwrap();
1825        // mantissa has 29 digits; capping to 28 drops the last fractional digit.
1826        assert_eq!(
1827            ctx.format_default(v),
1828            "-1234.567890123456789012345679",
1829            "negative + integer part preserved; fractional rounded half-even"
1830        );
1831    }
1832
1833    /// Integer-only excess: a 29-digit scale-0 Decimal must actually
1834    /// round (to nearest 10), not pass through unchanged. Pre-fix
1835    /// `cap_significant_digits` did `saturating_sub` on the scale
1836    /// which clamped to 0, and `round_dp_with_strategy(0, …)` left
1837    /// the integer alone — contradicting the doc comment. Caught by
1838    /// Copilot review on PR #1064.
1839    #[test]
1840    fn test_format_default_caps_integer_only_excess() {
1841        let ctx = DisplayContext::new();
1842        // 29 digits, scale 0. Cap to 28 → round to nearest 10.
1843        // 12345678901234567890123456789 / 10 = 1234567890123456789012345678.9
1844        // rounded half-even at 0dp = 1234567890123456789012345679
1845        // × 10 = 12345678901234567890123456790
1846        let v = Decimal::from_str_exact("12345678901234567890123456789").unwrap();
1847        assert_eq!(v.scale(), 0);
1848        assert_eq!(
1849            ctx.format_default(v),
1850            "12345678901234567890123456790",
1851            "29-digit integer must round to nearest 10 (28 sig figs), \
1852             trailing 0 marks the rounded position"
1853        );
1854    }
1855
1856    /// Zero values render at their intrinsic scale and skip the
1857    /// significant-digit cap (since `mantissa()` is 0). Guards against
1858    /// `checked_ilog10(0) → None` regressing into an off-by-one or
1859    /// accidental cap. Together with
1860    /// `test_format_default_does_not_pad_scale_zero_to_column_precision`
1861    /// this locks in bean-query parity for both `Decimal(0)` and
1862    /// `Decimal(0.00)` shapes.
1863    #[test]
1864    fn test_format_default_zero_preserves_intrinsic_scale() {
1865        let ctx = DisplayContext::new();
1866        assert_eq!(ctx.format_default(dec!(0)), "0", "Decimal(0) → \"0\"");
1867        assert_eq!(
1868            ctx.format_default(dec!(0.00)),
1869            "0.00",
1870            "Decimal(0.00) → \"0.00\" — the SUM-of-scale-2-zeros case from #954"
1871        );
1872        assert_eq!(
1873            ctx.format_default(dec!(-0.0000)),
1874            "0.0000",
1875            "Decimal(-0.0000) — rust_decimal canonicalizes negative zero"
1876        );
1877    }
1878
1879    /// The canonical builder's three-stage precedence (#1766): amount-scan
1880    /// inference < `display_precision` option < commodity `precision:`
1881    /// metadata. The loader and the FFI component's `session.format` both
1882    /// call this — the precedence must not depend on which one.
1883    #[test]
1884    fn from_directives_precedence_inferred_option_commodity() {
1885        use crate::{Amount, Balance, Commodity, Directive, MetaValue};
1886        let d = crate::naive_date(2024, 1, 1).unwrap();
1887        let mut usd_commodity = Commodity::new(d, "USD");
1888        usd_commodity
1889            .meta
1890            .insert("precision".to_string(), MetaValue::Int(4));
1891        let dirs = [
1892            // USD: 2dp twice, 0dp once -> inferred mode 2.
1893            Directive::Balance(Balance::new(d, "Assets:A", Amount::new(dec!(1.50), "USD"))),
1894            Directive::Balance(Balance::new(d, "Assets:B", Amount::new(dec!(2.25), "USD"))),
1895            Directive::Balance(Balance::new(d, "Assets:C", Amount::new(dec!(3), "USD"))),
1896            // EUR: single 1dp observation -> inferred 1.
1897            Directive::Balance(Balance::new(d, "Assets:D", Amount::new(dec!(9.5), "EUR"))),
1898            // JPY: never observed as an amount, no fixed override.
1899            Directive::Commodity(usd_commodity),
1900        ];
1901
1902        // No overrides: pure inference.
1903        let ctx = DisplayContext::from_directives(dirs.iter().take(4), std::iter::empty(), false);
1904        assert_eq!(ctx.get_precision("USD"), Some(2), "mode of {{2,2,0}}dp");
1905        assert_eq!(ctx.get_precision("EUR"), Some(1));
1906        assert_eq!(
1907            ctx.resolved_precisions(),
1908            vec![("EUR".to_string(), 1), ("USD".to_string(), 2)],
1909            "the wire export carries inferred precisions too — embedders \
1910             render from it without re-deriving inference"
1911        );
1912        assert_eq!(
1913            ctx.get_precision("JPY"),
1914            None,
1915            "unseen currency stays untracked"
1916        );
1917
1918        // Option override beats inference; commodity metadata beats both.
1919        let ctx = DisplayContext::from_directives(dirs.iter(), [("EUR", 3), ("USD", 5)], false);
1920        assert_eq!(
1921            ctx.get_precision("USD"),
1922            Some(4),
1923            "commodity `precision: 4` metadata wins over the option's 5"
1924        );
1925        assert_eq!(
1926            ctx.get_precision("EUR"),
1927            Some(3),
1928            "option override wins over the inferred 1"
1929        );
1930        assert_eq!(
1931            ctx.resolved_precisions(),
1932            vec![("EUR".to_string(), 3), ("USD".to_string(), 4)],
1933            "the wire export reflects the fixed-table precedence"
1934        );
1935    }
1936}
1937
1938#[cfg(test)]
1939mod custom_directive_precision_tests {
1940    use super::*;
1941    use crate::{Amount, Custom, Directive, MetaValue, Posting, Transaction, naive_date};
1942    use rust_decimal_macros::dec;
1943
1944    fn custom_with_amount(day: u32, amount: Amount) -> Directive {
1945        Directive::Custom(
1946            Custom::new(naive_date(2024, 1, day).unwrap(), "budget")
1947                .with_value(MetaValue::Amount(amount)),
1948        )
1949    }
1950
1951    /// Amounts inside `custom` directives do NOT inform display precision.
1952    ///
1953    /// The decimal count in a budget line is a stylistic choice about the
1954    /// declaration; a budget report's figure is pro-rated and repeating by
1955    /// construction. Inferring from the declaration rounded a 0.22580645 BTC
1956    /// accrual to `0.2`. A consumer needing to render a currency this context
1957    /// has never seen picks its own precision instead.
1958    #[test]
1959    fn custom_amounts_do_not_inform_precision() {
1960        let directives = [custom_with_amount(1, Amount::new(dec!(0.5), "BTC"))];
1961        let ctx = DisplayContext::from_directives(directives.iter(), std::iter::empty(), false);
1962        assert_eq!(ctx.get_precision("BTC"), None);
1963        assert!(
1964            !ctx.currencies().any(|c| c == "BTC"),
1965            "a currency seen only in metadata must not enter the ledger's \
1966             currency list, which crosses the FFI"
1967        );
1968    }
1969
1970    /// And they certainly must not outvote postings: three 4 dp budget lines
1971    /// against one 2 dp posting once moved USD's mode to 4 dp, silently
1972    /// re-rendering every report in the CLI.
1973    #[test]
1974    fn custom_amounts_do_not_outvote_postings() {
1975        let directives = [
1976            custom_with_amount(1, Amount::new(dec!(400.0000), "USD")),
1977            custom_with_amount(2, Amount::new(dec!(100.0000), "USD")),
1978            custom_with_amount(3, Amount::new(dec!(50.0000), "USD")),
1979            Directive::Transaction(
1980                Transaction::new(naive_date(2024, 2, 1).unwrap(), "a").with_synthesized_posting(
1981                    Posting::new("Expenses:Food", Amount::new(dec!(10.00), "USD")),
1982                ),
1983            ),
1984        ];
1985        let ctx = DisplayContext::from_directives(directives.iter(), std::iter::empty(), false);
1986        assert_eq!(ctx.get_precision("USD"), Some(2));
1987    }
1988
1989    /// The surface rule, stated once and asserted here so a new
1990    /// `OutputSurface` variant cannot quietly default to rendering separators
1991    /// (#1892).
1992    #[test]
1993    fn only_machine_surfaces_suppress_thousands_separators() {
1994        assert!(OutputSurface::Human.renders_thousands_separators());
1995        assert!(
1996            !OutputSurface::Machine.renders_thousands_separators(),
1997            "CSV/JSON consumers have no grammar admitting a separator; this \
1998             suppression outranks any ledger or per-commodity declaration"
1999        );
2000        assert!(
2001            OutputSurface::LedgerText.renders_thousands_separators(),
2002            "grouped numerals are Beancount syntax, so every conforming \
2003             reader accepts them — `format --ledger` groups and `query \
2004             --format beancount` must agree with it (#1896)"
2005        );
2006    }
2007
2008    /// A commodity's own `render_commas` declaration governs report and query
2009    /// text, not just `rledger format`.
2010    ///
2011    /// Review catch on #1896: the per-commodity overrides were parsed and
2012    /// stored but only the CST formatter consulted them, so `format` and
2013    /// `format_quantized` — which have the currency right there in the
2014    /// signature — still asked the ledger-wide flag. A commodity opting out of
2015    /// grouping was silently grouped in every report.
2016    #[test]
2017    fn a_commoditys_own_declaration_governs_report_text() {
2018        let mut ctx = DisplayContext::new();
2019        ctx.set_fixed_precision("USD", 2);
2020        ctx.set_fixed_precision("IQD", 2);
2021        ctx.set_render_commas(true);
2022        ctx.set_render_commas_for("USD", false);
2023
2024        assert_eq!(
2025            ctx.format(Decimal::from_str_exact("1234567.89").unwrap(), "USD"),
2026            "1234567.89",
2027            "USD declared render_commas: FALSE — a report must honor it"
2028        );
2029        assert_eq!(
2030            ctx.format(Decimal::from_str_exact("1234567.89").unwrap(), "IQD"),
2031            "1,234,567.89",
2032            "IQD declared nothing and takes the ledger-wide default"
2033        );
2034        // The ledger-text path (`query --format beancount`) resolves per
2035        // currency too, so the two surfaces cannot disagree.
2036        assert_eq!(
2037            ctx.format_quantized(Decimal::from_str_exact("1234567.89").unwrap(), "USD"),
2038            "1234567.89"
2039        );
2040        assert_eq!(
2041            ctx.format_quantized(Decimal::from_str_exact("1234567.89").unwrap(), "IQD"),
2042            "1,234,567.89"
2043        );
2044
2045        // And the inverse tier: nothing global, one commodity opting IN.
2046        let mut opted_in = DisplayContext::new();
2047        opted_in.set_fixed_precision("IQD", 2);
2048        opted_in.set_render_commas_for("IQD", true);
2049        assert_eq!(
2050            opted_in.format(Decimal::from_str_exact("1234567.89").unwrap(), "IQD"),
2051            "1,234,567.89"
2052        );
2053    }
2054
2055    /// Suppressing separators for a machine surface must clear the
2056    /// per-commodity opt-ins too, not just the ledger-wide flag.
2057    ///
2058    /// The borrow fast path used to test `render_commas` alone. A ledger whose
2059    /// global flag is off but which has one commodity declaring
2060    /// `render_commas: TRUE` would take that path and hand the CSV writer a
2061    /// context that still groups that commodity — `Decimal(field)` breaks on
2062    /// the result.
2063    #[test]
2064    fn machine_surfaces_suppress_per_commodity_opt_ins() {
2065        use std::borrow::Cow;
2066
2067        let mut ctx = DisplayContext::new();
2068        ctx.set_fixed_precision("IQD", 2);
2069        ctx.set_render_commas_for("IQD", true); // global stays FALSE
2070
2071        assert!(
2072            !ctx.render_commas(),
2073            "precondition: nothing is set ledger-wide"
2074        );
2075        assert!(ctx.renders_any_commas(), "but one commodity opts in");
2076
2077        let machine = ctx.for_surface(OutputSurface::Machine);
2078        assert!(
2079            matches!(machine, Cow::Owned(_)),
2080            "the global flag is off, but an override still has to be cleared"
2081        );
2082        assert!(!machine.render_commas_for("IQD"));
2083        assert_eq!(
2084            machine.format(Decimal::from_str_exact("1234567.89").unwrap(), "IQD"),
2085            "1234567.89",
2086            "a CSV/JSON consumer has no grammar for separators"
2087        );
2088
2089        // Human surface keeps the opt-in, and still borrows.
2090        let human = ctx.for_surface(OutputSurface::Human);
2091        assert!(matches!(human, Cow::Borrowed(_)));
2092        assert_eq!(
2093            human.format(Decimal::from_str_exact("1234567.89").unwrap(), "IQD"),
2094            "1,234,567.89"
2095        );
2096    }
2097
2098    /// `for_surface` avoids cloning whenever the flag does not have to change.
2099    ///
2100    /// The clone carries the per-currency histograms, and the JSON writer
2101    /// ignores the context entirely, so paying for one on every query would be
2102    /// pure waste (review catch on #1893). The two cases that matter most are
2103    /// the cheap ones: a ledger that never set `render_commas`, and any
2104    /// human-facing surface.
2105    #[test]
2106    fn for_surface_borrows_unless_the_flag_must_change() {
2107        use std::borrow::Cow;
2108
2109        let mut plain = DisplayContext::new();
2110        plain.set_fixed_precision("USD", 2);
2111        assert!(
2112            matches!(plain.for_surface(OutputSurface::Machine), Cow::Borrowed(_)),
2113            "a ledger without render_commas never needs a clone"
2114        );
2115
2116        let mut commas = DisplayContext::new();
2117        commas.set_render_commas(true);
2118        assert!(
2119            matches!(commas.for_surface(OutputSurface::Human), Cow::Borrowed(_)),
2120            "a human surface keeps the flag, so no clone"
2121        );
2122        assert!(
2123            matches!(commas.for_surface(OutputSurface::Machine), Cow::Owned(_)),
2124            "only actually suppressing separators clones"
2125        );
2126    }
2127
2128    /// `for_surface` narrows ONLY the separator flag — precision is a property
2129    /// of the data and must survive on every surface.
2130    #[test]
2131    fn for_surface_narrows_separators_but_keeps_precision() {
2132        let mut ctx = DisplayContext::new();
2133        ctx.set_render_commas(true);
2134        ctx.set_fixed_precision("USD", 2);
2135
2136        let human = ctx.for_surface(OutputSurface::Human);
2137        let machine = ctx.for_surface(OutputSurface::Machine);
2138        assert!(human.render_commas());
2139        assert!(!machine.render_commas());
2140        assert_eq!(
2141            machine.format_amount_number(rust_decimal_macros::dec!(1234.5), "USD"),
2142            human
2143                .format_amount_number(rust_decimal_macros::dec!(1234.5), "USD")
2144                .replace(',', ""),
2145            "the two differ ONLY by separators, never by precision"
2146        );
2147
2148        // A ledger that never asked for separators is unaffected either way.
2149        let mut plain = DisplayContext::new();
2150        plain.set_fixed_precision("USD", 2);
2151        assert!(!plain.for_surface(OutputSurface::Human).render_commas());
2152    }
2153
2154    /// `render_commas` must not corrupt the exponential form.
2155    ///
2156    /// `add_commas` groups from the right of the integer part, and an
2157    /// exponent has no decimal point to shield it: `0E-14` became `0E,-14`
2158    /// and `1E-7` became `1,E-7`. Only `1.234E-7` survived, because the `.`
2159    /// split happened to protect it — which is why this pins the bare-mantissa
2160    /// forms specifically.
2161    #[test]
2162    fn render_commas_leaves_scientific_notation_alone() {
2163        use std::str::FromStr;
2164
2165        let mut ctx = DisplayContext::new();
2166        ctx.set_render_commas(true);
2167
2168        for (input, want) in [
2169            ("0E-14", "0E-14"),
2170            ("1E-7", "1E-7"),
2171            ("-1E-7", "-1E-7"),
2172            ("1234E-10", "1.234E-7"),
2173        ] {
2174            let value = Decimal::from_str(input).expect("parses");
2175            assert_eq!(ctx.format_default(value), want, "input {input}");
2176        }
2177
2178        // And commas still apply to the plain form.
2179        assert_eq!(
2180            ctx.format_default(Decimal::from_str("1234567.89").unwrap()),
2181            "1,234,567.89",
2182        );
2183    }
2184
2185    /// `format_default` follows Python's `to-scientific-string`.
2186    ///
2187    /// Expectations produced by running each case through `CPython`'s
2188    /// `decimal` (3.13) rather than derived — the switch point is easy to
2189    /// state and easy to get off by one. `1E+2` is excluded from the round
2190    /// trip because `rust_decimal` cannot hold a positive exponent; every
2191    /// other case is a value it can represent.
2192    #[test]
2193    fn format_default_matches_python_decimal_str() {
2194        use std::str::FromStr;
2195
2196        let ctx = DisplayContext::new();
2197        for (input, want) in [
2198            ("0E-14", "0E-14"),
2199            ("0E-7", "0E-7"),
2200            ("0E-6", "0.000000"),
2201            ("0", "0"),
2202            ("0.00", "0.00"),
2203            ("1E-7", "1E-7"),
2204            ("0.0000001", "1E-7"),
2205            ("0.000001", "0.000001"),
2206            ("0.00001", "0.00001"),
2207            ("1.5E-9", "1.5E-9"),
2208            ("-1E-7", "-1E-7"),
2209            ("1234E-10", "1.234E-7"),
2210            ("1E-28", "1E-28"),
2211            ("0.1", "0.1"),
2212            ("123.456", "123.456"),
2213        ] {
2214            let value = Decimal::from_str(input).expect("parses");
2215            assert_eq!(ctx.format_default(value), want, "input {input}");
2216        }
2217    }
2218
2219    /// The switch is on the ADJUSTED exponent, not the scale, so a value with
2220    /// enough significant digits stays plain even at a deep scale.
2221    #[test]
2222    fn the_notation_switch_follows_the_adjusted_exponent() {
2223        use std::str::FromStr;
2224
2225        let ctx = DisplayContext::new();
2226        // scale 7 but adjusted -1: plain.
2227        assert_eq!(
2228            ctx.format_default(Decimal::from_str("0.1234567").unwrap()),
2229            "0.1234567"
2230        );
2231        // scale 7, adjusted -7: exponential.
2232        assert_eq!(
2233            ctx.format_default(Decimal::from_str("0.0000001").unwrap()),
2234            "1E-7"
2235        );
2236    }
2237}