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