formualizer_eval/
coercion.rs

1use formualizer_common::{ExcelError, ExcelErrorKind, LiteralValue};
2
3/// Centralized coercion and error policy utilities (Milestone 7).
4/// These functions implement invariant, Excel-compatible coercions and
5/// numeric sanitization. They should be used by the interpreter, builtins,
6/// and evaluation pipelines (map/fold/window) instead of ad-hoc parsing.
7/// Strict numeric coercion.
8/// - Accepts Number/Int/Boolean/Empty/Date-like serial-bearing variants
9/// - Rejects Text (returns #VALUE!)
10pub fn to_number_strict(value: &LiteralValue) -> Result<f64, ExcelError> {
11    match value {
12        LiteralValue::Number(n) => Ok(*n),
13        LiteralValue::Int(i) => Ok(*i as f64),
14        LiteralValue::Boolean(b) => Ok(if *b { 1.0 } else { 0.0 }),
15        LiteralValue::Empty => Ok(0.0),
16        // Date/time/duration map to serials
17        other if other.as_serial_number().is_some() => Ok(other.as_serial_number().unwrap()),
18        LiteralValue::Error(e) => Err(e.clone()),
19        _ => Err(ExcelError::new(ExcelErrorKind::Value)
20            .with_message("Cannot convert to number (strict)")),
21    }
22}
23
24/// Lenient numeric coercion.
25/// - As strict, but also parses numeric text using ASCII/invariant rules
26pub fn to_number_lenient(value: &LiteralValue) -> Result<f64, ExcelError> {
27    match value {
28        LiteralValue::Text(s) => s.trim().parse::<f64>().map_err(|_| {
29            ExcelError::new(ExcelErrorKind::Value)
30                .with_message(format!("Cannot convert '{s}' to number"))
31        }),
32        _ => to_number_strict(value),
33    }
34}
35
36/// Context-aware lenient numeric coercion using locale.
37pub fn to_number_lenient_with_locale(
38    value: &LiteralValue,
39    loc: &crate::locale::Locale,
40) -> Result<f64, ExcelError> {
41    match value {
42        LiteralValue::Text(s) => loc.parse_number_invariant(s).ok_or_else(|| {
43            ExcelError::new(ExcelErrorKind::Value)
44                .with_message(format!("Cannot convert '{s}' to number"))
45        }),
46        _ => to_number_strict(value),
47    }
48}
49
50/// Logical coercion.
51/// - Accepts Boolean
52/// - Numbers: nonzero → true, zero → false
53/// - Text: "TRUE"/"FALSE" (ASCII case-insensitive)
54pub fn to_logical(value: &LiteralValue) -> Result<bool, ExcelError> {
55    match value {
56        LiteralValue::Boolean(b) => Ok(*b),
57        LiteralValue::Number(n) => Ok(*n != 0.0),
58        LiteralValue::Int(i) => Ok(*i != 0),
59        LiteralValue::Text(s) => match s.to_ascii_lowercase().as_str() {
60            "true" => Ok(true),
61            "false" => Ok(false),
62            _ => Err(ExcelError::new(ExcelErrorKind::Value)
63                .with_message("Cannot convert text to logical")),
64        },
65        LiteralValue::Empty => Ok(false),
66        LiteralValue::Error(e) => Err(e.clone()),
67        _ => Err(ExcelError::new(ExcelErrorKind::Value).with_message("Cannot convert to logical")),
68    }
69}
70
71/// Invariant textification for comparisons/concatenation.
72pub fn to_text_invariant(value: &LiteralValue) -> String {
73    match value {
74        LiteralValue::Text(s) => s.clone(),
75        LiteralValue::Number(n) => n.to_string(),
76        LiteralValue::Int(i) => i.to_string(),
77        LiteralValue::Boolean(b) => if *b { "TRUE" } else { "FALSE" }.into(),
78        LiteralValue::Error(e) => e.to_string(),
79        LiteralValue::Empty => "".into(),
80        other => format!("{other:?}"),
81    }
82}
83
84/// Numeric sanitization: NaN/Inf → #NUM!
85pub fn sanitize_numeric(n: f64) -> Result<f64, ExcelError> {
86    if n.is_nan() || n.is_infinite() {
87        return Err(ExcelError::new_num());
88    }
89    Ok(n)
90}
91
92/// Coerce to Excel serial (date/time/duration) or error.
93pub fn to_datetime_serial(value: &LiteralValue) -> Result<f64, ExcelError> {
94    match value.as_serial_number() {
95        Some(n) => Ok(n),
96        None => Err(ExcelError::new(ExcelErrorKind::Value)
97            .with_message("Cannot convert to date/time serial")),
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn number_lenient_parses_text_and_booleans() {
107        assert_eq!(
108            to_number_lenient(&LiteralValue::Text(" 42 ".into())).unwrap(),
109            42.0
110        );
111        assert_eq!(
112            to_number_lenient(&LiteralValue::Boolean(true)).unwrap(),
113            1.0
114        );
115        assert_eq!(to_number_lenient(&LiteralValue::Empty).unwrap(), 0.0);
116    }
117
118    #[test]
119    fn number_strict_rejects_text() {
120        assert!(to_number_strict(&LiteralValue::Text("1".into())).is_err());
121    }
122
123    #[test]
124    fn logical_from_number_and_text() {
125        assert!(to_logical(&LiteralValue::Int(5)).unwrap());
126        assert!(!to_logical(&LiteralValue::Number(0.0)).unwrap());
127        assert!(to_logical(&LiteralValue::Text("TRUE".into())).unwrap());
128        assert!(to_logical(&LiteralValue::Text("true".into())).unwrap());
129        assert!(to_logical(&LiteralValue::Text(" True ".into())).is_err());
130    }
131
132    #[test]
133    fn sanitize_numeric_nan_inf() {
134        assert!(sanitize_numeric(f64::NAN).is_err());
135        assert!(sanitize_numeric(f64::INFINITY).is_err());
136        assert_eq!(sanitize_numeric(1.5).unwrap(), 1.5);
137    }
138}