rustledger_core/
display_context.rs

1//! Display context for formatting numbers with consistent precision.
2//!
3//! This module provides the [`DisplayContext`] type which tracks the precision
4//! (number of decimal places) seen for each currency during parsing. This allows
5//! numbers to be formatted consistently - for example, if a file contains both
6//! `100 USD` and `50.25 USD`, both should display with 2 decimal places.
7//!
8//! This matches Python beancount's `display_context` behavior.
9//!
10//! # Example
11//!
12//! ```
13//! use rustledger_core::DisplayContext;
14//! use rust_decimal_macros::dec;
15//!
16//! let mut ctx = DisplayContext::new();
17//!
18//! // Track precision from parsed numbers
19//! ctx.update(dec!(100), "USD");      // 0 decimal places
20//! ctx.update(dec!(50.25), "USD");    // 2 decimal places
21//! ctx.update(dec!(1.5), "EUR");      // 1 decimal place
22//!
23//! // Get the precision to use (maximum seen)
24//! assert_eq!(ctx.get_precision("USD"), Some(2));
25//! assert_eq!(ctx.get_precision("EUR"), Some(1));
26//! assert_eq!(ctx.get_precision("GBP"), None);  // Never seen
27//!
28//! // Format a number with the tracked precision
29//! assert_eq!(ctx.format(dec!(100), "USD"), "100.00");
30//! assert_eq!(ctx.format(dec!(50.25), "USD"), "50.25");
31//! assert_eq!(ctx.format(dec!(1.5), "EUR"), "1.5");
32//! ```
33
34use rust_decimal::Decimal;
35use std::collections::HashMap;
36
37/// Display context for formatting numbers with consistent precision per currency.
38///
39/// Tracks the maximum number of decimal places seen for each currency during parsing,
40/// and provides methods to format numbers with that precision.
41#[derive(Debug, Clone, Default)]
42pub struct DisplayContext {
43    /// Maximum decimal places seen per currency.
44    precisions: HashMap<String, u32>,
45
46    /// Whether to render commas in numbers (from `option "render_commas"`).
47    render_commas: bool,
48
49    /// Fixed precision overrides (from `option "display_precision"`).
50    /// These take precedence over inferred precision.
51    fixed_precisions: HashMap<String, u32>,
52}
53
54impl DisplayContext {
55    /// Create a new empty display context.
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Update the display context with a number for a currency.
62    ///
63    /// This records the decimal precision of the number (number of digits after
64    /// the decimal point) and updates the maximum precision seen for that currency.
65    pub fn update(&mut self, number: Decimal, currency: &str) {
66        let precision = Self::decimal_precision(number);
67        let entry = self.precisions.entry(currency.to_string()).or_insert(0);
68        *entry = (*entry).max(precision);
69    }
70
71    /// Update the display context from another display context.
72    ///
73    /// Takes the maximum precision for each currency from both contexts.
74    pub fn update_from(&mut self, other: &Self) {
75        for (currency, precision) in &other.precisions {
76            let entry = self.precisions.entry(currency.clone()).or_insert(0);
77            *entry = (*entry).max(*precision);
78        }
79    }
80
81    /// Set the `render_commas` flag.
82    pub const fn set_render_commas(&mut self, render_commas: bool) {
83        self.render_commas = render_commas;
84    }
85
86    /// Get the `render_commas` flag.
87    #[must_use]
88    pub const fn render_commas(&self) -> bool {
89        self.render_commas
90    }
91
92    /// Set a fixed precision for a currency (from `option "display_precision"`).
93    ///
94    /// Fixed precision takes precedence over inferred precision.
95    pub fn set_fixed_precision(&mut self, currency: &str, precision: u32) {
96        self.fixed_precisions
97            .insert(currency.to_string(), precision);
98    }
99
100    /// Get the precision for a currency.
101    ///
102    /// Returns the fixed precision if set, otherwise the maximum precision seen,
103    /// or None if the currency has never been seen.
104    #[must_use]
105    pub fn get_precision(&self, currency: &str) -> Option<u32> {
106        // Fixed precision takes precedence
107        if let Some(&precision) = self.fixed_precisions.get(currency) {
108            return Some(precision);
109        }
110        self.precisions.get(currency).copied()
111    }
112
113    /// Format a decimal number for a currency using the tracked precision.
114    ///
115    /// If the currency has been seen, formats with the maximum precision.
116    /// Otherwise, formats with the number's natural precision (no trailing zeros).
117    /// Uses half-up rounding to match Python beancount behavior.
118    #[must_use]
119    pub fn format(&self, number: Decimal, currency: &str) -> String {
120        let precision = self.get_precision(currency);
121
122        if let Some(dp) = precision {
123            // Round with half-up (MidpointAwayFromZero) to match Python behavior
124            // Note: format!("{:.N}", decimal) uses truncation which gives wrong results
125            // for values like -1202.00896 (would give -1202.00 instead of -1202.01)
126            let rounded = number.round_dp(dp);
127            let formatted = format!("{rounded}");
128            // Ensure we have the right number of decimal places (add trailing zeros if needed)
129            let formatted = Self::ensure_decimal_places(&formatted, dp);
130            if self.render_commas {
131                Self::add_commas(&formatted)
132            } else {
133                formatted
134            }
135        } else {
136            // No tracked precision - use natural formatting
137            let formatted = number.normalize().to_string();
138            if self.render_commas {
139                Self::add_commas(&formatted)
140            } else {
141                formatted
142            }
143        }
144    }
145
146    /// Format an amount (number + currency) using the tracked precision.
147    #[must_use]
148    pub fn format_amount(&self, number: Decimal, currency: &str) -> String {
149        format!("{} {}", self.format(number, currency), currency)
150    }
151
152    /// Get the decimal precision (number of digits after decimal point) of a number.
153    const fn decimal_precision(number: Decimal) -> u32 {
154        // scale() returns the number of decimal digits
155        number.scale()
156    }
157
158    /// Ensure a formatted number has exactly `dp` decimal places.
159    /// Adds trailing zeros if needed, or adds ".00..." if no decimal point.
160    fn ensure_decimal_places(s: &str, dp: u32) -> String {
161        if dp == 0 {
162            // No decimal places needed - remove any decimal point
163            return s.split('.').next().unwrap_or(s).to_string();
164        }
165
166        let dp = dp as usize;
167        if let Some(dot_pos) = s.find('.') {
168            let current_decimals = s.len() - dot_pos - 1;
169            if current_decimals >= dp {
170                // Already has enough or more decimals
171                s.to_string()
172            } else {
173                // Need to add trailing zeros
174                let zeros_needed = dp - current_decimals;
175                format!("{s}{}", "0".repeat(zeros_needed))
176            }
177        } else {
178            // No decimal point - add one with zeros
179            format!("{s}.{}", "0".repeat(dp))
180        }
181    }
182
183    /// Add thousand separators (commas) to a formatted number string.
184    fn add_commas(s: &str) -> String {
185        // Split on decimal point
186        let (integer_part, decimal_part) = match s.find('.') {
187            Some(pos) => (&s[..pos], Some(&s[pos..])),
188            None => (s, None),
189        };
190
191        // Handle negative sign
192        let (sign, digits) = if let Some(stripped) = integer_part.strip_prefix('-') {
193            ("-", stripped)
194        } else {
195            ("", integer_part)
196        };
197
198        // Add commas to integer part (from right to left)
199        let mut result = String::with_capacity(digits.len() + digits.len() / 3);
200        for (i, c) in digits.chars().rev().enumerate() {
201            if i > 0 && i % 3 == 0 {
202                result.push(',');
203            }
204            result.push(c);
205        }
206        let integer_with_commas: String = result.chars().rev().collect();
207
208        // Combine parts
209        match decimal_part {
210            Some(dec) => format!("{sign}{integer_with_commas}{dec}"),
211            None => format!("{sign}{integer_with_commas}"),
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use rust_decimal_macros::dec;
220
221    #[test]
222    fn test_update_and_get_precision() {
223        let mut ctx = DisplayContext::new();
224
225        ctx.update(dec!(100), "USD");
226        assert_eq!(ctx.get_precision("USD"), Some(0));
227
228        ctx.update(dec!(50.25), "USD");
229        assert_eq!(ctx.get_precision("USD"), Some(2));
230
231        // Maximum is kept
232        ctx.update(dec!(1), "USD");
233        assert_eq!(ctx.get_precision("USD"), Some(2));
234
235        // Unknown currency
236        assert_eq!(ctx.get_precision("EUR"), None);
237    }
238
239    #[test]
240    fn test_format_with_precision() {
241        let mut ctx = DisplayContext::new();
242        ctx.update(dec!(100), "USD");
243        ctx.update(dec!(50.25), "USD");
244
245        // Formats to max precision (2)
246        assert_eq!(ctx.format(dec!(100), "USD"), "100.00");
247        assert_eq!(ctx.format(dec!(50.25), "USD"), "50.25");
248        assert_eq!(ctx.format(dec!(7.5), "USD"), "7.50");
249    }
250
251    #[test]
252    fn test_format_unknown_currency() {
253        let ctx = DisplayContext::new();
254
255        // Unknown currency uses natural formatting
256        assert_eq!(ctx.format(dec!(100), "EUR"), "100");
257        assert_eq!(ctx.format(dec!(50.25), "EUR"), "50.25");
258    }
259
260    #[test]
261    fn test_fixed_precision_override() {
262        let mut ctx = DisplayContext::new();
263        ctx.update(dec!(100), "USD");
264        ctx.update(dec!(50.25), "USD");
265
266        // Inferred precision is 2
267        assert_eq!(ctx.get_precision("USD"), Some(2));
268
269        // Set fixed precision to 4
270        ctx.set_fixed_precision("USD", 4);
271        assert_eq!(ctx.get_precision("USD"), Some(4));
272
273        // Formatting uses fixed precision
274        assert_eq!(ctx.format(dec!(100), "USD"), "100.0000");
275    }
276
277    #[test]
278    fn test_render_commas() {
279        let mut ctx = DisplayContext::new();
280        ctx.set_render_commas(true);
281        ctx.update(dec!(1234567.89), "USD");
282
283        assert_eq!(ctx.format(dec!(1234567.89), "USD"), "1,234,567.89");
284        assert_eq!(ctx.format(dec!(1000), "USD"), "1,000.00");
285    }
286
287    #[test]
288    fn test_add_commas() {
289        assert_eq!(DisplayContext::add_commas("1234567"), "1,234,567");
290        assert_eq!(DisplayContext::add_commas("1234567.89"), "1,234,567.89");
291        assert_eq!(DisplayContext::add_commas("-1234567.89"), "-1,234,567.89");
292        assert_eq!(DisplayContext::add_commas("123"), "123");
293        assert_eq!(DisplayContext::add_commas("1"), "1");
294    }
295
296    #[test]
297    fn test_update_from() {
298        let mut ctx1 = DisplayContext::new();
299        ctx1.update(dec!(100), "USD");
300
301        let mut ctx2 = DisplayContext::new();
302        ctx2.update(dec!(50.25), "USD");
303        ctx2.update(dec!(1.5), "EUR");
304
305        ctx1.update_from(&ctx2);
306
307        assert_eq!(ctx1.get_precision("USD"), Some(2));
308        assert_eq!(ctx1.get_precision("EUR"), Some(1));
309    }
310
311    #[test]
312    fn test_format_amount() {
313        let mut ctx = DisplayContext::new();
314        ctx.update(dec!(50.25), "USD");
315
316        assert_eq!(ctx.format_amount(dec!(100), "USD"), "100.00 USD");
317    }
318}