#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FormatSpec {
pub decimal_places: u8,
pub integer_places: u8,
}
impl FormatSpec {
pub fn apply(&self, value: f64) -> f64 {
let scale = 10f64.powi(self.decimal_places as i32);
(value * scale).round() / scale
}
}
pub fn parse_format_spec(s: &str) -> Result<FormatSpec, String> {
if s.is_empty() {
return Err("empty format string".into());
}
if s.starts_with('%') {
return Err(format!(
"printf-style '{s}' not accepted; use Excel-style \
hash patterns like '#.##' or '0.000'"
));
}
let mut dot_seen = false;
for c in s.chars() {
match c {
'#' | '0' => {}
'.' => {
if dot_seen {
return Err(format!("format '{s}': multiple '.' separators"));
}
dot_seen = true;
}
other => {
return Err(format!(
"format '{s}': unexpected '{other}' \
(only '#', '0', '.' accepted)"
));
}
}
}
let (int_part, dec_part) = match s.split_once('.') {
Some((i, d)) => (i, d),
None => (s, ""),
};
let integer_places = int_part.len() as u8;
let decimal_places = dec_part.len() as u8;
if integer_places == 0 && decimal_places == 0 {
return Err(format!("format '{s}': no placeholders found"));
}
Ok(FormatSpec {
integer_places,
decimal_places,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_decimal_patterns() {
assert_eq!(
parse_format_spec("#.##").unwrap(),
FormatSpec {
integer_places: 1,
decimal_places: 2
}
);
assert_eq!(
parse_format_spec("##.###").unwrap(),
FormatSpec {
integer_places: 2,
decimal_places: 3
}
);
assert_eq!(
parse_format_spec("0.000").unwrap(),
FormatSpec {
integer_places: 1,
decimal_places: 3
}
);
}
#[test]
fn parse_integer_only() {
assert_eq!(
parse_format_spec("#").unwrap(),
FormatSpec {
integer_places: 1,
decimal_places: 0
}
);
assert_eq!(
parse_format_spec("0").unwrap(),
FormatSpec {
integer_places: 1,
decimal_places: 0
}
);
assert_eq!(
parse_format_spec("###").unwrap(),
FormatSpec {
integer_places: 3,
decimal_places: 0
}
);
}
#[test]
fn hash_and_zero_interchangeable() {
assert_eq!(
parse_format_spec("#.##").unwrap().decimal_places,
parse_format_spec("0.00").unwrap().decimal_places,
);
assert_eq!(
parse_format_spec("###").unwrap().decimal_places,
parse_format_spec("000").unwrap().decimal_places,
);
}
#[test]
fn rejects_printf_style() {
let err = parse_format_spec("%3.2f").unwrap_err();
assert!(err.contains("printf-style"));
}
#[test]
fn rejects_unknown_chars() {
assert!(parse_format_spec("#,###").is_err()); assert!(parse_format_spec("0.0%").is_err()); assert!(parse_format_spec("0.0e2").is_err()); assert!(parse_format_spec("$0.00").is_err()); }
#[test]
fn rejects_multiple_dots() {
assert!(parse_format_spec("0.0.0").is_err());
}
#[test]
fn rejects_empty_and_degenerate() {
assert!(parse_format_spec("").is_err());
assert!(parse_format_spec(".").is_err());
}
#[test]
fn apply_rounds_to_decimals() {
let two = parse_format_spec("#.##").unwrap();
assert_eq!(two.apply(1.0), 1.0);
assert_eq!(two.apply(1.234), 1.23);
assert_eq!(two.apply(1.235), 1.24); assert_eq!(two.apply(1.999), 2.0);
let three = parse_format_spec("0.000").unwrap();
assert_eq!(three.apply(1.23456), 1.235);
}
#[test]
fn apply_rounds_to_integer() {
let int = parse_format_spec("#").unwrap();
assert_eq!(int.apply(1.0), 1.0);
assert_eq!(int.apply(1.49), 1.0);
assert_eq!(int.apply(1.51), 2.0);
assert_eq!(int.apply(-0.5), -1.0);
assert_eq!(int.apply(0.5), 1.0);
}
#[test]
fn apply_preserves_negative_values() {
let two = parse_format_spec("#.##").unwrap();
assert_eq!(two.apply(-1.234), -1.23);
assert_eq!(two.apply(-1.235), -1.24);
}
}