Skip to main content

formualizer_eval/builtins/datetime/
weekday_workday.rs

1//! WEEKDAY, WEEKNUM, DATEDIF, NETWORKDAYS, WORKDAY functions
2
3use super::serial::{date_to_serial, serial_to_date};
4use crate::args::ArgSchema;
5use crate::function::Function;
6use crate::traits::{ArgumentHandle, CalcValue, FunctionContext};
7use chrono::{Datelike, NaiveDate, Weekday};
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::Date(d) => Ok(date_to_serial(&d)),
17        LiteralValue::DateTime(dt) => Ok(date_to_serial(&dt.date())),
18        LiteralValue::Text(s) => s
19            .parse::<f64>()
20            .map_err(|_| ExcelError::new_value().with_message("Not a valid number")),
21        LiteralValue::Boolean(b) => Ok(if b { 1.0 } else { 0.0 }),
22        LiteralValue::Empty => Ok(0.0),
23        LiteralValue::Error(e) => Err(e),
24        _ => Err(ExcelError::new_value()),
25    }
26}
27
28fn coerce_to_int(arg: &ArgumentHandle) -> Result<i64, ExcelError> {
29    let v = arg.value()?.into_literal();
30    match v {
31        LiteralValue::Number(f) => Ok(f.trunc() as i64),
32        LiteralValue::Int(i) => Ok(i),
33        LiteralValue::Boolean(b) => Ok(if b { 1 } else { 0 }),
34        LiteralValue::Empty => Ok(0),
35        LiteralValue::Error(e) => Err(e),
36        _ => Err(ExcelError::new_value()),
37    }
38}
39
40/// WEEKDAY(serial_number, [return_type]) - Returns the day of the week
41/// return_type:
42///   1 (default): 1 (Sunday) to 7 (Saturday)
43///   2: 1 (Monday) to 7 (Sunday)
44///   3: 0 (Monday) to 6 (Sunday)
45///   11-17: Various configurations
46#[derive(Debug)]
47pub struct WeekdayFn;
48impl Function for WeekdayFn {
49    func_caps!(PURE);
50    fn name(&self) -> &'static str {
51        "WEEKDAY"
52    }
53    fn min_args(&self) -> usize {
54        1
55    }
56    fn variadic(&self) -> bool {
57        true
58    }
59    fn arg_schema(&self) -> &'static [ArgSchema] {
60        use std::sync::LazyLock;
61        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
62            vec![
63                ArgSchema::number_lenient_scalar(),
64                ArgSchema::number_lenient_scalar(),
65            ]
66        });
67        &SCHEMA[..]
68    }
69    fn eval<'a, 'b, 'c>(
70        &self,
71        args: &'c [ArgumentHandle<'a, 'b>],
72        _ctx: &dyn FunctionContext<'b>,
73    ) -> Result<CalcValue<'b>, ExcelError> {
74        let serial = coerce_to_serial(&args[0])?;
75        let return_type = if args.len() > 1 {
76            coerce_to_int(&args[1])?
77        } else {
78            1
79        };
80
81        let date = serial_to_date(serial)?;
82        let weekday = date.weekday();
83
84        // Convert chrono weekday (Mon=0..Sun=6) to Excel format
85        let result = match return_type {
86            1 => match weekday {
87                Weekday::Sun => 1,
88                Weekday::Mon => 2,
89                Weekday::Tue => 3,
90                Weekday::Wed => 4,
91                Weekday::Thu => 5,
92                Weekday::Fri => 6,
93                Weekday::Sat => 7,
94            },
95            2 => match weekday {
96                Weekday::Mon => 1,
97                Weekday::Tue => 2,
98                Weekday::Wed => 3,
99                Weekday::Thu => 4,
100                Weekday::Fri => 5,
101                Weekday::Sat => 6,
102                Weekday::Sun => 7,
103            },
104            3 => match weekday {
105                Weekday::Mon => 0,
106                Weekday::Tue => 1,
107                Weekday::Wed => 2,
108                Weekday::Thu => 3,
109                Weekday::Fri => 4,
110                Weekday::Sat => 5,
111                Weekday::Sun => 6,
112            },
113            11 => match weekday {
114                // Mon=1..Sun=7
115                Weekday::Mon => 1,
116                Weekday::Tue => 2,
117                Weekday::Wed => 3,
118                Weekday::Thu => 4,
119                Weekday::Fri => 5,
120                Weekday::Sat => 6,
121                Weekday::Sun => 7,
122            },
123            12 => match weekday {
124                // Tue=1..Mon=7
125                Weekday::Tue => 1,
126                Weekday::Wed => 2,
127                Weekday::Thu => 3,
128                Weekday::Fri => 4,
129                Weekday::Sat => 5,
130                Weekday::Sun => 6,
131                Weekday::Mon => 7,
132            },
133            13 => match weekday {
134                // Wed=1..Tue=7
135                Weekday::Wed => 1,
136                Weekday::Thu => 2,
137                Weekday::Fri => 3,
138                Weekday::Sat => 4,
139                Weekday::Sun => 5,
140                Weekday::Mon => 6,
141                Weekday::Tue => 7,
142            },
143            14 => match weekday {
144                // Thu=1..Wed=7
145                Weekday::Thu => 1,
146                Weekday::Fri => 2,
147                Weekday::Sat => 3,
148                Weekday::Sun => 4,
149                Weekday::Mon => 5,
150                Weekday::Tue => 6,
151                Weekday::Wed => 7,
152            },
153            15 => match weekday {
154                // Fri=1..Thu=7
155                Weekday::Fri => 1,
156                Weekday::Sat => 2,
157                Weekday::Sun => 3,
158                Weekday::Mon => 4,
159                Weekday::Tue => 5,
160                Weekday::Wed => 6,
161                Weekday::Thu => 7,
162            },
163            16 => match weekday {
164                // Sat=1..Fri=7
165                Weekday::Sat => 1,
166                Weekday::Sun => 2,
167                Weekday::Mon => 3,
168                Weekday::Tue => 4,
169                Weekday::Wed => 5,
170                Weekday::Thu => 6,
171                Weekday::Fri => 7,
172            },
173            17 => match weekday {
174                // Sun=1..Sat=7
175                Weekday::Sun => 1,
176                Weekday::Mon => 2,
177                Weekday::Tue => 3,
178                Weekday::Wed => 4,
179                Weekday::Thu => 5,
180                Weekday::Fri => 6,
181                Weekday::Sat => 7,
182            },
183            _ => {
184                return Ok(CalcValue::Scalar(
185                    LiteralValue::Error(ExcelError::new_num()),
186                ));
187            }
188        };
189
190        Ok(CalcValue::Scalar(LiteralValue::Int(result)))
191    }
192}
193
194/// WEEKNUM(serial_number, [return_type]) - Returns the week number of the year
195#[derive(Debug)]
196pub struct WeeknumFn;
197impl Function for WeeknumFn {
198    func_caps!(PURE);
199    fn name(&self) -> &'static str {
200        "WEEKNUM"
201    }
202    fn min_args(&self) -> usize {
203        1
204    }
205    fn variadic(&self) -> bool {
206        true
207    }
208    fn arg_schema(&self) -> &'static [ArgSchema] {
209        use std::sync::LazyLock;
210        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
211            vec![
212                ArgSchema::number_lenient_scalar(),
213                ArgSchema::number_lenient_scalar(),
214            ]
215        });
216        &SCHEMA[..]
217    }
218    fn eval<'a, 'b, 'c>(
219        &self,
220        args: &'c [ArgumentHandle<'a, 'b>],
221        _ctx: &dyn FunctionContext<'b>,
222    ) -> Result<CalcValue<'b>, ExcelError> {
223        let serial = coerce_to_serial(&args[0])?;
224        let return_type = if args.len() > 1 {
225            coerce_to_int(&args[1])?
226        } else {
227            1
228        };
229
230        let date = serial_to_date(serial)?;
231
232        // Determine first day of week
233        let week_starts = match return_type {
234            1 | 17 => Weekday::Sun,
235            2 | 11 => Weekday::Mon,
236            12 => Weekday::Tue,
237            13 => Weekday::Wed,
238            14 => Weekday::Thu,
239            15 => Weekday::Fri,
240            16 => Weekday::Sat,
241            21 => {
242                // ISO week number (special case)
243                return Ok(CalcValue::Scalar(LiteralValue::Int(
244                    date.iso_week().week() as i64
245                )));
246            }
247            _ => {
248                return Ok(CalcValue::Scalar(
249                    LiteralValue::Error(ExcelError::new_num()),
250                ));
251            }
252        };
253
254        // Calculate week number based on when week starts
255        let jan1 = NaiveDate::from_ymd_opt(date.year(), 1, 1).unwrap();
256        let jan1_weekday = jan1.weekday();
257
258        // Days from week start day to Jan 1
259        let days_to_week_start = |wd: Weekday| -> i64 {
260            let target = week_starts.num_days_from_sunday() as i64;
261            let current = wd.num_days_from_sunday() as i64;
262            (current - target + 7) % 7
263        };
264
265        let jan1_offset = days_to_week_start(jan1_weekday);
266        let day_of_year = date.ordinal() as i64;
267
268        // Week 1 starts on the first occurrence of week_starts day, or Jan 1 if it is that day
269        let week_num = if jan1_offset == 0 {
270            (day_of_year - 1) / 7 + 1
271        } else {
272            (day_of_year + jan1_offset - 1) / 7 + 1
273        };
274
275        Ok(CalcValue::Scalar(LiteralValue::Int(week_num)))
276    }
277}
278
279/// DATEDIF(start_date, end_date, unit) - Calculates the difference between two dates
280/// unit: "Y" (years), "M" (months), "D" (days), "MD", "YM", "YD"
281///
282/// NOTE: The "YD" unit has a known minor edge case with Feb 29 leap year handling.
283/// It uses .min(28) which may produce slightly different results than Excel for
284/// certain leap year date combinations.
285#[derive(Debug)]
286pub struct DatedifFn;
287impl Function for DatedifFn {
288    func_caps!(PURE);
289    fn name(&self) -> &'static str {
290        "DATEDIF"
291    }
292    fn min_args(&self) -> usize {
293        3
294    }
295    fn arg_schema(&self) -> &'static [ArgSchema] {
296        use std::sync::LazyLock;
297        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
298            vec![
299                ArgSchema::number_lenient_scalar(),
300                ArgSchema::number_lenient_scalar(),
301                ArgSchema::any(),
302            ]
303        });
304        &SCHEMA[..]
305    }
306    fn eval<'a, 'b, 'c>(
307        &self,
308        args: &'c [ArgumentHandle<'a, 'b>],
309        _ctx: &dyn FunctionContext<'b>,
310    ) -> Result<CalcValue<'b>, ExcelError> {
311        let start_serial = coerce_to_serial(&args[0])?;
312        let end_serial = coerce_to_serial(&args[1])?;
313
314        let unit = match args[2].value()?.into_literal() {
315            LiteralValue::Text(s) => s.to_uppercase(),
316            LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
317            _ => {
318                return Ok(CalcValue::Scalar(LiteralValue::Error(
319                    ExcelError::new_value(),
320                )));
321            }
322        };
323
324        if start_serial > end_serial {
325            return Ok(CalcValue::Scalar(
326                LiteralValue::Error(ExcelError::new_num()),
327            ));
328        }
329
330        let start_date = serial_to_date(start_serial)?;
331        let end_date = serial_to_date(end_serial)?;
332
333        let result = match unit.as_str() {
334            "Y" => {
335                // Complete years
336                let mut years = end_date.year() - start_date.year();
337                if (end_date.month(), end_date.day()) < (start_date.month(), start_date.day()) {
338                    years -= 1;
339                }
340                years as i64
341            }
342            "M" => {
343                // Complete months
344                let mut months = (end_date.year() - start_date.year()) * 12
345                    + (end_date.month() as i32 - start_date.month() as i32);
346                if end_date.day() < start_date.day() {
347                    months -= 1;
348                }
349                months as i64
350            }
351            "D" => {
352                // Days
353                (end_date - start_date).num_days()
354            }
355            "MD" => {
356                // Days ignoring months and years
357                let mut days = end_date.day() as i64 - start_date.day() as i64;
358                if days < 0 {
359                    // Get days in the previous month
360                    let prev_month = if end_date.month() == 1 {
361                        NaiveDate::from_ymd_opt(end_date.year() - 1, 12, 1)
362                    } else {
363                        NaiveDate::from_ymd_opt(end_date.year(), end_date.month() - 1, 1)
364                    }
365                    .unwrap();
366                    let days_in_prev_month = (NaiveDate::from_ymd_opt(
367                        if prev_month.month() == 12 {
368                            prev_month.year() + 1
369                        } else {
370                            prev_month.year()
371                        },
372                        if prev_month.month() == 12 {
373                            1
374                        } else {
375                            prev_month.month() + 1
376                        },
377                        1,
378                    )
379                    .unwrap()
380                        - prev_month)
381                        .num_days();
382                    days += days_in_prev_month;
383                }
384                days
385            }
386            "YM" => {
387                // Months ignoring years
388                let mut months = end_date.month() as i64 - start_date.month() as i64;
389                if end_date.day() < start_date.day() {
390                    months -= 1;
391                }
392                if months < 0 {
393                    months += 12;
394                }
395                months
396            }
397            "YD" => {
398                // Days ignoring years
399                // NOTE: Known edge case - uses .min(28) for Feb 29 handling which may differ from Excel
400                let start_in_end_year = NaiveDate::from_ymd_opt(
401                    end_date.year(),
402                    start_date.month(),
403                    start_date.day().min(28), // Handle Feb 29 -> Feb 28
404                );
405                match start_in_end_year {
406                    Some(d) if d <= end_date => (end_date - d).num_days(),
407                    _ => {
408                        // Start date would be after end date in same year, use previous year
409                        let start_prev_year = NaiveDate::from_ymd_opt(
410                            end_date.year() - 1,
411                            start_date.month(),
412                            start_date.day().min(28),
413                        )
414                        .unwrap();
415                        (end_date - start_prev_year).num_days()
416                    }
417                }
418            }
419            _ => {
420                return Ok(CalcValue::Scalar(
421                    LiteralValue::Error(ExcelError::new_num()),
422                ));
423            }
424        };
425
426        Ok(CalcValue::Scalar(LiteralValue::Int(result)))
427    }
428}
429
430/// Helper: check if a date is a weekend (Saturday or Sunday)
431fn is_weekend(date: &NaiveDate) -> bool {
432    matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
433}
434
435/// NETWORKDAYS(start_date, end_date, [holidays]) - Returns working days between two dates
436///
437/// NOTE: The holidays parameter is currently accepted but not implemented.
438/// Holiday values passed to this function will be silently ignored.
439#[derive(Debug)]
440pub struct NetworkdaysFn;
441impl Function for NetworkdaysFn {
442    func_caps!(PURE);
443    fn name(&self) -> &'static str {
444        "NETWORKDAYS"
445    }
446    fn min_args(&self) -> usize {
447        2
448    }
449    fn variadic(&self) -> bool {
450        true
451    }
452    fn arg_schema(&self) -> &'static [ArgSchema] {
453        use std::sync::LazyLock;
454        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
455            vec![
456                ArgSchema::number_lenient_scalar(),
457                ArgSchema::number_lenient_scalar(),
458                ArgSchema::any(), // holidays (optional)
459            ]
460        });
461        &SCHEMA[..]
462    }
463    fn eval<'a, 'b, 'c>(
464        &self,
465        args: &'c [ArgumentHandle<'a, 'b>],
466        _ctx: &dyn FunctionContext<'b>,
467    ) -> Result<CalcValue<'b>, ExcelError> {
468        let start_serial = coerce_to_serial(&args[0])?;
469        let end_serial = coerce_to_serial(&args[1])?;
470
471        let start_date = serial_to_date(start_serial)?;
472        let end_date = serial_to_date(end_serial)?;
473
474        // Collect holidays if provided
475        // TODO: Implement holiday array support
476        let holidays: Vec<NaiveDate> = if args.len() > 2 {
477            // For now, skip holiday handling (would need array support)
478            vec![]
479        } else {
480            vec![]
481        };
482
483        let (start, end, sign) = if start_date <= end_date {
484            (start_date, end_date, 1i64)
485        } else {
486            (end_date, start_date, -1i64)
487        };
488
489        let mut count = 0i64;
490        let mut current = start;
491        while current <= end {
492            if !is_weekend(&current) && !holidays.contains(&current) {
493                count += 1;
494            }
495            current = current.succ_opt().unwrap_or(current);
496        }
497
498        Ok(CalcValue::Scalar(LiteralValue::Int(count * sign)))
499    }
500}
501
502/// WORKDAY(start_date, days, [holidays]) - Returns a date that is a specified number of working days away
503///
504/// NOTE: The holidays parameter is currently accepted but not implemented.
505/// Holiday values passed to this function will be silently ignored.
506#[derive(Debug)]
507pub struct WorkdayFn;
508impl Function for WorkdayFn {
509    func_caps!(PURE);
510    fn name(&self) -> &'static str {
511        "WORKDAY"
512    }
513    fn min_args(&self) -> usize {
514        2
515    }
516    fn variadic(&self) -> bool {
517        true
518    }
519    fn arg_schema(&self) -> &'static [ArgSchema] {
520        use std::sync::LazyLock;
521        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
522            vec![
523                ArgSchema::number_lenient_scalar(),
524                ArgSchema::number_lenient_scalar(),
525                ArgSchema::any(), // holidays (optional)
526            ]
527        });
528        &SCHEMA[..]
529    }
530    fn eval<'a, 'b, 'c>(
531        &self,
532        args: &'c [ArgumentHandle<'a, 'b>],
533        _ctx: &dyn FunctionContext<'b>,
534    ) -> Result<CalcValue<'b>, ExcelError> {
535        let start_serial = coerce_to_serial(&args[0])?;
536        let days = coerce_to_int(&args[1])?;
537
538        let start_date = serial_to_date(start_serial)?;
539
540        // Collect holidays if provided
541        // TODO: Implement holiday array support
542        // Holidays parameter is currently accepted but ignored.
543        let holidays: Vec<NaiveDate> = Vec::new();
544
545        let mut current = start_date;
546        let mut remaining = days.abs();
547        let direction: i64 = if days >= 0 { 1 } else { -1 };
548
549        while remaining > 0 {
550            current = if direction > 0 {
551                current.succ_opt().ok_or_else(ExcelError::new_num)?
552            } else {
553                current.pred_opt().ok_or_else(ExcelError::new_num)?
554            };
555
556            if !is_weekend(&current) && !holidays.contains(&current) {
557                remaining -= 1;
558            }
559        }
560
561        Ok(CalcValue::Scalar(LiteralValue::Number(date_to_serial(
562            &current,
563        ))))
564    }
565}
566
567pub fn register_builtins() {
568    use std::sync::Arc;
569    crate::function_registry::register_function(Arc::new(WeekdayFn));
570    crate::function_registry::register_function(Arc::new(WeeknumFn));
571    crate::function_registry::register_function(Arc::new(DatedifFn));
572    crate::function_registry::register_function(Arc::new(NetworkdaysFn));
573    crate::function_registry::register_function(Arc::new(WorkdayFn));
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use crate::test_workbook::TestWorkbook;
580    use crate::traits::ArgumentHandle;
581    use formualizer_parse::parser::{ASTNode, ASTNodeType};
582
583    fn interp(wb: &TestWorkbook) -> crate::interpreter::Interpreter<'_> {
584        wb.interpreter()
585    }
586    fn lit(v: LiteralValue) -> ASTNode {
587        ASTNode::new(ASTNodeType::Literal(v), None)
588    }
589
590    #[test]
591    fn weekday_basic() {
592        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(WeekdayFn));
593        let ctx = interp(&wb);
594        // Jan 1, 2024 is a Monday
595        // Serial for 2024-01-01: date_to_serial gives us the value
596        let serial = date_to_serial(&NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
597        let n = lit(LiteralValue::Number(serial));
598        let f = ctx.context.get_function("", "WEEKDAY").unwrap();
599        // Default return_type=1: Monday=2
600        assert_eq!(
601            f.dispatch(
602                &[ArgumentHandle::new(&n, &ctx)],
603                &ctx.function_context(None)
604            )
605            .unwrap()
606            .into_literal(),
607            LiteralValue::Int(2)
608        );
609    }
610
611    #[test]
612    fn datedif_years() {
613        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(DatedifFn));
614        let ctx = interp(&wb);
615        let start = date_to_serial(&NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
616        let end = date_to_serial(&NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
617        let s = lit(LiteralValue::Number(start));
618        let e = lit(LiteralValue::Number(end));
619        let unit = lit(LiteralValue::Text("Y".to_string()));
620        let f = ctx.context.get_function("", "DATEDIF").unwrap();
621        assert_eq!(
622            f.dispatch(
623                &[
624                    ArgumentHandle::new(&s, &ctx),
625                    ArgumentHandle::new(&e, &ctx),
626                    ArgumentHandle::new(&unit, &ctx)
627                ],
628                &ctx.function_context(None)
629            )
630            .unwrap()
631            .into_literal(),
632            LiteralValue::Int(4)
633        );
634    }
635}