Skip to main content

runmat_runtime/builtins/datetime/
mod.rs

1use std::cell::Cell;
2use std::collections::HashMap;
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 SERIAL_FIELD: &str = "__serial";
20const FORMAT_FIELD: &str = "Format";
21const DEFAULT_DATE_FORMAT: &str = "dd-MMM-yyyy";
22const DEFAULT_DATETIME_FORMAT: &str = "dd-MMM-yyyy HH:mm:ss";
23const UNIX_DATENUM: f64 = 719_529.0;
24const SECONDS_PER_DAY: f64 = 86_400.0;
25
26thread_local! {
27    static DATETIME_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
28}
29
30const DATETIME_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
31    code: "RM.DATETIME.INVALID_ARGUMENT",
32    identifier: Some("RunMat:datetime:InvalidArgument"),
33    when: "Arguments or option grammar do not match supported datetime forms.",
34    message: "datetime: invalid argument",
35};
36const DATETIME_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
37    code: "RM.DATETIME.INVALID_INPUT",
38    identifier: Some("RunMat:datetime:InvalidInput"),
39    when: "Input values cannot be parsed/converted/broadcast to a valid datetime result.",
40    message: "datetime: invalid input",
41};
42const DATETIME_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
43    code: "RM.DATETIME.INTERNAL",
44    identifier: Some("RunMat:datetime:Internal"),
45    when: "Internal datetime state or indexing/evaluation failed unexpectedly.",
46    message: "datetime: internal operation failed",
47};
48const DATETIME_ERRORS: [BuiltinErrorDescriptor; 3] = [
49    DATETIME_ERROR_INVALID_ARGUMENT,
50    DATETIME_ERROR_INVALID_INPUT,
51    DATETIME_ERROR_INTERNAL,
52];
53
54const OUT_DATETIME: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
55    name: "t",
56    ty: BuiltinParamType::Any,
57    arity: BuiltinParamArity::Required,
58    default: None,
59    description: "Datetime object result.",
60}];
61const OUT_NUMERIC: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
62    name: "X",
63    ty: BuiltinParamType::Any,
64    arity: BuiltinParamArity::Required,
65    default: None,
66    description: "Numeric scalar/tensor result.",
67}];
68const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
69    name: "out",
70    ty: BuiltinParamType::Any,
71    arity: BuiltinParamArity::Required,
72    default: None,
73    description: "Method result.",
74}];
75const DATETIME_ARGS_ONLY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
76    name: "args",
77    ty: BuiltinParamType::Any,
78    arity: BuiltinParamArity::Variadic,
79    default: None,
80    description: "Datetime constructor arguments.",
81}];
82const DATETIME_SINGLE_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
83    name: "value",
84    ty: BuiltinParamType::Any,
85    arity: BuiltinParamArity::Required,
86    default: None,
87    description: "Datetime input.",
88}];
89const DATETIME_BINARY_INPUTS: [BuiltinParamDescriptor; 2] = [
90    BuiltinParamDescriptor {
91        name: "lhs",
92        ty: BuiltinParamType::Any,
93        arity: BuiltinParamArity::Required,
94        default: None,
95        description: "Left datetime operand.",
96    },
97    BuiltinParamDescriptor {
98        name: "rhs",
99        ty: BuiltinParamType::Any,
100        arity: BuiltinParamArity::Required,
101        default: None,
102        description: "Right datetime/numeric/duration operand.",
103    },
104];
105const DATESHIFT_INPUTS: [BuiltinParamDescriptor; 4] = [
106    BuiltinParamDescriptor {
107        name: "t",
108        ty: BuiltinParamType::Any,
109        arity: BuiltinParamArity::Required,
110        default: None,
111        description: "Datetime input.",
112    },
113    BuiltinParamDescriptor {
114        name: "boundary",
115        ty: BuiltinParamType::StringScalar,
116        arity: BuiltinParamArity::Required,
117        default: None,
118        description: "Shift boundary: 'start', 'end', or 'nearest'.",
119    },
120    BuiltinParamDescriptor {
121        name: "unit",
122        ty: BuiltinParamType::StringScalar,
123        arity: BuiltinParamArity::Required,
124        default: None,
125        description: "Calendar/time unit.",
126    },
127    BuiltinParamDescriptor {
128        name: "weekdayOrOption",
129        ty: BuiltinParamType::Any,
130        arity: BuiltinParamArity::Optional,
131        default: None,
132        description: "Optional weekday for week-based shifts.",
133    },
134];
135const DATETIME_SUBSREF_INPUTS: [BuiltinParamDescriptor; 3] = [
136    BuiltinParamDescriptor {
137        name: "obj",
138        ty: BuiltinParamType::Any,
139        arity: BuiltinParamArity::Required,
140        default: None,
141        description: "Datetime receiver object.",
142    },
143    BuiltinParamDescriptor {
144        name: "kind",
145        ty: BuiltinParamType::StringScalar,
146        arity: BuiltinParamArity::Required,
147        default: None,
148        description: "Indexing kind token.",
149    },
150    BuiltinParamDescriptor {
151        name: "payload",
152        ty: BuiltinParamType::Any,
153        arity: BuiltinParamArity::Required,
154        default: None,
155        description: "Index/member payload.",
156    },
157];
158const DATETIME_SUBSASGN_INPUTS: [BuiltinParamDescriptor; 4] = [
159    BuiltinParamDescriptor {
160        name: "obj",
161        ty: BuiltinParamType::Any,
162        arity: BuiltinParamArity::Required,
163        default: None,
164        description: "Datetime receiver object.",
165    },
166    BuiltinParamDescriptor {
167        name: "kind",
168        ty: BuiltinParamType::StringScalar,
169        arity: BuiltinParamArity::Required,
170        default: None,
171        description: "Indexing kind token.",
172    },
173    BuiltinParamDescriptor {
174        name: "payload",
175        ty: BuiltinParamType::Any,
176        arity: BuiltinParamArity::Required,
177        default: None,
178        description: "Index/member payload.",
179    },
180    BuiltinParamDescriptor {
181        name: "rhs",
182        ty: BuiltinParamType::Any,
183        arity: BuiltinParamArity::Required,
184        default: None,
185        description: "Assigned value.",
186    },
187];
188
189const DATETIME_SIGNATURES: [BuiltinSignatureDescriptor; 11] = [
190    BuiltinSignatureDescriptor {
191        label: "t = datetime()",
192        inputs: &[],
193        outputs: &OUT_DATETIME,
194    },
195    BuiltinSignatureDescriptor {
196        label: "t = datetime(textOrArray)",
197        inputs: &[BuiltinParamDescriptor {
198            name: "textOrArray",
199            ty: BuiltinParamType::Any,
200            arity: BuiltinParamArity::Required,
201            default: None,
202            description: "String/char/date text input.",
203        }],
204        outputs: &OUT_DATETIME,
205    },
206    BuiltinSignatureDescriptor {
207        label: "t = datetime(serialDateNumbers)",
208        inputs: &[BuiltinParamDescriptor {
209            name: "serialDateNumbers",
210            ty: BuiltinParamType::NumericArray,
211            arity: BuiltinParamArity::Required,
212            default: None,
213            description: "Numeric serial date input.",
214        }],
215        outputs: &OUT_DATETIME,
216    },
217    BuiltinSignatureDescriptor {
218        label: "t = datetime(year, month, day)",
219        inputs: &[
220            BuiltinParamDescriptor {
221                name: "year",
222                ty: BuiltinParamType::NumericArray,
223                arity: BuiltinParamArity::Required,
224                default: None,
225                description: "Year component.",
226            },
227            BuiltinParamDescriptor {
228                name: "month",
229                ty: BuiltinParamType::NumericArray,
230                arity: BuiltinParamArity::Required,
231                default: None,
232                description: "Month component.",
233            },
234            BuiltinParamDescriptor {
235                name: "day",
236                ty: BuiltinParamType::NumericArray,
237                arity: BuiltinParamArity::Required,
238                default: None,
239                description: "Day component.",
240            },
241        ],
242        outputs: &OUT_DATETIME,
243    },
244    BuiltinSignatureDescriptor {
245        label: "t = datetime(year, month, day, hour)",
246        inputs: &[
247            BuiltinParamDescriptor {
248                name: "year",
249                ty: BuiltinParamType::NumericArray,
250                arity: BuiltinParamArity::Required,
251                default: None,
252                description: "Year component.",
253            },
254            BuiltinParamDescriptor {
255                name: "month",
256                ty: BuiltinParamType::NumericArray,
257                arity: BuiltinParamArity::Required,
258                default: None,
259                description: "Month component.",
260            },
261            BuiltinParamDescriptor {
262                name: "day",
263                ty: BuiltinParamType::NumericArray,
264                arity: BuiltinParamArity::Required,
265                default: None,
266                description: "Day component.",
267            },
268            BuiltinParamDescriptor {
269                name: "hour",
270                ty: BuiltinParamType::NumericArray,
271                arity: BuiltinParamArity::Required,
272                default: None,
273                description: "Hour component.",
274            },
275        ],
276        outputs: &OUT_DATETIME,
277    },
278    BuiltinSignatureDescriptor {
279        label: "t = datetime(year, month, day, hour, minute)",
280        inputs: &[
281            BuiltinParamDescriptor {
282                name: "year",
283                ty: BuiltinParamType::NumericArray,
284                arity: BuiltinParamArity::Required,
285                default: None,
286                description: "Year component.",
287            },
288            BuiltinParamDescriptor {
289                name: "month",
290                ty: BuiltinParamType::NumericArray,
291                arity: BuiltinParamArity::Required,
292                default: None,
293                description: "Month component.",
294            },
295            BuiltinParamDescriptor {
296                name: "day",
297                ty: BuiltinParamType::NumericArray,
298                arity: BuiltinParamArity::Required,
299                default: None,
300                description: "Day component.",
301            },
302            BuiltinParamDescriptor {
303                name: "hour",
304                ty: BuiltinParamType::NumericArray,
305                arity: BuiltinParamArity::Required,
306                default: None,
307                description: "Hour component.",
308            },
309            BuiltinParamDescriptor {
310                name: "minute",
311                ty: BuiltinParamType::NumericArray,
312                arity: BuiltinParamArity::Required,
313                default: None,
314                description: "Minute component.",
315            },
316        ],
317        outputs: &OUT_DATETIME,
318    },
319    BuiltinSignatureDescriptor {
320        label: "t = datetime(year, month, day, hour, minute, second)",
321        inputs: &[
322            BuiltinParamDescriptor {
323                name: "year",
324                ty: BuiltinParamType::NumericArray,
325                arity: BuiltinParamArity::Required,
326                default: None,
327                description: "Year component.",
328            },
329            BuiltinParamDescriptor {
330                name: "month",
331                ty: BuiltinParamType::NumericArray,
332                arity: BuiltinParamArity::Required,
333                default: None,
334                description: "Month component.",
335            },
336            BuiltinParamDescriptor {
337                name: "day",
338                ty: BuiltinParamType::NumericArray,
339                arity: BuiltinParamArity::Required,
340                default: None,
341                description: "Day component.",
342            },
343            BuiltinParamDescriptor {
344                name: "hour",
345                ty: BuiltinParamType::NumericArray,
346                arity: BuiltinParamArity::Required,
347                default: None,
348                description: "Hour component.",
349            },
350            BuiltinParamDescriptor {
351                name: "minute",
352                ty: BuiltinParamType::NumericArray,
353                arity: BuiltinParamArity::Required,
354                default: None,
355                description: "Minute component.",
356            },
357            BuiltinParamDescriptor {
358                name: "second",
359                ty: BuiltinParamType::NumericArray,
360                arity: BuiltinParamArity::Required,
361                default: None,
362                description: "Second component.",
363            },
364        ],
365        outputs: &OUT_DATETIME,
366    },
367    BuiltinSignatureDescriptor {
368        label: "t = datetime(serialDateNumbers, \"ConvertFrom\", \"datenum\")",
369        inputs: &[BuiltinParamDescriptor {
370            name: "args",
371            ty: BuiltinParamType::Any,
372            arity: BuiltinParamArity::Variadic,
373            default: None,
374            description: "Numeric serial input with ConvertFrom option.",
375        }],
376        outputs: &OUT_DATETIME,
377    },
378    BuiltinSignatureDescriptor {
379        label: "t = datetime(___, \"Format\", format)",
380        inputs: &DATETIME_ARGS_ONLY,
381        outputs: &OUT_DATETIME,
382    },
383    BuiltinSignatureDescriptor {
384        label: "t = datetime(textOrArray, \"InputFormat\", inputFormat)",
385        inputs: &DATETIME_ARGS_ONLY,
386        outputs: &OUT_DATETIME,
387    },
388    BuiltinSignatureDescriptor {
389        label: "t = datetime(___, Name, Value, ...)",
390        inputs: &DATETIME_ARGS_ONLY,
391        outputs: &OUT_DATETIME,
392    },
393];
394
395const DATETIME_YEAR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
396    label: "X = year(t)",
397    inputs: &DATETIME_SINGLE_INPUT,
398    outputs: &OUT_NUMERIC,
399}];
400const DATETIME_MONTH_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
401    label: "X = month(t)",
402    inputs: &DATETIME_SINGLE_INPUT,
403    outputs: &OUT_NUMERIC,
404}];
405const DATETIME_DAY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
406    label: "X = day(t)",
407    inputs: &DATETIME_SINGLE_INPUT,
408    outputs: &OUT_NUMERIC,
409}];
410const DATETIME_HOUR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
411    label: "X = hour(t)",
412    inputs: &DATETIME_SINGLE_INPUT,
413    outputs: &OUT_NUMERIC,
414}];
415const DATETIME_MINUTE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
416    label: "X = minute(t)",
417    inputs: &DATETIME_SINGLE_INPUT,
418    outputs: &OUT_NUMERIC,
419}];
420const DATETIME_SECOND_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
421    label: "X = second(t)",
422    inputs: &DATETIME_SINGLE_INPUT,
423    outputs: &OUT_NUMERIC,
424}];
425const DATETIME_SUBSREF_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
426    label: "out = datetime.subsref(obj, kind, payload)",
427    inputs: &DATETIME_SUBSREF_INPUTS,
428    outputs: &OUT_ANY,
429}];
430const DATETIME_SUBSASGN_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
431    [BuiltinSignatureDescriptor {
432        label: "out = datetime.subsasgn(obj, kind, payload, rhs)",
433        inputs: &DATETIME_SUBSASGN_INPUTS,
434        outputs: &OUT_ANY,
435    }];
436const DATETIME_BINARY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
437    label: "out = datetime.op(lhs, rhs)",
438    inputs: &DATETIME_BINARY_INPUTS,
439    outputs: &OUT_ANY,
440}];
441const DATESHIFT_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
442    BuiltinSignatureDescriptor {
443        label: "t2 = dateshift(t, boundary, unit)",
444        inputs: &DATESHIFT_INPUTS,
445        outputs: &OUT_DATETIME,
446    },
447    BuiltinSignatureDescriptor {
448        label: "t2 = dateshift(t, boundary, \"week\", weekday)",
449        inputs: &DATESHIFT_INPUTS,
450        outputs: &OUT_DATETIME,
451    },
452    BuiltinSignatureDescriptor {
453        label: "t2 = dateshift(t, \"dayofweek\", weekday)",
454        inputs: &DATESHIFT_INPUTS,
455        outputs: &OUT_DATETIME,
456    },
457];
458
459pub const DATETIME_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
460    signatures: &DATETIME_SIGNATURES,
461    output_mode: BuiltinOutputMode::Fixed,
462    completion_policy: BuiltinCompletionPolicy::Public,
463    errors: &DATETIME_ERRORS,
464};
465pub const DATETIME_YEAR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
466    signatures: &DATETIME_YEAR_SIGNATURES,
467    output_mode: BuiltinOutputMode::Fixed,
468    completion_policy: BuiltinCompletionPolicy::Public,
469    errors: &DATETIME_ERRORS,
470};
471pub const DATETIME_MONTH_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
472    signatures: &DATETIME_MONTH_SIGNATURES,
473    output_mode: BuiltinOutputMode::Fixed,
474    completion_policy: BuiltinCompletionPolicy::Public,
475    errors: &DATETIME_ERRORS,
476};
477pub const DATETIME_DAY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
478    signatures: &DATETIME_DAY_SIGNATURES,
479    output_mode: BuiltinOutputMode::Fixed,
480    completion_policy: BuiltinCompletionPolicy::Public,
481    errors: &DATETIME_ERRORS,
482};
483pub const DATETIME_HOUR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
484    signatures: &DATETIME_HOUR_SIGNATURES,
485    output_mode: BuiltinOutputMode::Fixed,
486    completion_policy: BuiltinCompletionPolicy::Public,
487    errors: &DATETIME_ERRORS,
488};
489pub const DATETIME_MINUTE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
490    signatures: &DATETIME_MINUTE_SIGNATURES,
491    output_mode: BuiltinOutputMode::Fixed,
492    completion_policy: BuiltinCompletionPolicy::Public,
493    errors: &DATETIME_ERRORS,
494};
495pub const DATETIME_SECOND_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
496    signatures: &DATETIME_SECOND_SIGNATURES,
497    output_mode: BuiltinOutputMode::Fixed,
498    completion_policy: BuiltinCompletionPolicy::Public,
499    errors: &DATETIME_ERRORS,
500};
501pub const DATETIME_SUBSREF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
502    signatures: &DATETIME_SUBSREF_SIGNATURES,
503    output_mode: BuiltinOutputMode::Fixed,
504    completion_policy: BuiltinCompletionPolicy::MethodOnly,
505    errors: &DATETIME_ERRORS,
506};
507pub const DATETIME_SUBSASGN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
508    signatures: &DATETIME_SUBSASGN_SIGNATURES,
509    output_mode: BuiltinOutputMode::Fixed,
510    completion_policy: BuiltinCompletionPolicy::MethodOnly,
511    errors: &DATETIME_ERRORS,
512};
513pub const DATETIME_BINARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
514    signatures: &DATETIME_BINARY_SIGNATURES,
515    output_mode: BuiltinOutputMode::Fixed,
516    completion_policy: BuiltinCompletionPolicy::MethodOnly,
517    errors: &DATETIME_ERRORS,
518};
519pub const DATESHIFT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
520    signatures: &DATESHIFT_SIGNATURES,
521    output_mode: BuiltinOutputMode::Fixed,
522    completion_policy: BuiltinCompletionPolicy::Public,
523    errors: &DATETIME_ERRORS,
524};
525
526fn datetime_error(message: impl Into<String>) -> RuntimeError {
527    build_runtime_error(message)
528        .with_builtin(BUILTIN_NAME)
529        .build()
530}
531
532fn ensure_datetime_class_registered() {
533    DATETIME_CLASS_REGISTERED.with(|registered| {
534        if registered.get() {
535            return;
536        }
537        let mut properties = HashMap::new();
538        properties.insert(
539            FORMAT_FIELD.to_string(),
540            PropertyDef {
541                name: FORMAT_FIELD.to_string(),
542                is_static: false,
543                is_constant: false,
544                is_dependent: false,
545                get_access: Access::Public,
546                set_access: Access::Public,
547                default_value: Some(Value::String(DEFAULT_DATETIME_FORMAT.to_string())),
548            },
549        );
550
551        let mut methods = HashMap::new();
552        for name in [
553            OBJECT_SUBSREF_METHOD,
554            OBJECT_SUBSASGN_METHOD,
555            "plus",
556            "minus",
557            "eq",
558            "ne",
559            "lt",
560            "le",
561            "gt",
562            "ge",
563        ] {
564            methods.insert(
565                name.to_string(),
566                MethodDef {
567                    name: name.to_string(),
568                    is_static: false,
569                    is_abstract: false,
570                    is_sealed: false,
571                    access: Access::Public,
572                    function_name: format!("{DATETIME_CLASS}.{name}"),
573                    implicit_class_argument: None,
574                },
575            );
576        }
577
578        runmat_builtins::register_class(ClassDef {
579            name: DATETIME_CLASS.to_string(),
580            parent: None,
581            properties,
582            methods,
583        });
584        registered.set(true);
585    });
586}
587
588async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
589    let mut out = Vec::with_capacity(args.len());
590    for arg in args {
591        out.push(
592            gather_if_needed_async(arg)
593                .await
594                .map_err(|err| datetime_error(format!("datetime: {}", err.message())))?,
595        );
596    }
597    Ok(out)
598}
599
600fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
601    match value {
602        Value::String(text) => Ok(text.clone()),
603        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
604        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
605        _ => Err(datetime_error(format!(
606            "datetime: {context} must be a string scalar or character vector"
607        ))),
608    }
609}
610
611#[derive(Default)]
612struct DatetimeOptions {
613    format: Option<String>,
614    convert_from: Option<String>,
615    input_format: Option<String>,
616}
617
618fn parse_trailing_options(args: &[Value]) -> BuiltinResult<(usize, DatetimeOptions)> {
619    let mut positional_end = args.len();
620    let mut options = DatetimeOptions::default();
621
622    while positional_end >= 2 {
623        let name = match scalar_text(&args[positional_end - 2], "option name") {
624            Ok(text) => text,
625            Err(_) => break,
626        };
627        let lowered = name.trim().to_ascii_lowercase();
628        let value = scalar_text(&args[positional_end - 1], &format!("{name} option"))?;
629        match lowered.as_str() {
630            "format" => options.format = Some(value),
631            "convertfrom" => options.convert_from = Some(value),
632            "inputformat" => options.input_format = Some(value),
633            _ => break,
634        }
635        positional_end -= 2;
636    }
637
638    Ok((positional_end, options))
639}
640
641fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
642    tensor::value_into_tensor_for(context, value)
643        .map_err(|message| datetime_error(format!("datetime: {message}")))
644}
645
646fn serial_tensor_from_value(value: Value, context: &str) -> BuiltinResult<Tensor> {
647    let tensor = tensor_from_numeric(value, context)?;
648    Tensor::new(
649        tensor.data.clone(),
650        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
651    )
652    .map_err(|err| datetime_error(format!("datetime: {err}")))
653}
654
655fn format_for_object(obj: &ObjectInstance) -> String {
656    match obj.properties.get(FORMAT_FIELD) {
657        Some(Value::String(text)) => text.clone(),
658        Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
659        Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
660        _ => DEFAULT_DATETIME_FORMAT.to_string(),
661    }
662}
663
664fn serial_tensor_for_object(obj: &ObjectInstance) -> BuiltinResult<Tensor> {
665    match obj.properties.get(SERIAL_FIELD) {
666        Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
667        Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
668            .map_err(|err| datetime_error(format!("datetime: {err}"))),
669        Some(other) => Err(datetime_error(format!(
670            "datetime: invalid internal serial storage {other:?}"
671        ))),
672        None => Err(datetime_error("datetime: missing internal serial storage")),
673    }
674}
675
676pub(crate) fn datetime_object_from_serial_tensor(
677    serials: Tensor,
678    format: impl Into<String>,
679) -> BuiltinResult<Value> {
680    ensure_datetime_class_registered();
681    let mut object = ObjectInstance::new(DATETIME_CLASS.to_string());
682    object
683        .properties
684        .insert(SERIAL_FIELD.to_string(), Value::Tensor(serials));
685    object
686        .properties
687        .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
688    Ok(Value::Object(object))
689}
690
691fn datetime_object_from_serials(
692    serials: Vec<f64>,
693    shape: Vec<usize>,
694    format: impl Into<String>,
695) -> BuiltinResult<Value> {
696    let tensor =
697        Tensor::new(serials, shape).map_err(|err| datetime_error(format!("datetime: {err}")))?;
698    datetime_object_from_serial_tensor(tensor, format)
699}
700
701fn format_token_to_strftime(format: &str) -> String {
702    let mut out = format.to_string();
703    for (src, dst) in [
704        ("yyyy", "%Y"),
705        ("MMM", "%b"),
706        ("MM", "%m"),
707        ("dd", "%d"),
708        ("HH", "%H"),
709        ("mm", "%M"),
710        ("ss", "%S"),
711    ] {
712        out = out.replace(src, dst);
713    }
714    out
715}
716
717pub(crate) fn datenum_from_naive(datetime: NaiveDateTime) -> f64 {
718    let base = NaiveDate::from_ymd_opt(1970, 1, 1)
719        .unwrap()
720        .and_hms_opt(0, 0, 0)
721        .unwrap();
722    let duration = datetime - base;
723    let seconds = duration.num_seconds();
724    let nanos = (duration - Duration::seconds(seconds))
725        .num_nanoseconds()
726        .unwrap_or(0);
727    let total_seconds = seconds as f64 + nanos as f64 / 1_000_000_000.0;
728    total_seconds / SECONDS_PER_DAY + UNIX_DATENUM
729}
730
731fn naive_from_datenum(serial: f64) -> BuiltinResult<NaiveDateTime> {
732    if !serial.is_finite() {
733        return Err(datetime_error(
734            "datetime: serial date numbers must be finite",
735        ));
736    }
737    let total_nanos = ((serial - UNIX_DATENUM) * SECONDS_PER_DAY * 1_000_000_000.0).round() as i128;
738    let seconds = total_nanos.div_euclid(1_000_000_000) as i64;
739    let nanos = total_nanos.rem_euclid(1_000_000_000) as i64;
740    let base = NaiveDate::from_ymd_opt(1970, 1, 1)
741        .unwrap()
742        .and_hms_opt(0, 0, 0)
743        .unwrap();
744    Ok(base + Duration::seconds(seconds) + Duration::nanoseconds(nanos))
745}
746
747fn format_serial(serial: f64, format: &str) -> BuiltinResult<String> {
748    let naive = naive_from_datenum(serial)?;
749    let chrono_format = format_token_to_strftime(format);
750    Ok(naive.format(&chrono_format).to_string())
751}
752
753fn parse_datetime_text(text: &str) -> Option<(NaiveDateTime, bool)> {
754    let trimmed = text.trim();
755    if trimmed.is_empty() {
756        return None;
757    }
758
759    if let Ok(value) = DateTime::parse_from_rfc3339(trimmed) {
760        return Some((value.with_timezone(&Local).naive_local(), true));
761    }
762
763    for (pattern, has_time) in [
764        ("%Y-%m-%d %H:%M:%S", true),
765        ("%Y-%m-%d", false),
766        ("%d-%b-%Y %H:%M:%S", true),
767        ("%d-%b-%Y", false),
768        ("%m/%d/%Y %H:%M:%S", true),
769        ("%m/%d/%Y", false),
770    ] {
771        if has_time {
772            if let Ok(value) = NaiveDateTime::parse_from_str(trimmed, pattern) {
773                return Some((value, true));
774            }
775        } else if let Ok(value) = NaiveDate::parse_from_str(trimmed, pattern) {
776            return Some((value.and_hms_opt(0, 0, 0).unwrap(), false));
777        }
778    }
779
780    None
781}
782
783fn parse_datetime_text_with_input_format(
784    text: &str,
785    input_format: Option<&str>,
786) -> Option<(NaiveDateTime, bool)> {
787    let trimmed = text.trim();
788    if trimmed.is_empty() {
789        return None;
790    }
791    let Some(input_format) = input_format else {
792        return parse_datetime_text(trimmed);
793    };
794    let chrono_format = format_token_to_strftime(input_format);
795    if let Ok(value) = NaiveDateTime::parse_from_str(trimmed, &chrono_format) {
796        return Some((value, true));
797    }
798    if let Ok(value) = NaiveDate::parse_from_str(trimmed, &chrono_format) {
799        return Some((value.and_hms_opt(0, 0, 0).unwrap(), false));
800    }
801    None
802}
803
804fn parse_text_input(
805    value: Value,
806    input_format: Option<&str>,
807) -> BuiltinResult<(Vec<f64>, Vec<usize>, String)> {
808    match value {
809        Value::String(text) => {
810            if text.trim().eq_ignore_ascii_case("now") {
811                let now = Local::now().naive_local();
812                return Ok((
813                    vec![datenum_from_naive(now)],
814                    vec![1, 1],
815                    DEFAULT_DATETIME_FORMAT.to_string(),
816                ));
817            }
818            let (naive, has_time) = parse_datetime_text_with_input_format(&text, input_format)
819                .ok_or_else(|| {
820                    datetime_error(format!("datetime: unable to parse date/time text '{text}'"))
821                })?;
822            Ok((
823                vec![datenum_from_naive(naive)],
824                vec![1, 1],
825                if has_time {
826                    DEFAULT_DATETIME_FORMAT.to_string()
827                } else {
828                    DEFAULT_DATE_FORMAT.to_string()
829                },
830            ))
831        }
832        Value::StringArray(array) => {
833            let mut serials = Vec::with_capacity(array.data.len());
834            let mut has_time = false;
835            for text in &array.data {
836                let (naive, parsed_has_time) =
837                    parse_datetime_text_with_input_format(text, input_format).ok_or_else(|| {
838                        datetime_error(format!("datetime: unable to parse date/time text '{text}'"))
839                    })?;
840                serials.push(datenum_from_naive(naive));
841                has_time |= parsed_has_time;
842            }
843            Ok((
844                serials,
845                tensor::default_shape_for(&array.shape, array.data.len()),
846                if has_time {
847                    DEFAULT_DATETIME_FORMAT.to_string()
848                } else {
849                    DEFAULT_DATE_FORMAT.to_string()
850                },
851            ))
852        }
853        Value::CharArray(array) => {
854            let mut texts = Vec::with_capacity(array.rows);
855            for row in 0..array.rows {
856                let start = row * array.cols;
857                let end = start + array.cols;
858                texts.push(
859                    array.data[start..end]
860                        .iter()
861                        .collect::<String>()
862                        .trim_end()
863                        .to_string(),
864                );
865            }
866            parse_text_input(
867                Value::StringArray(
868                    StringArray::new(texts, vec![array.rows, 1])
869                        .map_err(|err| datetime_error(format!("datetime: {err}")))?,
870                ),
871                input_format,
872            )
873        }
874        _ => Err(datetime_error(
875            "datetime: text input must be a string scalar, string array, or character array",
876        )),
877    }
878}
879
880fn round_component(value: f64, label: &str, min: i64, max: i64) -> BuiltinResult<i64> {
881    if !value.is_finite() {
882        return Err(datetime_error(format!(
883            "datetime: {label} values must be finite"
884        )));
885    }
886    let rounded = value.round();
887    if (rounded - value).abs() > 1e-9 {
888        return Err(datetime_error(format!(
889            "datetime: {label} values must be integers"
890        )));
891    }
892    let integer = rounded as i64;
893    if integer < min || integer > max {
894        return Err(datetime_error(format!(
895            "datetime: {label} values must be in the range [{min}, {max}]"
896        )));
897    }
898    Ok(integer)
899}
900
901fn naive_from_components(
902    year: f64,
903    month: f64,
904    day: f64,
905    hour: f64,
906    minute: f64,
907    second: f64,
908) -> BuiltinResult<NaiveDateTime> {
909    let year = round_component(year, "year", -262_000, 262_000)? as i32;
910    let month = round_component(month, "month", 1, 12)? as u32;
911    let day = round_component(day, "day", 1, 31)? as u32;
912    let hour = round_component(hour, "hour", 0, 23)? as u32;
913    let minute = round_component(minute, "minute", 0, 59)? as u32;
914    if !second.is_finite() {
915        return Err(datetime_error("datetime: second values must be finite"));
916    }
917    if !(0.0..60.0).contains(&second) {
918        return Err(datetime_error(
919            "datetime: second values must be in the range [0, 60)",
920        ));
921    }
922
923    let base_date = NaiveDate::from_ymd_opt(year, month, day)
924        .ok_or_else(|| datetime_error("datetime: invalid calendar date"))?;
925    let whole_second = second.floor();
926    let mut nanos = ((second - whole_second) * 1_000_000_000.0).round() as u32;
927    let mut secs = whole_second as u32;
928    if nanos == 1_000_000_000 {
929        secs += 1;
930        nanos = 0;
931    }
932    let time = base_date
933        .and_hms_nano_opt(hour, minute, secs, nanos)
934        .ok_or_else(|| datetime_error("datetime: invalid time components"))?;
935    Ok(time)
936}
937
938fn broadcast_component_data(
939    arrays: &[Tensor],
940    labels: &[&str],
941) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
942    let mut target_shape = vec![1, 1];
943    let mut target_len = 1usize;
944
945    for array in arrays {
946        let len = array.data.len();
947        if len > 1 {
948            let shape = tensor::default_shape_for(&array.shape, len);
949            if target_len == 1 {
950                target_len = len;
951                target_shape = shape;
952            } else if len != target_len || shape != target_shape {
953                return Err(datetime_error(
954                    "datetime: non-scalar component inputs must have matching sizes",
955                ));
956            }
957        }
958    }
959
960    let mut broadcasted = Vec::with_capacity(arrays.len());
961    for (idx, array) in arrays.iter().enumerate() {
962        if array.data.len() == 1 {
963            broadcasted.push(vec![array.data[0]; target_len]);
964        } else if array.data.len() == target_len {
965            broadcasted.push(array.data.clone());
966        } else {
967            return Err(datetime_error(format!(
968                "datetime: {} input size does not match the other components",
969                labels[idx]
970            )));
971        }
972    }
973
974    Ok((broadcasted, target_shape))
975}
976
977fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
978    let tensor = tensor_from_numeric(value, context)?;
979    Tensor::new(
980        tensor.data.clone(),
981        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
982    )
983    .map_err(|err| datetime_error(format!("datetime: {err}")))
984}
985
986fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
987    let labels = ["year", "month", "day", "hour", "minute", "second"];
988    let input_count = args.len();
989    let mut arrays = Vec::with_capacity(args.len());
990    for (idx, arg) in args.into_iter().enumerate() {
991        arrays.push(component_tensor(arg, labels[idx])?);
992    }
993    while arrays.len() < 6 {
994        arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
995    }
996
997    let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
998    let len = broadcasted[0].len();
999    let mut serials = Vec::with_capacity(len);
1000    for idx in 0..len {
1001        let naive = naive_from_components(
1002            broadcasted[0][idx],
1003            broadcasted[1][idx],
1004            broadcasted[2][idx],
1005            broadcasted[3][idx],
1006            broadcasted[4][idx],
1007            broadcasted[5][idx],
1008        )?;
1009        serials.push(datenum_from_naive(naive));
1010    }
1011
1012    let default_format = if let Some(format) = format {
1013        format
1014    } else if input_count > 3 {
1015        DEFAULT_DATETIME_FORMAT.to_string()
1016    } else {
1017        DEFAULT_DATE_FORMAT.to_string()
1018    };
1019    datetime_object_from_serials(serials, shape, default_format)
1020}
1021
1022fn numeric_value_to_datetime(value: Value, format: Option<String>) -> BuiltinResult<Value> {
1023    let serials = serial_tensor_from_value(value, "datetime")?;
1024    datetime_object_from_serial_tensor(
1025        serials,
1026        format.unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string()),
1027    )
1028}
1029
1030pub fn is_datetime_object(value: &Value) -> bool {
1031    matches!(value, Value::Object(obj) if obj.is_class(DATETIME_CLASS))
1032}
1033
1034pub(crate) fn serials_from_datetime_value(value: &Value) -> BuiltinResult<Tensor> {
1035    match value {
1036        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj),
1037        _ => Err(datetime_error("datetime: expected a datetime value")),
1038    }
1039}
1040
1041pub(crate) fn datetime_format_from_value(value: &Value) -> String {
1042    match value {
1043        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => format_for_object(obj),
1044        _ => DEFAULT_DATETIME_FORMAT.to_string(),
1045    }
1046}
1047
1048pub fn datetime_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
1049    let Value::Object(obj) = value else {
1050        return Ok(None);
1051    };
1052    if !obj.is_class(DATETIME_CLASS) {
1053        return Ok(None);
1054    }
1055    let serials = serial_tensor_for_object(obj)?;
1056    let format = format_for_object(obj);
1057    let mut strings = Vec::with_capacity(serials.data.len());
1058    for serial in &serials.data {
1059        strings.push(format_serial(*serial, &format)?);
1060    }
1061    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1062    let array = StringArray::new(strings, shape)
1063        .map_err(|err| datetime_error(format!("datetime: {err}")))?;
1064    Ok(Some(array))
1065}
1066
1067pub fn datetime_display_text(value: &Value) -> BuiltinResult<Option<String>> {
1068    let Some(array) = datetime_string_array(value)? else {
1069        return Ok(None);
1070    };
1071    if array.data.len() == 1 {
1072        return Ok(Some(array.data[0].clone()));
1073    }
1074
1075    let rows = array.rows;
1076    let cols = array.cols;
1077    let mut widths = vec![0usize; cols];
1078    for col in 0..cols {
1079        for row in 0..rows {
1080            let idx = row + col * rows;
1081            widths[col] = widths[col].max(array.data[idx].chars().count());
1082        }
1083    }
1084
1085    let mut lines = Vec::with_capacity(rows);
1086    for row in 0..rows {
1087        let mut line = String::new();
1088        for col in 0..cols {
1089            if col > 0 {
1090                line.push_str("  ");
1091            }
1092            let idx = row + col * rows;
1093            let text = &array.data[idx];
1094            line.push_str(text);
1095            let padding = widths[col].saturating_sub(text.chars().count());
1096            if padding > 0 {
1097                line.push_str(&" ".repeat(padding));
1098            }
1099        }
1100        lines.push(line);
1101    }
1102    Ok(Some(lines.join("\n")))
1103}
1104
1105pub fn datetime_summary(value: &Value) -> BuiltinResult<Option<String>> {
1106    let Value::Object(obj) = value else {
1107        return Ok(None);
1108    };
1109    if !obj.is_class(DATETIME_CLASS) {
1110        return Ok(None);
1111    }
1112    let serials = serial_tensor_for_object(obj)?;
1113    if serials.data.len() == 1 {
1114        return datetime_display_text(value);
1115    }
1116    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1117    Ok(Some(format!(
1118        "[{} datetime]",
1119        shape
1120            .iter()
1121            .map(|dim| dim.to_string())
1122            .collect::<Vec<_>>()
1123            .join("x")
1124    )))
1125}
1126
1127fn component_tensor_from_datetime(
1128    value: &Value,
1129    label: &str,
1130    extractor: impl Fn(&NaiveDateTime) -> f64,
1131) -> BuiltinResult<Value> {
1132    let serials = serials_from_datetime_value(value)?;
1133    let mut out = Vec::with_capacity(serials.data.len());
1134    for serial in &serials.data {
1135        let naive = naive_from_datenum(*serial)?;
1136        out.push(extractor(&naive));
1137    }
1138    if out.len() == 1 {
1139        Ok(Value::Num(out[0]))
1140    } else {
1141        let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1142        let tensor =
1143            Tensor::new(out, shape).map_err(|err| datetime_error(format!("{label}: {err}")))?;
1144        Ok(Value::Tensor(tensor))
1145    }
1146}
1147
1148fn tensor_or_scalar(data: Vec<f64>, shape: Vec<usize>) -> BuiltinResult<Value> {
1149    if data.len() == 1 {
1150        Ok(Value::Num(data[0]))
1151    } else {
1152        Ok(Value::Tensor(Tensor::new(data, shape).map_err(|err| {
1153            datetime_error(format!("datetime: {err}"))
1154        })?))
1155    }
1156}
1157
1158async fn datetime_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
1159    let Value::Object(object) = obj else {
1160        return Err(datetime_error(
1161            "datetime.subsref: receiver must be a datetime object",
1162        ));
1163    };
1164    let format = format_for_object(&object);
1165    let serials = serial_tensor_for_object(&object)?;
1166
1167    let Value::Cell(cell) = payload else {
1168        return Err(datetime_error(
1169            "datetime.subsref: indexing payload must be a cell array",
1170        ));
1171    };
1172    if cell.data.is_empty() {
1173        return datetime_object_from_serial_tensor(serials, format);
1174    }
1175    if cell.data.len() != 1 {
1176        return Err(datetime_error(
1177            "datetime.subsref: only linear datetime indexing is currently supported",
1178        ));
1179    }
1180    let selector = cell.data[0].clone();
1181    let selector = match selector {
1182        Value::Tensor(tensor) => tensor,
1183        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
1184            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1185        Value::Int(value) => Tensor::new(vec![value.to_f64()], vec![1, 1])
1186            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1187        Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
1188            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1189        other => {
1190            return Err(datetime_error(format!(
1191                "datetime.subsref: unsupported index value {other:?}"
1192            )))
1193        }
1194    };
1195    let indexed = crate::perform_indexing(&Value::Tensor(serials), &selector.data)
1196        .await
1197        .map_err(|err| datetime_error(format!("datetime.subsref: {}", err.message())))?;
1198    let indexed_serials = match indexed {
1199        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
1200            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1201        Value::Tensor(tensor) => tensor,
1202        other => {
1203            return Err(datetime_error(format!(
1204                "datetime.subsref: unexpected indexing result {other:?}"
1205            )))
1206        }
1207    };
1208    datetime_object_from_serial_tensor(indexed_serials, format)
1209}
1210
1211#[runmat_macros::runtime_builtin(
1212    name = "datetime",
1213    descriptor(crate::builtins::datetime::DATETIME_DESCRIPTOR),
1214    builtin_path = "crate::builtins::datetime",
1215    category = "datetime",
1216    summary = "Create datetime arrays from text, components, or serial date numbers.",
1217    keywords = "datetime,date,time,datenum,Format",
1218    related = "year,month,day,hour,minute,second,string,char,disp",
1219    examples = "t = datetime(2024, 4, 9, 13, 30, 0);"
1220)]
1221async fn datetime_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
1222    ensure_datetime_class_registered();
1223    let args = gather_args(&args).await?;
1224    let (positional_end, options) = parse_trailing_options(&args)?;
1225    let positional = args[..positional_end].to_vec();
1226
1227    if let Some(convert_from) = options.convert_from {
1228        if !convert_from.eq_ignore_ascii_case("datenum") {
1229            return Err(datetime_error(format!(
1230                "datetime: unsupported ConvertFrom value '{convert_from}'"
1231            )));
1232        }
1233        if positional.len() != 1 {
1234            return Err(datetime_error(
1235                "datetime: ConvertFrom='datenum' expects exactly one numeric input",
1236            ));
1237        }
1238        return numeric_value_to_datetime(positional[0].clone(), options.format);
1239    }
1240
1241    match positional.len() {
1242        0 => {
1243            let now = Local::now().naive_local();
1244            datetime_object_from_serials(
1245                vec![datenum_from_naive(now)],
1246                vec![1, 1],
1247                options
1248                    .format
1249                    .unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string()),
1250            )
1251        }
1252        1 => match &positional[0] {
1253            Value::Object(obj) if obj.is_class(DATETIME_CLASS) => {
1254                let serials = serials_from_datetime_value(&positional[0])?;
1255                let format = options
1256                    .format
1257                    .unwrap_or_else(|| datetime_format_from_value(&positional[0]));
1258                datetime_object_from_serial_tensor(serials, format)
1259            }
1260            Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
1261                let (serials, shape, inferred_format) =
1262                    parse_text_input(positional[0].clone(), options.input_format.as_deref())?;
1263                datetime_object_from_serials(
1264                    serials,
1265                    shape,
1266                    options.format.unwrap_or(inferred_format),
1267                )
1268            }
1269            _ => numeric_value_to_datetime(positional[0].clone(), options.format),
1270        },
1271        3..=6 => build_from_components(positional, options.format),
1272        _ => Err(datetime_error(
1273            "datetime: unsupported argument pattern; use text, serial dates, or Y/M/D component inputs",
1274        )),
1275    }
1276}
1277
1278#[runmat_macros::runtime_builtin(
1279    name = "year",
1280    descriptor(crate::builtins::datetime::DATETIME_YEAR_DESCRIPTOR),
1281    builtin_path = "crate::builtins::datetime",
1282    category = "datetime",
1283    summary = "Extract calendar year components from datetime values.",
1284    keywords = "year,datetime,date component"
1285)]
1286async fn year_builtin(value: Value) -> crate::BuiltinResult<Value> {
1287    component_tensor_from_datetime(&value, "year", |naive| naive.year() as f64)
1288}
1289
1290#[runmat_macros::runtime_builtin(
1291    name = "month",
1292    descriptor(crate::builtins::datetime::DATETIME_MONTH_DESCRIPTOR),
1293    builtin_path = "crate::builtins::datetime",
1294    category = "datetime",
1295    summary = "Extract month numbers from datetime arrays.",
1296    keywords = "month,datetime,date component"
1297)]
1298async fn month_builtin(value: Value) -> crate::BuiltinResult<Value> {
1299    component_tensor_from_datetime(&value, "month", |naive| naive.month() as f64)
1300}
1301
1302#[runmat_macros::runtime_builtin(
1303    name = "day",
1304    descriptor(crate::builtins::datetime::DATETIME_DAY_DESCRIPTOR),
1305    builtin_path = "crate::builtins::datetime",
1306    category = "datetime",
1307    summary = "Extract day-of-month numbers from datetime values.",
1308    keywords = "day,datetime,date component"
1309)]
1310async fn day_builtin(value: Value) -> crate::BuiltinResult<Value> {
1311    component_tensor_from_datetime(&value, "day", |naive| naive.day() as f64)
1312}
1313
1314#[runmat_macros::runtime_builtin(
1315    name = "hour",
1316    descriptor(crate::builtins::datetime::DATETIME_HOUR_DESCRIPTOR),
1317    builtin_path = "crate::builtins::datetime",
1318    category = "datetime",
1319    summary = "Extract hour components from datetime values.",
1320    keywords = "hour,datetime,time component"
1321)]
1322async fn hour_builtin(value: Value) -> crate::BuiltinResult<Value> {
1323    component_tensor_from_datetime(&value, "hour", |naive| naive.hour() as f64)
1324}
1325
1326#[runmat_macros::runtime_builtin(
1327    name = "minute",
1328    descriptor(crate::builtins::datetime::DATETIME_MINUTE_DESCRIPTOR),
1329    builtin_path = "crate::builtins::datetime",
1330    category = "datetime",
1331    summary = "Extract minute numbers from datetime arrays.",
1332    keywords = "minute,datetime,time component"
1333)]
1334async fn minute_builtin(value: Value) -> crate::BuiltinResult<Value> {
1335    component_tensor_from_datetime(&value, "minute", |naive| naive.minute() as f64)
1336}
1337
1338#[runmat_macros::runtime_builtin(
1339    name = "second",
1340    descriptor(crate::builtins::datetime::DATETIME_SECOND_DESCRIPTOR),
1341    builtin_path = "crate::builtins::datetime",
1342    category = "datetime",
1343    summary = "Extract second components from datetime values.",
1344    keywords = "second,datetime,time component"
1345)]
1346async fn second_builtin(value: Value) -> crate::BuiltinResult<Value> {
1347    component_tensor_from_datetime(&value, "second", |naive| {
1348        naive.second() as f64 + f64::from(naive.nanosecond()) / 1_000_000_000.0
1349    })
1350}
1351
1352#[runmat_macros::runtime_builtin(
1353    name = "datetime.subsref",
1354    descriptor(crate::builtins::datetime::DATETIME_SUBSREF_DESCRIPTOR),
1355    builtin_path = "crate::builtins::datetime"
1356)]
1357async fn datetime_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
1358    match kind.as_str() {
1359        OBJECT_INDEX_PAREN => datetime_indexing(obj, payload).await,
1360        OBJECT_INDEX_MEMBER => {
1361            let Value::Object(object) = obj else {
1362                return Err(datetime_error(
1363                    "datetime.subsref: receiver must be a datetime object",
1364                ));
1365            };
1366            let field = scalar_text(&payload, "field selector")?;
1367            match field.as_str() {
1368                FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
1369                _ => Err(datetime_error(format!(
1370                    "datetime.subsref: unsupported datetime property '{field}'"
1371                ))),
1372            }
1373        }
1374        other => Err(datetime_error(format!(
1375            "datetime.subsref: unsupported indexing kind '{other}'"
1376        ))),
1377    }
1378}
1379
1380#[runmat_macros::runtime_builtin(
1381    name = "datetime.subsasgn",
1382    descriptor(crate::builtins::datetime::DATETIME_SUBSASGN_DESCRIPTOR),
1383    builtin_path = "crate::builtins::datetime"
1384)]
1385async fn datetime_subsasgn(
1386    obj: Value,
1387    kind: String,
1388    payload: Value,
1389    rhs: Value,
1390) -> crate::BuiltinResult<Value> {
1391    let Value::Object(mut object) = obj else {
1392        return Err(datetime_error(
1393            "datetime.subsasgn: receiver must be a datetime object",
1394        ));
1395    };
1396    match kind.as_str() {
1397        OBJECT_INDEX_MEMBER => {
1398            let field = scalar_text(&payload, "field selector")?;
1399            match field.as_str() {
1400                FORMAT_FIELD => {
1401                    let text = scalar_text(&rhs, "Format value")?;
1402                    object
1403                        .properties
1404                        .insert(FORMAT_FIELD.to_string(), Value::String(text));
1405                    Ok(Value::Object(object))
1406                }
1407                _ => Err(datetime_error(format!(
1408                    "datetime.subsasgn: unsupported datetime property '{field}'"
1409                ))),
1410            }
1411        }
1412        _ => Err(datetime_error(format!(
1413            "datetime.subsasgn: unsupported indexing kind '{kind}'"
1414        ))),
1415    }
1416}
1417
1418fn datetime_binary_serials(
1419    lhs: Value,
1420    rhs: Value,
1421    context: &str,
1422) -> BuiltinResult<(Tensor, Tensor, Vec<usize>, String)> {
1423    let lhs_serials = serials_from_datetime_value(&lhs)?;
1424    let rhs_serials = match &rhs {
1425        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj)?,
1426        _ => serial_tensor_from_value(rhs, context)?,
1427    };
1428    let (left, right, shape) =
1429        tensor::binary_numeric_tensors(&lhs_serials, &rhs_serials, context, BUILTIN_NAME)?;
1430    let left_tensor = Tensor::new(left, shape.clone())
1431        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
1432    let right_tensor = Tensor::new(right, shape.clone())
1433        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
1434    Ok((
1435        left_tensor,
1436        right_tensor,
1437        shape,
1438        datetime_format_from_value(&lhs),
1439    ))
1440}
1441
1442fn compare_datetime(
1443    lhs: Value,
1444    rhs: Value,
1445    op: &str,
1446    cmp: impl Fn(f64, f64) -> bool,
1447) -> BuiltinResult<Value> {
1448    let (left, right, shape, _) = datetime_binary_serials(lhs, rhs, op)?;
1449    let out = left
1450        .data
1451        .iter()
1452        .zip(right.data.iter())
1453        .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
1454        .collect::<Vec<_>>();
1455    tensor_or_scalar(out, shape)
1456}
1457
1458#[runmat_macros::runtime_builtin(
1459    name = "datetime.eq",
1460    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1461    builtin_path = "crate::builtins::datetime"
1462)]
1463async fn datetime_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1464    compare_datetime(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
1465}
1466
1467#[runmat_macros::runtime_builtin(
1468    name = "datetime.ne",
1469    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1470    builtin_path = "crate::builtins::datetime"
1471)]
1472async fn datetime_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1473    compare_datetime(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
1474}
1475
1476#[runmat_macros::runtime_builtin(
1477    name = "datetime.lt",
1478    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1479    builtin_path = "crate::builtins::datetime"
1480)]
1481async fn datetime_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1482    compare_datetime(lhs, rhs, "lt", |a, b| a < b)
1483}
1484
1485#[runmat_macros::runtime_builtin(
1486    name = "datetime.le",
1487    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1488    builtin_path = "crate::builtins::datetime"
1489)]
1490async fn datetime_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1491    compare_datetime(lhs, rhs, "le", |a, b| a <= b)
1492}
1493
1494#[runmat_macros::runtime_builtin(
1495    name = "datetime.gt",
1496    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1497    builtin_path = "crate::builtins::datetime"
1498)]
1499async fn datetime_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1500    compare_datetime(lhs, rhs, "gt", |a, b| a > b)
1501}
1502
1503#[runmat_macros::runtime_builtin(
1504    name = "datetime.ge",
1505    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1506    builtin_path = "crate::builtins::datetime"
1507)]
1508async fn datetime_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1509    compare_datetime(lhs, rhs, "ge", |a, b| a >= b)
1510}
1511
1512#[runmat_macros::runtime_builtin(
1513    name = "datetime.plus",
1514    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1515    builtin_path = "crate::builtins::datetime"
1516)]
1517async fn datetime_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1518    let lhs_serials = serials_from_datetime_value(&lhs)?;
1519    let rhs_numeric = if crate::builtins::duration::is_duration_object(&rhs) {
1520        crate::builtins::duration::duration_tensor_from_duration_value(&rhs)?
1521    } else {
1522        serial_tensor_from_value(rhs, "plus")?
1523    };
1524    let (left, right, shape) =
1525        tensor::binary_numeric_tensors(&lhs_serials, &rhs_numeric, "plus", BUILTIN_NAME)?;
1526    let serials = left
1527        .iter()
1528        .zip(right.iter())
1529        .map(|(a, b)| a + b)
1530        .collect::<Vec<_>>();
1531    datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
1532}
1533
1534#[runmat_macros::runtime_builtin(
1535    name = "datetime.minus",
1536    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1537    builtin_path = "crate::builtins::datetime"
1538)]
1539async fn datetime_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1540    let lhs_serials = serials_from_datetime_value(&lhs)?;
1541    match &rhs {
1542        _ if crate::builtins::duration::is_duration_object(&rhs) => {
1543            let rhs_days = crate::builtins::duration::duration_tensor_from_duration_value(&rhs)?;
1544            let (left, right, shape) =
1545                tensor::binary_numeric_tensors(&lhs_serials, &rhs_days, "minus", BUILTIN_NAME)?;
1546            let serials = left
1547                .iter()
1548                .zip(right.iter())
1549                .map(|(a, b)| a - b)
1550                .collect::<Vec<_>>();
1551            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
1552        }
1553        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => {
1554            let rhs_serials = serial_tensor_for_object(obj)?;
1555            let (left, right, shape) =
1556                tensor::binary_numeric_tensors(&lhs_serials, &rhs_serials, "minus", BUILTIN_NAME)?;
1557            let deltas = left
1558                .iter()
1559                .zip(right.iter())
1560                .map(|(a, b)| a - b)
1561                .collect::<Vec<_>>();
1562            tensor_or_scalar(deltas, shape)
1563        }
1564        _ => {
1565            let rhs_numeric = serial_tensor_from_value(rhs, "minus")?;
1566            let (left, right, shape) =
1567                tensor::binary_numeric_tensors(&lhs_serials, &rhs_numeric, "minus", BUILTIN_NAME)?;
1568            let serials = left
1569                .iter()
1570                .zip(right.iter())
1571                .map(|(a, b)| a - b)
1572                .collect::<Vec<_>>();
1573            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
1574        }
1575    }
1576}
1577
1578#[derive(Clone, Copy, PartialEq, Eq)]
1579enum DateShiftBoundary {
1580    Start,
1581    End,
1582    Nearest,
1583    DayOfWeek,
1584}
1585
1586impl DateShiftBoundary {
1587    fn parse(value: &Value) -> BuiltinResult<Self> {
1588        let text = scalar_text(value, "dateshift boundary")?;
1589        match text.trim().to_ascii_lowercase().as_str() {
1590            "start" => Ok(Self::Start),
1591            "end" => Ok(Self::End),
1592            "nearest" => Ok(Self::Nearest),
1593            "dayofweek" => Ok(Self::DayOfWeek),
1594            other => Err(datetime_error(format!(
1595                "dateshift: unsupported boundary '{other}'"
1596            ))),
1597        }
1598    }
1599}
1600
1601#[derive(Clone, Copy)]
1602enum DateShiftUnit {
1603    Year,
1604    Quarter,
1605    Month,
1606    Week,
1607    Day,
1608    Hour,
1609    Minute,
1610    Second,
1611}
1612
1613impl DateShiftUnit {
1614    fn parse(value: &Value) -> BuiltinResult<Self> {
1615        let text = scalar_text(value, "dateshift unit")?;
1616        match text.trim().to_ascii_lowercase().as_str() {
1617            "year" | "years" => Ok(Self::Year),
1618            "quarter" | "quarters" => Ok(Self::Quarter),
1619            "month" | "months" => Ok(Self::Month),
1620            "week" | "weeks" => Ok(Self::Week),
1621            "day" | "days" => Ok(Self::Day),
1622            "hour" | "hours" => Ok(Self::Hour),
1623            "minute" | "minutes" => Ok(Self::Minute),
1624            "second" | "seconds" => Ok(Self::Second),
1625            other => Err(datetime_error(format!(
1626                "dateshift: unsupported unit '{other}'"
1627            ))),
1628        }
1629    }
1630}
1631
1632fn parse_weekday(value: &Value) -> BuiltinResult<Weekday> {
1633    match value {
1634        Value::Num(n) if n.is_finite() && (*n - n.round()).abs() <= f64::EPSILON => {
1635            weekday_from_matlab_index(n.round() as i64)
1636        }
1637        Value::Int(i) => weekday_from_matlab_index(i.to_i64()),
1638        _ => {
1639            let text = scalar_text(value, "weekday")?;
1640            match text.trim().to_ascii_lowercase().as_str() {
1641                "sun" | "sunday" => Ok(Weekday::Sun),
1642                "mon" | "monday" => Ok(Weekday::Mon),
1643                "tue" | "tues" | "tuesday" => Ok(Weekday::Tue),
1644                "wed" | "wednesday" => Ok(Weekday::Wed),
1645                "thu" | "thur" | "thurs" | "thursday" => Ok(Weekday::Thu),
1646                "fri" | "friday" => Ok(Weekday::Fri),
1647                "sat" | "saturday" => Ok(Weekday::Sat),
1648                other => Err(datetime_error(format!(
1649                    "dateshift: unsupported weekday '{other}'"
1650                ))),
1651            }
1652        }
1653    }
1654}
1655
1656fn weekday_from_matlab_index(index: i64) -> BuiltinResult<Weekday> {
1657    match index {
1658        1 => Ok(Weekday::Sun),
1659        2 => Ok(Weekday::Mon),
1660        3 => Ok(Weekday::Tue),
1661        4 => Ok(Weekday::Wed),
1662        5 => Ok(Weekday::Thu),
1663        6 => Ok(Weekday::Fri),
1664        7 => Ok(Weekday::Sat),
1665        _ => Err(datetime_error(
1666            "dateshift: numeric weekdays must be in the range 1..7",
1667        )),
1668    }
1669}
1670
1671fn midnight(date: NaiveDate) -> NaiveDateTime {
1672    date.and_hms_opt(0, 0, 0).unwrap()
1673}
1674
1675fn start_of_week(value: NaiveDateTime, week_start: Weekday) -> NaiveDateTime {
1676    let current = value.weekday().num_days_from_monday() as i64;
1677    let start = week_start.num_days_from_monday() as i64;
1678    let delta = (current - start).rem_euclid(7);
1679    midnight(value.date() - Duration::days(delta))
1680}
1681
1682fn start_of_unit(value: NaiveDateTime, unit: DateShiftUnit, week_start: Weekday) -> NaiveDateTime {
1683    match unit {
1684        DateShiftUnit::Year => midnight(NaiveDate::from_ymd_opt(value.year(), 1, 1).unwrap()),
1685        DateShiftUnit::Quarter => {
1686            let month = ((value.month() - 1) / 3) * 3 + 1;
1687            midnight(NaiveDate::from_ymd_opt(value.year(), month, 1).unwrap())
1688        }
1689        DateShiftUnit::Month => {
1690            midnight(NaiveDate::from_ymd_opt(value.year(), value.month(), 1).unwrap())
1691        }
1692        DateShiftUnit::Week => start_of_week(value, week_start),
1693        DateShiftUnit::Day => midnight(value.date()),
1694        DateShiftUnit::Hour => value
1695            .date()
1696            .and_hms_nano_opt(value.hour(), 0, 0, 0)
1697            .unwrap(),
1698        DateShiftUnit::Minute => value
1699            .date()
1700            .and_hms_nano_opt(value.hour(), value.minute(), 0, 0)
1701            .unwrap(),
1702        DateShiftUnit::Second => value
1703            .date()
1704            .and_hms_nano_opt(value.hour(), value.minute(), value.second(), 0)
1705            .unwrap(),
1706    }
1707}
1708
1709fn add_months(year: i32, month: u32, delta: u32) -> (i32, u32) {
1710    let zero_based = year as i64 * 12 + i64::from(month - 1) + i64::from(delta);
1711    let out_year = zero_based.div_euclid(12) as i32;
1712    let out_month = zero_based.rem_euclid(12) as u32 + 1;
1713    (out_year, out_month)
1714}
1715
1716fn next_unit_start(start: NaiveDateTime, unit: DateShiftUnit) -> NaiveDateTime {
1717    match unit {
1718        DateShiftUnit::Year => midnight(NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).unwrap()),
1719        DateShiftUnit::Quarter => {
1720            let (year, month) = add_months(start.year(), start.month(), 3);
1721            midnight(NaiveDate::from_ymd_opt(year, month, 1).unwrap())
1722        }
1723        DateShiftUnit::Month => {
1724            let (year, month) = add_months(start.year(), start.month(), 1);
1725            midnight(NaiveDate::from_ymd_opt(year, month, 1).unwrap())
1726        }
1727        DateShiftUnit::Week => start + Duration::days(7),
1728        DateShiftUnit::Day => start + Duration::days(1),
1729        DateShiftUnit::Hour => start + Duration::hours(1),
1730        DateShiftUnit::Minute => start + Duration::minutes(1),
1731        DateShiftUnit::Second => start + Duration::seconds(1),
1732    }
1733}
1734
1735fn shift_naive_datetime(
1736    value: NaiveDateTime,
1737    boundary: DateShiftBoundary,
1738    unit: DateShiftUnit,
1739    week_start: Weekday,
1740) -> NaiveDateTime {
1741    let start = start_of_unit(value, unit, week_start);
1742    match boundary {
1743        DateShiftBoundary::Start => start,
1744        DateShiftBoundary::End => next_unit_start(start, unit) - Duration::milliseconds(1),
1745        DateShiftBoundary::Nearest => {
1746            let next = next_unit_start(start, unit);
1747            if value - start <= next - value {
1748                start
1749            } else {
1750                next
1751            }
1752        }
1753        DateShiftBoundary::DayOfWeek => value,
1754    }
1755}
1756
1757fn shift_to_dayofweek(value: NaiveDateTime, weekday: Weekday) -> NaiveDateTime {
1758    let current = value.weekday().num_days_from_monday() as i64;
1759    let target = weekday.num_days_from_monday() as i64;
1760    let delta = (target - current).rem_euclid(7);
1761    midnight(value.date() + Duration::days(delta))
1762}
1763
1764#[runmat_macros::runtime_builtin(
1765    name = "dateshift",
1766    descriptor(crate::builtins::datetime::DATESHIFT_DESCRIPTOR),
1767    builtin_path = "crate::builtins::datetime",
1768    category = "datetime",
1769    summary = "Shift datetime values to calendar or clock boundaries.",
1770    keywords = "dateshift,datetime,start,end,nearest,week,month,year",
1771    related = "datetime,year,month,day"
1772)]
1773async fn dateshift_builtin(
1774    value: Value,
1775    boundary: Value,
1776    unit: Value,
1777    rest: Vec<Value>,
1778) -> crate::BuiltinResult<Value> {
1779    let value = gather_if_needed_async(&value)
1780        .await
1781        .map_err(|err| datetime_error(format!("dateshift: {}", err.message())))?;
1782    let boundary = gather_if_needed_async(&boundary)
1783        .await
1784        .map_err(|err| datetime_error(format!("dateshift: {}", err.message())))?;
1785    let unit = gather_if_needed_async(&unit)
1786        .await
1787        .map_err(|err| datetime_error(format!("dateshift: {}", err.message())))?;
1788    let rest = gather_args(&rest).await?;
1789    let serials = serials_from_datetime_value(&value)?;
1790    let format = datetime_format_from_value(&value);
1791    let boundary = DateShiftBoundary::parse(&boundary)?;
1792
1793    let mut out = Vec::with_capacity(serials.data.len());
1794    if boundary == DateShiftBoundary::DayOfWeek {
1795        if !rest.is_empty() {
1796            return Err(datetime_error(
1797                "dateshift: dayofweek boundary does not accept extra arguments",
1798            ));
1799        }
1800        let weekday = parse_weekday(&unit)?;
1801        for serial in &serials.data {
1802            out.push(datenum_from_naive(shift_to_dayofweek(
1803                naive_from_datenum(*serial)?,
1804                weekday,
1805            )));
1806        }
1807    } else {
1808        let unit = DateShiftUnit::parse(&unit)?;
1809        let week_start = if matches!(unit, DateShiftUnit::Week) {
1810            if rest.len() > 1 {
1811                return Err(datetime_error(
1812                    "dateshift: week unit accepts at most one weekday argument",
1813                ));
1814            }
1815            rest.first()
1816                .map(parse_weekday)
1817                .transpose()?
1818                .unwrap_or(Weekday::Mon)
1819        } else {
1820            if !rest.is_empty() {
1821                return Err(datetime_error(
1822                    "dateshift: extra arguments are only supported for week units",
1823                ));
1824            }
1825            Weekday::Mon
1826        };
1827        for serial in &serials.data {
1828            out.push(datenum_from_naive(shift_naive_datetime(
1829                naive_from_datenum(*serial)?,
1830                boundary,
1831                unit,
1832                week_start,
1833            )));
1834        }
1835    }
1836
1837    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1838    datetime_object_from_serials(out, shape, format)
1839}
1840
1841pub fn datetime_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
1842    let Some(array) = datetime_string_array(value)? else {
1843        return Ok(None);
1844    };
1845    let width = array
1846        .data
1847        .iter()
1848        .map(|s| s.chars().count())
1849        .max()
1850        .unwrap_or(0);
1851    let rows = array.data.len();
1852    let mut data = vec![' '; rows * width];
1853    for (row, text) in array.data.iter().enumerate() {
1854        for (col, ch) in text.chars().enumerate() {
1855            data[row * width + col] = ch;
1856        }
1857    }
1858    let out = CharArray::new(data, rows, width)
1859        .map_err(|err| datetime_error(format!("datetime: {err}")))?;
1860    Ok(Some(out))
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865    use super::*;
1866
1867    fn run_datetime(args: Vec<Value>) -> Value {
1868        futures::executor::block_on(datetime_builtin(args)).expect("datetime")
1869    }
1870
1871    fn as_datetime(value: Value) -> ObjectInstance {
1872        match value {
1873            Value::Object(object) => object,
1874            other => panic!("expected datetime object, got {other:?}"),
1875        }
1876    }
1877
1878    #[test]
1879    fn datetime_descriptor_signatures_cover_constructor_and_methods() {
1880        let labels: Vec<&str> = DATETIME_DESCRIPTOR
1881            .signatures
1882            .iter()
1883            .map(|sig| sig.label)
1884            .collect();
1885        assert!(labels.contains(&"t = datetime()"));
1886        assert!(labels.contains(&"t = datetime(year, month, day, hour, minute, second)"));
1887        assert!(labels.contains(&"t = datetime(serialDateNumbers, \"ConvertFrom\", \"datenum\")"));
1888
1889        assert_eq!(DATETIME_YEAR_DESCRIPTOR.signatures[0].label, "X = year(t)");
1890        assert_eq!(
1891            DATETIME_SUBSREF_DESCRIPTOR.signatures[0].label,
1892            "out = datetime.subsref(obj, kind, payload)"
1893        );
1894        assert_eq!(
1895            DATETIME_BINARY_DESCRIPTOR.signatures[0].label,
1896            "out = datetime.op(lhs, rhs)"
1897        );
1898    }
1899
1900    #[test]
1901    fn datetime_builds_from_components() {
1902        let value = run_datetime(vec![Value::Num(2024.0), Value::Num(3.0), Value::Num(14.0)]);
1903        let object = as_datetime(value);
1904        assert_eq!(object.class_name, DATETIME_CLASS);
1905        assert_eq!(format_for_object(&object), DEFAULT_DATE_FORMAT);
1906        let serials = serial_tensor_for_object(&object).expect("serials");
1907        assert_eq!(serials.data.len(), 1);
1908        let year =
1909            futures::executor::block_on(year_builtin(Value::Object(object.clone()))).expect("year");
1910        assert_eq!(year, Value::Num(2024.0));
1911    }
1912
1913    #[test]
1914    fn datetime_builds_arrays_from_component_vectors() {
1915        let years = Value::Tensor(Tensor::new(vec![2024.0, 2025.0], vec![1, 2]).unwrap());
1916        let months = Value::Tensor(Tensor::new(vec![1.0, 6.0], vec![1, 2]).unwrap());
1917        let days = Value::Tensor(Tensor::new(vec![15.0, 20.0], vec![1, 2]).unwrap());
1918        let value = run_datetime(vec![years, months, days]);
1919        let object = as_datetime(value.clone());
1920        let serials = serial_tensor_for_object(&object).expect("serials");
1921        assert_eq!(serials.shape, vec![1, 2]);
1922        let rendered = datetime_display_text(&value)
1923            .expect("display")
1924            .expect("datetime text");
1925        assert!(rendered.contains("15-Jan-2024"));
1926        assert!(rendered.contains("20-Jun-2025"));
1927    }
1928
1929    #[test]
1930    fn datetime_parses_text_and_converts_to_strings() {
1931        let value = run_datetime(vec![Value::String("2024-03-14 09:26:53".to_string())]);
1932        let rendered = datetime_string_array(&value)
1933            .expect("string array")
1934            .expect("datetime strings");
1935        assert_eq!(rendered.data, vec!["14-Mar-2024 09:26:53".to_string()]);
1936    }
1937
1938    #[test]
1939    fn datetime_accepts_existing_datetime_input() {
1940        let value = run_datetime(vec![Value::String("2024-03-14".to_string())]);
1941        let converted = run_datetime(vec![
1942            value.clone(),
1943            Value::from("InputFormat"),
1944            Value::from("yyyy-MM-dd"),
1945        ]);
1946        assert_eq!(
1947            serials_from_datetime_value(&converted).unwrap().data,
1948            serials_from_datetime_value(&value).unwrap().data
1949        );
1950    }
1951
1952    #[test]
1953    fn datetime_parses_text_with_input_format() {
1954        let input = Value::StringArray(
1955            StringArray::new(
1956                vec!["2024/03/14".to_string(), "2024/03/15".to_string()],
1957                vec![2, 1],
1958            )
1959            .unwrap(),
1960        );
1961        let value = run_datetime(vec![
1962            input,
1963            Value::from("InputFormat"),
1964            Value::from("yyyy/MM/dd"),
1965            Value::from("Format"),
1966            Value::from("yyyy-MM-dd"),
1967        ]);
1968        let rendered = datetime_string_array(&value)
1969            .expect("string array")
1970            .expect("datetime strings");
1971        assert_eq!(
1972            rendered.data,
1973            vec!["2024-03-14".to_string(), "2024-03-15".to_string()]
1974        );
1975    }
1976
1977    #[test]
1978    fn dateshift_supports_start_of_week_and_month_end() {
1979        let input = run_datetime(vec![
1980            Value::StringArray(
1981                StringArray::new(
1982                    vec!["2024-03-14".to_string(), "2024-03-18".to_string()],
1983                    vec![2, 1],
1984                )
1985                .unwrap(),
1986            ),
1987            Value::from("Format"),
1988            Value::from("yyyy-MM-dd"),
1989        ]);
1990        let shifted = futures::executor::block_on(dateshift_builtin(
1991            input,
1992            Value::from("start"),
1993            Value::from("week"),
1994            Vec::new(),
1995        ))
1996        .expect("dateshift start week");
1997        let rendered = datetime_string_array(&shifted)
1998            .expect("string array")
1999            .expect("datetime strings");
2000        assert_eq!(
2001            rendered.data,
2002            vec!["2024-03-11".to_string(), "2024-03-18".to_string()]
2003        );
2004
2005        let month_end = futures::executor::block_on(dateshift_builtin(
2006            run_datetime(vec![
2007                Value::from("2024-02-10"),
2008                Value::from("Format"),
2009                Value::from("yyyy-MM-dd HH:mm:ss"),
2010            ]),
2011            Value::from("end"),
2012            Value::from("month"),
2013            Vec::new(),
2014        ))
2015        .expect("dateshift end month");
2016        let rendered = datetime_string_array(&month_end)
2017            .expect("string array")
2018            .expect("datetime strings");
2019        assert_eq!(rendered.data, vec!["2024-02-29 23:59:59".to_string()]);
2020    }
2021
2022    #[test]
2023    fn dateshift_rejects_unsupported_extra_arguments() {
2024        let input = run_datetime(vec![Value::from("2024-03-14")]);
2025        let err = futures::executor::block_on(dateshift_builtin(
2026            input.clone(),
2027            Value::from("dayofweek"),
2028            Value::from("monday"),
2029            vec![Value::from("extra")],
2030        ))
2031        .expect_err("dayofweek extra argument should fail");
2032        assert!(err.message().contains("does not accept extra arguments"));
2033
2034        let err = futures::executor::block_on(dateshift_builtin(
2035            input.clone(),
2036            Value::from("start"),
2037            Value::from("week"),
2038            vec![Value::from("monday"), Value::from("extra")],
2039        ))
2040        .expect_err("week second extra argument should fail");
2041        assert!(err.message().contains("at most one weekday argument"));
2042
2043        let err = futures::executor::block_on(dateshift_builtin(
2044            input,
2045            Value::from("start"),
2046            Value::from("month"),
2047            vec![Value::from("monday")],
2048        ))
2049        .expect_err("non-week extra argument should fail");
2050        assert!(err
2051            .message()
2052            .contains("extra arguments are only supported for week units"));
2053    }
2054
2055    #[test]
2056    fn datetime_supports_format_assignment() {
2057        let value = run_datetime(vec![Value::Num(2024.0), Value::Num(3.0), Value::Num(14.0)]);
2058        let updated = futures::executor::block_on(datetime_subsasgn(
2059            value,
2060            ".".to_string(),
2061            Value::String(FORMAT_FIELD.to_string()),
2062            Value::String("yyyy-MM-dd".to_string()),
2063        ))
2064        .expect("subsasgn");
2065        let rendered = datetime_display_text(&updated)
2066            .expect("display")
2067            .expect("datetime text");
2068        assert_eq!(rendered, "2024-03-14");
2069    }
2070
2071    #[test]
2072    fn datetime_supports_indexing_and_comparison() {
2073        let years = Value::Tensor(Tensor::new(vec![2024.0, 2025.0], vec![1, 2]).unwrap());
2074        let months = Value::Tensor(Tensor::new(vec![1.0, 6.0], vec![1, 2]).unwrap());
2075        let days = Value::Tensor(Tensor::new(vec![15.0, 20.0], vec![1, 2]).unwrap());
2076        let value = run_datetime(vec![years, months, days]);
2077        let payload =
2078            Value::Cell(runmat_builtins::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
2079        let indexed =
2080            futures::executor::block_on(datetime_subsref(value.clone(), "()".to_string(), payload))
2081                .expect("subsref");
2082        let year = futures::executor::block_on(year_builtin(indexed)).expect("year");
2083        assert_eq!(year, Value::Num(2025.0));
2084
2085        let lhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(1.0)]);
2086        let rhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(2.0)]);
2087        let cmp = futures::executor::block_on(datetime_lt(lhs, rhs)).expect("lt");
2088        assert_eq!(cmp, Value::Num(1.0));
2089    }
2090
2091    #[test]
2092    fn datetime_and_duration_interoperate() {
2093        let lhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(1.0)]);
2094        let rhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(2.0)]);
2095        let delta = futures::executor::block_on(datetime_minus(rhs.clone(), lhs.clone()))
2096            .expect("datetime minus datetime");
2097        assert_eq!(delta, Value::Num(1.0));
2098
2099        let duration = crate::builtins::duration::duration_object_from_days_tensor(
2100            Tensor::new(vec![1.0], vec![1, 1]).unwrap(),
2101            crate::builtins::duration::DEFAULT_DURATION_FORMAT,
2102        )
2103        .expect("duration");
2104
2105        let round_trip = futures::executor::block_on(datetime_plus(lhs.clone(), duration.clone()))
2106            .expect("plus");
2107        let round_trip_text = datetime_display_text(&round_trip)
2108            .expect("datetime display")
2109            .expect("datetime text");
2110        assert_eq!(round_trip_text, "02-Jan-2024");
2111
2112        let restored =
2113            futures::executor::block_on(datetime_minus(rhs, duration)).expect("minus duration");
2114        let restored_text = datetime_display_text(&restored)
2115            .expect("datetime display")
2116            .expect("datetime text");
2117        assert_eq!(restored_text, "01-Jan-2024");
2118    }
2119}