Skip to main content

runmat_runtime/builtins/datetime/
mod.rs

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