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}