Skip to main content

formualizer_eval/builtins/datetime/
edate_eomonth.rs

1//! EDATE and EOMONTH functions for date arithmetic
2
3use super::serial::{date_to_serial, serial_to_date};
4use crate::args::ArgSchema;
5use crate::function::Function;
6use crate::traits::{ArgumentHandle, FunctionContext};
7use chrono::{Datelike, NaiveDate};
8use formualizer_common::{ExcelError, LiteralValue};
9use formualizer_macros::func_caps;
10
11fn coerce_to_serial(arg: &ArgumentHandle) -> Result<f64, ExcelError> {
12    let v = arg.value()?.into_literal();
13    match v {
14        LiteralValue::Number(f) => Ok(f),
15        LiteralValue::Int(i) => Ok(i as f64),
16        LiteralValue::Text(s) => s.parse::<f64>().map_err(|_| {
17            ExcelError::new_value().with_message("EDATE/EOMONTH start_date is not a valid number")
18        }),
19        LiteralValue::Boolean(b) => Ok(if b { 1.0 } else { 0.0 }),
20        LiteralValue::Empty => Ok(0.0),
21        LiteralValue::Error(e) => Err(e),
22        _ => Err(ExcelError::new_value()
23            .with_message("EDATE/EOMONTH expects numeric or text-numeric arguments")),
24    }
25}
26
27fn coerce_to_int(arg: &ArgumentHandle) -> Result<i32, ExcelError> {
28    let v = arg.value()?.into_literal();
29    match v {
30        LiteralValue::Int(i) => Ok(i as i32),
31        LiteralValue::Number(f) => Ok(f.trunc() as i32),
32        LiteralValue::Text(s) => s.parse::<f64>().map(|f| f.trunc() as i32).map_err(|_| {
33            ExcelError::new_value().with_message("EDATE/EOMONTH months is not a valid number")
34        }),
35        LiteralValue::Boolean(b) => Ok(if b { 1 } else { 0 }),
36        LiteralValue::Empty => Ok(0),
37        LiteralValue::Error(e) => Err(e),
38        _ => Err(ExcelError::new_value()
39            .with_message("EDATE/EOMONTH expects numeric or text-numeric arguments")),
40    }
41}
42
43/// EDATE(start_date, months) - Returns date that is months away from start_date
44#[derive(Debug)]
45pub struct EdateFn;
46
47impl Function for EdateFn {
48    func_caps!(PURE);
49
50    fn name(&self) -> &'static str {
51        "EDATE"
52    }
53
54    fn min_args(&self) -> usize {
55        2
56    }
57
58    fn arg_schema(&self) -> &'static [ArgSchema] {
59        use std::sync::LazyLock;
60        static TWO: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
61            vec![
62                // start_date serial (numeric lenient)
63                ArgSchema::number_lenient_scalar(),
64                // months offset (numeric lenient)
65                ArgSchema::number_lenient_scalar(),
66            ]
67        });
68        &TWO[..]
69    }
70
71    fn eval<'a, 'b, 'c>(
72        &self,
73        args: &'c [ArgumentHandle<'a, 'b>],
74        _ctx: &dyn FunctionContext<'b>,
75    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
76        let start_serial = coerce_to_serial(&args[0])?;
77        let months = coerce_to_int(&args[1])?;
78
79        let start_date = serial_to_date(start_serial)?;
80
81        // Calculate target year and month
82        let total_months = start_date.year() * 12 + start_date.month() as i32 + months;
83        let target_year = total_months / 12;
84        let target_month = ((total_months % 12) + 12) % 12; // Handle negative modulo
85        let target_month = if target_month == 0 { 12 } else { target_month };
86
87        // Keep the same day, but handle month-end overflow
88        let max_day = last_day_of_month(target_year, target_month as u32);
89        let target_day = start_date.day().min(max_day);
90
91        let target_date = NaiveDate::from_ymd_opt(target_year, target_month as u32, target_day)
92            .ok_or_else(ExcelError::new_num)?;
93
94        Ok(crate::traits::CalcValue::Scalar(LiteralValue::Number(
95            date_to_serial(&target_date),
96        )))
97    }
98}
99
100/// EOMONTH(start_date, months) - Returns last day of month that is months away
101#[derive(Debug)]
102pub struct EomonthFn;
103
104impl Function for EomonthFn {
105    func_caps!(PURE);
106
107    fn name(&self) -> &'static str {
108        "EOMONTH"
109    }
110
111    fn min_args(&self) -> usize {
112        2
113    }
114
115    fn arg_schema(&self) -> &'static [ArgSchema] {
116        use std::sync::LazyLock;
117        static TWO: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
118            vec![
119                ArgSchema::number_lenient_scalar(),
120                ArgSchema::number_lenient_scalar(),
121            ]
122        });
123        &TWO[..]
124    }
125
126    fn eval<'a, 'b, 'c>(
127        &self,
128        args: &'c [ArgumentHandle<'a, 'b>],
129        _ctx: &dyn FunctionContext<'b>,
130    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
131        let start_serial = coerce_to_serial(&args[0])?;
132        let months = coerce_to_int(&args[1])?;
133
134        let start_date = serial_to_date(start_serial)?;
135
136        // Calculate target year and month
137        let total_months = start_date.year() * 12 + start_date.month() as i32 + months;
138        let target_year = total_months / 12;
139        let target_month = ((total_months % 12) + 12) % 12; // Handle negative modulo
140        let target_month = if target_month == 0 { 12 } else { target_month };
141
142        // Get the last day of the target month
143        let last_day = last_day_of_month(target_year, target_month as u32);
144
145        let target_date = NaiveDate::from_ymd_opt(target_year, target_month as u32, last_day)
146            .ok_or_else(ExcelError::new_num)?;
147
148        Ok(crate::traits::CalcValue::Scalar(LiteralValue::Number(
149            date_to_serial(&target_date),
150        )))
151    }
152}
153
154/// Helper to get the last day of a month
155fn last_day_of_month(year: i32, month: u32) -> u32 {
156    // Try day 31, then 30, 29, 28
157    for day in (28..=31).rev() {
158        if NaiveDate::from_ymd_opt(year, month, day).is_some() {
159            return day;
160        }
161    }
162    28 // Fallback (should never reach here for valid months)
163}
164
165pub fn register_builtins() {
166    use std::sync::Arc;
167    crate::function_registry::register_function(Arc::new(EdateFn));
168    crate::function_registry::register_function(Arc::new(EomonthFn));
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::test_workbook::TestWorkbook;
175    use formualizer_parse::parser::{ASTNode, ASTNodeType};
176    use std::sync::Arc;
177
178    fn lit(v: LiteralValue) -> ASTNode {
179        ASTNode::new(ASTNodeType::Literal(v), None)
180    }
181
182    #[test]
183    fn test_edate_basic() {
184        let wb = TestWorkbook::new().with_function(Arc::new(EdateFn));
185        let ctx = wb.interpreter();
186        let f = ctx.context.get_function("", "EDATE").unwrap();
187
188        // Test adding months
189        // Use a known date serial (e.g., 44927 = 2023-01-01)
190        let start = lit(LiteralValue::Number(44927.0));
191        let months = lit(LiteralValue::Int(3));
192
193        let result = f
194            .dispatch(
195                &[
196                    ArgumentHandle::new(&start, &ctx),
197                    ArgumentHandle::new(&months, &ctx),
198                ],
199                &ctx.function_context(None),
200            )
201            .unwrap()
202            .into_literal();
203
204        // Should return a date 3 months later
205        assert!(matches!(result, LiteralValue::Number(_)));
206    }
207
208    #[test]
209    fn test_edate_negative_months() {
210        let wb = TestWorkbook::new().with_function(Arc::new(EdateFn));
211        let ctx = wb.interpreter();
212        let f = ctx.context.get_function("", "EDATE").unwrap();
213
214        // Test subtracting months
215        let start = lit(LiteralValue::Number(44927.0)); // 2023-01-01
216        let months = lit(LiteralValue::Int(-2));
217
218        let result = f
219            .dispatch(
220                &[
221                    ArgumentHandle::new(&start, &ctx),
222                    ArgumentHandle::new(&months, &ctx),
223                ],
224                &ctx.function_context(None),
225            )
226            .unwrap()
227            .into_literal();
228
229        // Should return a date 2 months earlier
230        assert!(matches!(result, LiteralValue::Number(_)));
231    }
232
233    #[test]
234    fn test_eomonth_basic() {
235        let wb = TestWorkbook::new().with_function(Arc::new(EomonthFn));
236        let ctx = wb.interpreter();
237        let f = ctx.context.get_function("", "EOMONTH").unwrap();
238
239        // Test end of month
240        let start = lit(LiteralValue::Number(44927.0)); // 2023-01-01
241        let months = lit(LiteralValue::Int(0));
242
243        let result = f
244            .dispatch(
245                &[
246                    ArgumentHandle::new(&start, &ctx),
247                    ArgumentHandle::new(&months, &ctx),
248                ],
249                &ctx.function_context(None),
250            )
251            .unwrap()
252            .into_literal();
253
254        // Should return Jan 31, 2023
255        assert!(matches!(result, LiteralValue::Number(_)));
256    }
257
258    #[test]
259    fn test_eomonth_february() {
260        let wb = TestWorkbook::new().with_function(Arc::new(EomonthFn));
261        let ctx = wb.interpreter();
262        let f = ctx.context.get_function("", "EOMONTH").unwrap();
263
264        // Test February (checking leap year handling)
265        let start = lit(LiteralValue::Number(44927.0)); // 2023-01-01
266        let months = lit(LiteralValue::Int(1)); // Move to February
267
268        let result = f
269            .dispatch(
270                &[
271                    ArgumentHandle::new(&start, &ctx),
272                    ArgumentHandle::new(&months, &ctx),
273                ],
274                &ctx.function_context(None),
275            )
276            .unwrap()
277            .into_literal();
278
279        // Should return Feb 28, 2023 (not a leap year)
280        assert!(matches!(result, LiteralValue::Number(_)));
281    }
282}