Skip to main content

zenith_core/data/
format.rs

1//! Deterministic, pure data-value formatter.
2//!
3//! All formatting is done by hand — no external locale or number-format crate.
4//! Same bytes in → same bytes out on any machine, making this safe on the
5//! render path.
6
7/// The display format to apply to a resolved data field value.
8#[derive(Debug, Clone, PartialEq)]
9pub enum DataFormat {
10    /// Currency: `"$1,234.56"`. Negative values render as `"-$1,234.56"`.
11    /// `locale` is reserved for future locale codes (currently unused; en-US
12    /// thousands/decimal separators are always used). `precision` defaults to 2.
13    Currency {
14        locale: Option<String>,
15        precision: Option<u8>,
16    },
17    /// Percentage: value × 100 + `"%"`. `precision` defaults to 1.
18    Percent { precision: Option<u8> },
19    /// Plain number with thousands separators. `precision` defaults to 0.
20    Number { precision: Option<u8> },
21}
22
23/// Format `raw` according to `fmt`.
24///
25/// If `raw` does not parse as an `f64` it is returned unchanged (passthrough).
26/// All arithmetic and string construction is deterministic and allocation-only
27/// (no `f64::to_string` locale dependencies — we drive the digits ourselves).
28pub fn format_data_value(raw: &str, fmt: &DataFormat) -> String {
29    let value: f64 = match raw.parse() {
30        Ok(v) => v,
31        Err(_) => return raw.to_owned(),
32    };
33
34    match fmt {
35        DataFormat::Currency { precision, .. } => {
36            let prec = precision.unwrap_or(2) as usize;
37            let negative = value < 0.0;
38            let abs_val = value.abs();
39            let formatted = format_number_parts(abs_val, prec);
40            if negative {
41                format!("-${formatted}")
42            } else {
43                format!("${formatted}")
44            }
45        }
46        DataFormat::Percent { precision } => {
47            let prec = precision.unwrap_or(1) as usize;
48            let pct = value * 100.0;
49            let formatted = format_fixed(pct, prec);
50            format!("{formatted}%")
51        }
52        DataFormat::Number { precision } => {
53            let prec = precision.unwrap_or(0) as usize;
54            let negative = value < 0.0;
55            let abs_val = value.abs();
56            let formatted = format_number_parts(abs_val, prec);
57            if negative {
58                format!("-{formatted}")
59            } else {
60                formatted
61            }
62        }
63    }
64}
65
66// ── Internal helpers ──────────────────────────────────────────────────────────
67
68/// Format `value` with `prec` decimal places AND thousands separators (`,`).
69/// Always uses en-US conventions: comma thousands, period decimal.
70fn format_number_parts(value: f64, prec: usize) -> String {
71    let fixed = format_fixed(value, prec);
72    // Split on the decimal point (if any).
73    let (integer_part, decimal_part) = if let Some(dot) = fixed.find('.') {
74        (&fixed[..dot], Some(&fixed[dot..]))
75    } else {
76        (fixed.as_str(), None)
77    };
78
79    let with_thousands = insert_thousands(integer_part);
80    match decimal_part {
81        Some(dec) => format!("{with_thousands}{dec}"),
82        None => with_thousands,
83    }
84}
85
86/// Format `value` with exactly `prec` decimal places, no thousands separators.
87///
88/// Uses `f64`'s built-in `format!("{:.prec$}")` which is deterministic (IEEE
89/// 754 round-to-nearest-even) but does NOT apply locale — the decimal separator
90/// is always `.`, matching our target en-US output.
91fn format_fixed(value: f64, prec: usize) -> String {
92    format!("{value:.prec$}")
93}
94
95/// Insert a `,` every three digits from the right into `integer_str`.
96///
97/// `integer_str` must contain only ASCII digits (no sign, no decimal). This is
98/// a pure string manipulation — no arithmetic — so it is branch-free and
99/// byte-stable across all platforms.
100fn insert_thousands(integer_str: &str) -> String {
101    if integer_str.len() <= 3 {
102        return integer_str.to_owned();
103    }
104    let chars: Vec<char> = integer_str.chars().collect();
105    let len = chars.len();
106    // Number of commas to insert.
107    let comma_count = (len - 1) / 3;
108    let mut result = String::with_capacity(len + comma_count);
109    for (i, ch) in chars.iter().enumerate() {
110        if i > 0 && (len - i) % 3 == 0 {
111            result.push(',');
112        }
113        result.push(*ch);
114    }
115    result
116}
117
118// ── Unit tests ────────────────────────────────────────────────────────────────
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    // Currency ─────────────────────────────────────────────────────────────────
125
126    #[test]
127    fn currency_default_precision() {
128        assert_eq!(
129            format_data_value(
130                "1234.5",
131                &DataFormat::Currency {
132                    locale: None,
133                    precision: None
134                }
135            ),
136            "$1,234.50"
137        );
138    }
139
140    #[test]
141    fn currency_zero_precision() {
142        assert_eq!(
143            format_data_value(
144                "9999.99",
145                &DataFormat::Currency {
146                    locale: None,
147                    precision: Some(0)
148                }
149            ),
150            "$10,000"
151        );
152    }
153
154    #[test]
155    fn currency_negative() {
156        assert_eq!(
157            format_data_value(
158                "-42.5",
159                &DataFormat::Currency {
160                    locale: None,
161                    precision: None
162                }
163            ),
164            "-$42.50"
165        );
166    }
167
168    #[test]
169    fn currency_thousands() {
170        assert_eq!(
171            format_data_value(
172                "1000000.0",
173                &DataFormat::Currency {
174                    locale: None,
175                    precision: Some(2)
176                }
177            ),
178            "$1,000,000.00"
179        );
180    }
181
182    #[test]
183    fn currency_small() {
184        assert_eq!(
185            format_data_value(
186                "5.0",
187                &DataFormat::Currency {
188                    locale: None,
189                    precision: Some(2)
190                }
191            ),
192            "$5.00"
193        );
194    }
195
196    // Percent ──────────────────────────────────────────────────────────────────
197
198    #[test]
199    fn percent_default_precision() {
200        assert_eq!(
201            format_data_value("0.1234", &DataFormat::Percent { precision: None }),
202            "12.3%"
203        );
204    }
205
206    #[test]
207    fn percent_zero_precision() {
208        assert_eq!(
209            format_data_value("0.5", &DataFormat::Percent { precision: Some(0) }),
210            "50%"
211        );
212    }
213
214    #[test]
215    fn percent_negative() {
216        assert_eq!(
217            format_data_value("-0.05", &DataFormat::Percent { precision: Some(1) }),
218            "-5.0%"
219        );
220    }
221
222    #[test]
223    fn percent_high_precision() {
224        assert_eq!(
225            format_data_value("0.12345", &DataFormat::Percent { precision: Some(3) }),
226            "12.345%"
227        );
228    }
229
230    // Number ───────────────────────────────────────────────────────────────────
231
232    #[test]
233    fn number_default_precision() {
234        assert_eq!(
235            format_data_value("1234567.8", &DataFormat::Number { precision: None }),
236            "1,234,568"
237        );
238    }
239
240    #[test]
241    fn number_with_precision() {
242        assert_eq!(
243            format_data_value("1234.5", &DataFormat::Number { precision: Some(2) }),
244            "1,234.50"
245        );
246    }
247
248    #[test]
249    fn number_negative() {
250        assert_eq!(
251            format_data_value("-9876.0", &DataFormat::Number { precision: Some(0) }),
252            "-9,876"
253        );
254    }
255
256    #[test]
257    fn number_small_no_thousands() {
258        assert_eq!(
259            format_data_value("42.0", &DataFormat::Number { precision: Some(0) }),
260            "42"
261        );
262    }
263
264    // Non-numeric passthrough ──────────────────────────────────────────────────
265
266    #[test]
267    fn passthrough_non_numeric() {
268        assert_eq!(
269            format_data_value(
270                "N/A",
271                &DataFormat::Currency {
272                    locale: None,
273                    precision: None
274                }
275            ),
276            "N/A"
277        );
278    }
279
280    #[test]
281    fn passthrough_empty() {
282        assert_eq!(
283            format_data_value("", &DataFormat::Number { precision: None }),
284            ""
285        );
286    }
287
288    #[test]
289    fn passthrough_string_with_letters() {
290        assert_eq!(
291            format_data_value("twelve", &DataFormat::Percent { precision: None }),
292            "twelve"
293        );
294    }
295}