Skip to main content

nmbrs_workload/
metric_format.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-40b §1 generation-time numeric sanitiser for synthetic
5//! metrics. Parses Excel-style hash-pattern format strings
6//! (`#.##`, `0.000`) into a [`FormatSpec`] that the
7//! `MetricsDispenser` wrapper consults at registration time
8//! to derive a round operation applied before each cycle's
9//! value is recorded on the instrument.
10//!
11//! Excel-style only — printf-style (`%3.2f`) is **not**
12//! accepted, per SRD-40b's "one syntax avoids two paths"
13//! rule. `#` and `0` placeholders are interchangeable for
14//! precision purposes (Excel's render-time drop-trailing-zero
15//! distinction doesn't apply here — we're rounding the
16//! stored value, not rendering a string).
17
18/// Compiled form of an SRD-40b `format:` declaration. The
19/// only field that matters at value-sanitiser time is
20/// `decimal_places`; the integer-side layout is recorded for
21/// diagnostic display but not enforced on the value.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct FormatSpec {
24    /// Number of decimal places to round to. `0` means "round
25    /// to integer."
26    pub decimal_places: u8,
27    /// Number of placeholder characters before the decimal
28    /// point. Layout-only; not applied to the value.
29    pub integer_places: u8,
30}
31
32impl FormatSpec {
33    /// Round `value` to this spec's precision. Used by the
34    /// MetricsDispenser before recording on the instrument.
35    pub fn apply(&self, value: f64) -> f64 {
36        let scale = 10f64.powi(self.decimal_places as i32);
37        (value * scale).round() / scale
38    }
39}
40
41/// Parse an Excel-style hash-pattern format string.
42///
43/// Accepted shapes (`#` and `0` interchangeable):
44/// - `"#"` / `"0"` — round to integer, no decimal portion.
45/// - `"#.##"` / `"0.00"` — round to 2 decimal places.
46/// - `"##.###"` / `"00.000"` — round to 3 decimal places.
47/// - `"#."` — equivalent to `"#"` (trailing `.` allowed,
48///   zero decimal places).
49///
50/// Rejects:
51/// - Empty strings.
52/// - Printf-style (`%3.2f`).
53/// - Multiple `.`s.
54/// - Non-`#`/`0` characters (no `,`, no `%`, no `e`).
55///
56/// Returns the compiled [`FormatSpec`].
57pub fn parse_format_spec(s: &str) -> Result<FormatSpec, String> {
58    if s.is_empty() {
59        return Err("empty format string".into());
60    }
61    if s.starts_with('%') {
62        return Err(format!(
63            "printf-style '{s}' not accepted; use Excel-style \
64             hash patterns like '#.##' or '0.000'"
65        ));
66    }
67
68    // Validate every character is `#`, `0`, or `.`. One `.` max.
69    let mut dot_seen = false;
70    for c in s.chars() {
71        match c {
72            '#' | '0' => {}
73            '.' => {
74                if dot_seen {
75                    return Err(format!("format '{s}': multiple '.' separators"));
76                }
77                dot_seen = true;
78            }
79            other => {
80                return Err(format!(
81                    "format '{s}': unexpected '{other}' \
82                 (only '#', '0', '.' accepted)"
83                ));
84            }
85        }
86    }
87
88    let (int_part, dec_part) = match s.split_once('.') {
89        Some((i, d)) => (i, d),
90        None => (s, ""),
91    };
92
93    let integer_places = int_part.len() as u8;
94    let decimal_places = dec_part.len() as u8;
95
96    // Allow `"."` (no integer placeholders) only if there are
97    // decimal placeholders — `""` and `"."` are degenerate.
98    if integer_places == 0 && decimal_places == 0 {
99        return Err(format!("format '{s}': no placeholders found"));
100    }
101
102    Ok(FormatSpec {
103        integer_places,
104        decimal_places,
105    })
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn parse_basic_decimal_patterns() {
114        assert_eq!(
115            parse_format_spec("#.##").unwrap(),
116            FormatSpec {
117                integer_places: 1,
118                decimal_places: 2
119            }
120        );
121        assert_eq!(
122            parse_format_spec("##.###").unwrap(),
123            FormatSpec {
124                integer_places: 2,
125                decimal_places: 3
126            }
127        );
128        assert_eq!(
129            parse_format_spec("0.000").unwrap(),
130            FormatSpec {
131                integer_places: 1,
132                decimal_places: 3
133            }
134        );
135    }
136
137    #[test]
138    fn parse_integer_only() {
139        assert_eq!(
140            parse_format_spec("#").unwrap(),
141            FormatSpec {
142                integer_places: 1,
143                decimal_places: 0
144            }
145        );
146        assert_eq!(
147            parse_format_spec("0").unwrap(),
148            FormatSpec {
149                integer_places: 1,
150                decimal_places: 0
151            }
152        );
153        assert_eq!(
154            parse_format_spec("###").unwrap(),
155            FormatSpec {
156                integer_places: 3,
157                decimal_places: 0
158            }
159        );
160    }
161
162    #[test]
163    fn hash_and_zero_interchangeable() {
164        // SRD-40b: `#` and `0` are interchangeable for
165        // precision purposes (Excel's drop-trailing-zero
166        // distinction doesn't apply — we're rounding, not
167        // rendering).
168        assert_eq!(
169            parse_format_spec("#.##").unwrap().decimal_places,
170            parse_format_spec("0.00").unwrap().decimal_places,
171        );
172        assert_eq!(
173            parse_format_spec("###").unwrap().decimal_places,
174            parse_format_spec("000").unwrap().decimal_places,
175        );
176    }
177
178    #[test]
179    fn rejects_printf_style() {
180        let err = parse_format_spec("%3.2f").unwrap_err();
181        assert!(err.contains("printf-style"));
182    }
183
184    #[test]
185    fn rejects_unknown_chars() {
186        assert!(parse_format_spec("#,###").is_err()); // comma
187        assert!(parse_format_spec("0.0%").is_err()); // percent
188        assert!(parse_format_spec("0.0e2").is_err()); // sci-notation
189        assert!(parse_format_spec("$0.00").is_err()); // currency
190    }
191
192    #[test]
193    fn rejects_multiple_dots() {
194        assert!(parse_format_spec("0.0.0").is_err());
195    }
196
197    #[test]
198    fn rejects_empty_and_degenerate() {
199        assert!(parse_format_spec("").is_err());
200        assert!(parse_format_spec(".").is_err());
201    }
202
203    #[test]
204    fn apply_rounds_to_decimals() {
205        let two = parse_format_spec("#.##").unwrap();
206        assert_eq!(two.apply(1.0), 1.0);
207        assert_eq!(two.apply(1.234), 1.23);
208        assert_eq!(two.apply(1.235), 1.24); // round half up
209        assert_eq!(two.apply(1.999), 2.0);
210
211        let three = parse_format_spec("0.000").unwrap();
212        assert_eq!(three.apply(1.23456), 1.235);
213    }
214
215    #[test]
216    fn apply_rounds_to_integer() {
217        let int = parse_format_spec("#").unwrap();
218        assert_eq!(int.apply(1.0), 1.0);
219        assert_eq!(int.apply(1.49), 1.0);
220        assert_eq!(int.apply(1.51), 2.0);
221        // Rust f64::round rounds half AWAY from zero (not
222        // half-to-even). -0.5 rounds to -1.0; the test pins
223        // that contract so a future libstd change here won't
224        // silently shift synthetic-metric storage values.
225        assert_eq!(int.apply(-0.5), -1.0);
226        assert_eq!(int.apply(0.5), 1.0);
227    }
228
229    #[test]
230    fn apply_preserves_negative_values() {
231        let two = parse_format_spec("#.##").unwrap();
232        assert_eq!(two.apply(-1.234), -1.23);
233        assert_eq!(two.apply(-1.235), -1.24);
234    }
235}