Skip to main content

runmat_runtime/builtins/datetime/
mod.rs

1use std::cell::Cell;
2use std::collections::{HashMap, HashSet};
3
4use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, Timelike, Weekday};
5use runmat_builtins::{
6    Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8    CharArray, ClassDef, MethodDef, ObjectInstance, PropertyDef, StringArray, Tensor, Value,
9};
10
11use crate::builtins::common::tensor;
12use crate::{
13    build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError, OBJECT_INDEX_MEMBER,
14    OBJECT_INDEX_PAREN, OBJECT_SUBSASGN_METHOD, OBJECT_SUBSREF_METHOD,
15};
16
17const BUILTIN_NAME: &str = "datetime";
18const DATETIME_CLASS: &str = "datetime";
19const CALENDAR_DURATION_CLASS: &str = "calendarDuration";
20const SERIAL_FIELD: &str = "__serial";
21const CALENDAR_MONTHS_FIELD: &str = "__months";
22const CALENDAR_DAYS_FIELD: &str = "__days";
23const FORMAT_FIELD: &str = "Format";
24const DEFAULT_DATE_FORMAT: &str = "dd-MMM-yyyy";
25const DEFAULT_DATETIME_FORMAT: &str = "dd-MMM-yyyy HH:mm:ss";
26const UNIX_DATENUM: f64 = 719_529.0;
27const SECONDS_PER_DAY: f64 = 86_400.0;
28const MAX_HOLIDAY_YEAR_SPAN: i32 = 1_000;
29const MAX_BUSDAYS_OUTPUT_LEN: i64 = 1_000_000;
30
31type Broadcast3 = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<usize>);
32
33thread_local! {
34    static DATETIME_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
35    static CALENDAR_DURATION_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
36}
37
38const DATETIME_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
39    code: "RM.DATETIME.INVALID_ARGUMENT",
40    identifier: Some("RunMat:datetime:InvalidArgument"),
41    when: "Arguments or option grammar do not match supported datetime forms.",
42    message: "datetime: invalid argument",
43};
44const DATETIME_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
45    code: "RM.DATETIME.INVALID_INPUT",
46    identifier: Some("RunMat:datetime:InvalidInput"),
47    when: "Input values cannot be parsed/converted/broadcast to a valid datetime result.",
48    message: "datetime: invalid input",
49};
50const DATETIME_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
51    code: "RM.DATETIME.INTERNAL",
52    identifier: Some("RunMat:datetime:Internal"),
53    when: "Internal datetime state or indexing/evaluation failed unexpectedly.",
54    message: "datetime: internal operation failed",
55};
56const DATETIME_ERRORS: [BuiltinErrorDescriptor; 3] = [
57    DATETIME_ERROR_INVALID_ARGUMENT,
58    DATETIME_ERROR_INVALID_INPUT,
59    DATETIME_ERROR_INTERNAL,
60];
61
62const OUT_DATETIME: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
63    name: "t",
64    ty: BuiltinParamType::Any,
65    arity: BuiltinParamArity::Required,
66    default: None,
67    description: "Datetime object result.",
68}];
69const OUT_NUMERIC: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
70    name: "X",
71    ty: BuiltinParamType::Any,
72    arity: BuiltinParamArity::Required,
73    default: None,
74    description: "Numeric scalar/tensor result.",
75}];
76const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
77    name: "out",
78    ty: BuiltinParamType::Any,
79    arity: BuiltinParamArity::Required,
80    default: None,
81    description: "Method result.",
82}];
83const DATETIME_ARGS_ONLY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
84    name: "args",
85    ty: BuiltinParamType::Any,
86    arity: BuiltinParamArity::Variadic,
87    default: None,
88    description: "Datetime constructor arguments.",
89}];
90const DATETIME_SINGLE_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
91    name: "value",
92    ty: BuiltinParamType::Any,
93    arity: BuiltinParamArity::Required,
94    default: None,
95    description: "Datetime input.",
96}];
97const DATETIME_BINARY_INPUTS: [BuiltinParamDescriptor; 2] = [
98    BuiltinParamDescriptor {
99        name: "lhs",
100        ty: BuiltinParamType::Any,
101        arity: BuiltinParamArity::Required,
102        default: None,
103        description: "Left datetime operand.",
104    },
105    BuiltinParamDescriptor {
106        name: "rhs",
107        ty: BuiltinParamType::Any,
108        arity: BuiltinParamArity::Required,
109        default: None,
110        description: "Right datetime/numeric/duration operand.",
111    },
112];
113const DATESHIFT_INPUTS: [BuiltinParamDescriptor; 4] = [
114    BuiltinParamDescriptor {
115        name: "t",
116        ty: BuiltinParamType::Any,
117        arity: BuiltinParamArity::Required,
118        default: None,
119        description: "Datetime input.",
120    },
121    BuiltinParamDescriptor {
122        name: "boundary",
123        ty: BuiltinParamType::StringScalar,
124        arity: BuiltinParamArity::Required,
125        default: None,
126        description: "Shift boundary: 'start', 'end', or 'nearest'.",
127    },
128    BuiltinParamDescriptor {
129        name: "unit",
130        ty: BuiltinParamType::StringScalar,
131        arity: BuiltinParamArity::Required,
132        default: None,
133        description: "Calendar/time unit.",
134    },
135    BuiltinParamDescriptor {
136        name: "weekdayOrOption",
137        ty: BuiltinParamType::Any,
138        arity: BuiltinParamArity::Optional,
139        default: None,
140        description: "Optional weekday for week-based shifts.",
141    },
142];
143const DATETIME_SUBSREF_INPUTS: [BuiltinParamDescriptor; 3] = [
144    BuiltinParamDescriptor {
145        name: "obj",
146        ty: BuiltinParamType::Any,
147        arity: BuiltinParamArity::Required,
148        default: None,
149        description: "Datetime receiver object.",
150    },
151    BuiltinParamDescriptor {
152        name: "kind",
153        ty: BuiltinParamType::StringScalar,
154        arity: BuiltinParamArity::Required,
155        default: None,
156        description: "Indexing kind token.",
157    },
158    BuiltinParamDescriptor {
159        name: "payload",
160        ty: BuiltinParamType::Any,
161        arity: BuiltinParamArity::Required,
162        default: None,
163        description: "Index/member payload.",
164    },
165];
166const DATETIME_SUBSASGN_INPUTS: [BuiltinParamDescriptor; 4] = [
167    BuiltinParamDescriptor {
168        name: "obj",
169        ty: BuiltinParamType::Any,
170        arity: BuiltinParamArity::Required,
171        default: None,
172        description: "Datetime receiver object.",
173    },
174    BuiltinParamDescriptor {
175        name: "kind",
176        ty: BuiltinParamType::StringScalar,
177        arity: BuiltinParamArity::Required,
178        default: None,
179        description: "Indexing kind token.",
180    },
181    BuiltinParamDescriptor {
182        name: "payload",
183        ty: BuiltinParamType::Any,
184        arity: BuiltinParamArity::Required,
185        default: None,
186        description: "Index/member payload.",
187    },
188    BuiltinParamDescriptor {
189        name: "rhs",
190        ty: BuiltinParamType::Any,
191        arity: BuiltinParamArity::Required,
192        default: None,
193        description: "Assigned value.",
194    },
195];
196
197const DATETIME_SIGNATURES: [BuiltinSignatureDescriptor; 11] = [
198    BuiltinSignatureDescriptor {
199        label: "t = datetime()",
200        inputs: &[],
201        outputs: &OUT_DATETIME,
202    },
203    BuiltinSignatureDescriptor {
204        label: "t = datetime(textOrArray)",
205        inputs: &[BuiltinParamDescriptor {
206            name: "textOrArray",
207            ty: BuiltinParamType::Any,
208            arity: BuiltinParamArity::Required,
209            default: None,
210            description: "String/char/date text input.",
211        }],
212        outputs: &OUT_DATETIME,
213    },
214    BuiltinSignatureDescriptor {
215        label: "t = datetime(serialDateNumbers)",
216        inputs: &[BuiltinParamDescriptor {
217            name: "serialDateNumbers",
218            ty: BuiltinParamType::NumericArray,
219            arity: BuiltinParamArity::Required,
220            default: None,
221            description: "Numeric serial date input.",
222        }],
223        outputs: &OUT_DATETIME,
224    },
225    BuiltinSignatureDescriptor {
226        label: "t = datetime(year, month, day)",
227        inputs: &[
228            BuiltinParamDescriptor {
229                name: "year",
230                ty: BuiltinParamType::NumericArray,
231                arity: BuiltinParamArity::Required,
232                default: None,
233                description: "Year component.",
234            },
235            BuiltinParamDescriptor {
236                name: "month",
237                ty: BuiltinParamType::NumericArray,
238                arity: BuiltinParamArity::Required,
239                default: None,
240                description: "Month component.",
241            },
242            BuiltinParamDescriptor {
243                name: "day",
244                ty: BuiltinParamType::NumericArray,
245                arity: BuiltinParamArity::Required,
246                default: None,
247                description: "Day component.",
248            },
249        ],
250        outputs: &OUT_DATETIME,
251    },
252    BuiltinSignatureDescriptor {
253        label: "t = datetime(year, month, day, hour)",
254        inputs: &[
255            BuiltinParamDescriptor {
256                name: "year",
257                ty: BuiltinParamType::NumericArray,
258                arity: BuiltinParamArity::Required,
259                default: None,
260                description: "Year component.",
261            },
262            BuiltinParamDescriptor {
263                name: "month",
264                ty: BuiltinParamType::NumericArray,
265                arity: BuiltinParamArity::Required,
266                default: None,
267                description: "Month component.",
268            },
269            BuiltinParamDescriptor {
270                name: "day",
271                ty: BuiltinParamType::NumericArray,
272                arity: BuiltinParamArity::Required,
273                default: None,
274                description: "Day component.",
275            },
276            BuiltinParamDescriptor {
277                name: "hour",
278                ty: BuiltinParamType::NumericArray,
279                arity: BuiltinParamArity::Required,
280                default: None,
281                description: "Hour component.",
282            },
283        ],
284        outputs: &OUT_DATETIME,
285    },
286    BuiltinSignatureDescriptor {
287        label: "t = datetime(year, month, day, hour, minute)",
288        inputs: &[
289            BuiltinParamDescriptor {
290                name: "year",
291                ty: BuiltinParamType::NumericArray,
292                arity: BuiltinParamArity::Required,
293                default: None,
294                description: "Year component.",
295            },
296            BuiltinParamDescriptor {
297                name: "month",
298                ty: BuiltinParamType::NumericArray,
299                arity: BuiltinParamArity::Required,
300                default: None,
301                description: "Month component.",
302            },
303            BuiltinParamDescriptor {
304                name: "day",
305                ty: BuiltinParamType::NumericArray,
306                arity: BuiltinParamArity::Required,
307                default: None,
308                description: "Day component.",
309            },
310            BuiltinParamDescriptor {
311                name: "hour",
312                ty: BuiltinParamType::NumericArray,
313                arity: BuiltinParamArity::Required,
314                default: None,
315                description: "Hour component.",
316            },
317            BuiltinParamDescriptor {
318                name: "minute",
319                ty: BuiltinParamType::NumericArray,
320                arity: BuiltinParamArity::Required,
321                default: None,
322                description: "Minute component.",
323            },
324        ],
325        outputs: &OUT_DATETIME,
326    },
327    BuiltinSignatureDescriptor {
328        label: "t = datetime(year, month, day, hour, minute, second)",
329        inputs: &[
330            BuiltinParamDescriptor {
331                name: "year",
332                ty: BuiltinParamType::NumericArray,
333                arity: BuiltinParamArity::Required,
334                default: None,
335                description: "Year component.",
336            },
337            BuiltinParamDescriptor {
338                name: "month",
339                ty: BuiltinParamType::NumericArray,
340                arity: BuiltinParamArity::Required,
341                default: None,
342                description: "Month component.",
343            },
344            BuiltinParamDescriptor {
345                name: "day",
346                ty: BuiltinParamType::NumericArray,
347                arity: BuiltinParamArity::Required,
348                default: None,
349                description: "Day component.",
350            },
351            BuiltinParamDescriptor {
352                name: "hour",
353                ty: BuiltinParamType::NumericArray,
354                arity: BuiltinParamArity::Required,
355                default: None,
356                description: "Hour component.",
357            },
358            BuiltinParamDescriptor {
359                name: "minute",
360                ty: BuiltinParamType::NumericArray,
361                arity: BuiltinParamArity::Required,
362                default: None,
363                description: "Minute component.",
364            },
365            BuiltinParamDescriptor {
366                name: "second",
367                ty: BuiltinParamType::NumericArray,
368                arity: BuiltinParamArity::Required,
369                default: None,
370                description: "Second component.",
371            },
372        ],
373        outputs: &OUT_DATETIME,
374    },
375    BuiltinSignatureDescriptor {
376        label: "t = datetime(serialDateNumbers, \"ConvertFrom\", \"datenum\")",
377        inputs: &[BuiltinParamDescriptor {
378            name: "args",
379            ty: BuiltinParamType::Any,
380            arity: BuiltinParamArity::Variadic,
381            default: None,
382            description: "Numeric serial input with ConvertFrom option.",
383        }],
384        outputs: &OUT_DATETIME,
385    },
386    BuiltinSignatureDescriptor {
387        label: "t = datetime(___, \"Format\", format)",
388        inputs: &DATETIME_ARGS_ONLY,
389        outputs: &OUT_DATETIME,
390    },
391    BuiltinSignatureDescriptor {
392        label: "t = datetime(textOrArray, \"InputFormat\", inputFormat)",
393        inputs: &DATETIME_ARGS_ONLY,
394        outputs: &OUT_DATETIME,
395    },
396    BuiltinSignatureDescriptor {
397        label: "t = datetime(___, Name, Value, ...)",
398        inputs: &DATETIME_ARGS_ONLY,
399        outputs: &OUT_DATETIME,
400    },
401];
402
403const DATETIME_YEAR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
404    label: "X = year(t)",
405    inputs: &DATETIME_SINGLE_INPUT,
406    outputs: &OUT_NUMERIC,
407}];
408const DATETIME_MONTH_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
409    label: "X = month(t)",
410    inputs: &DATETIME_SINGLE_INPUT,
411    outputs: &OUT_NUMERIC,
412}];
413const DATETIME_DAY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
414    label: "X = day(t)",
415    inputs: &DATETIME_SINGLE_INPUT,
416    outputs: &OUT_NUMERIC,
417}];
418const DATETIME_HOUR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
419    label: "X = hour(t)",
420    inputs: &DATETIME_SINGLE_INPUT,
421    outputs: &OUT_NUMERIC,
422}];
423const DATETIME_MINUTE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
424    label: "X = minute(t)",
425    inputs: &DATETIME_SINGLE_INPUT,
426    outputs: &OUT_NUMERIC,
427}];
428const DATETIME_SECOND_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
429    label: "X = second(t)",
430    inputs: &DATETIME_SINGLE_INPUT,
431    outputs: &OUT_NUMERIC,
432}];
433const DATETIME_SUBSREF_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
434    label: "out = datetime.subsref(obj, kind, payload)",
435    inputs: &DATETIME_SUBSREF_INPUTS,
436    outputs: &OUT_ANY,
437}];
438const DATETIME_SUBSASGN_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
439    [BuiltinSignatureDescriptor {
440        label: "out = datetime.subsasgn(obj, kind, payload, rhs)",
441        inputs: &DATETIME_SUBSASGN_INPUTS,
442        outputs: &OUT_ANY,
443    }];
444const DATETIME_BINARY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
445    label: "out = datetime.op(lhs, rhs)",
446    inputs: &DATETIME_BINARY_INPUTS,
447    outputs: &OUT_ANY,
448}];
449const DATESHIFT_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
450    BuiltinSignatureDescriptor {
451        label: "t2 = dateshift(t, boundary, unit)",
452        inputs: &DATESHIFT_INPUTS,
453        outputs: &OUT_DATETIME,
454    },
455    BuiltinSignatureDescriptor {
456        label: "t2 = dateshift(t, boundary, \"week\", weekday)",
457        inputs: &DATESHIFT_INPUTS,
458        outputs: &OUT_DATETIME,
459    },
460    BuiltinSignatureDescriptor {
461        label: "t2 = dateshift(t, \"dayofweek\", weekday)",
462        inputs: &DATESHIFT_INPUTS,
463        outputs: &OUT_DATETIME,
464    },
465];
466
467pub const DATETIME_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
468    signatures: &DATETIME_SIGNATURES,
469    output_mode: BuiltinOutputMode::Fixed,
470    completion_policy: BuiltinCompletionPolicy::Public,
471    errors: &DATETIME_ERRORS,
472};
473pub const DATETIME_YEAR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
474    signatures: &DATETIME_YEAR_SIGNATURES,
475    output_mode: BuiltinOutputMode::Fixed,
476    completion_policy: BuiltinCompletionPolicy::Public,
477    errors: &DATETIME_ERRORS,
478};
479pub const DATETIME_MONTH_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
480    signatures: &DATETIME_MONTH_SIGNATURES,
481    output_mode: BuiltinOutputMode::Fixed,
482    completion_policy: BuiltinCompletionPolicy::Public,
483    errors: &DATETIME_ERRORS,
484};
485pub const DATETIME_DAY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
486    signatures: &DATETIME_DAY_SIGNATURES,
487    output_mode: BuiltinOutputMode::Fixed,
488    completion_policy: BuiltinCompletionPolicy::Public,
489    errors: &DATETIME_ERRORS,
490};
491pub const DATETIME_HOUR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
492    signatures: &DATETIME_HOUR_SIGNATURES,
493    output_mode: BuiltinOutputMode::Fixed,
494    completion_policy: BuiltinCompletionPolicy::Public,
495    errors: &DATETIME_ERRORS,
496};
497pub const DATETIME_MINUTE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
498    signatures: &DATETIME_MINUTE_SIGNATURES,
499    output_mode: BuiltinOutputMode::Fixed,
500    completion_policy: BuiltinCompletionPolicy::Public,
501    errors: &DATETIME_ERRORS,
502};
503pub const DATETIME_SECOND_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
504    signatures: &DATETIME_SECOND_SIGNATURES,
505    output_mode: BuiltinOutputMode::Fixed,
506    completion_policy: BuiltinCompletionPolicy::Public,
507    errors: &DATETIME_ERRORS,
508};
509pub const DATETIME_SUBSREF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
510    signatures: &DATETIME_SUBSREF_SIGNATURES,
511    output_mode: BuiltinOutputMode::Fixed,
512    completion_policy: BuiltinCompletionPolicy::MethodOnly,
513    errors: &DATETIME_ERRORS,
514};
515pub const DATETIME_SUBSASGN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
516    signatures: &DATETIME_SUBSASGN_SIGNATURES,
517    output_mode: BuiltinOutputMode::Fixed,
518    completion_policy: BuiltinCompletionPolicy::MethodOnly,
519    errors: &DATETIME_ERRORS,
520};
521pub const DATETIME_BINARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
522    signatures: &DATETIME_BINARY_SIGNATURES,
523    output_mode: BuiltinOutputMode::Fixed,
524    completion_policy: BuiltinCompletionPolicy::MethodOnly,
525    errors: &DATETIME_ERRORS,
526};
527pub const DATESHIFT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
528    signatures: &DATESHIFT_SIGNATURES,
529    output_mode: BuiltinOutputMode::Fixed,
530    completion_policy: BuiltinCompletionPolicy::Public,
531    errors: &DATETIME_ERRORS,
532};
533
534fn datetime_error(message: impl Into<String>) -> RuntimeError {
535    build_runtime_error(message)
536        .with_builtin(BUILTIN_NAME)
537        .build()
538}
539
540fn ensure_datetime_class_registered() {
541    DATETIME_CLASS_REGISTERED.with(|registered| {
542        if registered.get() {
543            return;
544        }
545        let mut properties = HashMap::new();
546        properties.insert(
547            FORMAT_FIELD.to_string(),
548            PropertyDef {
549                name: FORMAT_FIELD.to_string(),
550                is_static: false,
551                is_constant: false,
552                is_dependent: false,
553                get_access: Access::Public,
554                set_access: Access::Public,
555                default_value: Some(Value::String(DEFAULT_DATETIME_FORMAT.to_string())),
556            },
557        );
558
559        let mut methods = HashMap::new();
560        for name in [
561            OBJECT_SUBSREF_METHOD,
562            OBJECT_SUBSASGN_METHOD,
563            "plus",
564            "minus",
565            "eq",
566            "ne",
567            "lt",
568            "le",
569            "gt",
570            "ge",
571        ] {
572            methods.insert(
573                name.to_string(),
574                MethodDef {
575                    name: name.to_string(),
576                    is_static: false,
577                    is_abstract: false,
578                    is_sealed: false,
579                    access: Access::Public,
580                    function_name: format!("{DATETIME_CLASS}.{name}"),
581                    implicit_class_argument: None,
582                },
583            );
584        }
585
586        runmat_builtins::register_class(ClassDef {
587            name: DATETIME_CLASS.to_string(),
588            parent: None,
589            properties,
590            methods,
591        });
592        registered.set(true);
593    });
594}
595
596fn ensure_calendar_duration_class_registered() {
597    CALENDAR_DURATION_CLASS_REGISTERED.with(|registered| {
598        if registered.get() {
599            return;
600        }
601
602        let mut properties = HashMap::new();
603        for name in [CALENDAR_MONTHS_FIELD, CALENDAR_DAYS_FIELD] {
604            properties.insert(
605                name.to_string(),
606                PropertyDef {
607                    name: name.to_string(),
608                    is_static: false,
609                    is_constant: false,
610                    is_dependent: false,
611                    get_access: Access::Public,
612                    set_access: Access::Public,
613                    default_value: Some(Value::Num(0.0)),
614                },
615            );
616        }
617
618        let mut methods = HashMap::new();
619        for name in ["plus", "minus", "eq", "ne"] {
620            methods.insert(
621                name.to_string(),
622                MethodDef {
623                    name: name.to_string(),
624                    is_static: false,
625                    is_abstract: false,
626                    is_sealed: false,
627                    access: Access::Public,
628                    function_name: format!("{CALENDAR_DURATION_CLASS}.{name}"),
629                    implicit_class_argument: None,
630                },
631            );
632        }
633
634        runmat_builtins::register_class(ClassDef {
635            name: CALENDAR_DURATION_CLASS.to_string(),
636            parent: None,
637            properties,
638            methods,
639        });
640        registered.set(true);
641    });
642}
643
644async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
645    let mut out = Vec::with_capacity(args.len());
646    for arg in args {
647        out.push(
648            gather_if_needed_async(arg)
649                .await
650                .map_err(|err| datetime_error(format!("datetime: {}", err.message())))?,
651        );
652    }
653    Ok(out)
654}
655
656fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
657    match value {
658        Value::String(text) => Ok(text.clone()),
659        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
660        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
661        _ => Err(datetime_error(format!(
662            "datetime: {context} must be a string scalar or character vector"
663        ))),
664    }
665}
666
667#[derive(Default)]
668struct DatetimeOptions {
669    format: Option<String>,
670    convert_from: Option<String>,
671    input_format: Option<String>,
672}
673
674fn parse_trailing_options(args: &[Value]) -> BuiltinResult<(usize, DatetimeOptions)> {
675    let mut positional_end = args.len();
676    let mut options = DatetimeOptions::default();
677
678    while positional_end >= 2 {
679        let name = match scalar_text(&args[positional_end - 2], "option name") {
680            Ok(text) => text,
681            Err(_) => break,
682        };
683        let lowered = name.trim().to_ascii_lowercase();
684        let value = scalar_text(&args[positional_end - 1], &format!("{name} option"))?;
685        match lowered.as_str() {
686            "format" => options.format = Some(value),
687            "convertfrom" => options.convert_from = Some(value),
688            "inputformat" => options.input_format = Some(value),
689            _ => break,
690        }
691        positional_end -= 2;
692    }
693
694    Ok((positional_end, options))
695}
696
697fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
698    tensor::value_into_tensor_for(context, value)
699        .map_err(|message| datetime_error(format!("datetime: {message}")))
700}
701
702fn serial_tensor_from_value(value: Value, context: &str) -> BuiltinResult<Tensor> {
703    let tensor = tensor_from_numeric(value, context)?;
704    Tensor::new(
705        tensor.data.clone(),
706        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
707    )
708    .map_err(|err| datetime_error(format!("datetime: {err}")))
709}
710
711fn format_for_object(obj: &ObjectInstance) -> String {
712    match obj.properties.get(FORMAT_FIELD) {
713        Some(Value::String(text)) => text.clone(),
714        Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
715        Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
716        _ => DEFAULT_DATETIME_FORMAT.to_string(),
717    }
718}
719
720fn serial_tensor_for_object(obj: &ObjectInstance) -> BuiltinResult<Tensor> {
721    match obj.properties.get(SERIAL_FIELD) {
722        Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
723        Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
724            .map_err(|err| datetime_error(format!("datetime: {err}"))),
725        Some(other) => Err(datetime_error(format!(
726            "datetime: invalid internal serial storage {other:?}"
727        ))),
728        None => Err(datetime_error("datetime: missing internal serial storage")),
729    }
730}
731
732pub(crate) fn datetime_object_from_serial_tensor(
733    serials: Tensor,
734    format: impl Into<String>,
735) -> BuiltinResult<Value> {
736    ensure_datetime_class_registered();
737    let mut object = ObjectInstance::new(DATETIME_CLASS.to_string());
738    object
739        .properties
740        .insert(SERIAL_FIELD.to_string(), Value::Tensor(serials));
741    object
742        .properties
743        .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
744    Ok(Value::Object(object))
745}
746
747fn datetime_object_from_serials(
748    serials: Vec<f64>,
749    shape: Vec<usize>,
750    format: impl Into<String>,
751) -> BuiltinResult<Value> {
752    let tensor =
753        Tensor::new(serials, shape).map_err(|err| datetime_error(format!("datetime: {err}")))?;
754    datetime_object_from_serial_tensor(tensor, format)
755}
756
757fn format_token_to_strftime(format: &str) -> String {
758    let mut out = format.to_string();
759    for (src, dst) in [
760        ("yyyy", "%Y"),
761        ("MMM", "%b"),
762        ("MM", "%m"),
763        ("dd", "%d"),
764        ("HH", "%H"),
765        ("mm", "%M"),
766        ("ss", "%S"),
767    ] {
768        out = out.replace(src, dst);
769    }
770    out
771}
772
773pub(crate) fn datenum_from_naive(datetime: NaiveDateTime) -> f64 {
774    let base = NaiveDate::from_ymd_opt(1970, 1, 1)
775        .unwrap()
776        .and_hms_opt(0, 0, 0)
777        .unwrap();
778    let duration = datetime - base;
779    let seconds = duration.num_seconds();
780    let nanos = (duration - Duration::seconds(seconds))
781        .num_nanoseconds()
782        .unwrap_or(0);
783    let total_seconds = seconds as f64 + nanos as f64 / 1_000_000_000.0;
784    total_seconds / SECONDS_PER_DAY + UNIX_DATENUM
785}
786
787fn naive_from_datenum(serial: f64) -> BuiltinResult<NaiveDateTime> {
788    if !serial.is_finite() {
789        return Err(datetime_error(
790            "datetime: serial date numbers must be finite",
791        ));
792    }
793    let total_nanos = ((serial - UNIX_DATENUM) * SECONDS_PER_DAY * 1_000_000_000.0).round() as i128;
794    let seconds = total_nanos.div_euclid(1_000_000_000) as i64;
795    let nanos = total_nanos.rem_euclid(1_000_000_000) as i64;
796    let base = NaiveDate::from_ymd_opt(1970, 1, 1)
797        .unwrap()
798        .and_hms_opt(0, 0, 0)
799        .unwrap();
800    Ok(base + Duration::seconds(seconds) + Duration::nanoseconds(nanos))
801}
802
803fn format_serial(serial: f64, format: &str) -> BuiltinResult<String> {
804    if serial.is_nan() {
805        return Ok("NaT".to_string());
806    }
807    let naive = naive_from_datenum(serial)?;
808    let chrono_format = format_token_to_strftime(format);
809    Ok(naive.format(&chrono_format).to_string())
810}
811
812fn parse_datetime_text(text: &str) -> Option<(NaiveDateTime, bool)> {
813    let trimmed = text.trim();
814    if trimmed.is_empty() {
815        return None;
816    }
817
818    if let Ok(value) = DateTime::parse_from_rfc3339(trimmed) {
819        return Some((value.with_timezone(&Local).naive_local(), true));
820    }
821
822    for (pattern, has_time) in [
823        ("%Y-%m-%d %H:%M:%S", true),
824        ("%Y-%m-%d", false),
825        ("%d-%b-%Y %H:%M:%S", true),
826        ("%d-%b-%Y", false),
827        ("%m/%d/%Y %H:%M:%S", true),
828        ("%m/%d/%Y", false),
829    ] {
830        if has_time {
831            if let Ok(value) = NaiveDateTime::parse_from_str(trimmed, pattern) {
832                return Some((value, true));
833            }
834        } else if let Ok(value) = NaiveDate::parse_from_str(trimmed, pattern) {
835            return Some((value.and_hms_opt(0, 0, 0).unwrap(), false));
836        }
837    }
838
839    None
840}
841
842fn parse_datetime_text_with_input_format(
843    text: &str,
844    input_format: Option<&str>,
845) -> Option<(NaiveDateTime, bool)> {
846    let trimmed = text.trim();
847    if trimmed.is_empty() {
848        return None;
849    }
850    let Some(input_format) = input_format else {
851        return parse_datetime_text(trimmed);
852    };
853    let chrono_format = format_token_to_strftime(input_format);
854    if let Ok(value) = NaiveDateTime::parse_from_str(trimmed, &chrono_format) {
855        return Some((value, true));
856    }
857    if let Ok(value) = NaiveDate::parse_from_str(trimmed, &chrono_format) {
858        return Some((value.and_hms_opt(0, 0, 0).unwrap(), false));
859    }
860    None
861}
862
863fn parse_text_input(
864    value: Value,
865    input_format: Option<&str>,
866) -> BuiltinResult<(Vec<f64>, Vec<usize>, String)> {
867    match value {
868        Value::String(text) => {
869            if text.trim().eq_ignore_ascii_case("now") {
870                let now = Local::now().naive_local();
871                return Ok((
872                    vec![datenum_from_naive(now)],
873                    vec![1, 1],
874                    DEFAULT_DATETIME_FORMAT.to_string(),
875                ));
876            }
877            let (naive, has_time) = parse_datetime_text_with_input_format(&text, input_format)
878                .ok_or_else(|| {
879                    datetime_error(format!("datetime: unable to parse date/time text '{text}'"))
880                })?;
881            Ok((
882                vec![datenum_from_naive(naive)],
883                vec![1, 1],
884                if has_time {
885                    DEFAULT_DATETIME_FORMAT.to_string()
886                } else {
887                    DEFAULT_DATE_FORMAT.to_string()
888                },
889            ))
890        }
891        Value::StringArray(array) => {
892            let mut serials = Vec::with_capacity(array.data.len());
893            let mut has_time = false;
894            for text in &array.data {
895                let (naive, parsed_has_time) =
896                    parse_datetime_text_with_input_format(text, input_format).ok_or_else(|| {
897                        datetime_error(format!("datetime: unable to parse date/time text '{text}'"))
898                    })?;
899                serials.push(datenum_from_naive(naive));
900                has_time |= parsed_has_time;
901            }
902            Ok((
903                serials,
904                tensor::default_shape_for(&array.shape, array.data.len()),
905                if has_time {
906                    DEFAULT_DATETIME_FORMAT.to_string()
907                } else {
908                    DEFAULT_DATE_FORMAT.to_string()
909                },
910            ))
911        }
912        Value::CharArray(array) => {
913            let mut texts = Vec::with_capacity(array.rows);
914            for row in 0..array.rows {
915                let start = row * array.cols;
916                let end = start + array.cols;
917                texts.push(
918                    array.data[start..end]
919                        .iter()
920                        .collect::<String>()
921                        .trim_end()
922                        .to_string(),
923                );
924            }
925            parse_text_input(
926                Value::StringArray(
927                    StringArray::new(texts, vec![array.rows, 1])
928                        .map_err(|err| datetime_error(format!("datetime: {err}")))?,
929                ),
930                input_format,
931            )
932        }
933        _ => Err(datetime_error(
934            "datetime: text input must be a string scalar, string array, or character array",
935        )),
936    }
937}
938
939fn round_component(value: f64, label: &str, min: i64, max: i64) -> BuiltinResult<i64> {
940    if !value.is_finite() {
941        return Err(datetime_error(format!(
942            "datetime: {label} values must be finite"
943        )));
944    }
945    let rounded = value.round();
946    if (rounded - value).abs() > 1e-9 {
947        return Err(datetime_error(format!(
948            "datetime: {label} values must be integers"
949        )));
950    }
951    let integer = rounded as i64;
952    if integer < min || integer > max {
953        return Err(datetime_error(format!(
954            "datetime: {label} values must be in the range [{min}, {max}]"
955        )));
956    }
957    Ok(integer)
958}
959
960fn naive_from_components(
961    year: f64,
962    month: f64,
963    day: f64,
964    hour: f64,
965    minute: f64,
966    second: f64,
967) -> BuiltinResult<NaiveDateTime> {
968    let year = round_component(year, "year", -262_000, 262_000)? as i32;
969    let month = round_component(month, "month", 1, 12)? as u32;
970    let day = round_component(day, "day", 1, 31)? as u32;
971    let hour = round_component(hour, "hour", 0, 23)? as u32;
972    let minute = round_component(minute, "minute", 0, 59)? as u32;
973    if !second.is_finite() {
974        return Err(datetime_error("datetime: second values must be finite"));
975    }
976    if !(0.0..60.0).contains(&second) {
977        return Err(datetime_error(
978            "datetime: second values must be in the range [0, 60)",
979        ));
980    }
981
982    let base_date = NaiveDate::from_ymd_opt(year, month, day)
983        .ok_or_else(|| datetime_error("datetime: invalid calendar date"))?;
984    let whole_second = second.floor();
985    let mut nanos = ((second - whole_second) * 1_000_000_000.0).round() as u32;
986    let mut secs = whole_second as u32;
987    if nanos == 1_000_000_000 {
988        secs += 1;
989        nanos = 0;
990    }
991    let time = base_date
992        .and_hms_nano_opt(hour, minute, secs, nanos)
993        .ok_or_else(|| datetime_error("datetime: invalid time components"))?;
994    Ok(time)
995}
996
997fn broadcast_component_data(
998    arrays: &[Tensor],
999    labels: &[&str],
1000) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
1001    let mut target_shape = vec![1, 1];
1002    let mut target_len = 1usize;
1003
1004    for array in arrays {
1005        let len = array.data.len();
1006        if len > 1 {
1007            let shape = tensor::default_shape_for(&array.shape, len);
1008            if target_len == 1 {
1009                target_len = len;
1010                target_shape = shape;
1011            } else if len != target_len || shape != target_shape {
1012                return Err(datetime_error(
1013                    "datetime: non-scalar component inputs must have matching sizes",
1014                ));
1015            }
1016        }
1017    }
1018
1019    let mut broadcasted = Vec::with_capacity(arrays.len());
1020    for (idx, array) in arrays.iter().enumerate() {
1021        if array.data.len() == 1 {
1022            broadcasted.push(vec![array.data[0]; target_len]);
1023        } else if array.data.len() == target_len {
1024            broadcasted.push(array.data.clone());
1025        } else {
1026            return Err(datetime_error(format!(
1027                "datetime: {} input size does not match the other components",
1028                labels[idx]
1029            )));
1030        }
1031    }
1032
1033    Ok((broadcasted, target_shape))
1034}
1035
1036fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
1037    let tensor = tensor_from_numeric(value, context)?;
1038    Tensor::new(
1039        tensor.data.clone(),
1040        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
1041    )
1042    .map_err(|err| datetime_error(format!("datetime: {err}")))
1043}
1044
1045fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
1046    let labels = ["year", "month", "day", "hour", "minute", "second"];
1047    let input_count = args.len();
1048    let mut arrays = Vec::with_capacity(args.len());
1049    for (idx, arg) in args.into_iter().enumerate() {
1050        arrays.push(component_tensor(arg, labels[idx])?);
1051    }
1052    while arrays.len() < 6 {
1053        arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
1054    }
1055
1056    let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
1057    let len = broadcasted[0].len();
1058    let mut serials = Vec::with_capacity(len);
1059    for idx in 0..len {
1060        let naive = naive_from_components(
1061            broadcasted[0][idx],
1062            broadcasted[1][idx],
1063            broadcasted[2][idx],
1064            broadcasted[3][idx],
1065            broadcasted[4][idx],
1066            broadcasted[5][idx],
1067        )?;
1068        serials.push(datenum_from_naive(naive));
1069    }
1070
1071    let default_format = if let Some(format) = format {
1072        format
1073    } else if input_count > 3 {
1074        DEFAULT_DATETIME_FORMAT.to_string()
1075    } else {
1076        DEFAULT_DATE_FORMAT.to_string()
1077    };
1078    datetime_object_from_serials(serials, shape, default_format)
1079}
1080
1081fn numeric_value_to_datetime(value: Value, format: Option<String>) -> BuiltinResult<Value> {
1082    let serials = serial_tensor_from_value(value, "datetime")?;
1083    datetime_object_from_serial_tensor(
1084        serials,
1085        format.unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string()),
1086    )
1087}
1088
1089pub fn is_datetime_object(value: &Value) -> bool {
1090    matches!(value, Value::Object(obj) if obj.is_class(DATETIME_CLASS))
1091}
1092
1093pub fn is_calendar_duration_object(value: &Value) -> bool {
1094    matches!(value, Value::Object(obj) if obj.is_class(CALENDAR_DURATION_CLASS))
1095}
1096
1097fn calendar_duration_tensor_for_object(obj: &ObjectInstance, field: &str) -> BuiltinResult<Tensor> {
1098    match obj.properties.get(field) {
1099        Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
1100        Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
1101            .map_err(|err| datetime_error(format!("calendarDuration: {err}"))),
1102        Some(other) => Err(datetime_error(format!(
1103            "calendarDuration: invalid internal {field} storage {other:?}"
1104        ))),
1105        None => Err(datetime_error(format!(
1106            "calendarDuration: missing internal {field} storage"
1107        ))),
1108    }
1109}
1110
1111fn calendar_duration_tensors_from_value(value: &Value) -> BuiltinResult<(Tensor, Tensor)> {
1112    match value {
1113        Value::Object(obj) if obj.is_class(CALENDAR_DURATION_CLASS) => Ok((
1114            calendar_duration_tensor_for_object(obj, CALENDAR_MONTHS_FIELD)?,
1115            calendar_duration_tensor_for_object(obj, CALENDAR_DAYS_FIELD)?,
1116        )),
1117        _ => Err(datetime_error(
1118            "calendarDuration: expected a calendarDuration value",
1119        )),
1120    }
1121}
1122
1123fn calendar_duration_object_from_tensors(months: Tensor, days: Tensor) -> BuiltinResult<Value> {
1124    ensure_calendar_duration_class_registered();
1125    let mut object = ObjectInstance::new(CALENDAR_DURATION_CLASS.to_string());
1126    object
1127        .properties
1128        .insert(CALENDAR_MONTHS_FIELD.to_string(), Value::Tensor(months));
1129    object
1130        .properties
1131        .insert(CALENDAR_DAYS_FIELD.to_string(), Value::Tensor(days));
1132    Ok(Value::Object(object))
1133}
1134
1135fn calendar_duration_object_from_components(
1136    months: Vec<f64>,
1137    days: Vec<f64>,
1138    shape: Vec<usize>,
1139) -> BuiltinResult<Value> {
1140    let month_tensor = Tensor::new(months, shape.clone())
1141        .map_err(|err| datetime_error(format!("calendarDuration: {err}")))?;
1142    let day_tensor = Tensor::new(days, shape)
1143        .map_err(|err| datetime_error(format!("calendarDuration: {err}")))?;
1144    calendar_duration_object_from_tensors(month_tensor, day_tensor)
1145}
1146
1147fn calendar_duration_unit_value(
1148    value: Value,
1149    unit_name: &str,
1150    months_per_unit: f64,
1151    days_per_unit: f64,
1152) -> BuiltinResult<Value> {
1153    if is_calendar_duration_object(&value) {
1154        let (months, days) = calendar_duration_tensors_from_value(&value)?;
1155        let (month_data, day_data, shape) =
1156            tensor::binary_numeric_tensors(&months, &days, unit_name, BUILTIN_NAME)?;
1157        let data = month_data
1158            .iter()
1159            .zip(day_data.iter())
1160            .map(|(months, days)| {
1161                if months_per_unit != 0.0 {
1162                    months / months_per_unit + days / 30.436875 / months_per_unit
1163                } else {
1164                    days / days_per_unit
1165                }
1166            })
1167            .collect::<Vec<_>>();
1168        return tensor_or_scalar(data, shape);
1169    }
1170
1171    let numeric = component_tensor(value, unit_name)?;
1172    let shape = tensor::default_shape_for(&numeric.shape, numeric.data.len());
1173    let mut months = Vec::with_capacity(numeric.data.len());
1174    let mut days = Vec::with_capacity(numeric.data.len());
1175    for value in &numeric.data {
1176        if !value.is_finite() {
1177            return Err(datetime_error(format!(
1178                "{unit_name}: values must be finite"
1179            )));
1180        }
1181        let month_value = value * months_per_unit;
1182        let day_value = value * days_per_unit;
1183        if !month_value.is_finite() || !day_value.is_finite() {
1184            return Err(datetime_error(format!(
1185                "{unit_name}: resulting calendar duration is outside supported range"
1186            )));
1187        }
1188        months.push(month_value);
1189        days.push(day_value);
1190    }
1191    calendar_duration_object_from_components(months, days, shape)
1192}
1193
1194fn add_months_clamped(value: NaiveDateTime, month_delta: i64) -> BuiltinResult<NaiveDateTime> {
1195    let current_month = i64::from(value.year())
1196        .checked_mul(12)
1197        .and_then(|base| base.checked_add(i64::from(value.month() - 1)))
1198        .ok_or_else(|| datetime_error("calendarDuration: result date is out of range"))?;
1199    let zero_based = current_month
1200        .checked_add(month_delta)
1201        .ok_or_else(|| datetime_error("calendarDuration: result date is out of range"))?;
1202    let year_i64 = zero_based.div_euclid(12);
1203    let year = i32::try_from(year_i64)
1204        .map_err(|_| datetime_error("calendarDuration: result date is out of range"))?;
1205    let month = zero_based.rem_euclid(12) as u32 + 1;
1206    let day = value.day().min(days_in_month(year, month)?);
1207    NaiveDate::from_ymd_opt(year, month, day)
1208        .and_then(|date| {
1209            date.and_hms_nano_opt(
1210                value.hour(),
1211                value.minute(),
1212                value.second(),
1213                value.nanosecond(),
1214            )
1215        })
1216        .ok_or_else(|| datetime_error("calendarDuration: result date is out of range"))
1217}
1218
1219fn add_fractional_days(value: NaiveDateTime, days: f64) -> BuiltinResult<NaiveDateTime> {
1220    if !days.is_finite() {
1221        return Err(datetime_error(
1222            "calendarDuration: day components must be finite",
1223        ));
1224    }
1225    let nanos = (days * SECONDS_PER_DAY * 1_000_000_000.0).round();
1226    if !nanos.is_finite() || nanos < i64::MIN as f64 || nanos > i64::MAX as f64 {
1227        return Err(datetime_error(
1228            "calendarDuration: day component is outside supported range",
1229        ));
1230    }
1231    Ok(value + Duration::nanoseconds(nanos as i64))
1232}
1233
1234fn apply_calendar_duration_to_serials(
1235    serials: &Tensor,
1236    months: &Tensor,
1237    days: &Tensor,
1238    sign: f64,
1239    context: &str,
1240) -> BuiltinResult<(Vec<f64>, Vec<usize>)> {
1241    let (serial_data, month_data, day_data, shape) =
1242        broadcast_three_numeric_tensors(serials, months, days, context)?;
1243    let mut out = Vec::with_capacity(serial_data.len());
1244    for ((serial, months), days) in serial_data
1245        .iter()
1246        .zip(month_data.iter())
1247        .zip(day_data.iter())
1248    {
1249        if !months.is_finite() {
1250            return Err(datetime_error(format!(
1251                "{context}: month components must be finite"
1252            )));
1253        }
1254        let signed_months = months * sign;
1255        let rounded_months = signed_months.round();
1256        if (rounded_months - signed_months).abs() > 1e-9 {
1257            return Err(datetime_error(format!(
1258                "{context}: calendar month components must be integers for datetime arithmetic"
1259            )));
1260        }
1261        if rounded_months < i64::MIN as f64 || rounded_months > i64::MAX as f64 {
1262            return Err(datetime_error(format!(
1263                "{context}: calendar month component is outside supported range"
1264            )));
1265        }
1266        let shifted = add_months_clamped(naive_from_datenum(*serial)?, rounded_months as i64)?;
1267        out.push(datenum_from_naive(add_fractional_days(
1268            shifted,
1269            days * sign,
1270        )?));
1271    }
1272    Ok((out, shape))
1273}
1274
1275pub(crate) fn serials_from_datetime_value(value: &Value) -> BuiltinResult<Tensor> {
1276    match value {
1277        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj),
1278        _ => Err(datetime_error("datetime: expected a datetime value")),
1279    }
1280}
1281
1282pub(crate) fn datetime_format_from_value(value: &Value) -> String {
1283    match value {
1284        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => format_for_object(obj),
1285        _ => DEFAULT_DATETIME_FORMAT.to_string(),
1286    }
1287}
1288
1289pub fn datetime_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
1290    let Value::Object(obj) = value else {
1291        return Ok(None);
1292    };
1293    if !obj.is_class(DATETIME_CLASS) {
1294        return Ok(None);
1295    }
1296    let serials = serial_tensor_for_object(obj)?;
1297    let format = format_for_object(obj);
1298    let mut strings = Vec::with_capacity(serials.data.len());
1299    for serial in &serials.data {
1300        strings.push(format_serial(*serial, &format)?);
1301    }
1302    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1303    let array = StringArray::new(strings, shape)
1304        .map_err(|err| datetime_error(format!("datetime: {err}")))?;
1305    Ok(Some(array))
1306}
1307
1308pub fn datetime_display_text(value: &Value) -> BuiltinResult<Option<String>> {
1309    let Some(array) = datetime_string_array(value)? else {
1310        return Ok(None);
1311    };
1312    if array.data.len() == 1 {
1313        return Ok(Some(array.data[0].clone()));
1314    }
1315
1316    let rows = array.rows;
1317    let cols = array.cols;
1318    let mut widths = vec![0usize; cols];
1319    for col in 0..cols {
1320        for row in 0..rows {
1321            let idx = row + col * rows;
1322            widths[col] = widths[col].max(array.data[idx].chars().count());
1323        }
1324    }
1325
1326    let mut lines = Vec::with_capacity(rows);
1327    for row in 0..rows {
1328        let mut line = String::new();
1329        for col in 0..cols {
1330            if col > 0 {
1331                line.push_str("  ");
1332            }
1333            let idx = row + col * rows;
1334            let text = &array.data[idx];
1335            line.push_str(text);
1336            let padding = widths[col].saturating_sub(text.chars().count());
1337            if padding > 0 {
1338                line.push_str(&" ".repeat(padding));
1339            }
1340        }
1341        lines.push(line);
1342    }
1343    Ok(Some(lines.join("\n")))
1344}
1345
1346pub fn datetime_summary(value: &Value) -> BuiltinResult<Option<String>> {
1347    let Value::Object(obj) = value else {
1348        return Ok(None);
1349    };
1350    if !obj.is_class(DATETIME_CLASS) {
1351        return Ok(None);
1352    }
1353    let serials = serial_tensor_for_object(obj)?;
1354    if serials.data.len() == 1 {
1355        return datetime_display_text(value);
1356    }
1357    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1358    Ok(Some(format!(
1359        "[{} datetime]",
1360        shape
1361            .iter()
1362            .map(|dim| dim.to_string())
1363            .collect::<Vec<_>>()
1364            .join("x")
1365    )))
1366}
1367
1368fn component_tensor_from_datetime(
1369    value: &Value,
1370    label: &str,
1371    extractor: impl Fn(&NaiveDateTime) -> f64,
1372) -> BuiltinResult<Value> {
1373    let serials = serials_from_datetime_value(value)?;
1374    let mut out = Vec::with_capacity(serials.data.len());
1375    for serial in &serials.data {
1376        let naive = naive_from_datenum(*serial)?;
1377        out.push(extractor(&naive));
1378    }
1379    if out.len() == 1 {
1380        Ok(Value::Num(out[0]))
1381    } else {
1382        let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1383        let tensor =
1384            Tensor::new(out, shape).map_err(|err| datetime_error(format!("{label}: {err}")))?;
1385        Ok(Value::Tensor(tensor))
1386    }
1387}
1388
1389fn tensor_or_scalar(data: Vec<f64>, shape: Vec<usize>) -> BuiltinResult<Value> {
1390    if data.len() == 1 {
1391        Ok(Value::Num(data[0]))
1392    } else {
1393        Ok(Value::Tensor(Tensor::new(data, shape).map_err(|err| {
1394            datetime_error(format!("datetime: {err}"))
1395        })?))
1396    }
1397}
1398
1399fn numeric_or_datetime_serial_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
1400    match &value {
1401        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj),
1402        Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
1403            let (serials, shape, _) = parse_text_input(value, None)?;
1404            Tensor::new(serials, shape).map_err(|err| datetime_error(format!("{context}: {err}")))
1405        }
1406        _ => serial_tensor_from_value(value, context),
1407    }
1408}
1409
1410fn datevec_components_from_serial(serial: f64) -> BuiltinResult<[f64; 6]> {
1411    let naive = naive_from_datenum(serial)?;
1412    Ok([
1413        naive.year() as f64,
1414        naive.month() as f64,
1415        naive.day() as f64,
1416        naive.hour() as f64,
1417        naive.minute() as f64,
1418        naive.second() as f64 + f64::from(naive.nanosecond()) / 1_000_000_000.0,
1419    ])
1420}
1421
1422fn datevec_matrix_from_serial_tensor(serials: &Tensor) -> BuiltinResult<Tensor> {
1423    let rows = serials.data.len();
1424    let mut data = vec![0.0; rows.saturating_mul(6)];
1425    for (row, serial) in serials.data.iter().enumerate() {
1426        let components = datevec_components_from_serial(*serial)?;
1427        for col in 0..6 {
1428            data[col * rows + row] = components[col];
1429        }
1430    }
1431    Tensor::new(data, vec![rows, 6]).map_err(|err| datetime_error(format!("datevec: {err}")))
1432}
1433
1434fn datetime_from_date_only(
1435    naive: NaiveDateTime,
1436    format: impl Into<String>,
1437) -> BuiltinResult<Value> {
1438    datetime_object_from_serials(vec![datenum_from_naive(naive)], vec![1, 1], format)
1439}
1440
1441fn current_naive_local() -> NaiveDateTime {
1442    Local::now().naive_local()
1443}
1444
1445fn days_in_month(year: i32, month: u32) -> BuiltinResult<u32> {
1446    let _ = NaiveDate::from_ymd_opt(year, month, 1)
1447        .ok_or_else(|| datetime_error("eomday: invalid year/month"))?;
1448    let (next_year, next_month) = if month == 12 {
1449        (year + 1, 1)
1450    } else {
1451        (year, month + 1)
1452    };
1453    let next = NaiveDate::from_ymd_opt(next_year, next_month, 1)
1454        .ok_or_else(|| datetime_error("eomday: invalid year/month"))?;
1455    Ok((next - Duration::days(1)).day())
1456}
1457
1458fn tensor_from_datevec_like(value: Value, context: &str) -> BuiltinResult<Tensor> {
1459    let tensor = tensor_from_numeric(value, context)?;
1460    let shape = tensor::default_shape_for(&tensor.shape, tensor.data.len());
1461    let normalize = |rows: usize, cols: usize, data: Vec<f64>| -> BuiltinResult<Tensor> {
1462        if cols == 6 {
1463            return Tensor::new(data, vec![rows, 6])
1464                .map_err(|err| datetime_error(format!("{context}: {err}")));
1465        }
1466        let mut padded = vec![0.0; rows.saturating_mul(6)];
1467        for col in 0..3 {
1468            for row in 0..rows {
1469                padded[col * rows + row] = data[col * rows + row];
1470            }
1471        }
1472        Tensor::new(padded, vec![rows, 6])
1473            .map_err(|err| datetime_error(format!("{context}: {err}")))
1474    };
1475    if tensor.data.len() == 3 {
1476        return normalize(1, 3, tensor.data);
1477    }
1478    if tensor.data.len() == 6 {
1479        return normalize(1, 6, tensor.data);
1480    }
1481    if shape.len() >= 2 && (shape[1] == 3 || shape[1] == 6) {
1482        return normalize(shape[0], shape[1], tensor.data);
1483    }
1484    Err(datetime_error(format!(
1485        "{context}: expected a date vector with three or six columns"
1486    )))
1487}
1488
1489fn datenum_from_datevec_tensor(tensor: &Tensor, context: &str) -> BuiltinResult<Tensor> {
1490    let rows = tensor.rows;
1491    let cols = tensor.cols;
1492    if cols != 6 {
1493        return Err(datetime_error(format!(
1494            "{context}: date vectors must have six columns"
1495        )));
1496    }
1497    let mut out = Vec::with_capacity(rows);
1498    for row in 0..rows {
1499        let component = |col: usize| tensor.data[col * rows + row];
1500        let naive = naive_from_components(
1501            component(0),
1502            component(1),
1503            component(2),
1504            component(3),
1505            component(4),
1506            component(5),
1507        )?;
1508        out.push(datenum_from_naive(naive));
1509    }
1510    Tensor::new(out, vec![rows, 1]).map_err(|err| datetime_error(format!("{context}: {err}")))
1511}
1512
1513fn char_array_from_rows(rows: &[String], context: &str) -> BuiltinResult<CharArray> {
1514    let width = rows
1515        .iter()
1516        .map(|row| row.chars().count())
1517        .max()
1518        .unwrap_or(0);
1519    let mut data = vec![' '; rows.len().saturating_mul(width)];
1520    for (row_idx, row) in rows.iter().enumerate() {
1521        for (col, ch) in row.chars().enumerate() {
1522            data[row_idx * width + col] = ch;
1523        }
1524    }
1525    CharArray::new(data, rows.len(), width)
1526        .map_err(|err| datetime_error(format!("{context}: {err}")))
1527}
1528
1529fn broadcast_three_numeric_tensors(
1530    a: &Tensor,
1531    b: &Tensor,
1532    c: &Tensor,
1533    context: &str,
1534) -> BuiltinResult<Broadcast3> {
1535    let mut output_shape = Vec::new();
1536    let mut output_len = 1usize;
1537    for operand in [a, b, c] {
1538        if operand.data.len() == 1 {
1539            continue;
1540        }
1541        let shape = tensor::default_shape_for(&operand.shape, operand.data.len());
1542        if output_shape.is_empty() {
1543            output_len = operand.data.len();
1544            output_shape = shape;
1545        } else if operand.data.len() != output_len || shape != output_shape {
1546            return Err(datetime_error(format!(
1547                "{context}: operands must be scalar or have matching sizes"
1548            )));
1549        }
1550    }
1551    if output_shape.is_empty() {
1552        output_shape = vec![1, 1];
1553        output_len = 1;
1554    }
1555
1556    let expand = |operand: &Tensor| -> BuiltinResult<Vec<f64>> {
1557        match operand.data.len() {
1558            1 => Ok(vec![operand.data[0]; output_len]),
1559            len if len == output_len => Ok(operand.data.clone()),
1560            _ => Err(datetime_error(format!(
1561                "{context}: operands must be scalar or have matching sizes"
1562            ))),
1563        }
1564    };
1565
1566    Ok((expand(a)?, expand(b)?, expand(c)?, output_shape))
1567}
1568
1569fn serial_date_key(serial: f64) -> BuiltinResult<i64> {
1570    if !serial.is_finite() {
1571        return Err(datetime_error("date values must be finite"));
1572    }
1573    let key = serial.floor();
1574    if key < i64::MIN as f64 || key > i64::MAX as f64 {
1575        return Err(datetime_error("date value is outside supported range"));
1576    }
1577    Ok(key as i64)
1578}
1579
1580fn date_from_key(key: i64) -> BuiltinResult<NaiveDate> {
1581    Ok(naive_from_datenum(key as f64)?.date())
1582}
1583
1584fn key_from_date(date: NaiveDate) -> i64 {
1585    datenum_from_naive(midnight(date)).floor() as i64
1586}
1587
1588fn observed_fixed_holiday(year: i32, month: u32, day: u32) -> BuiltinResult<i64> {
1589    let date = NaiveDate::from_ymd_opt(year, month, day)
1590        .ok_or_else(|| datetime_error("holidays: invalid fixed holiday date"))?;
1591    let observed = match date.weekday() {
1592        Weekday::Sat => date - Duration::days(1),
1593        Weekday::Sun => date + Duration::days(1),
1594        _ => date,
1595    };
1596    Ok(key_from_date(observed))
1597}
1598
1599fn nth_weekday(year: i32, month: u32, weekday: Weekday, n: u32) -> BuiltinResult<i64> {
1600    let mut date = NaiveDate::from_ymd_opt(year, month, 1)
1601        .ok_or_else(|| datetime_error("holidays: invalid nth weekday month"))?;
1602    while date.weekday() != weekday {
1603        date += Duration::days(1);
1604    }
1605    date += Duration::days(i64::from(n.saturating_sub(1)) * 7);
1606    Ok(key_from_date(date))
1607}
1608
1609fn last_weekday(year: i32, month: u32, weekday: Weekday) -> BuiltinResult<i64> {
1610    let last_day = days_in_month(year, month)?;
1611    let mut date = NaiveDate::from_ymd_opt(year, month, last_day)
1612        .ok_or_else(|| datetime_error("holidays: invalid last weekday month"))?;
1613    while date.weekday() != weekday {
1614        date -= Duration::days(1);
1615    }
1616    Ok(key_from_date(date))
1617}
1618
1619fn easter_sunday(year: i32) -> BuiltinResult<NaiveDate> {
1620    let a = year.rem_euclid(19);
1621    let b = year.div_euclid(100);
1622    let c = year.rem_euclid(100);
1623    let d = b.div_euclid(4);
1624    let e = b.rem_euclid(4);
1625    let f = (b + 8).div_euclid(25);
1626    let g = (b - f + 1).div_euclid(3);
1627    let h = (19 * a + b - d - g + 15).rem_euclid(30);
1628    let i = c.div_euclid(4);
1629    let k = c.rem_euclid(4);
1630    let l = (32 + 2 * e + 2 * i - h - k).rem_euclid(7);
1631    let m = (a + 11 * h + 22 * l).div_euclid(451);
1632    let month = (h + l - 7 * m + 114).div_euclid(31) as u32;
1633    let day = ((h + l - 7 * m + 114).rem_euclid(31) + 1) as u32;
1634    NaiveDate::from_ymd_opt(year, month, day)
1635        .ok_or_else(|| datetime_error("holidays: invalid computed Easter date"))
1636}
1637
1638fn market_holiday_keys_for_year(year: i32) -> BuiltinResult<Vec<i64>> {
1639    let mut keys = vec![
1640        observed_fixed_holiday(year, 1, 1)?,
1641        nth_weekday(year, 1, Weekday::Mon, 3)?,
1642        nth_weekday(year, 2, Weekday::Mon, 3)?,
1643        key_from_date(easter_sunday(year)? - Duration::days(2)),
1644        last_weekday(year, 5, Weekday::Mon)?,
1645        observed_fixed_holiday(year, 6, 19)?,
1646        observed_fixed_holiday(year, 7, 4)?,
1647        nth_weekday(year, 9, Weekday::Mon, 1)?,
1648        nth_weekday(year, 11, Weekday::Thu, 4)?,
1649        observed_fixed_holiday(year, 12, 25)?,
1650    ];
1651    keys.sort_unstable();
1652    keys.dedup();
1653    Ok(keys)
1654}
1655
1656fn holiday_keys_between(start_key: i64, end_key: i64) -> BuiltinResult<Vec<i64>> {
1657    let start_year = date_from_key(start_key.min(end_key))?
1658        .year()
1659        .checked_sub(1)
1660        .ok_or_else(|| datetime_error("holidays: date range is outside supported range"))?;
1661    let end_year = date_from_key(start_key.max(end_key))?
1662        .year()
1663        .checked_add(1)
1664        .ok_or_else(|| datetime_error("holidays: date range is outside supported range"))?;
1665    if end_year - start_year > MAX_HOLIDAY_YEAR_SPAN {
1666        return Err(datetime_error(format!(
1667            "holidays: date range spans more than {MAX_HOLIDAY_YEAR_SPAN} years"
1668        )));
1669    }
1670    let mut keys = Vec::new();
1671    for year in start_year..=end_year {
1672        keys.extend(market_holiday_keys_for_year(year)?);
1673    }
1674    keys.sort_unstable();
1675    keys.dedup();
1676    Ok(keys
1677        .into_iter()
1678        .filter(|key| *key >= start_key.min(end_key) && *key <= start_key.max(end_key))
1679        .collect())
1680}
1681
1682fn holiday_set_for_range(start_key: i64, end_key: i64) -> BuiltinResult<HashSet<i64>> {
1683    Ok(holiday_keys_between(start_key, end_key)?
1684        .into_iter()
1685        .collect())
1686}
1687
1688fn holiday_set_from_optional_or_default(
1689    value: Option<Value>,
1690    context: &str,
1691    start_key: i64,
1692    end_key: i64,
1693) -> BuiltinResult<HashSet<i64>> {
1694    if let Some(value) = value {
1695        let serials = numeric_or_datetime_serial_tensor(value, context)?;
1696        return serials
1697            .data
1698            .iter()
1699            .map(|serial| serial_date_key(*serial))
1700            .collect::<BuiltinResult<HashSet<_>>>();
1701    }
1702    holiday_set_for_range(start_key, end_key)
1703}
1704
1705fn date_key_range(tensors: &[&Tensor]) -> BuiltinResult<(i64, i64)> {
1706    let mut min_key = i64::MAX;
1707    let mut max_key = i64::MIN;
1708    let mut found = false;
1709    for tensor in tensors {
1710        for serial in &tensor.data {
1711            let key = serial_date_key(*serial)?;
1712            min_key = min_key.min(key);
1713            max_key = max_key.max(key);
1714            found = true;
1715        }
1716    }
1717    if found {
1718        Ok((min_key, max_key))
1719    } else {
1720        Ok((0, 0))
1721    }
1722}
1723
1724fn is_business_day_key(key: i64, holidays: &HashSet<i64>) -> BuiltinResult<bool> {
1725    let date = date_from_key(key)?;
1726    Ok(!matches!(date.weekday(), Weekday::Sat | Weekday::Sun) && !holidays.contains(&key))
1727}
1728
1729fn count_weekdays_forward(start_key: i64, end_key: i64) -> BuiltinResult<i64> {
1730    let total_days = end_key
1731        .checked_sub(start_key)
1732        .and_then(|delta| delta.checked_add(1))
1733        .ok_or_else(|| datetime_error("business-day date range is outside supported range"))?;
1734    let full_weeks = total_days / 7;
1735    let mut count = full_weeks * 5;
1736    let remainder = total_days % 7;
1737    for offset in 0..remainder {
1738        let key = start_key
1739            .checked_add(offset)
1740            .ok_or_else(|| datetime_error("business-day date range is outside supported range"))?;
1741        if !matches!(date_from_key(key)?.weekday(), Weekday::Sat | Weekday::Sun) {
1742            count += 1;
1743        }
1744    }
1745    Ok(count)
1746}
1747
1748fn count_business_days(
1749    start_key: i64,
1750    end_key: i64,
1751    holidays: &HashSet<i64>,
1752) -> BuiltinResult<i64> {
1753    if start_key > end_key {
1754        return Ok(-count_business_days(end_key, start_key, holidays)?);
1755    }
1756    let mut count = count_weekdays_forward(start_key, end_key)?;
1757    for holiday in holidays {
1758        if *holiday >= start_key
1759            && *holiday <= end_key
1760            && !matches!(
1761                date_from_key(*holiday)?.weekday(),
1762                Weekday::Sat | Weekday::Sun
1763            )
1764        {
1765            count -= 1;
1766        }
1767    }
1768    Ok(count)
1769}
1770
1771fn first_business_day_key(year: i32, month: u32, holidays: &HashSet<i64>) -> BuiltinResult<i64> {
1772    let mut date = NaiveDate::from_ymd_opt(year, month, 1)
1773        .ok_or_else(|| datetime_error("fbusdate: invalid year/month"))?;
1774    loop {
1775        let key = key_from_date(date);
1776        if is_business_day_key(key, holidays)? {
1777            return Ok(key);
1778        }
1779        date += Duration::days(1);
1780    }
1781}
1782
1783fn last_business_day_key(year: i32, month: u32, holidays: &HashSet<i64>) -> BuiltinResult<i64> {
1784    let mut date = NaiveDate::from_ymd_opt(year, month, days_in_month(year, month)?)
1785        .ok_or_else(|| datetime_error("lbusdate: invalid year/month"))?;
1786    loop {
1787        let key = key_from_date(date);
1788        if is_business_day_key(key, holidays)? {
1789            return Ok(key);
1790        }
1791        date -= Duration::days(1);
1792    }
1793}
1794
1795async fn datetime_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
1796    let Value::Object(object) = obj else {
1797        return Err(datetime_error(
1798            "datetime.subsref: receiver must be a datetime object",
1799        ));
1800    };
1801    let format = format_for_object(&object);
1802    let serials = serial_tensor_for_object(&object)?;
1803
1804    let Value::Cell(cell) = payload else {
1805        return Err(datetime_error(
1806            "datetime.subsref: indexing payload must be a cell array",
1807        ));
1808    };
1809    if cell.data.is_empty() {
1810        return datetime_object_from_serial_tensor(serials, format);
1811    }
1812    if cell.data.len() != 1 {
1813        return Err(datetime_error(
1814            "datetime.subsref: only linear datetime indexing is currently supported",
1815        ));
1816    }
1817    let selector = cell.data[0].clone();
1818    let selector = match selector {
1819        Value::Tensor(tensor) => tensor,
1820        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
1821            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1822        Value::Int(value) => Tensor::new(vec![value.to_f64()], vec![1, 1])
1823            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1824        Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
1825            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1826        other => {
1827            return Err(datetime_error(format!(
1828                "datetime.subsref: unsupported index value {other:?}"
1829            )))
1830        }
1831    };
1832    let indexed = crate::perform_indexing(&Value::Tensor(serials), &selector.data)
1833        .await
1834        .map_err(|err| datetime_error(format!("datetime.subsref: {}", err.message())))?;
1835    let indexed_serials = match indexed {
1836        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
1837            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1838        Value::Tensor(tensor) => tensor,
1839        other => {
1840            return Err(datetime_error(format!(
1841                "datetime.subsref: unexpected indexing result {other:?}"
1842            )))
1843        }
1844    };
1845    datetime_object_from_serial_tensor(indexed_serials, format)
1846}
1847
1848#[runmat_macros::runtime_builtin(
1849    name = "datetime",
1850    descriptor(crate::builtins::datetime::DATETIME_DESCRIPTOR),
1851    builtin_path = "crate::builtins::datetime",
1852    category = "datetime",
1853    summary = "Create datetime arrays from text, components, or serial date numbers.",
1854    keywords = "datetime,date,time,datenum,Format",
1855    related = "year,month,day,hour,minute,second,string,char,disp",
1856    examples = "t = datetime(2024, 4, 9, 13, 30, 0);"
1857)]
1858async fn datetime_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
1859    ensure_datetime_class_registered();
1860    let args = gather_args(&args).await?;
1861    let (positional_end, options) = parse_trailing_options(&args)?;
1862    let positional = args[..positional_end].to_vec();
1863
1864    if let Some(convert_from) = options.convert_from {
1865        if !convert_from.eq_ignore_ascii_case("datenum") {
1866            return Err(datetime_error(format!(
1867                "datetime: unsupported ConvertFrom value '{convert_from}'"
1868            )));
1869        }
1870        if positional.len() != 1 {
1871            return Err(datetime_error(
1872                "datetime: ConvertFrom='datenum' expects exactly one numeric input",
1873            ));
1874        }
1875        return numeric_value_to_datetime(positional[0].clone(), options.format);
1876    }
1877
1878    match positional.len() {
1879        0 => {
1880            let now = Local::now().naive_local();
1881            datetime_object_from_serials(
1882                vec![datenum_from_naive(now)],
1883                vec![1, 1],
1884                options
1885                    .format
1886                    .unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string()),
1887            )
1888        }
1889        1 => match &positional[0] {
1890            Value::Object(obj) if obj.is_class(DATETIME_CLASS) => {
1891                let serials = serials_from_datetime_value(&positional[0])?;
1892                let format = options
1893                    .format
1894                    .unwrap_or_else(|| datetime_format_from_value(&positional[0]));
1895                datetime_object_from_serial_tensor(serials, format)
1896            }
1897            Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
1898                let (serials, shape, inferred_format) =
1899                    parse_text_input(positional[0].clone(), options.input_format.as_deref())?;
1900                datetime_object_from_serials(
1901                    serials,
1902                    shape,
1903                    options.format.unwrap_or(inferred_format),
1904                )
1905            }
1906            _ => numeric_value_to_datetime(positional[0].clone(), options.format),
1907        },
1908        3..=6 => build_from_components(positional, options.format),
1909        _ => Err(datetime_error(
1910            "datetime: unsupported argument pattern; use text, serial dates, or Y/M/D component inputs",
1911        )),
1912    }
1913}
1914
1915#[runmat_macros::runtime_builtin(
1916    name = "year",
1917    descriptor(crate::builtins::datetime::DATETIME_YEAR_DESCRIPTOR),
1918    builtin_path = "crate::builtins::datetime",
1919    category = "datetime",
1920    summary = "Extract calendar year components from datetime values.",
1921    keywords = "year,datetime,date component"
1922)]
1923async fn year_builtin(value: Value) -> crate::BuiltinResult<Value> {
1924    component_tensor_from_datetime(&value, "year", |naive| naive.year() as f64)
1925}
1926
1927#[runmat_macros::runtime_builtin(
1928    name = "month",
1929    descriptor(crate::builtins::datetime::DATETIME_MONTH_DESCRIPTOR),
1930    builtin_path = "crate::builtins::datetime",
1931    category = "datetime",
1932    summary = "Extract month numbers from datetime arrays.",
1933    keywords = "month,datetime,date component"
1934)]
1935async fn month_builtin(value: Value) -> crate::BuiltinResult<Value> {
1936    component_tensor_from_datetime(&value, "month", |naive| naive.month() as f64)
1937}
1938
1939#[runmat_macros::runtime_builtin(
1940    name = "day",
1941    descriptor(crate::builtins::datetime::DATETIME_DAY_DESCRIPTOR),
1942    builtin_path = "crate::builtins::datetime",
1943    category = "datetime",
1944    summary = "Extract day-of-month numbers from datetime values.",
1945    keywords = "day,datetime,date component"
1946)]
1947async fn day_builtin(value: Value) -> crate::BuiltinResult<Value> {
1948    component_tensor_from_datetime(&value, "day", |naive| naive.day() as f64)
1949}
1950
1951#[runmat_macros::runtime_builtin(
1952    name = "hour",
1953    descriptor(crate::builtins::datetime::DATETIME_HOUR_DESCRIPTOR),
1954    builtin_path = "crate::builtins::datetime",
1955    category = "datetime",
1956    summary = "Extract hour components from datetime values.",
1957    keywords = "hour,datetime,time component"
1958)]
1959async fn hour_builtin(value: Value) -> crate::BuiltinResult<Value> {
1960    component_tensor_from_datetime(&value, "hour", |naive| naive.hour() as f64)
1961}
1962
1963#[runmat_macros::runtime_builtin(
1964    name = "minute",
1965    descriptor(crate::builtins::datetime::DATETIME_MINUTE_DESCRIPTOR),
1966    builtin_path = "crate::builtins::datetime",
1967    category = "datetime",
1968    summary = "Extract minute numbers from datetime arrays.",
1969    keywords = "minute,datetime,time component"
1970)]
1971async fn minute_builtin(value: Value) -> crate::BuiltinResult<Value> {
1972    component_tensor_from_datetime(&value, "minute", |naive| naive.minute() as f64)
1973}
1974
1975#[runmat_macros::runtime_builtin(
1976    name = "second",
1977    descriptor(crate::builtins::datetime::DATETIME_SECOND_DESCRIPTOR),
1978    builtin_path = "crate::builtins::datetime",
1979    category = "datetime",
1980    summary = "Extract second components from datetime values.",
1981    keywords = "second,datetime,time component"
1982)]
1983async fn second_builtin(value: Value) -> crate::BuiltinResult<Value> {
1984    component_tensor_from_datetime(&value, "second", |naive| {
1985        naive.second() as f64 + f64::from(naive.nanosecond()) / 1_000_000_000.0
1986    })
1987}
1988
1989#[runmat_macros::runtime_builtin(
1990    name = "isdatetime",
1991    builtin_path = "crate::builtins::datetime",
1992    category = "datetime",
1993    summary = "Return true for datetime values.",
1994    keywords = "isdatetime,datetime,predicate"
1995)]
1996fn isdatetime_builtin(value: Value) -> crate::BuiltinResult<Value> {
1997    Ok(Value::Bool(is_datetime_object(&value)))
1998}
1999
2000#[runmat_macros::runtime_builtin(
2001    name = "now",
2002    builtin_path = "crate::builtins::datetime",
2003    category = "datetime",
2004    summary = "Return the current local date and time as a MATLAB serial date number.",
2005    keywords = "now,datenum,current time"
2006)]
2007fn now_builtin() -> crate::BuiltinResult<Value> {
2008    Ok(Value::Num(datenum_from_naive(current_naive_local())))
2009}
2010
2011#[runmat_macros::runtime_builtin(
2012    name = "today",
2013    builtin_path = "crate::builtins::datetime",
2014    category = "datetime",
2015    summary = "Return the current local date as a datetime scalar.",
2016    keywords = "today,datetime,current date"
2017)]
2018fn today_builtin() -> crate::BuiltinResult<Value> {
2019    let today = Local::now().date_naive().and_hms_opt(0, 0, 0).unwrap();
2020    datetime_from_date_only(today, DEFAULT_DATE_FORMAT)
2021}
2022
2023#[runmat_macros::runtime_builtin(
2024    name = "clock",
2025    builtin_path = "crate::builtins::datetime",
2026    category = "datetime",
2027    summary = "Return the current local date and time as a date vector.",
2028    keywords = "clock,datevec,current time"
2029)]
2030fn clock_builtin() -> crate::BuiltinResult<Value> {
2031    let components = datevec_components_from_serial(datenum_from_naive(current_naive_local()))?;
2032    Ok(Value::Tensor(
2033        Tensor::new(components.to_vec(), vec![1, 6])
2034            .map_err(|err| datetime_error(format!("clock: {err}")))?,
2035    ))
2036}
2037
2038#[runmat_macros::runtime_builtin(
2039    name = "datenum",
2040    builtin_path = "crate::builtins::datetime",
2041    category = "datetime",
2042    summary = "Convert date/time inputs to MATLAB serial date numbers.",
2043    keywords = "datenum,datetime,datevec,serial date"
2044)]
2045async fn datenum_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
2046    let args = gather_args(&args).await?;
2047    let tensor = match args.len() {
2048        0 => Tensor::new(vec![datenum_from_naive(current_naive_local())], vec![1, 1])
2049            .map_err(|err| datetime_error(format!("datenum: {err}")))?,
2050        1 => match &args[0] {
2051            Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj)?,
2052            Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
2053                let (serials, shape, _) = parse_text_input(args[0].clone(), None)?;
2054                Tensor::new(serials, shape)
2055                    .map_err(|err| datetime_error(format!("datenum: {err}")))?
2056            }
2057            Value::Tensor(_) => {
2058                if let Ok(datevec) = tensor_from_datevec_like(args[0].clone(), "datenum") {
2059                    datenum_from_datevec_tensor(&datevec, "datenum")?
2060                } else {
2061                    tensor_from_numeric(args[0].clone(), "datenum")?
2062                }
2063            }
2064            _ => tensor_from_numeric(args[0].clone(), "datenum")?,
2065        },
2066        3..=6 => {
2067            let datetime = build_from_components(args, None)?;
2068            serials_from_datetime_value(&datetime)?
2069        }
2070        _ => {
2071            return Err(datetime_error(
2072                "datenum: expected datetime, text, date vector, or Y/M/D components",
2073            ))
2074        }
2075    };
2076    if tensor.data.len() == 1 {
2077        Ok(Value::Num(tensor.data[0]))
2078    } else {
2079        Ok(Value::Tensor(tensor))
2080    }
2081}
2082
2083#[runmat_macros::runtime_builtin(
2084    name = "datevec",
2085    builtin_path = "crate::builtins::datetime",
2086    category = "datetime",
2087    summary = "Convert date/time inputs to date vectors.",
2088    keywords = "datevec,datetime,datenum"
2089)]
2090async fn datevec_builtin(value: Value) -> crate::BuiltinResult<Value> {
2091    let value = gather_if_needed_async(&value)
2092        .await
2093        .map_err(|err| datetime_error(format!("datevec: {}", err.message())))?;
2094    let serials = numeric_or_datetime_serial_tensor(value, "datevec")?;
2095    let matrix = datevec_matrix_from_serial_tensor(&serials)?;
2096    if let Some(out_count) = crate::output_count::current_output_count() {
2097        if out_count == 0 {
2098            return Ok(Value::OutputList(Vec::new()));
2099        }
2100        let mut outputs = Vec::with_capacity(out_count.min(6));
2101        for col in 0..6.min(out_count) {
2102            let mut data = Vec::with_capacity(matrix.rows);
2103            for row in 0..matrix.rows {
2104                data.push(matrix.data[col * matrix.rows + row]);
2105            }
2106            outputs.push(if data.len() == 1 {
2107                Value::Num(data[0])
2108            } else {
2109                Value::Tensor(
2110                    Tensor::new(data, vec![matrix.rows, 1])
2111                        .map_err(|err| datetime_error(format!("datevec: {err}")))?,
2112                )
2113            });
2114        }
2115        return Ok(crate::output_count::output_list_with_padding(
2116            out_count, outputs,
2117        ));
2118    }
2119    Ok(Value::Tensor(matrix))
2120}
2121
2122#[runmat_macros::runtime_builtin(
2123    name = "datestr",
2124    builtin_path = "crate::builtins::datetime",
2125    category = "datetime",
2126    summary = "Format date/time inputs as character rows.",
2127    keywords = "datestr,datetime,datenum,date formatting"
2128)]
2129async fn datestr_builtin(value: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
2130    let value = gather_if_needed_async(&value)
2131        .await
2132        .map_err(|err| datetime_error(format!("datestr: {}", err.message())))?;
2133    let rest = gather_args(&rest).await?;
2134    if rest.len() > 1 {
2135        return Err(datetime_error(
2136            "datestr: expected at most one format argument",
2137        ));
2138    }
2139    let format = rest
2140        .first()
2141        .map(|value| scalar_text(value, "datestr format"))
2142        .transpose()?
2143        .unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string());
2144    let serials = match &value {
2145        Value::Tensor(_) => {
2146            if let Ok(datevec) = tensor_from_datevec_like(value.clone(), "datestr") {
2147                datenum_from_datevec_tensor(&datevec, "datestr")?
2148            } else {
2149                numeric_or_datetime_serial_tensor(value, "datestr")?
2150            }
2151        }
2152        _ => numeric_or_datetime_serial_tensor(value, "datestr")?,
2153    };
2154    let mut rows = Vec::with_capacity(serials.data.len());
2155    for serial in &serials.data {
2156        rows.push(format_serial(*serial, &format)?);
2157    }
2158    Ok(Value::CharArray(char_array_from_rows(&rows, "datestr")?))
2159}
2160
2161#[runmat_macros::runtime_builtin(
2162    name = "weekday",
2163    builtin_path = "crate::builtins::datetime",
2164    category = "datetime",
2165    summary = "Return weekday numbers and names for date/time inputs.",
2166    keywords = "weekday,datetime,datenum"
2167)]
2168async fn weekday_builtin(value: Value) -> crate::BuiltinResult<Value> {
2169    let value = gather_if_needed_async(&value)
2170        .await
2171        .map_err(|err| datetime_error(format!("weekday: {}", err.message())))?;
2172    let serials = numeric_or_datetime_serial_tensor(value, "weekday")?;
2173    let mut nums = Vec::with_capacity(serials.data.len());
2174    let mut names = Vec::with_capacity(serials.data.len());
2175    for serial in &serials.data {
2176        let weekday = naive_from_datenum(*serial)?.weekday();
2177        nums.push(f64::from(weekday.num_days_from_sunday()) + 1.0);
2178        names.push(
2179            match weekday {
2180                Weekday::Sun => "Sunday",
2181                Weekday::Mon => "Monday",
2182                Weekday::Tue => "Tuesday",
2183                Weekday::Wed => "Wednesday",
2184                Weekday::Thu => "Thursday",
2185                Weekday::Fri => "Friday",
2186                Weekday::Sat => "Saturday",
2187            }
2188            .to_string(),
2189        );
2190    }
2191    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
2192    let num_value = tensor_or_scalar(nums, shape.clone())?;
2193    let name_value = Value::StringArray(
2194        StringArray::new(names, shape).map_err(|err| datetime_error(format!("weekday: {err}")))?,
2195    );
2196    if let Some(out_count) = crate::output_count::current_output_count() {
2197        return Ok(crate::output_count::output_list_with_padding(
2198            out_count,
2199            vec![num_value, name_value],
2200        ));
2201    }
2202    Ok(num_value)
2203}
2204
2205#[runmat_macros::runtime_builtin(
2206    name = "eomday",
2207    builtin_path = "crate::builtins::datetime",
2208    category = "datetime",
2209    summary = "Return the last day number for month/year pairs.",
2210    keywords = "eomday,end of month,calendar"
2211)]
2212async fn eomday_builtin(year: Value, month: Value) -> crate::BuiltinResult<Value> {
2213    let year = gather_if_needed_async(&year)
2214        .await
2215        .map_err(|err| datetime_error(format!("eomday: {}", err.message())))?;
2216    let month = gather_if_needed_async(&month)
2217        .await
2218        .map_err(|err| datetime_error(format!("eomday: {}", err.message())))?;
2219    let years = component_tensor(year, "year")?;
2220    let months = component_tensor(month, "month")?;
2221    let (year_data, month_data, shape) =
2222        tensor::binary_numeric_tensors(&years, &months, "eomday", BUILTIN_NAME)?;
2223    let mut out = Vec::with_capacity(year_data.len());
2224    for (year, month) in year_data.iter().zip(month_data.iter()) {
2225        let year = round_component(*year, "year", -262_000, 262_000)? as i32;
2226        let month = round_component(*month, "month", 1, 12)? as u32;
2227        out.push(f64::from(days_in_month(year, month)?));
2228    }
2229    tensor_or_scalar(out, shape)
2230}
2231
2232#[runmat_macros::runtime_builtin(
2233    name = "etime",
2234    builtin_path = "crate::builtins::datetime",
2235    category = "datetime",
2236    summary = "Return elapsed seconds between date vectors.",
2237    keywords = "etime,datevec,elapsed time"
2238)]
2239async fn etime_builtin(t2: Value, t1: Value) -> crate::BuiltinResult<Value> {
2240    let t2 = gather_if_needed_async(&t2)
2241        .await
2242        .map_err(|err| datetime_error(format!("etime: {}", err.message())))?;
2243    let t1 = gather_if_needed_async(&t1)
2244        .await
2245        .map_err(|err| datetime_error(format!("etime: {}", err.message())))?;
2246    let t2 = datenum_from_datevec_tensor(&tensor_from_datevec_like(t2, "etime")?, "etime")?;
2247    let t1 = datenum_from_datevec_tensor(&tensor_from_datevec_like(t1, "etime")?, "etime")?;
2248    let (left, right, shape) = tensor::binary_numeric_tensors(&t2, &t1, "etime", BUILTIN_NAME)?;
2249    let out = left
2250        .iter()
2251        .zip(right.iter())
2252        .map(|(a, b)| (a - b) * SECONDS_PER_DAY)
2253        .collect::<Vec<_>>();
2254    tensor_or_scalar(out, shape)
2255}
2256
2257#[runmat_macros::runtime_builtin(
2258    name = "isbetween",
2259    builtin_path = "crate::builtins::datetime",
2260    category = "datetime",
2261    summary = "Return true where values fall between lower and upper bounds.",
2262    keywords = "isbetween,datetime,duration,comparison"
2263)]
2264async fn isbetween_builtin(
2265    value: Value,
2266    lower: Value,
2267    upper: Value,
2268) -> crate::BuiltinResult<Value> {
2269    let value = gather_if_needed_async(&value)
2270        .await
2271        .map_err(|err| datetime_error(format!("isbetween: {}", err.message())))?;
2272    let lower = gather_if_needed_async(&lower)
2273        .await
2274        .map_err(|err| datetime_error(format!("isbetween: {}", err.message())))?;
2275    let upper = gather_if_needed_async(&upper)
2276        .await
2277        .map_err(|err| datetime_error(format!("isbetween: {}", err.message())))?;
2278    let values = numeric_or_datetime_serial_tensor(value, "isbetween")?;
2279    let lower = numeric_or_datetime_serial_tensor(lower, "isbetween")?;
2280    let upper = numeric_or_datetime_serial_tensor(upper, "isbetween")?;
2281    let (values_data, lower_data, upper_data, shape) =
2282        broadcast_three_numeric_tensors(&values, &lower, &upper, "isbetween")?;
2283    let out = values_data
2284        .iter()
2285        .zip(lower_data.iter())
2286        .zip(upper_data.iter())
2287        .map(|((value, lower), upper)| {
2288            if value >= lower && value <= upper {
2289                1.0
2290            } else {
2291                0.0
2292            }
2293        })
2294        .collect::<Vec<_>>();
2295    tensor_or_scalar(out, shape)
2296}
2297
2298#[runmat_macros::runtime_builtin(
2299    name = "calendarDuration",
2300    builtin_path = "crate::builtins::datetime",
2301    category = "datetime",
2302    summary = "Create calendar duration values from calendar components.",
2303    keywords = "calendarDuration,caldays,calmonths,calyears,datetime"
2304)]
2305async fn calendar_duration_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
2306    let args = gather_args(&args).await?;
2307    if args.is_empty() {
2308        return calendar_duration_object_from_components(vec![0.0], vec![0.0], vec![1, 1]);
2309    }
2310    if args.len() == 1 && is_calendar_duration_object(&args[0]) {
2311        return Ok(args[0].clone());
2312    }
2313
2314    let labels = ["years", "months", "days", "hours", "minutes", "seconds"];
2315    let positional = match args.len() {
2316        1 => {
2317            let days = component_tensor(args[0].clone(), "calendarDuration")?;
2318            let shape = tensor::default_shape_for(&days.shape, days.data.len());
2319            return calendar_duration_object_from_components(
2320                vec![0.0; days.data.len()],
2321                days.data,
2322                shape,
2323            );
2324        }
2325        3..=6 => args,
2326        _ => {
2327            return Err(datetime_error(
2328                "calendarDuration: expected no input, days, or Y/M/D[/H/M/S] components",
2329            ))
2330        }
2331    };
2332
2333    let mut arrays = Vec::with_capacity(6);
2334    for (idx, arg) in positional.into_iter().enumerate() {
2335        arrays.push(component_tensor(arg, labels[idx])?);
2336    }
2337    while arrays.len() < 6 {
2338        arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
2339    }
2340    let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
2341    let len = broadcasted[0].len();
2342    let mut months = Vec::with_capacity(len);
2343    let mut days = Vec::with_capacity(len);
2344    for idx in 0..len {
2345        let month_value = broadcasted[0][idx] * 12.0 + broadcasted[1][idx];
2346        let day_value = broadcasted[2][idx]
2347            + broadcasted[3][idx] / 24.0
2348            + broadcasted[4][idx] / (24.0 * 60.0)
2349            + broadcasted[5][idx] / SECONDS_PER_DAY;
2350        if !month_value.is_finite() || !day_value.is_finite() {
2351            return Err(datetime_error(
2352                "calendarDuration: resulting calendar duration is outside supported range",
2353            ));
2354        }
2355        months.push(month_value);
2356        days.push(day_value);
2357    }
2358    calendar_duration_object_from_components(months, days, shape)
2359}
2360
2361#[runmat_macros::runtime_builtin(
2362    name = "caldays",
2363    builtin_path = "crate::builtins::datetime",
2364    category = "datetime",
2365    summary = "Create calendar durations from days or convert calendar durations to day counts.",
2366    keywords = "caldays,calendarDuration,datetime"
2367)]
2368async fn caldays_builtin(value: Value) -> crate::BuiltinResult<Value> {
2369    let value = gather_if_needed_async(&value)
2370        .await
2371        .map_err(|err| datetime_error(format!("caldays: {}", err.message())))?;
2372    calendar_duration_unit_value(value, "caldays", 0.0, 1.0)
2373}
2374
2375#[runmat_macros::runtime_builtin(
2376    name = "calweeks",
2377    builtin_path = "crate::builtins::datetime",
2378    category = "datetime",
2379    summary = "Create calendar durations from weeks or convert calendar durations to week counts.",
2380    keywords = "calweeks,calendarDuration,datetime"
2381)]
2382async fn calweeks_builtin(value: Value) -> crate::BuiltinResult<Value> {
2383    let value = gather_if_needed_async(&value)
2384        .await
2385        .map_err(|err| datetime_error(format!("calweeks: {}", err.message())))?;
2386    calendar_duration_unit_value(value, "calweeks", 0.0, 7.0)
2387}
2388
2389#[runmat_macros::runtime_builtin(
2390    name = "calmonths",
2391    builtin_path = "crate::builtins::datetime",
2392    category = "datetime",
2393    summary = "Create calendar durations from months or convert calendar durations to month counts.",
2394    keywords = "calmonths,calendarDuration,datetime"
2395)]
2396async fn calmonths_builtin(value: Value) -> crate::BuiltinResult<Value> {
2397    let value = gather_if_needed_async(&value)
2398        .await
2399        .map_err(|err| datetime_error(format!("calmonths: {}", err.message())))?;
2400    calendar_duration_unit_value(value, "calmonths", 1.0, 0.0)
2401}
2402
2403#[runmat_macros::runtime_builtin(
2404    name = "calquarters",
2405    builtin_path = "crate::builtins::datetime",
2406    category = "datetime",
2407    summary = "Create calendar durations from quarters or convert calendar durations to quarter counts.",
2408    keywords = "calquarters,calendarDuration,datetime"
2409)]
2410async fn calquarters_builtin(value: Value) -> crate::BuiltinResult<Value> {
2411    let value = gather_if_needed_async(&value)
2412        .await
2413        .map_err(|err| datetime_error(format!("calquarters: {}", err.message())))?;
2414    calendar_duration_unit_value(value, "calquarters", 3.0, 0.0)
2415}
2416
2417#[runmat_macros::runtime_builtin(
2418    name = "calyears",
2419    builtin_path = "crate::builtins::datetime",
2420    category = "datetime",
2421    summary = "Create calendar durations from years or convert calendar durations to year counts.",
2422    keywords = "calyears,calendarDuration,datetime"
2423)]
2424async fn calyears_builtin(value: Value) -> crate::BuiltinResult<Value> {
2425    let value = gather_if_needed_async(&value)
2426        .await
2427        .map_err(|err| datetime_error(format!("calyears: {}", err.message())))?;
2428    calendar_duration_unit_value(value, "calyears", 12.0, 0.0)
2429}
2430
2431#[runmat_macros::runtime_builtin(
2432    name = "iscalendarduration",
2433    builtin_path = "crate::builtins::datetime",
2434    category = "datetime",
2435    summary = "Return true for calendarDuration values.",
2436    keywords = "iscalendarduration,calendarDuration,predicate"
2437)]
2438fn iscalendarduration_builtin(value: Value) -> crate::BuiltinResult<Value> {
2439    Ok(Value::Bool(is_calendar_duration_object(&value)))
2440}
2441
2442#[runmat_macros::runtime_builtin(
2443    name = "isbusday",
2444    builtin_path = "crate::builtins::datetime",
2445    category = "datetime",
2446    summary = "Return true where date values are business days.",
2447    keywords = "isbusday,business day,datetime,financial"
2448)]
2449async fn isbusday_builtin(value: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
2450    let value = gather_if_needed_async(&value)
2451        .await
2452        .map_err(|err| datetime_error(format!("isbusday: {}", err.message())))?;
2453    let rest = gather_args(&rest).await?;
2454    if rest.len() > 1 {
2455        return Err(datetime_error(
2456            "isbusday: expected at most one holiday list",
2457        ));
2458    }
2459    let serials = numeric_or_datetime_serial_tensor(value, "isbusday")?;
2460    let (start_key, end_key) = date_key_range(&[&serials])?;
2461    let holidays = holiday_set_from_optional_or_default(
2462        rest.into_iter().next(),
2463        "isbusday",
2464        start_key,
2465        end_key,
2466    )?;
2467    let mut out = Vec::with_capacity(serials.data.len());
2468    for serial in &serials.data {
2469        out.push(
2470            if is_business_day_key(serial_date_key(*serial)?, &holidays)? {
2471                1.0
2472            } else {
2473                0.0
2474            },
2475        );
2476    }
2477    tensor_or_scalar(
2478        out,
2479        tensor::default_shape_for(&serials.shape, serials.data.len()),
2480    )
2481}
2482
2483#[runmat_macros::runtime_builtin(
2484    name = "holidays",
2485    builtin_path = "crate::builtins::datetime",
2486    category = "datetime",
2487    summary = "Return exchange-style market holidays for a year or date range.",
2488    keywords = "holidays,business day,datetime,financial"
2489)]
2490async fn holidays_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
2491    let args = gather_args(&args).await?;
2492    let keys = match args.len() {
2493        0 => {
2494            let year = current_naive_local().year();
2495            let start = key_from_date(NaiveDate::from_ymd_opt(year, 1, 1).unwrap());
2496            let end = key_from_date(NaiveDate::from_ymd_opt(year, 12, 31).unwrap());
2497            holiday_keys_between(start, end)?
2498        }
2499        1 => {
2500            let tensor = tensor_from_numeric(args[0].clone(), "holidays");
2501            if let Ok(tensor) = tensor {
2502                if tensor.data.len() == 1 && (1000.0..=9999.0).contains(&tensor.data[0]) {
2503                    let keys = market_holiday_keys_for_year(tensor.data[0].round() as i32)?;
2504                    let len = keys.len();
2505                    return datetime_object_from_serials(
2506                        keys.into_iter().map(|key| key as f64).collect(),
2507                        vec![len, 1],
2508                        DEFAULT_DATE_FORMAT,
2509                    );
2510                }
2511            }
2512            let serials = numeric_or_datetime_serial_tensor(args[0].clone(), "holidays")?;
2513            let year = date_from_key(serial_date_key(serials.data[0])?)?.year();
2514            let start = key_from_date(NaiveDate::from_ymd_opt(year, 1, 1).unwrap());
2515            let end = key_from_date(NaiveDate::from_ymd_opt(year, 12, 31).unwrap());
2516            holiday_keys_between(start, end)?
2517        }
2518        2 => {
2519            let start = numeric_or_datetime_serial_tensor(args[0].clone(), "holidays")?;
2520            let end = numeric_or_datetime_serial_tensor(args[1].clone(), "holidays")?;
2521            if start.data.len() != 1 || end.data.len() != 1 {
2522                return Err(datetime_error(
2523                    "holidays: start and end dates must be scalar",
2524                ));
2525            }
2526            holiday_keys_between(
2527                serial_date_key(start.data[0])?,
2528                serial_date_key(end.data[0])?,
2529            )?
2530        }
2531        _ => {
2532            return Err(datetime_error(
2533                "holidays: expected zero, one, or two inputs",
2534            ))
2535        }
2536    };
2537    let len = keys.len();
2538    datetime_object_from_serials(
2539        keys.into_iter().map(|key| key as f64).collect(),
2540        vec![len, 1],
2541        DEFAULT_DATE_FORMAT,
2542    )
2543}
2544
2545#[runmat_macros::runtime_builtin(
2546    name = "busdays",
2547    builtin_path = "crate::builtins::datetime",
2548    category = "datetime",
2549    summary = "Return serial date numbers for business days in a scalar date range.",
2550    keywords = "busdays,business day,datetime,financial"
2551)]
2552async fn busdays_builtin(
2553    start: Value,
2554    end: Value,
2555    rest: Vec<Value>,
2556) -> crate::BuiltinResult<Value> {
2557    let start = gather_if_needed_async(&start)
2558        .await
2559        .map_err(|err| datetime_error(format!("busdays: {}", err.message())))?;
2560    let end = gather_if_needed_async(&end)
2561        .await
2562        .map_err(|err| datetime_error(format!("busdays: {}", err.message())))?;
2563    let rest = gather_args(&rest).await?;
2564    if rest.len() > 1 {
2565        return Err(datetime_error("busdays: expected at most one holiday list"));
2566    }
2567    let start = numeric_or_datetime_serial_tensor(start, "busdays")?;
2568    let end = numeric_or_datetime_serial_tensor(end, "busdays")?;
2569    if start.data.len() != 1 || end.data.len() != 1 {
2570        return Err(datetime_error(
2571            "busdays: start and end dates must be scalar",
2572        ));
2573    }
2574    let mut key = serial_date_key(start.data[0])?;
2575    let end_key = serial_date_key(end.data[0])?;
2576    let span = key
2577        .max(end_key)
2578        .checked_sub(key.min(end_key))
2579        .and_then(|delta| delta.checked_add(1))
2580        .ok_or_else(|| datetime_error("busdays: date range is outside supported range"))?;
2581    if span > MAX_BUSDAYS_OUTPUT_LEN {
2582        return Err(datetime_error(format!(
2583            "busdays: output would exceed {MAX_BUSDAYS_OUTPUT_LEN} dates"
2584        )));
2585    }
2586    let holidays =
2587        holiday_set_from_optional_or_default(rest.into_iter().next(), "busdays", key, end_key)?;
2588    let step = if key <= end_key { 1 } else { -1 };
2589    let mut out = Vec::new();
2590    loop {
2591        if is_business_day_key(key, &holidays)? {
2592            out.push(key as f64);
2593        }
2594        if key == end_key {
2595            break;
2596        }
2597        key = key
2598            .checked_add(step)
2599            .ok_or_else(|| datetime_error("busdays: date range is outside supported range"))?;
2600    }
2601    let len = out.len();
2602    Tensor::new(out, vec![len, 1])
2603        .map(Value::Tensor)
2604        .map_err(|err| datetime_error(format!("busdays: {err}")))
2605}
2606
2607#[runmat_macros::runtime_builtin(
2608    name = "days252bus",
2609    builtin_path = "crate::builtins::datetime",
2610    category = "datetime",
2611    summary = "Count business days between date values using a 252-business-day calendar.",
2612    keywords = "days252bus,business day,datetime,financial"
2613)]
2614async fn days252bus_builtin(
2615    start: Value,
2616    end: Value,
2617    rest: Vec<Value>,
2618) -> crate::BuiltinResult<Value> {
2619    let start = gather_if_needed_async(&start)
2620        .await
2621        .map_err(|err| datetime_error(format!("days252bus: {}", err.message())))?;
2622    let end = gather_if_needed_async(&end)
2623        .await
2624        .map_err(|err| datetime_error(format!("days252bus: {}", err.message())))?;
2625    let rest = gather_args(&rest).await?;
2626    if rest.len() > 1 {
2627        return Err(datetime_error(
2628            "days252bus: expected at most one holiday list",
2629        ));
2630    }
2631    let starts = numeric_or_datetime_serial_tensor(start, "days252bus")?;
2632    let ends = numeric_or_datetime_serial_tensor(end, "days252bus")?;
2633    let (start_key, end_key) = date_key_range(&[&starts, &ends])?;
2634    let holidays = holiday_set_from_optional_or_default(
2635        rest.into_iter().next(),
2636        "days252bus",
2637        start_key,
2638        end_key,
2639    )?;
2640    let (start_data, end_data, shape) =
2641        tensor::binary_numeric_tensors(&starts, &ends, "days252bus", BUILTIN_NAME)?;
2642    let counts = start_data
2643        .iter()
2644        .zip(end_data.iter())
2645        .map(|(start, end)| {
2646            Ok(
2647                count_business_days(serial_date_key(*start)?, serial_date_key(*end)?, &holidays)?
2648                    as f64,
2649            )
2650        })
2651        .collect::<BuiltinResult<Vec<_>>>()?;
2652    tensor_or_scalar(counts, shape)
2653}
2654
2655#[runmat_macros::runtime_builtin(
2656    name = "daysdif",
2657    builtin_path = "crate::builtins::datetime",
2658    category = "datetime",
2659    summary = "Return date differences using actual or 30/360 day-count bases.",
2660    keywords = "daysdif,date difference,datetime,financial"
2661)]
2662async fn daysdif_builtin(
2663    start: Value,
2664    end: Value,
2665    rest: Vec<Value>,
2666) -> crate::BuiltinResult<Value> {
2667    let start = gather_if_needed_async(&start)
2668        .await
2669        .map_err(|err| datetime_error(format!("daysdif: {}", err.message())))?;
2670    let end = gather_if_needed_async(&end)
2671        .await
2672        .map_err(|err| datetime_error(format!("daysdif: {}", err.message())))?;
2673    let rest = gather_args(&rest).await?;
2674    if rest.len() > 1 {
2675        return Err(datetime_error(
2676            "daysdif: expected at most one basis argument",
2677        ));
2678    }
2679    let basis = rest
2680        .first()
2681        .map(|value| tensor_from_numeric(value.clone(), "daysdif"))
2682        .transpose()?
2683        .and_then(|tensor| tensor.data.first().copied())
2684        .unwrap_or(0.0)
2685        .round() as i64;
2686    let starts = numeric_or_datetime_serial_tensor(start, "daysdif")?;
2687    let ends = numeric_or_datetime_serial_tensor(end, "daysdif")?;
2688    let (start_data, end_data, shape) =
2689        tensor::binary_numeric_tensors(&starts, &ends, "daysdif", BUILTIN_NAME)?;
2690    let out = start_data
2691        .iter()
2692        .zip(end_data.iter())
2693        .map(|(start, end)| {
2694            let start_key = serial_date_key(*start)?;
2695            let end_key = serial_date_key(*end)?;
2696            if basis == 1 {
2697                let s = date_from_key(start_key)?;
2698                let e = date_from_key(end_key)?;
2699                let sd = s.day().min(30) as i32;
2700                let ed = if sd == 30 { e.day().min(30) } else { e.day() } as i32;
2701                Ok(f64::from(
2702                    (e.year() - s.year()) * 360
2703                        + (e.month() as i32 - s.month() as i32) * 30
2704                        + (ed - sd),
2705                ))
2706            } else {
2707                Ok((end_key - start_key) as f64)
2708            }
2709        })
2710        .collect::<BuiltinResult<Vec<_>>>()?;
2711    tensor_or_scalar(out, shape)
2712}
2713
2714#[runmat_macros::runtime_builtin(
2715    name = "fbusdate",
2716    builtin_path = "crate::builtins::datetime",
2717    category = "datetime",
2718    summary = "Return first business day serial date numbers for month/year pairs.",
2719    keywords = "fbusdate,business day,datetime,financial"
2720)]
2721async fn fbusdate_builtin(
2722    year: Value,
2723    month: Value,
2724    rest: Vec<Value>,
2725) -> crate::BuiltinResult<Value> {
2726    let year = gather_if_needed_async(&year)
2727        .await
2728        .map_err(|err| datetime_error(format!("fbusdate: {}", err.message())))?;
2729    let month = gather_if_needed_async(&month)
2730        .await
2731        .map_err(|err| datetime_error(format!("fbusdate: {}", err.message())))?;
2732    let rest = gather_args(&rest).await?;
2733    if rest.len() > 1 {
2734        return Err(datetime_error(
2735            "fbusdate: expected at most one holiday list",
2736        ));
2737    }
2738    let years = component_tensor(year, "fbusdate year")?;
2739    let months = component_tensor(month, "fbusdate month")?;
2740    let (year_data, month_data, shape) =
2741        tensor::binary_numeric_tensors(&years, &months, "fbusdate", BUILTIN_NAME)?;
2742    let mut min_key = i64::MAX;
2743    let mut max_key = i64::MIN;
2744    for (year, month) in year_data.iter().zip(month_data.iter()) {
2745        let year = round_component(*year, "year", -262_000, 262_000)? as i32;
2746        let month = round_component(*month, "month", 1, 12)? as u32;
2747        min_key = min_key.min(key_from_date(
2748            NaiveDate::from_ymd_opt(year, month, 1)
2749                .ok_or_else(|| datetime_error("fbusdate: invalid year/month"))?,
2750        ));
2751        max_key = max_key.max(key_from_date(
2752            NaiveDate::from_ymd_opt(year, month, days_in_month(year, month)?)
2753                .ok_or_else(|| datetime_error("fbusdate: invalid year/month"))?,
2754        ));
2755    }
2756    let holidays = holiday_set_from_optional_or_default(
2757        rest.into_iter().next(),
2758        "fbusdate",
2759        min_key,
2760        max_key,
2761    )?;
2762    let out = year_data
2763        .iter()
2764        .zip(month_data.iter())
2765        .map(|(year, month)| {
2766            Ok(first_business_day_key(
2767                round_component(*year, "year", -262_000, 262_000)? as i32,
2768                round_component(*month, "month", 1, 12)? as u32,
2769                &holidays,
2770            )? as f64)
2771        })
2772        .collect::<BuiltinResult<Vec<_>>>()?;
2773    tensor_or_scalar(out, shape)
2774}
2775
2776#[runmat_macros::runtime_builtin(
2777    name = "lbusdate",
2778    builtin_path = "crate::builtins::datetime",
2779    category = "datetime",
2780    summary = "Return last business day serial date numbers for month/year pairs.",
2781    keywords = "lbusdate,business day,datetime,financial"
2782)]
2783async fn lbusdate_builtin(
2784    year: Value,
2785    month: Value,
2786    rest: Vec<Value>,
2787) -> crate::BuiltinResult<Value> {
2788    let year = gather_if_needed_async(&year)
2789        .await
2790        .map_err(|err| datetime_error(format!("lbusdate: {}", err.message())))?;
2791    let month = gather_if_needed_async(&month)
2792        .await
2793        .map_err(|err| datetime_error(format!("lbusdate: {}", err.message())))?;
2794    let rest = gather_args(&rest).await?;
2795    if rest.len() > 1 {
2796        return Err(datetime_error(
2797            "lbusdate: expected at most one holiday list",
2798        ));
2799    }
2800    let years = component_tensor(year, "lbusdate year")?;
2801    let months = component_tensor(month, "lbusdate month")?;
2802    let (year_data, month_data, shape) =
2803        tensor::binary_numeric_tensors(&years, &months, "lbusdate", BUILTIN_NAME)?;
2804    let mut min_key = i64::MAX;
2805    let mut max_key = i64::MIN;
2806    for (year, month) in year_data.iter().zip(month_data.iter()) {
2807        let year = round_component(*year, "year", -262_000, 262_000)? as i32;
2808        let month = round_component(*month, "month", 1, 12)? as u32;
2809        min_key = min_key.min(key_from_date(
2810            NaiveDate::from_ymd_opt(year, month, 1)
2811                .ok_or_else(|| datetime_error("lbusdate: invalid year/month"))?,
2812        ));
2813        max_key = max_key.max(key_from_date(
2814            NaiveDate::from_ymd_opt(year, month, days_in_month(year, month)?)
2815                .ok_or_else(|| datetime_error("lbusdate: invalid year/month"))?,
2816        ));
2817    }
2818    let holidays = holiday_set_from_optional_or_default(
2819        rest.into_iter().next(),
2820        "lbusdate",
2821        min_key,
2822        max_key,
2823    )?;
2824    let out = year_data
2825        .iter()
2826        .zip(month_data.iter())
2827        .map(|(year, month)| {
2828            Ok(last_business_day_key(
2829                round_component(*year, "year", -262_000, 262_000)? as i32,
2830                round_component(*month, "month", 1, 12)? as u32,
2831                &holidays,
2832            )? as f64)
2833        })
2834        .collect::<BuiltinResult<Vec<_>>>()?;
2835    tensor_or_scalar(out, shape)
2836}
2837
2838#[runmat_macros::runtime_builtin(
2839    name = "datetick",
2840    builtin_path = "crate::builtins::datetime",
2841    category = "plotting",
2842    summary = "Accept MATLAB date-axis formatting calls for compatibility.",
2843    keywords = "datetick,plot,date axis"
2844)]
2845async fn datetick_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
2846    let _args = gather_args(&args).await?;
2847    Ok(Value::Num(0.0))
2848}
2849
2850#[runmat_macros::runtime_builtin(
2851    name = "datetime.subsref",
2852    descriptor(crate::builtins::datetime::DATETIME_SUBSREF_DESCRIPTOR),
2853    builtin_path = "crate::builtins::datetime"
2854)]
2855async fn datetime_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
2856    match kind.as_str() {
2857        OBJECT_INDEX_PAREN => datetime_indexing(obj, payload).await,
2858        OBJECT_INDEX_MEMBER => {
2859            let Value::Object(object) = obj else {
2860                return Err(datetime_error(
2861                    "datetime.subsref: receiver must be a datetime object",
2862                ));
2863            };
2864            let field = scalar_text(&payload, "field selector")?;
2865            match field.as_str() {
2866                FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
2867                _ => Err(datetime_error(format!(
2868                    "datetime.subsref: unsupported datetime property '{field}'"
2869                ))),
2870            }
2871        }
2872        other => Err(datetime_error(format!(
2873            "datetime.subsref: unsupported indexing kind '{other}'"
2874        ))),
2875    }
2876}
2877
2878#[runmat_macros::runtime_builtin(
2879    name = "datetime.subsasgn",
2880    descriptor(crate::builtins::datetime::DATETIME_SUBSASGN_DESCRIPTOR),
2881    builtin_path = "crate::builtins::datetime"
2882)]
2883async fn datetime_subsasgn(
2884    obj: Value,
2885    kind: String,
2886    payload: Value,
2887    rhs: Value,
2888) -> crate::BuiltinResult<Value> {
2889    let Value::Object(mut object) = obj else {
2890        return Err(datetime_error(
2891            "datetime.subsasgn: receiver must be a datetime object",
2892        ));
2893    };
2894    match kind.as_str() {
2895        OBJECT_INDEX_MEMBER => {
2896            let field = scalar_text(&payload, "field selector")?;
2897            match field.as_str() {
2898                FORMAT_FIELD => {
2899                    let text = scalar_text(&rhs, "Format value")?;
2900                    object
2901                        .properties
2902                        .insert(FORMAT_FIELD.to_string(), Value::String(text));
2903                    Ok(Value::Object(object))
2904                }
2905                _ => Err(datetime_error(format!(
2906                    "datetime.subsasgn: unsupported datetime property '{field}'"
2907                ))),
2908            }
2909        }
2910        _ => Err(datetime_error(format!(
2911            "datetime.subsasgn: unsupported indexing kind '{kind}'"
2912        ))),
2913    }
2914}
2915
2916fn datetime_binary_serials(
2917    lhs: Value,
2918    rhs: Value,
2919    context: &str,
2920) -> BuiltinResult<(Tensor, Tensor, Vec<usize>, String)> {
2921    let lhs_serials = serials_from_datetime_value(&lhs)?;
2922    let rhs_serials = match &rhs {
2923        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj)?,
2924        _ => serial_tensor_from_value(rhs, context)?,
2925    };
2926    let (left, right, shape) =
2927        tensor::binary_numeric_tensors(&lhs_serials, &rhs_serials, context, BUILTIN_NAME)?;
2928    let left_tensor = Tensor::new(left, shape.clone())
2929        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
2930    let right_tensor = Tensor::new(right, shape.clone())
2931        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
2932    Ok((
2933        left_tensor,
2934        right_tensor,
2935        shape,
2936        datetime_format_from_value(&lhs),
2937    ))
2938}
2939
2940fn compare_datetime(
2941    lhs: Value,
2942    rhs: Value,
2943    op: &str,
2944    cmp: impl Fn(f64, f64) -> bool,
2945) -> BuiltinResult<Value> {
2946    let (left, right, shape, _) = datetime_binary_serials(lhs, rhs, op)?;
2947    let out = left
2948        .data
2949        .iter()
2950        .zip(right.data.iter())
2951        .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
2952        .collect::<Vec<_>>();
2953    tensor_or_scalar(out, shape)
2954}
2955
2956#[runmat_macros::runtime_builtin(
2957    name = "datetime.eq",
2958    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
2959    builtin_path = "crate::builtins::datetime"
2960)]
2961async fn datetime_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
2962    compare_datetime(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
2963}
2964
2965#[runmat_macros::runtime_builtin(
2966    name = "datetime.ne",
2967    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
2968    builtin_path = "crate::builtins::datetime"
2969)]
2970async fn datetime_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
2971    compare_datetime(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
2972}
2973
2974#[runmat_macros::runtime_builtin(
2975    name = "datetime.lt",
2976    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
2977    builtin_path = "crate::builtins::datetime"
2978)]
2979async fn datetime_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
2980    compare_datetime(lhs, rhs, "lt", |a, b| a < b)
2981}
2982
2983#[runmat_macros::runtime_builtin(
2984    name = "datetime.le",
2985    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
2986    builtin_path = "crate::builtins::datetime"
2987)]
2988async fn datetime_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
2989    compare_datetime(lhs, rhs, "le", |a, b| a <= b)
2990}
2991
2992#[runmat_macros::runtime_builtin(
2993    name = "datetime.gt",
2994    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
2995    builtin_path = "crate::builtins::datetime"
2996)]
2997async fn datetime_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
2998    compare_datetime(lhs, rhs, "gt", |a, b| a > b)
2999}
3000
3001#[runmat_macros::runtime_builtin(
3002    name = "datetime.ge",
3003    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
3004    builtin_path = "crate::builtins::datetime"
3005)]
3006async fn datetime_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
3007    compare_datetime(lhs, rhs, "ge", |a, b| a >= b)
3008}
3009
3010#[runmat_macros::runtime_builtin(
3011    name = "datetime.plus",
3012    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
3013    builtin_path = "crate::builtins::datetime"
3014)]
3015async fn datetime_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
3016    let lhs_serials = serials_from_datetime_value(&lhs)?;
3017    if is_calendar_duration_object(&rhs) {
3018        let (months, days) = calendar_duration_tensors_from_value(&rhs)?;
3019        let (serials, shape) =
3020            apply_calendar_duration_to_serials(&lhs_serials, &months, &days, 1.0, "plus")?;
3021        return datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs));
3022    }
3023    let rhs_numeric = if crate::builtins::duration::is_duration_object(&rhs) {
3024        crate::builtins::duration::duration_tensor_from_duration_value(&rhs)?
3025    } else {
3026        serial_tensor_from_value(rhs, "plus")?
3027    };
3028    let (left, right, shape) =
3029        tensor::binary_numeric_tensors(&lhs_serials, &rhs_numeric, "plus", BUILTIN_NAME)?;
3030    let serials = left
3031        .iter()
3032        .zip(right.iter())
3033        .map(|(a, b)| a + b)
3034        .collect::<Vec<_>>();
3035    datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
3036}
3037
3038#[runmat_macros::runtime_builtin(
3039    name = "datetime.minus",
3040    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
3041    builtin_path = "crate::builtins::datetime"
3042)]
3043async fn datetime_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
3044    let lhs_serials = serials_from_datetime_value(&lhs)?;
3045    match &rhs {
3046        _ if is_calendar_duration_object(&rhs) => {
3047            let (months, days) = calendar_duration_tensors_from_value(&rhs)?;
3048            let (serials, shape) =
3049                apply_calendar_duration_to_serials(&lhs_serials, &months, &days, -1.0, "minus")?;
3050            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
3051        }
3052        _ if crate::builtins::duration::is_duration_object(&rhs) => {
3053            let rhs_days = crate::builtins::duration::duration_tensor_from_duration_value(&rhs)?;
3054            let (left, right, shape) =
3055                tensor::binary_numeric_tensors(&lhs_serials, &rhs_days, "minus", BUILTIN_NAME)?;
3056            let serials = left
3057                .iter()
3058                .zip(right.iter())
3059                .map(|(a, b)| a - b)
3060                .collect::<Vec<_>>();
3061            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
3062        }
3063        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => {
3064            let rhs_serials = serial_tensor_for_object(obj)?;
3065            let (left, right, shape) =
3066                tensor::binary_numeric_tensors(&lhs_serials, &rhs_serials, "minus", BUILTIN_NAME)?;
3067            let deltas = left
3068                .iter()
3069                .zip(right.iter())
3070                .map(|(a, b)| a - b)
3071                .collect::<Vec<_>>();
3072            tensor_or_scalar(deltas, shape)
3073        }
3074        _ => {
3075            let rhs_numeric = serial_tensor_from_value(rhs, "minus")?;
3076            let (left, right, shape) =
3077                tensor::binary_numeric_tensors(&lhs_serials, &rhs_numeric, "minus", BUILTIN_NAME)?;
3078            let serials = left
3079                .iter()
3080                .zip(right.iter())
3081                .map(|(a, b)| a - b)
3082                .collect::<Vec<_>>();
3083            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
3084        }
3085    }
3086}
3087
3088fn combine_calendar_durations(
3089    lhs: &Value,
3090    rhs: &Value,
3091    sign: f64,
3092    context: &str,
3093) -> BuiltinResult<Value> {
3094    let (lhs_months, lhs_days) = calendar_duration_tensors_from_value(lhs)?;
3095    let (rhs_months, rhs_days) = calendar_duration_tensors_from_value(rhs)?;
3096    let (left_months, right_months, shape) =
3097        tensor::binary_numeric_tensors(&lhs_months, &rhs_months, context, BUILTIN_NAME)?;
3098    let lhs_days_shape = tensor::default_shape_for(&lhs_days.shape, lhs_days.data.len());
3099    let rhs_days_shape = tensor::default_shape_for(&rhs_days.shape, rhs_days.data.len());
3100    let lhs_day_tensor = Tensor::new(lhs_days.data, lhs_days_shape)
3101        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
3102    let rhs_day_tensor = Tensor::new(rhs_days.data, rhs_days_shape)
3103        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
3104    let (left_days, right_days, day_shape) =
3105        tensor::binary_numeric_tensors(&lhs_day_tensor, &rhs_day_tensor, context, BUILTIN_NAME)?;
3106    if day_shape != shape {
3107        return Err(datetime_error(format!(
3108            "{context}: calendarDuration operands must have matching component sizes"
3109        )));
3110    }
3111    let months = left_months
3112        .iter()
3113        .zip(right_months.iter())
3114        .map(|(left, right)| left + sign * right)
3115        .collect::<Vec<_>>();
3116    let days = left_days
3117        .iter()
3118        .zip(right_days.iter())
3119        .map(|(left, right)| left + sign * right)
3120        .collect::<Vec<_>>();
3121    calendar_duration_object_from_components(months, days, shape)
3122}
3123
3124#[runmat_macros::runtime_builtin(
3125    name = "calendarDuration.plus",
3126    builtin_path = "crate::builtins::datetime"
3127)]
3128async fn calendar_duration_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
3129    if is_datetime_object(&rhs) {
3130        let (months, days) = calendar_duration_tensors_from_value(&lhs)?;
3131        let rhs_serials = serials_from_datetime_value(&rhs)?;
3132        let (serials, shape) =
3133            apply_calendar_duration_to_serials(&rhs_serials, &months, &days, 1.0, "plus")?;
3134        return datetime_object_from_serials(serials, shape, datetime_format_from_value(&rhs));
3135    }
3136    combine_calendar_durations(&lhs, &rhs, 1.0, "plus")
3137}
3138
3139#[runmat_macros::runtime_builtin(
3140    name = "calendarDuration.minus",
3141    builtin_path = "crate::builtins::datetime"
3142)]
3143async fn calendar_duration_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
3144    combine_calendar_durations(&lhs, &rhs, -1.0, "minus")
3145}
3146
3147#[runmat_macros::runtime_builtin(
3148    name = "calendarDuration.eq",
3149    builtin_path = "crate::builtins::datetime"
3150)]
3151async fn calendar_duration_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
3152    let (lhs_months, lhs_days) = calendar_duration_tensors_from_value(&lhs)?;
3153    let (rhs_months, rhs_days) = calendar_duration_tensors_from_value(&rhs)?;
3154    let (left_months, right_months, shape) =
3155        tensor::binary_numeric_tensors(&lhs_months, &rhs_months, "eq", BUILTIN_NAME)?;
3156    let (left_days, right_days, day_shape) =
3157        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "eq", BUILTIN_NAME)?;
3158    if day_shape != shape {
3159        return Err(datetime_error(
3160            "eq: calendarDuration operands must have matching component sizes",
3161        ));
3162    }
3163    let out = left_months
3164        .iter()
3165        .zip(right_months.iter())
3166        .zip(left_days.iter().zip(right_days.iter()))
3167        .map(|((lm, rm), (ld, rd))| {
3168            if (lm - rm).abs() <= 1e-12 && (ld - rd).abs() <= 1e-12 {
3169                1.0
3170            } else {
3171                0.0
3172            }
3173        })
3174        .collect::<Vec<_>>();
3175    tensor_or_scalar(out, shape)
3176}
3177
3178#[runmat_macros::runtime_builtin(
3179    name = "calendarDuration.ne",
3180    builtin_path = "crate::builtins::datetime"
3181)]
3182async fn calendar_duration_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
3183    let eq = calendar_duration_eq(lhs, rhs).await?;
3184    match eq {
3185        Value::Num(value) => Ok(Value::Num(if value == 0.0 { 1.0 } else { 0.0 })),
3186        Value::Tensor(tensor) => Ok(Value::Tensor(
3187            Tensor::new(
3188                tensor
3189                    .data
3190                    .into_iter()
3191                    .map(|value| if value == 0.0 { 1.0 } else { 0.0 })
3192                    .collect(),
3193                tensor.shape,
3194            )
3195            .map_err(|err| datetime_error(format!("ne: {err}")))?,
3196        )),
3197        other => Ok(other),
3198    }
3199}
3200
3201#[derive(Clone, Copy, PartialEq, Eq)]
3202enum DateShiftBoundary {
3203    Start,
3204    End,
3205    Nearest,
3206    DayOfWeek,
3207}
3208
3209impl DateShiftBoundary {
3210    fn parse(value: &Value) -> BuiltinResult<Self> {
3211        let text = scalar_text(value, "dateshift boundary")?;
3212        match text.trim().to_ascii_lowercase().as_str() {
3213            "start" => Ok(Self::Start),
3214            "end" => Ok(Self::End),
3215            "nearest" => Ok(Self::Nearest),
3216            "dayofweek" => Ok(Self::DayOfWeek),
3217            other => Err(datetime_error(format!(
3218                "dateshift: unsupported boundary '{other}'"
3219            ))),
3220        }
3221    }
3222}
3223
3224#[derive(Clone, Copy)]
3225enum DateShiftUnit {
3226    Year,
3227    Quarter,
3228    Month,
3229    Week,
3230    Day,
3231    Hour,
3232    Minute,
3233    Second,
3234}
3235
3236impl DateShiftUnit {
3237    fn parse(value: &Value) -> BuiltinResult<Self> {
3238        let text = scalar_text(value, "dateshift unit")?;
3239        match text.trim().to_ascii_lowercase().as_str() {
3240            "year" | "years" => Ok(Self::Year),
3241            "quarter" | "quarters" => Ok(Self::Quarter),
3242            "month" | "months" => Ok(Self::Month),
3243            "week" | "weeks" => Ok(Self::Week),
3244            "day" | "days" => Ok(Self::Day),
3245            "hour" | "hours" => Ok(Self::Hour),
3246            "minute" | "minutes" => Ok(Self::Minute),
3247            "second" | "seconds" => Ok(Self::Second),
3248            other => Err(datetime_error(format!(
3249                "dateshift: unsupported unit '{other}'"
3250            ))),
3251        }
3252    }
3253}
3254
3255fn parse_weekday(value: &Value) -> BuiltinResult<Weekday> {
3256    match value {
3257        Value::Num(n) if n.is_finite() && (*n - n.round()).abs() <= f64::EPSILON => {
3258            weekday_from_matlab_index(n.round() as i64)
3259        }
3260        Value::Int(i) => weekday_from_matlab_index(i.to_i64()),
3261        _ => {
3262            let text = scalar_text(value, "weekday")?;
3263            match text.trim().to_ascii_lowercase().as_str() {
3264                "sun" | "sunday" => Ok(Weekday::Sun),
3265                "mon" | "monday" => Ok(Weekday::Mon),
3266                "tue" | "tues" | "tuesday" => Ok(Weekday::Tue),
3267                "wed" | "wednesday" => Ok(Weekday::Wed),
3268                "thu" | "thur" | "thurs" | "thursday" => Ok(Weekday::Thu),
3269                "fri" | "friday" => Ok(Weekday::Fri),
3270                "sat" | "saturday" => Ok(Weekday::Sat),
3271                other => Err(datetime_error(format!(
3272                    "dateshift: unsupported weekday '{other}'"
3273                ))),
3274            }
3275        }
3276    }
3277}
3278
3279fn weekday_from_matlab_index(index: i64) -> BuiltinResult<Weekday> {
3280    match index {
3281        1 => Ok(Weekday::Sun),
3282        2 => Ok(Weekday::Mon),
3283        3 => Ok(Weekday::Tue),
3284        4 => Ok(Weekday::Wed),
3285        5 => Ok(Weekday::Thu),
3286        6 => Ok(Weekday::Fri),
3287        7 => Ok(Weekday::Sat),
3288        _ => Err(datetime_error(
3289            "dateshift: numeric weekdays must be in the range 1..7",
3290        )),
3291    }
3292}
3293
3294fn midnight(date: NaiveDate) -> NaiveDateTime {
3295    date.and_hms_opt(0, 0, 0).unwrap()
3296}
3297
3298fn start_of_week(value: NaiveDateTime, week_start: Weekday) -> NaiveDateTime {
3299    let current = value.weekday().num_days_from_monday() as i64;
3300    let start = week_start.num_days_from_monday() as i64;
3301    let delta = (current - start).rem_euclid(7);
3302    midnight(value.date() - Duration::days(delta))
3303}
3304
3305fn start_of_unit(value: NaiveDateTime, unit: DateShiftUnit, week_start: Weekday) -> NaiveDateTime {
3306    match unit {
3307        DateShiftUnit::Year => midnight(NaiveDate::from_ymd_opt(value.year(), 1, 1).unwrap()),
3308        DateShiftUnit::Quarter => {
3309            let month = ((value.month() - 1) / 3) * 3 + 1;
3310            midnight(NaiveDate::from_ymd_opt(value.year(), month, 1).unwrap())
3311        }
3312        DateShiftUnit::Month => {
3313            midnight(NaiveDate::from_ymd_opt(value.year(), value.month(), 1).unwrap())
3314        }
3315        DateShiftUnit::Week => start_of_week(value, week_start),
3316        DateShiftUnit::Day => midnight(value.date()),
3317        DateShiftUnit::Hour => value
3318            .date()
3319            .and_hms_nano_opt(value.hour(), 0, 0, 0)
3320            .unwrap(),
3321        DateShiftUnit::Minute => value
3322            .date()
3323            .and_hms_nano_opt(value.hour(), value.minute(), 0, 0)
3324            .unwrap(),
3325        DateShiftUnit::Second => value
3326            .date()
3327            .and_hms_nano_opt(value.hour(), value.minute(), value.second(), 0)
3328            .unwrap(),
3329    }
3330}
3331
3332fn add_months(year: i32, month: u32, delta: u32) -> (i32, u32) {
3333    let zero_based = year as i64 * 12 + i64::from(month - 1) + i64::from(delta);
3334    let out_year = zero_based.div_euclid(12) as i32;
3335    let out_month = zero_based.rem_euclid(12) as u32 + 1;
3336    (out_year, out_month)
3337}
3338
3339fn next_unit_start(start: NaiveDateTime, unit: DateShiftUnit) -> NaiveDateTime {
3340    match unit {
3341        DateShiftUnit::Year => midnight(NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).unwrap()),
3342        DateShiftUnit::Quarter => {
3343            let (year, month) = add_months(start.year(), start.month(), 3);
3344            midnight(NaiveDate::from_ymd_opt(year, month, 1).unwrap())
3345        }
3346        DateShiftUnit::Month => {
3347            let (year, month) = add_months(start.year(), start.month(), 1);
3348            midnight(NaiveDate::from_ymd_opt(year, month, 1).unwrap())
3349        }
3350        DateShiftUnit::Week => start + Duration::days(7),
3351        DateShiftUnit::Day => start + Duration::days(1),
3352        DateShiftUnit::Hour => start + Duration::hours(1),
3353        DateShiftUnit::Minute => start + Duration::minutes(1),
3354        DateShiftUnit::Second => start + Duration::seconds(1),
3355    }
3356}
3357
3358fn shift_naive_datetime(
3359    value: NaiveDateTime,
3360    boundary: DateShiftBoundary,
3361    unit: DateShiftUnit,
3362    week_start: Weekday,
3363) -> NaiveDateTime {
3364    let start = start_of_unit(value, unit, week_start);
3365    match boundary {
3366        DateShiftBoundary::Start => start,
3367        DateShiftBoundary::End => next_unit_start(start, unit) - Duration::milliseconds(1),
3368        DateShiftBoundary::Nearest => {
3369            let next = next_unit_start(start, unit);
3370            if value - start <= next - value {
3371                start
3372            } else {
3373                next
3374            }
3375        }
3376        DateShiftBoundary::DayOfWeek => value,
3377    }
3378}
3379
3380fn shift_to_dayofweek(value: NaiveDateTime, weekday: Weekday) -> NaiveDateTime {
3381    let current = value.weekday().num_days_from_monday() as i64;
3382    let target = weekday.num_days_from_monday() as i64;
3383    let delta = (target - current).rem_euclid(7);
3384    midnight(value.date() + Duration::days(delta))
3385}
3386
3387#[runmat_macros::runtime_builtin(
3388    name = "dateshift",
3389    descriptor(crate::builtins::datetime::DATESHIFT_DESCRIPTOR),
3390    builtin_path = "crate::builtins::datetime",
3391    category = "datetime",
3392    summary = "Shift datetime values to calendar or clock boundaries.",
3393    keywords = "dateshift,datetime,start,end,nearest,week,month,year",
3394    related = "datetime,year,month,day"
3395)]
3396async fn dateshift_builtin(
3397    value: Value,
3398    boundary: Value,
3399    unit: Value,
3400    rest: Vec<Value>,
3401) -> crate::BuiltinResult<Value> {
3402    let value = gather_if_needed_async(&value)
3403        .await
3404        .map_err(|err| datetime_error(format!("dateshift: {}", err.message())))?;
3405    let boundary = gather_if_needed_async(&boundary)
3406        .await
3407        .map_err(|err| datetime_error(format!("dateshift: {}", err.message())))?;
3408    let unit = gather_if_needed_async(&unit)
3409        .await
3410        .map_err(|err| datetime_error(format!("dateshift: {}", err.message())))?;
3411    let rest = gather_args(&rest).await?;
3412    let serials = serials_from_datetime_value(&value)?;
3413    let format = datetime_format_from_value(&value);
3414    let boundary = DateShiftBoundary::parse(&boundary)?;
3415
3416    let mut out = Vec::with_capacity(serials.data.len());
3417    if boundary == DateShiftBoundary::DayOfWeek {
3418        if !rest.is_empty() {
3419            return Err(datetime_error(
3420                "dateshift: dayofweek boundary does not accept extra arguments",
3421            ));
3422        }
3423        let weekday = parse_weekday(&unit)?;
3424        for serial in &serials.data {
3425            out.push(datenum_from_naive(shift_to_dayofweek(
3426                naive_from_datenum(*serial)?,
3427                weekday,
3428            )));
3429        }
3430    } else {
3431        let unit = DateShiftUnit::parse(&unit)?;
3432        let week_start = if matches!(unit, DateShiftUnit::Week) {
3433            if rest.len() > 1 {
3434                return Err(datetime_error(
3435                    "dateshift: week unit accepts at most one weekday argument",
3436                ));
3437            }
3438            rest.first()
3439                .map(parse_weekday)
3440                .transpose()?
3441                .unwrap_or(Weekday::Mon)
3442        } else {
3443            if !rest.is_empty() {
3444                return Err(datetime_error(
3445                    "dateshift: extra arguments are only supported for week units",
3446                ));
3447            }
3448            Weekday::Mon
3449        };
3450        for serial in &serials.data {
3451            out.push(datenum_from_naive(shift_naive_datetime(
3452                naive_from_datenum(*serial)?,
3453                boundary,
3454                unit,
3455                week_start,
3456            )));
3457        }
3458    }
3459
3460    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
3461    datetime_object_from_serials(out, shape, format)
3462}
3463
3464pub fn datetime_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
3465    let Some(array) = datetime_string_array(value)? else {
3466        return Ok(None);
3467    };
3468    let width = array
3469        .data
3470        .iter()
3471        .map(|s| s.chars().count())
3472        .max()
3473        .unwrap_or(0);
3474    let rows = array.data.len();
3475    let mut data = vec![' '; rows * width];
3476    for (row, text) in array.data.iter().enumerate() {
3477        for (col, ch) in text.chars().enumerate() {
3478            data[row * width + col] = ch;
3479        }
3480    }
3481    let out = CharArray::new(data, rows, width)
3482        .map_err(|err| datetime_error(format!("datetime: {err}")))?;
3483    Ok(Some(out))
3484}
3485
3486#[cfg(test)]
3487mod tests {
3488    use super::*;
3489
3490    fn run_datetime(args: Vec<Value>) -> Value {
3491        futures::executor::block_on(datetime_builtin(args)).expect("datetime")
3492    }
3493
3494    fn as_datetime(value: Value) -> ObjectInstance {
3495        match value {
3496            Value::Object(object) => object,
3497            other => panic!("expected datetime object, got {other:?}"),
3498        }
3499    }
3500
3501    fn serial_for_date(year: i32, month: u32, day: u32) -> f64 {
3502        datenum_from_naive(midnight(NaiveDate::from_ymd_opt(year, month, day).unwrap()))
3503    }
3504
3505    #[test]
3506    fn datetime_descriptor_signatures_cover_constructor_and_methods() {
3507        let labels: Vec<&str> = DATETIME_DESCRIPTOR
3508            .signatures
3509            .iter()
3510            .map(|sig| sig.label)
3511            .collect();
3512        assert!(labels.contains(&"t = datetime()"));
3513        assert!(labels.contains(&"t = datetime(year, month, day, hour, minute, second)"));
3514        assert!(labels.contains(&"t = datetime(serialDateNumbers, \"ConvertFrom\", \"datenum\")"));
3515
3516        assert_eq!(DATETIME_YEAR_DESCRIPTOR.signatures[0].label, "X = year(t)");
3517        assert_eq!(
3518            DATETIME_SUBSREF_DESCRIPTOR.signatures[0].label,
3519            "out = datetime.subsref(obj, kind, payload)"
3520        );
3521        assert_eq!(
3522            DATETIME_BINARY_DESCRIPTOR.signatures[0].label,
3523            "out = datetime.op(lhs, rhs)"
3524        );
3525    }
3526
3527    #[test]
3528    fn datetime_builds_from_components() {
3529        let value = run_datetime(vec![Value::Num(2024.0), Value::Num(3.0), Value::Num(14.0)]);
3530        let object = as_datetime(value);
3531        assert_eq!(object.class_name, DATETIME_CLASS);
3532        assert_eq!(format_for_object(&object), DEFAULT_DATE_FORMAT);
3533        let serials = serial_tensor_for_object(&object).expect("serials");
3534        assert_eq!(serials.data.len(), 1);
3535        let year =
3536            futures::executor::block_on(year_builtin(Value::Object(object.clone()))).expect("year");
3537        assert_eq!(year, Value::Num(2024.0));
3538    }
3539
3540    #[test]
3541    fn datetime_builds_arrays_from_component_vectors() {
3542        let years = Value::Tensor(Tensor::new(vec![2024.0, 2025.0], vec![1, 2]).unwrap());
3543        let months = Value::Tensor(Tensor::new(vec![1.0, 6.0], vec![1, 2]).unwrap());
3544        let days = Value::Tensor(Tensor::new(vec![15.0, 20.0], vec![1, 2]).unwrap());
3545        let value = run_datetime(vec![years, months, days]);
3546        let object = as_datetime(value.clone());
3547        let serials = serial_tensor_for_object(&object).expect("serials");
3548        assert_eq!(serials.shape, vec![1, 2]);
3549        let rendered = datetime_display_text(&value)
3550            .expect("display")
3551            .expect("datetime text");
3552        assert!(rendered.contains("15-Jan-2024"));
3553        assert!(rendered.contains("20-Jun-2025"));
3554    }
3555
3556    #[test]
3557    fn datetime_parses_text_and_converts_to_strings() {
3558        let value = run_datetime(vec![Value::String("2024-03-14 09:26:53".to_string())]);
3559        let rendered = datetime_string_array(&value)
3560            .expect("string array")
3561            .expect("datetime strings");
3562        assert_eq!(rendered.data, vec!["14-Mar-2024 09:26:53".to_string()]);
3563    }
3564
3565    #[test]
3566    fn datetime_missing_serial_renders_as_nat() {
3567        let value = datetime_object_from_serial_tensor(
3568            Tensor::new(vec![f64::NAN], vec![1, 1]).unwrap(),
3569            DEFAULT_DATETIME_FORMAT,
3570        )
3571        .expect("datetime object");
3572        let rendered = datetime_string_array(&value)
3573            .expect("string array")
3574            .expect("datetime strings");
3575        assert_eq!(rendered.data, vec!["NaT".to_string()]);
3576        assert_eq!(
3577            datetime_display_text(&value).expect("display"),
3578            Some("NaT".to_string())
3579        );
3580    }
3581
3582    #[test]
3583    fn datetime_accepts_existing_datetime_input() {
3584        let value = run_datetime(vec![Value::String("2024-03-14".to_string())]);
3585        let converted = run_datetime(vec![
3586            value.clone(),
3587            Value::from("InputFormat"),
3588            Value::from("yyyy-MM-dd"),
3589        ]);
3590        assert_eq!(
3591            serials_from_datetime_value(&converted).unwrap().data,
3592            serials_from_datetime_value(&value).unwrap().data
3593        );
3594    }
3595
3596    #[test]
3597    fn datetime_parses_text_with_input_format() {
3598        let input = Value::StringArray(
3599            StringArray::new(
3600                vec!["2024/03/14".to_string(), "2024/03/15".to_string()],
3601                vec![2, 1],
3602            )
3603            .unwrap(),
3604        );
3605        let value = run_datetime(vec![
3606            input,
3607            Value::from("InputFormat"),
3608            Value::from("yyyy/MM/dd"),
3609            Value::from("Format"),
3610            Value::from("yyyy-MM-dd"),
3611        ]);
3612        let rendered = datetime_string_array(&value)
3613            .expect("string array")
3614            .expect("datetime strings");
3615        assert_eq!(
3616            rendered.data,
3617            vec!["2024-03-14".to_string(), "2024-03-15".to_string()]
3618        );
3619    }
3620
3621    #[test]
3622    fn dateshift_supports_start_of_week_and_month_end() {
3623        let input = run_datetime(vec![
3624            Value::StringArray(
3625                StringArray::new(
3626                    vec!["2024-03-14".to_string(), "2024-03-18".to_string()],
3627                    vec![2, 1],
3628                )
3629                .unwrap(),
3630            ),
3631            Value::from("Format"),
3632            Value::from("yyyy-MM-dd"),
3633        ]);
3634        let shifted = futures::executor::block_on(dateshift_builtin(
3635            input,
3636            Value::from("start"),
3637            Value::from("week"),
3638            Vec::new(),
3639        ))
3640        .expect("dateshift start week");
3641        let rendered = datetime_string_array(&shifted)
3642            .expect("string array")
3643            .expect("datetime strings");
3644        assert_eq!(
3645            rendered.data,
3646            vec!["2024-03-11".to_string(), "2024-03-18".to_string()]
3647        );
3648
3649        let month_end = futures::executor::block_on(dateshift_builtin(
3650            run_datetime(vec![
3651                Value::from("2024-02-10"),
3652                Value::from("Format"),
3653                Value::from("yyyy-MM-dd HH:mm:ss"),
3654            ]),
3655            Value::from("end"),
3656            Value::from("month"),
3657            Vec::new(),
3658        ))
3659        .expect("dateshift end month");
3660        let rendered = datetime_string_array(&month_end)
3661            .expect("string array")
3662            .expect("datetime strings");
3663        assert_eq!(rendered.data, vec!["2024-02-29 23:59:59".to_string()]);
3664    }
3665
3666    #[test]
3667    fn dateshift_rejects_unsupported_extra_arguments() {
3668        let input = run_datetime(vec![Value::from("2024-03-14")]);
3669        let err = futures::executor::block_on(dateshift_builtin(
3670            input.clone(),
3671            Value::from("dayofweek"),
3672            Value::from("monday"),
3673            vec![Value::from("extra")],
3674        ))
3675        .expect_err("dayofweek extra argument should fail");
3676        assert!(err.message().contains("does not accept extra arguments"));
3677
3678        let err = futures::executor::block_on(dateshift_builtin(
3679            input.clone(),
3680            Value::from("start"),
3681            Value::from("week"),
3682            vec![Value::from("monday"), Value::from("extra")],
3683        ))
3684        .expect_err("week second extra argument should fail");
3685        assert!(err.message().contains("at most one weekday argument"));
3686
3687        let err = futures::executor::block_on(dateshift_builtin(
3688            input,
3689            Value::from("start"),
3690            Value::from("month"),
3691            vec![Value::from("monday")],
3692        ))
3693        .expect_err("non-week extra argument should fail");
3694        assert!(err
3695            .message()
3696            .contains("extra arguments are only supported for week units"));
3697    }
3698
3699    #[test]
3700    fn datetime_supports_format_assignment() {
3701        let value = run_datetime(vec![Value::Num(2024.0), Value::Num(3.0), Value::Num(14.0)]);
3702        let updated = futures::executor::block_on(datetime_subsasgn(
3703            value,
3704            ".".to_string(),
3705            Value::String(FORMAT_FIELD.to_string()),
3706            Value::String("yyyy-MM-dd".to_string()),
3707        ))
3708        .expect("subsasgn");
3709        let rendered = datetime_display_text(&updated)
3710            .expect("display")
3711            .expect("datetime text");
3712        assert_eq!(rendered, "2024-03-14");
3713    }
3714
3715    #[test]
3716    fn datetime_supports_indexing_and_comparison() {
3717        let years = Value::Tensor(Tensor::new(vec![2024.0, 2025.0], vec![1, 2]).unwrap());
3718        let months = Value::Tensor(Tensor::new(vec![1.0, 6.0], vec![1, 2]).unwrap());
3719        let days = Value::Tensor(Tensor::new(vec![15.0, 20.0], vec![1, 2]).unwrap());
3720        let value = run_datetime(vec![years, months, days]);
3721        let payload =
3722            Value::Cell(runmat_builtins::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
3723        let indexed =
3724            futures::executor::block_on(datetime_subsref(value.clone(), "()".to_string(), payload))
3725                .expect("subsref");
3726        let year = futures::executor::block_on(year_builtin(indexed)).expect("year");
3727        assert_eq!(year, Value::Num(2025.0));
3728
3729        let lhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(1.0)]);
3730        let rhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(2.0)]);
3731        let cmp = futures::executor::block_on(datetime_lt(lhs, rhs)).expect("lt");
3732        assert_eq!(cmp, Value::Num(1.0));
3733    }
3734
3735    #[test]
3736    fn datetime_and_duration_interoperate() {
3737        let lhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(1.0)]);
3738        let rhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(2.0)]);
3739        let delta = futures::executor::block_on(datetime_minus(rhs.clone(), lhs.clone()))
3740            .expect("datetime minus datetime");
3741        assert_eq!(delta, Value::Num(1.0));
3742
3743        let duration = crate::builtins::duration::duration_object_from_days_tensor(
3744            Tensor::new(vec![1.0], vec![1, 1]).unwrap(),
3745            crate::builtins::duration::DEFAULT_DURATION_FORMAT,
3746        )
3747        .expect("duration");
3748
3749        let round_trip = futures::executor::block_on(datetime_plus(lhs.clone(), duration.clone()))
3750            .expect("plus");
3751        let round_trip_text = datetime_display_text(&round_trip)
3752            .expect("datetime display")
3753            .expect("datetime text");
3754        assert_eq!(round_trip_text, "02-Jan-2024");
3755
3756        let restored =
3757            futures::executor::block_on(datetime_minus(rhs, duration)).expect("minus duration");
3758        let restored_text = datetime_display_text(&restored)
3759            .expect("datetime display")
3760            .expect("datetime text");
3761        assert_eq!(restored_text, "01-Jan-2024");
3762    }
3763
3764    #[test]
3765    fn legacy_date_conversion_and_query_helpers_work() {
3766        let serial = serial_for_date(2024, 3, 14);
3767        let date_vector =
3768            futures::executor::block_on(datevec_builtin(Value::Num(serial))).expect("datevec");
3769        let Value::Tensor(date_vector) = date_vector else {
3770            panic!("expected datevec tensor");
3771        };
3772        assert_eq!(date_vector.shape, vec![1, 6]);
3773        assert_eq!(&date_vector.data[..3], &[2024.0, 3.0, 14.0]);
3774
3775        let round_trip =
3776            futures::executor::block_on(datenum_builtin(vec![Value::Tensor(date_vector.clone())]))
3777                .expect("datenum");
3778        assert_eq!(round_trip, Value::Num(serial));
3779        let date_only_round_trip =
3780            futures::executor::block_on(datenum_builtin(vec![Value::Tensor(
3781                Tensor::new(vec![2024.0, 3.0, 14.0], vec![1, 3]).unwrap(),
3782            )]))
3783            .expect("datenum date vector");
3784        assert_eq!(date_only_round_trip, Value::Num(serial));
3785
3786        let text = futures::executor::block_on(datestr_builtin(
3787            Value::Num(serial),
3788            vec![Value::from("yyyy-MM-dd")],
3789        ))
3790        .expect("datestr");
3791        let Value::CharArray(text) = text else {
3792            panic!("expected datestr char array");
3793        };
3794        assert_eq!(text.data.iter().collect::<String>(), "2024-03-14");
3795        let text_from_datevec = futures::executor::block_on(datestr_builtin(
3796            Value::Tensor(Tensor::new(vec![2024.0, 3.0, 14.0], vec![1, 3]).unwrap()),
3797            vec![Value::from("yyyy-MM-dd")],
3798        ))
3799        .expect("datestr date vector");
3800        let Value::CharArray(text_from_datevec) = text_from_datevec else {
3801            panic!("expected datestr char array");
3802        };
3803        assert_eq!(
3804            text_from_datevec.data.iter().collect::<String>(),
3805            "2024-03-14"
3806        );
3807
3808        let weekday =
3809            futures::executor::block_on(weekday_builtin(Value::Num(serial))).expect("weekday");
3810        assert_eq!(weekday, Value::Num(5.0));
3811        assert_eq!(
3812            futures::executor::block_on(eomday_builtin(Value::Num(2024.0), Value::Num(2.0)))
3813                .expect("eomday"),
3814            Value::Num(29.0)
3815        );
3816        assert_eq!(
3817            futures::executor::block_on(etime_builtin(
3818                Value::Tensor(
3819                    Tensor::new(vec![2024.0, 1.0, 2.0, 0.0, 0.0, 0.0], vec![1, 6]).unwrap(),
3820                ),
3821                Value::Tensor(
3822                    Tensor::new(vec![2024.0, 1.0, 1.0, 0.0, 0.0, 0.0], vec![1, 6]).unwrap(),
3823                ),
3824            ))
3825            .expect("etime"),
3826            Value::Num(SECONDS_PER_DAY)
3827        );
3828        assert_eq!(
3829            futures::executor::block_on(isbetween_builtin(
3830                Value::Num(serial),
3831                Value::Num(serial - 1.0),
3832                Value::Num(serial + 1.0),
3833            ))
3834            .expect("isbetween"),
3835            Value::Num(1.0)
3836        );
3837    }
3838
3839    #[test]
3840    fn calendar_duration_helpers_and_datetime_arithmetic_work() {
3841        let one_month =
3842            futures::executor::block_on(calmonths_builtin(Value::Num(1.0))).expect("calmonths");
3843        assert_eq!(
3844            iscalendarduration_builtin(one_month.clone()).expect("predicate"),
3845            Value::Bool(true)
3846        );
3847        assert_eq!(
3848            futures::executor::block_on(calmonths_builtin(one_month.clone()))
3849                .expect("calmonths convert"),
3850            Value::Num(1.0)
3851        );
3852
3853        let jan31 = run_datetime(vec![
3854            Value::from("2024-01-31"),
3855            Value::from("Format"),
3856            Value::from("yyyy-MM-dd"),
3857        ]);
3858        let shifted = futures::executor::block_on(datetime_plus(jan31, one_month)).expect("plus");
3859        let rendered = datetime_string_array(&shifted)
3860            .expect("string array")
3861            .expect("datetime strings");
3862        assert_eq!(rendered.data, vec!["2024-02-29".to_string()]);
3863
3864        let duration = futures::executor::block_on(calendar_duration_builtin(vec![
3865            Value::Num(1.0),
3866            Value::Num(2.0),
3867            Value::Num(3.0),
3868        ]))
3869        .expect("calendarDuration");
3870        let (months, days) = calendar_duration_tensors_from_value(&duration).expect("components");
3871        assert_eq!(months.data, vec![14.0]);
3872        assert_eq!(days.data, vec![3.0]);
3873
3874        assert!(futures::executor::block_on(calyears_builtin(Value::Num(f64::MAX))).is_err());
3875        assert!(futures::executor::block_on(calendar_duration_builtin(vec![
3876            Value::Num(f64::MAX),
3877            Value::Num(f64::MAX),
3878            Value::Num(0.0),
3879        ]))
3880        .is_err());
3881    }
3882
3883    #[test]
3884    fn business_day_helpers_use_weekends_and_holidays() {
3885        let new_year = serial_for_date(2024, 1, 1);
3886        let friday = serial_for_date(2024, 1, 5);
3887        let saturday = serial_for_date(2024, 1, 6);
3888        let mask = futures::executor::block_on(isbusday_builtin(
3889            Value::Tensor(Tensor::new(vec![new_year, friday, saturday], vec![1, 3]).unwrap()),
3890            Vec::new(),
3891        ))
3892        .expect("isbusday");
3893        let Value::Tensor(mask) = mask else {
3894            panic!("expected isbusday tensor");
3895        };
3896        assert_eq!(mask.data, vec![0.0, 1.0, 0.0]);
3897
3898        assert_eq!(
3899            futures::executor::block_on(isbusday_builtin(
3900                Value::Num(friday),
3901                vec![Value::Num(friday)],
3902            ))
3903            .expect("custom holiday"),
3904            Value::Num(0.0)
3905        );
3906
3907        let business_days = futures::executor::block_on(busdays_builtin(
3908            Value::Num(friday),
3909            Value::Num(friday + 3.0),
3910            Vec::new(),
3911        ))
3912        .expect("busdays");
3913        let Value::Tensor(business_days) = business_days else {
3914            panic!("expected busdays tensor");
3915        };
3916        assert_eq!(business_days.data, vec![friday, friday + 3.0]);
3917
3918        assert_eq!(
3919            futures::executor::block_on(days252bus_builtin(
3920                Value::Num(friday),
3921                Value::Num(friday + 3.0),
3922                Vec::new(),
3923            ))
3924            .expect("days252bus"),
3925            Value::Num(2.0)
3926        );
3927        assert_eq!(
3928            futures::executor::block_on(daysdif_builtin(
3929                Value::Num(friday),
3930                Value::Num(friday + 3.0),
3931                Vec::new(),
3932            ))
3933            .expect("daysdif"),
3934            Value::Num(3.0)
3935        );
3936        assert_eq!(
3937            futures::executor::block_on(fbusdate_builtin(
3938                Value::Num(2024.0),
3939                Value::Num(1.0),
3940                Vec::new(),
3941            ))
3942            .expect("fbusdate"),
3943            Value::Num(serial_for_date(2024, 1, 2))
3944        );
3945        assert_eq!(
3946            futures::executor::block_on(lbusdate_builtin(
3947                Value::Num(2024.0),
3948                Value::Num(6.0),
3949                Vec::new(),
3950            ))
3951            .expect("lbusdate"),
3952            Value::Num(serial_for_date(2024, 6, 28))
3953        );
3954
3955        let holidays = futures::executor::block_on(holidays_builtin(vec![Value::Num(2024.0)]))
3956            .expect("holidays");
3957        let serials = serials_from_datetime_value(&holidays).expect("holiday serials");
3958        assert!(serials.data.contains(&serial_for_date(2024, 1, 1)));
3959    }
3960}