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};
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 DATETIME_SUBSREF_INPUTS: [BuiltinParamDescriptor; 3] = [
104    BuiltinParamDescriptor {
105        name: "obj",
106        ty: BuiltinParamType::Any,
107        arity: BuiltinParamArity::Required,
108        default: None,
109        description: "Datetime receiver object.",
110    },
111    BuiltinParamDescriptor {
112        name: "kind",
113        ty: BuiltinParamType::StringScalar,
114        arity: BuiltinParamArity::Required,
115        default: None,
116        description: "Indexing kind token.",
117    },
118    BuiltinParamDescriptor {
119        name: "payload",
120        ty: BuiltinParamType::Any,
121        arity: BuiltinParamArity::Required,
122        default: None,
123        description: "Index/member payload.",
124    },
125];
126const DATETIME_SUBSASGN_INPUTS: [BuiltinParamDescriptor; 4] = [
127    BuiltinParamDescriptor {
128        name: "obj",
129        ty: BuiltinParamType::Any,
130        arity: BuiltinParamArity::Required,
131        default: None,
132        description: "Datetime receiver object.",
133    },
134    BuiltinParamDescriptor {
135        name: "kind",
136        ty: BuiltinParamType::StringScalar,
137        arity: BuiltinParamArity::Required,
138        default: None,
139        description: "Indexing kind token.",
140    },
141    BuiltinParamDescriptor {
142        name: "payload",
143        ty: BuiltinParamType::Any,
144        arity: BuiltinParamArity::Required,
145        default: None,
146        description: "Index/member payload.",
147    },
148    BuiltinParamDescriptor {
149        name: "rhs",
150        ty: BuiltinParamType::Any,
151        arity: BuiltinParamArity::Required,
152        default: None,
153        description: "Assigned value.",
154    },
155];
156
157const DATETIME_SIGNATURES: [BuiltinSignatureDescriptor; 10] = [
158    BuiltinSignatureDescriptor {
159        label: "t = datetime()",
160        inputs: &[],
161        outputs: &OUT_DATETIME,
162    },
163    BuiltinSignatureDescriptor {
164        label: "t = datetime(textOrArray)",
165        inputs: &[BuiltinParamDescriptor {
166            name: "textOrArray",
167            ty: BuiltinParamType::Any,
168            arity: BuiltinParamArity::Required,
169            default: None,
170            description: "String/char/date text input.",
171        }],
172        outputs: &OUT_DATETIME,
173    },
174    BuiltinSignatureDescriptor {
175        label: "t = datetime(serialDateNumbers)",
176        inputs: &[BuiltinParamDescriptor {
177            name: "serialDateNumbers",
178            ty: BuiltinParamType::NumericArray,
179            arity: BuiltinParamArity::Required,
180            default: None,
181            description: "Numeric serial date input.",
182        }],
183        outputs: &OUT_DATETIME,
184    },
185    BuiltinSignatureDescriptor {
186        label: "t = datetime(year, month, day)",
187        inputs: &[
188            BuiltinParamDescriptor {
189                name: "year",
190                ty: BuiltinParamType::NumericArray,
191                arity: BuiltinParamArity::Required,
192                default: None,
193                description: "Year component.",
194            },
195            BuiltinParamDescriptor {
196                name: "month",
197                ty: BuiltinParamType::NumericArray,
198                arity: BuiltinParamArity::Required,
199                default: None,
200                description: "Month component.",
201            },
202            BuiltinParamDescriptor {
203                name: "day",
204                ty: BuiltinParamType::NumericArray,
205                arity: BuiltinParamArity::Required,
206                default: None,
207                description: "Day component.",
208            },
209        ],
210        outputs: &OUT_DATETIME,
211    },
212    BuiltinSignatureDescriptor {
213        label: "t = datetime(year, month, day, hour)",
214        inputs: &[
215            BuiltinParamDescriptor {
216                name: "year",
217                ty: BuiltinParamType::NumericArray,
218                arity: BuiltinParamArity::Required,
219                default: None,
220                description: "Year component.",
221            },
222            BuiltinParamDescriptor {
223                name: "month",
224                ty: BuiltinParamType::NumericArray,
225                arity: BuiltinParamArity::Required,
226                default: None,
227                description: "Month component.",
228            },
229            BuiltinParamDescriptor {
230                name: "day",
231                ty: BuiltinParamType::NumericArray,
232                arity: BuiltinParamArity::Required,
233                default: None,
234                description: "Day component.",
235            },
236            BuiltinParamDescriptor {
237                name: "hour",
238                ty: BuiltinParamType::NumericArray,
239                arity: BuiltinParamArity::Required,
240                default: None,
241                description: "Hour component.",
242            },
243        ],
244        outputs: &OUT_DATETIME,
245    },
246    BuiltinSignatureDescriptor {
247        label: "t = datetime(year, month, day, hour, minute)",
248        inputs: &[
249            BuiltinParamDescriptor {
250                name: "year",
251                ty: BuiltinParamType::NumericArray,
252                arity: BuiltinParamArity::Required,
253                default: None,
254                description: "Year component.",
255            },
256            BuiltinParamDescriptor {
257                name: "month",
258                ty: BuiltinParamType::NumericArray,
259                arity: BuiltinParamArity::Required,
260                default: None,
261                description: "Month component.",
262            },
263            BuiltinParamDescriptor {
264                name: "day",
265                ty: BuiltinParamType::NumericArray,
266                arity: BuiltinParamArity::Required,
267                default: None,
268                description: "Day component.",
269            },
270            BuiltinParamDescriptor {
271                name: "hour",
272                ty: BuiltinParamType::NumericArray,
273                arity: BuiltinParamArity::Required,
274                default: None,
275                description: "Hour component.",
276            },
277            BuiltinParamDescriptor {
278                name: "minute",
279                ty: BuiltinParamType::NumericArray,
280                arity: BuiltinParamArity::Required,
281                default: None,
282                description: "Minute component.",
283            },
284        ],
285        outputs: &OUT_DATETIME,
286    },
287    BuiltinSignatureDescriptor {
288        label: "t = datetime(year, month, day, hour, minute, second)",
289        inputs: &[
290            BuiltinParamDescriptor {
291                name: "year",
292                ty: BuiltinParamType::NumericArray,
293                arity: BuiltinParamArity::Required,
294                default: None,
295                description: "Year component.",
296            },
297            BuiltinParamDescriptor {
298                name: "month",
299                ty: BuiltinParamType::NumericArray,
300                arity: BuiltinParamArity::Required,
301                default: None,
302                description: "Month component.",
303            },
304            BuiltinParamDescriptor {
305                name: "day",
306                ty: BuiltinParamType::NumericArray,
307                arity: BuiltinParamArity::Required,
308                default: None,
309                description: "Day component.",
310            },
311            BuiltinParamDescriptor {
312                name: "hour",
313                ty: BuiltinParamType::NumericArray,
314                arity: BuiltinParamArity::Required,
315                default: None,
316                description: "Hour component.",
317            },
318            BuiltinParamDescriptor {
319                name: "minute",
320                ty: BuiltinParamType::NumericArray,
321                arity: BuiltinParamArity::Required,
322                default: None,
323                description: "Minute component.",
324            },
325            BuiltinParamDescriptor {
326                name: "second",
327                ty: BuiltinParamType::NumericArray,
328                arity: BuiltinParamArity::Required,
329                default: None,
330                description: "Second component.",
331            },
332        ],
333        outputs: &OUT_DATETIME,
334    },
335    BuiltinSignatureDescriptor {
336        label: "t = datetime(serialDateNumbers, \"ConvertFrom\", \"datenum\")",
337        inputs: &[BuiltinParamDescriptor {
338            name: "args",
339            ty: BuiltinParamType::Any,
340            arity: BuiltinParamArity::Variadic,
341            default: None,
342            description: "Numeric serial input with ConvertFrom option.",
343        }],
344        outputs: &OUT_DATETIME,
345    },
346    BuiltinSignatureDescriptor {
347        label: "t = datetime(___, \"Format\", format)",
348        inputs: &DATETIME_ARGS_ONLY,
349        outputs: &OUT_DATETIME,
350    },
351    BuiltinSignatureDescriptor {
352        label: "t = datetime(___, Name, Value, ...)",
353        inputs: &DATETIME_ARGS_ONLY,
354        outputs: &OUT_DATETIME,
355    },
356];
357
358const DATETIME_YEAR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
359    label: "X = year(t)",
360    inputs: &DATETIME_SINGLE_INPUT,
361    outputs: &OUT_NUMERIC,
362}];
363const DATETIME_MONTH_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
364    label: "X = month(t)",
365    inputs: &DATETIME_SINGLE_INPUT,
366    outputs: &OUT_NUMERIC,
367}];
368const DATETIME_DAY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
369    label: "X = day(t)",
370    inputs: &DATETIME_SINGLE_INPUT,
371    outputs: &OUT_NUMERIC,
372}];
373const DATETIME_HOUR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
374    label: "X = hour(t)",
375    inputs: &DATETIME_SINGLE_INPUT,
376    outputs: &OUT_NUMERIC,
377}];
378const DATETIME_MINUTE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
379    label: "X = minute(t)",
380    inputs: &DATETIME_SINGLE_INPUT,
381    outputs: &OUT_NUMERIC,
382}];
383const DATETIME_SECOND_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
384    label: "X = second(t)",
385    inputs: &DATETIME_SINGLE_INPUT,
386    outputs: &OUT_NUMERIC,
387}];
388const DATETIME_SUBSREF_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
389    label: "out = datetime.subsref(obj, kind, payload)",
390    inputs: &DATETIME_SUBSREF_INPUTS,
391    outputs: &OUT_ANY,
392}];
393const DATETIME_SUBSASGN_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
394    [BuiltinSignatureDescriptor {
395        label: "out = datetime.subsasgn(obj, kind, payload, rhs)",
396        inputs: &DATETIME_SUBSASGN_INPUTS,
397        outputs: &OUT_ANY,
398    }];
399const DATETIME_BINARY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
400    label: "out = datetime.op(lhs, rhs)",
401    inputs: &DATETIME_BINARY_INPUTS,
402    outputs: &OUT_ANY,
403}];
404
405pub const DATETIME_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
406    signatures: &DATETIME_SIGNATURES,
407    output_mode: BuiltinOutputMode::Fixed,
408    completion_policy: BuiltinCompletionPolicy::Public,
409    errors: &DATETIME_ERRORS,
410};
411pub const DATETIME_YEAR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
412    signatures: &DATETIME_YEAR_SIGNATURES,
413    output_mode: BuiltinOutputMode::Fixed,
414    completion_policy: BuiltinCompletionPolicy::Public,
415    errors: &DATETIME_ERRORS,
416};
417pub const DATETIME_MONTH_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
418    signatures: &DATETIME_MONTH_SIGNATURES,
419    output_mode: BuiltinOutputMode::Fixed,
420    completion_policy: BuiltinCompletionPolicy::Public,
421    errors: &DATETIME_ERRORS,
422};
423pub const DATETIME_DAY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
424    signatures: &DATETIME_DAY_SIGNATURES,
425    output_mode: BuiltinOutputMode::Fixed,
426    completion_policy: BuiltinCompletionPolicy::Public,
427    errors: &DATETIME_ERRORS,
428};
429pub const DATETIME_HOUR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
430    signatures: &DATETIME_HOUR_SIGNATURES,
431    output_mode: BuiltinOutputMode::Fixed,
432    completion_policy: BuiltinCompletionPolicy::Public,
433    errors: &DATETIME_ERRORS,
434};
435pub const DATETIME_MINUTE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
436    signatures: &DATETIME_MINUTE_SIGNATURES,
437    output_mode: BuiltinOutputMode::Fixed,
438    completion_policy: BuiltinCompletionPolicy::Public,
439    errors: &DATETIME_ERRORS,
440};
441pub const DATETIME_SECOND_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
442    signatures: &DATETIME_SECOND_SIGNATURES,
443    output_mode: BuiltinOutputMode::Fixed,
444    completion_policy: BuiltinCompletionPolicy::Public,
445    errors: &DATETIME_ERRORS,
446};
447pub const DATETIME_SUBSREF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
448    signatures: &DATETIME_SUBSREF_SIGNATURES,
449    output_mode: BuiltinOutputMode::Fixed,
450    completion_policy: BuiltinCompletionPolicy::MethodOnly,
451    errors: &DATETIME_ERRORS,
452};
453pub const DATETIME_SUBSASGN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
454    signatures: &DATETIME_SUBSASGN_SIGNATURES,
455    output_mode: BuiltinOutputMode::Fixed,
456    completion_policy: BuiltinCompletionPolicy::MethodOnly,
457    errors: &DATETIME_ERRORS,
458};
459pub const DATETIME_BINARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
460    signatures: &DATETIME_BINARY_SIGNATURES,
461    output_mode: BuiltinOutputMode::Fixed,
462    completion_policy: BuiltinCompletionPolicy::MethodOnly,
463    errors: &DATETIME_ERRORS,
464};
465
466fn datetime_error(message: impl Into<String>) -> RuntimeError {
467    build_runtime_error(message)
468        .with_builtin(BUILTIN_NAME)
469        .build()
470}
471
472fn ensure_datetime_class_registered() {
473    DATETIME_CLASS_REGISTERED.get_or_init(|| {
474        let mut properties = HashMap::new();
475        properties.insert(
476            FORMAT_FIELD.to_string(),
477            PropertyDef {
478                name: FORMAT_FIELD.to_string(),
479                is_static: false,
480                is_constant: false,
481                is_dependent: false,
482                get_access: Access::Public,
483                set_access: Access::Public,
484                default_value: Some(Value::String(DEFAULT_DATETIME_FORMAT.to_string())),
485            },
486        );
487
488        let mut methods = HashMap::new();
489        for name in [
490            OBJECT_SUBSREF_METHOD,
491            OBJECT_SUBSASGN_METHOD,
492            "plus",
493            "minus",
494            "eq",
495            "ne",
496            "lt",
497            "le",
498            "gt",
499            "ge",
500        ] {
501            methods.insert(
502                name.to_string(),
503                MethodDef {
504                    name: name.to_string(),
505                    is_static: false,
506                    is_abstract: false,
507                    is_sealed: false,
508                    access: Access::Public,
509                    function_name: format!("{DATETIME_CLASS}.{name}"),
510                    implicit_class_argument: None,
511                },
512            );
513        }
514
515        runmat_builtins::register_class(ClassDef {
516            name: DATETIME_CLASS.to_string(),
517            parent: None,
518            properties,
519            methods,
520        });
521    });
522}
523
524async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
525    let mut out = Vec::with_capacity(args.len());
526    for arg in args {
527        out.push(
528            gather_if_needed_async(arg)
529                .await
530                .map_err(|err| datetime_error(format!("datetime: {}", err.message())))?,
531        );
532    }
533    Ok(out)
534}
535
536fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
537    match value {
538        Value::String(text) => Ok(text.clone()),
539        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
540        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
541        _ => Err(datetime_error(format!(
542            "datetime: {context} must be a string scalar or character vector"
543        ))),
544    }
545}
546
547fn parse_trailing_options(
548    args: &[Value],
549) -> BuiltinResult<(usize, Option<String>, Option<String>)> {
550    let mut positional_end = args.len();
551    let mut format = None;
552    let mut convert_from = None;
553
554    while positional_end >= 2 {
555        let name = match scalar_text(&args[positional_end - 2], "option name") {
556            Ok(text) => text,
557            Err(_) => break,
558        };
559        let lowered = name.trim().to_ascii_lowercase();
560        let value = scalar_text(&args[positional_end - 1], &format!("{name} option"))?;
561        match lowered.as_str() {
562            "format" => format = Some(value),
563            "convertfrom" => convert_from = Some(value),
564            _ => break,
565        }
566        positional_end -= 2;
567    }
568
569    Ok((positional_end, format, convert_from))
570}
571
572fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
573    tensor::value_into_tensor_for(context, value)
574        .map_err(|message| datetime_error(format!("datetime: {message}")))
575}
576
577fn serial_tensor_from_value(value: Value, context: &str) -> BuiltinResult<Tensor> {
578    let tensor = tensor_from_numeric(value, context)?;
579    Tensor::new(
580        tensor.data.clone(),
581        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
582    )
583    .map_err(|err| datetime_error(format!("datetime: {err}")))
584}
585
586fn format_for_object(obj: &ObjectInstance) -> String {
587    match obj.properties.get(FORMAT_FIELD) {
588        Some(Value::String(text)) => text.clone(),
589        Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
590        Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
591        _ => DEFAULT_DATETIME_FORMAT.to_string(),
592    }
593}
594
595fn serial_tensor_for_object(obj: &ObjectInstance) -> BuiltinResult<Tensor> {
596    match obj.properties.get(SERIAL_FIELD) {
597        Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
598        Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
599            .map_err(|err| datetime_error(format!("datetime: {err}"))),
600        Some(other) => Err(datetime_error(format!(
601            "datetime: invalid internal serial storage {other:?}"
602        ))),
603        None => Err(datetime_error("datetime: missing internal serial storage")),
604    }
605}
606
607pub(crate) fn datetime_object_from_serial_tensor(
608    serials: Tensor,
609    format: impl Into<String>,
610) -> BuiltinResult<Value> {
611    ensure_datetime_class_registered();
612    let mut object = ObjectInstance::new(DATETIME_CLASS.to_string());
613    object
614        .properties
615        .insert(SERIAL_FIELD.to_string(), Value::Tensor(serials));
616    object
617        .properties
618        .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
619    Ok(Value::Object(object))
620}
621
622fn datetime_object_from_serials(
623    serials: Vec<f64>,
624    shape: Vec<usize>,
625    format: impl Into<String>,
626) -> BuiltinResult<Value> {
627    let tensor =
628        Tensor::new(serials, shape).map_err(|err| datetime_error(format!("datetime: {err}")))?;
629    datetime_object_from_serial_tensor(tensor, format)
630}
631
632fn format_token_to_strftime(format: &str) -> String {
633    let mut out = format.to_string();
634    for (src, dst) in [
635        ("yyyy", "%Y"),
636        ("MMM", "%b"),
637        ("MM", "%m"),
638        ("dd", "%d"),
639        ("HH", "%H"),
640        ("mm", "%M"),
641        ("ss", "%S"),
642    ] {
643        out = out.replace(src, dst);
644    }
645    out
646}
647
648fn datenum_from_naive(datetime: NaiveDateTime) -> f64 {
649    let base = NaiveDate::from_ymd_opt(1970, 1, 1)
650        .unwrap()
651        .and_hms_opt(0, 0, 0)
652        .unwrap();
653    let duration = datetime - base;
654    let seconds = duration.num_seconds();
655    let nanos = (duration - Duration::seconds(seconds))
656        .num_nanoseconds()
657        .unwrap_or(0);
658    let total_seconds = seconds as f64 + nanos as f64 / 1_000_000_000.0;
659    total_seconds / SECONDS_PER_DAY + UNIX_DATENUM
660}
661
662fn naive_from_datenum(serial: f64) -> BuiltinResult<NaiveDateTime> {
663    if !serial.is_finite() {
664        return Err(datetime_error(
665            "datetime: serial date numbers must be finite",
666        ));
667    }
668    let total_seconds = (serial - UNIX_DATENUM) * SECONDS_PER_DAY;
669    let whole_seconds = total_seconds.floor();
670    let mut nanos = ((total_seconds - whole_seconds) * 1_000_000_000.0).round() as i64;
671    let mut seconds = whole_seconds as i64;
672    if nanos == 1_000_000_000 {
673        seconds += 1;
674        nanos = 0;
675    }
676    let base = NaiveDate::from_ymd_opt(1970, 1, 1)
677        .unwrap()
678        .and_hms_opt(0, 0, 0)
679        .unwrap();
680    Ok(base + Duration::seconds(seconds) + Duration::nanoseconds(nanos))
681}
682
683fn format_serial(serial: f64, format: &str) -> BuiltinResult<String> {
684    let naive = naive_from_datenum(serial)?;
685    let chrono_format = format_token_to_strftime(format);
686    Ok(naive.format(&chrono_format).to_string())
687}
688
689fn parse_datetime_text(text: &str) -> Option<(NaiveDateTime, bool)> {
690    let trimmed = text.trim();
691    if trimmed.is_empty() {
692        return None;
693    }
694
695    if let Ok(value) = DateTime::parse_from_rfc3339(trimmed) {
696        return Some((value.with_timezone(&Local).naive_local(), true));
697    }
698
699    for (pattern, has_time) in [
700        ("%Y-%m-%d %H:%M:%S", true),
701        ("%Y-%m-%d", false),
702        ("%d-%b-%Y %H:%M:%S", true),
703        ("%d-%b-%Y", false),
704        ("%m/%d/%Y %H:%M:%S", true),
705        ("%m/%d/%Y", false),
706    ] {
707        if has_time {
708            if let Ok(value) = NaiveDateTime::parse_from_str(trimmed, pattern) {
709                return Some((value, true));
710            }
711        } else if let Ok(value) = NaiveDate::parse_from_str(trimmed, pattern) {
712            return Some((value.and_hms_opt(0, 0, 0).unwrap(), false));
713        }
714    }
715
716    None
717}
718
719fn parse_text_input(value: Value) -> BuiltinResult<(Vec<f64>, Vec<usize>, String)> {
720    match value {
721        Value::String(text) => {
722            if text.trim().eq_ignore_ascii_case("now") {
723                let now = Local::now().naive_local();
724                return Ok((
725                    vec![datenum_from_naive(now)],
726                    vec![1, 1],
727                    DEFAULT_DATETIME_FORMAT.to_string(),
728                ));
729            }
730            let (naive, has_time) = parse_datetime_text(&text).ok_or_else(|| {
731                datetime_error(format!("datetime: unable to parse date/time text '{text}'"))
732            })?;
733            Ok((
734                vec![datenum_from_naive(naive)],
735                vec![1, 1],
736                if has_time {
737                    DEFAULT_DATETIME_FORMAT.to_string()
738                } else {
739                    DEFAULT_DATE_FORMAT.to_string()
740                },
741            ))
742        }
743        Value::StringArray(array) => {
744            let mut serials = Vec::with_capacity(array.data.len());
745            let mut has_time = false;
746            for text in &array.data {
747                let (naive, parsed_has_time) = parse_datetime_text(text).ok_or_else(|| {
748                    datetime_error(format!("datetime: unable to parse date/time text '{text}'"))
749                })?;
750                serials.push(datenum_from_naive(naive));
751                has_time |= parsed_has_time;
752            }
753            Ok((
754                serials,
755                tensor::default_shape_for(&array.shape, array.data.len()),
756                if has_time {
757                    DEFAULT_DATETIME_FORMAT.to_string()
758                } else {
759                    DEFAULT_DATE_FORMAT.to_string()
760                },
761            ))
762        }
763        Value::CharArray(array) => {
764            let mut texts = Vec::with_capacity(array.rows);
765            for row in 0..array.rows {
766                let start = row * array.cols;
767                let end = start + array.cols;
768                texts.push(
769                    array.data[start..end]
770                        .iter()
771                        .collect::<String>()
772                        .trim_end()
773                        .to_string(),
774                );
775            }
776            parse_text_input(Value::StringArray(
777                StringArray::new(texts, vec![array.rows, 1])
778                    .map_err(|err| datetime_error(format!("datetime: {err}")))?,
779            ))
780        }
781        _ => Err(datetime_error(
782            "datetime: text input must be a string scalar, string array, or character array",
783        )),
784    }
785}
786
787fn round_component(value: f64, label: &str, min: i64, max: i64) -> BuiltinResult<i64> {
788    if !value.is_finite() {
789        return Err(datetime_error(format!(
790            "datetime: {label} values must be finite"
791        )));
792    }
793    let rounded = value.round();
794    if (rounded - value).abs() > 1e-9 {
795        return Err(datetime_error(format!(
796            "datetime: {label} values must be integers"
797        )));
798    }
799    let integer = rounded as i64;
800    if integer < min || integer > max {
801        return Err(datetime_error(format!(
802            "datetime: {label} values must be in the range [{min}, {max}]"
803        )));
804    }
805    Ok(integer)
806}
807
808fn naive_from_components(
809    year: f64,
810    month: f64,
811    day: f64,
812    hour: f64,
813    minute: f64,
814    second: f64,
815) -> BuiltinResult<NaiveDateTime> {
816    let year = round_component(year, "year", -262_000, 262_000)? as i32;
817    let month = round_component(month, "month", 1, 12)? as u32;
818    let day = round_component(day, "day", 1, 31)? as u32;
819    let hour = round_component(hour, "hour", 0, 23)? as u32;
820    let minute = round_component(minute, "minute", 0, 59)? as u32;
821    if !second.is_finite() {
822        return Err(datetime_error("datetime: second values must be finite"));
823    }
824    if !(0.0..60.0).contains(&second) {
825        return Err(datetime_error(
826            "datetime: second values must be in the range [0, 60)",
827        ));
828    }
829
830    let base_date = NaiveDate::from_ymd_opt(year, month, day)
831        .ok_or_else(|| datetime_error("datetime: invalid calendar date"))?;
832    let whole_second = second.floor();
833    let mut nanos = ((second - whole_second) * 1_000_000_000.0).round() as u32;
834    let mut secs = whole_second as u32;
835    if nanos == 1_000_000_000 {
836        secs += 1;
837        nanos = 0;
838    }
839    let time = base_date
840        .and_hms_nano_opt(hour, minute, secs, nanos)
841        .ok_or_else(|| datetime_error("datetime: invalid time components"))?;
842    Ok(time)
843}
844
845fn broadcast_component_data(
846    arrays: &[Tensor],
847    labels: &[&str],
848) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
849    let mut target_shape = vec![1, 1];
850    let mut target_len = 1usize;
851
852    for array in arrays {
853        let len = array.data.len();
854        if len > 1 {
855            let shape = tensor::default_shape_for(&array.shape, len);
856            if target_len == 1 {
857                target_len = len;
858                target_shape = shape;
859            } else if len != target_len || shape != target_shape {
860                return Err(datetime_error(
861                    "datetime: non-scalar component inputs must have matching sizes",
862                ));
863            }
864        }
865    }
866
867    let mut broadcasted = Vec::with_capacity(arrays.len());
868    for (idx, array) in arrays.iter().enumerate() {
869        if array.data.len() == 1 {
870            broadcasted.push(vec![array.data[0]; target_len]);
871        } else if array.data.len() == target_len {
872            broadcasted.push(array.data.clone());
873        } else {
874            return Err(datetime_error(format!(
875                "datetime: {} input size does not match the other components",
876                labels[idx]
877            )));
878        }
879    }
880
881    Ok((broadcasted, target_shape))
882}
883
884fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
885    let tensor = tensor_from_numeric(value, context)?;
886    Tensor::new(
887        tensor.data.clone(),
888        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
889    )
890    .map_err(|err| datetime_error(format!("datetime: {err}")))
891}
892
893fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
894    let labels = ["year", "month", "day", "hour", "minute", "second"];
895    let input_count = args.len();
896    let mut arrays = Vec::with_capacity(args.len());
897    for (idx, arg) in args.into_iter().enumerate() {
898        arrays.push(component_tensor(arg, labels[idx])?);
899    }
900    while arrays.len() < 6 {
901        arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
902    }
903
904    let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
905    let len = broadcasted[0].len();
906    let mut serials = Vec::with_capacity(len);
907    for idx in 0..len {
908        let naive = naive_from_components(
909            broadcasted[0][idx],
910            broadcasted[1][idx],
911            broadcasted[2][idx],
912            broadcasted[3][idx],
913            broadcasted[4][idx],
914            broadcasted[5][idx],
915        )?;
916        serials.push(datenum_from_naive(naive));
917    }
918
919    let default_format = if let Some(format) = format {
920        format
921    } else if input_count > 3 {
922        DEFAULT_DATETIME_FORMAT.to_string()
923    } else {
924        DEFAULT_DATE_FORMAT.to_string()
925    };
926    datetime_object_from_serials(serials, shape, default_format)
927}
928
929fn numeric_value_to_datetime(value: Value, format: Option<String>) -> BuiltinResult<Value> {
930    let serials = serial_tensor_from_value(value, "datetime")?;
931    datetime_object_from_serial_tensor(
932        serials,
933        format.unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string()),
934    )
935}
936
937pub fn is_datetime_object(value: &Value) -> bool {
938    matches!(value, Value::Object(obj) if obj.is_class(DATETIME_CLASS))
939}
940
941pub(crate) fn serials_from_datetime_value(value: &Value) -> BuiltinResult<Tensor> {
942    match value {
943        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj),
944        _ => Err(datetime_error("datetime: expected a datetime value")),
945    }
946}
947
948pub(crate) fn datetime_format_from_value(value: &Value) -> String {
949    match value {
950        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => format_for_object(obj),
951        _ => DEFAULT_DATETIME_FORMAT.to_string(),
952    }
953}
954
955pub fn datetime_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
956    let Value::Object(obj) = value else {
957        return Ok(None);
958    };
959    if !obj.is_class(DATETIME_CLASS) {
960        return Ok(None);
961    }
962    let serials = serial_tensor_for_object(obj)?;
963    let format = format_for_object(obj);
964    let mut strings = Vec::with_capacity(serials.data.len());
965    for serial in &serials.data {
966        strings.push(format_serial(*serial, &format)?);
967    }
968    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
969    let array = StringArray::new(strings, shape)
970        .map_err(|err| datetime_error(format!("datetime: {err}")))?;
971    Ok(Some(array))
972}
973
974pub fn datetime_display_text(value: &Value) -> BuiltinResult<Option<String>> {
975    let Some(array) = datetime_string_array(value)? else {
976        return Ok(None);
977    };
978    if array.data.len() == 1 {
979        return Ok(Some(array.data[0].clone()));
980    }
981
982    let rows = array.rows;
983    let cols = array.cols;
984    let mut widths = vec![0usize; cols];
985    for col in 0..cols {
986        for row in 0..rows {
987            let idx = row + col * rows;
988            widths[col] = widths[col].max(array.data[idx].chars().count());
989        }
990    }
991
992    let mut lines = Vec::with_capacity(rows);
993    for row in 0..rows {
994        let mut line = String::new();
995        for col in 0..cols {
996            if col > 0 {
997                line.push_str("  ");
998            }
999            let idx = row + col * rows;
1000            let text = &array.data[idx];
1001            line.push_str(text);
1002            let padding = widths[col].saturating_sub(text.chars().count());
1003            if padding > 0 {
1004                line.push_str(&" ".repeat(padding));
1005            }
1006        }
1007        lines.push(line);
1008    }
1009    Ok(Some(lines.join("\n")))
1010}
1011
1012pub fn datetime_summary(value: &Value) -> BuiltinResult<Option<String>> {
1013    let Value::Object(obj) = value else {
1014        return Ok(None);
1015    };
1016    if !obj.is_class(DATETIME_CLASS) {
1017        return Ok(None);
1018    }
1019    let serials = serial_tensor_for_object(obj)?;
1020    if serials.data.len() == 1 {
1021        return datetime_display_text(value);
1022    }
1023    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1024    Ok(Some(format!(
1025        "[{} datetime]",
1026        shape
1027            .iter()
1028            .map(|dim| dim.to_string())
1029            .collect::<Vec<_>>()
1030            .join("x")
1031    )))
1032}
1033
1034fn component_tensor_from_datetime(
1035    value: &Value,
1036    label: &str,
1037    extractor: impl Fn(&NaiveDateTime) -> f64,
1038) -> BuiltinResult<Value> {
1039    let serials = serials_from_datetime_value(value)?;
1040    let mut out = Vec::with_capacity(serials.data.len());
1041    for serial in &serials.data {
1042        let naive = naive_from_datenum(*serial)?;
1043        out.push(extractor(&naive));
1044    }
1045    if out.len() == 1 {
1046        Ok(Value::Num(out[0]))
1047    } else {
1048        let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
1049        let tensor =
1050            Tensor::new(out, shape).map_err(|err| datetime_error(format!("{label}: {err}")))?;
1051        Ok(Value::Tensor(tensor))
1052    }
1053}
1054
1055fn tensor_or_scalar(data: Vec<f64>, shape: Vec<usize>) -> BuiltinResult<Value> {
1056    if data.len() == 1 {
1057        Ok(Value::Num(data[0]))
1058    } else {
1059        Ok(Value::Tensor(Tensor::new(data, shape).map_err(|err| {
1060            datetime_error(format!("datetime: {err}"))
1061        })?))
1062    }
1063}
1064
1065async fn datetime_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
1066    let Value::Object(object) = obj else {
1067        return Err(datetime_error(
1068            "datetime.subsref: receiver must be a datetime object",
1069        ));
1070    };
1071    let format = format_for_object(&object);
1072    let serials = serial_tensor_for_object(&object)?;
1073
1074    let Value::Cell(cell) = payload else {
1075        return Err(datetime_error(
1076            "datetime.subsref: indexing payload must be a cell array",
1077        ));
1078    };
1079    if cell.data.is_empty() {
1080        return datetime_object_from_serial_tensor(serials, format);
1081    }
1082    if cell.data.len() != 1 {
1083        return Err(datetime_error(
1084            "datetime.subsref: only linear datetime indexing is currently supported",
1085        ));
1086    }
1087    let selector = (*cell.data[0]).clone();
1088    let selector = match selector {
1089        Value::Tensor(tensor) => tensor,
1090        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
1091            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1092        Value::Int(value) => Tensor::new(vec![value.to_f64()], vec![1, 1])
1093            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1094        Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
1095            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1096        other => {
1097            return Err(datetime_error(format!(
1098                "datetime.subsref: unsupported index value {other:?}"
1099            )))
1100        }
1101    };
1102    let indexed = crate::perform_indexing(&Value::Tensor(serials), &selector.data)
1103        .await
1104        .map_err(|err| datetime_error(format!("datetime.subsref: {}", err.message())))?;
1105    let indexed_serials = match indexed {
1106        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
1107            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
1108        Value::Tensor(tensor) => tensor,
1109        other => {
1110            return Err(datetime_error(format!(
1111                "datetime.subsref: unexpected indexing result {other:?}"
1112            )))
1113        }
1114    };
1115    datetime_object_from_serial_tensor(indexed_serials, format)
1116}
1117
1118#[runmat_macros::runtime_builtin(
1119    name = "datetime",
1120    descriptor(crate::builtins::datetime::DATETIME_DESCRIPTOR),
1121    builtin_path = "crate::builtins::datetime",
1122    category = "datetime",
1123    summary = "Create datetime arrays from text, components, or serial date numbers.",
1124    keywords = "datetime,date,time,datenum,Format",
1125    related = "year,month,day,hour,minute,second,string,char,disp",
1126    examples = "t = datetime(2024, 4, 9, 13, 30, 0);"
1127)]
1128async fn datetime_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
1129    ensure_datetime_class_registered();
1130    let args = gather_args(&args).await?;
1131    let (positional_end, format, convert_from) = parse_trailing_options(&args)?;
1132    let positional = args[..positional_end].to_vec();
1133
1134    if let Some(convert_from) = convert_from {
1135        if !convert_from.eq_ignore_ascii_case("datenum") {
1136            return Err(datetime_error(format!(
1137                "datetime: unsupported ConvertFrom value '{convert_from}'"
1138            )));
1139        }
1140        if positional.len() != 1 {
1141            return Err(datetime_error(
1142                "datetime: ConvertFrom='datenum' expects exactly one numeric input",
1143            ));
1144        }
1145        return numeric_value_to_datetime(positional[0].clone(), format);
1146    }
1147
1148    match positional.len() {
1149        0 => {
1150            let now = Local::now().naive_local();
1151            datetime_object_from_serials(
1152                vec![datenum_from_naive(now)],
1153                vec![1, 1],
1154                format.unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string()),
1155            )
1156        }
1157        1 => match &positional[0] {
1158            Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
1159                let (serials, shape, inferred_format) = parse_text_input(positional[0].clone())?;
1160                datetime_object_from_serials(serials, shape, format.unwrap_or(inferred_format))
1161            }
1162            _ => numeric_value_to_datetime(positional[0].clone(), format),
1163        },
1164        3..=6 => build_from_components(positional, format),
1165        _ => Err(datetime_error(
1166            "datetime: unsupported argument pattern; use text, serial dates, or Y/M/D component inputs",
1167        )),
1168    }
1169}
1170
1171#[runmat_macros::runtime_builtin(
1172    name = "year",
1173    descriptor(crate::builtins::datetime::DATETIME_YEAR_DESCRIPTOR),
1174    builtin_path = "crate::builtins::datetime",
1175    category = "datetime",
1176    summary = "Extract calendar year components from datetime values.",
1177    keywords = "year,datetime,date component"
1178)]
1179async fn year_builtin(value: Value) -> crate::BuiltinResult<Value> {
1180    component_tensor_from_datetime(&value, "year", |naive| naive.year() as f64)
1181}
1182
1183#[runmat_macros::runtime_builtin(
1184    name = "month",
1185    descriptor(crate::builtins::datetime::DATETIME_MONTH_DESCRIPTOR),
1186    builtin_path = "crate::builtins::datetime",
1187    category = "datetime",
1188    summary = "Extract month numbers from datetime arrays.",
1189    keywords = "month,datetime,date component"
1190)]
1191async fn month_builtin(value: Value) -> crate::BuiltinResult<Value> {
1192    component_tensor_from_datetime(&value, "month", |naive| naive.month() as f64)
1193}
1194
1195#[runmat_macros::runtime_builtin(
1196    name = "day",
1197    descriptor(crate::builtins::datetime::DATETIME_DAY_DESCRIPTOR),
1198    builtin_path = "crate::builtins::datetime",
1199    category = "datetime",
1200    summary = "Extract day-of-month numbers from datetime values.",
1201    keywords = "day,datetime,date component"
1202)]
1203async fn day_builtin(value: Value) -> crate::BuiltinResult<Value> {
1204    component_tensor_from_datetime(&value, "day", |naive| naive.day() as f64)
1205}
1206
1207#[runmat_macros::runtime_builtin(
1208    name = "hour",
1209    descriptor(crate::builtins::datetime::DATETIME_HOUR_DESCRIPTOR),
1210    builtin_path = "crate::builtins::datetime",
1211    category = "datetime",
1212    summary = "Extract hour components from datetime values.",
1213    keywords = "hour,datetime,time component"
1214)]
1215async fn hour_builtin(value: Value) -> crate::BuiltinResult<Value> {
1216    component_tensor_from_datetime(&value, "hour", |naive| naive.hour() as f64)
1217}
1218
1219#[runmat_macros::runtime_builtin(
1220    name = "minute",
1221    descriptor(crate::builtins::datetime::DATETIME_MINUTE_DESCRIPTOR),
1222    builtin_path = "crate::builtins::datetime",
1223    category = "datetime",
1224    summary = "Extract minute numbers from datetime arrays.",
1225    keywords = "minute,datetime,time component"
1226)]
1227async fn minute_builtin(value: Value) -> crate::BuiltinResult<Value> {
1228    component_tensor_from_datetime(&value, "minute", |naive| naive.minute() as f64)
1229}
1230
1231#[runmat_macros::runtime_builtin(
1232    name = "second",
1233    descriptor(crate::builtins::datetime::DATETIME_SECOND_DESCRIPTOR),
1234    builtin_path = "crate::builtins::datetime",
1235    category = "datetime",
1236    summary = "Extract second components from datetime values.",
1237    keywords = "second,datetime,time component"
1238)]
1239async fn second_builtin(value: Value) -> crate::BuiltinResult<Value> {
1240    component_tensor_from_datetime(&value, "second", |naive| {
1241        naive.second() as f64 + f64::from(naive.nanosecond()) / 1_000_000_000.0
1242    })
1243}
1244
1245#[runmat_macros::runtime_builtin(
1246    name = "datetime.subsref",
1247    descriptor(crate::builtins::datetime::DATETIME_SUBSREF_DESCRIPTOR),
1248    builtin_path = "crate::builtins::datetime"
1249)]
1250async fn datetime_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
1251    match kind.as_str() {
1252        OBJECT_INDEX_PAREN => datetime_indexing(obj, payload).await,
1253        OBJECT_INDEX_MEMBER => {
1254            let Value::Object(object) = obj else {
1255                return Err(datetime_error(
1256                    "datetime.subsref: receiver must be a datetime object",
1257                ));
1258            };
1259            let field = scalar_text(&payload, "field selector")?;
1260            match field.as_str() {
1261                FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
1262                _ => Err(datetime_error(format!(
1263                    "datetime.subsref: unsupported datetime property '{field}'"
1264                ))),
1265            }
1266        }
1267        other => Err(datetime_error(format!(
1268            "datetime.subsref: unsupported indexing kind '{other}'"
1269        ))),
1270    }
1271}
1272
1273#[runmat_macros::runtime_builtin(
1274    name = "datetime.subsasgn",
1275    descriptor(crate::builtins::datetime::DATETIME_SUBSASGN_DESCRIPTOR),
1276    builtin_path = "crate::builtins::datetime"
1277)]
1278async fn datetime_subsasgn(
1279    obj: Value,
1280    kind: String,
1281    payload: Value,
1282    rhs: Value,
1283) -> crate::BuiltinResult<Value> {
1284    let Value::Object(mut object) = obj else {
1285        return Err(datetime_error(
1286            "datetime.subsasgn: receiver must be a datetime object",
1287        ));
1288    };
1289    match kind.as_str() {
1290        OBJECT_INDEX_MEMBER => {
1291            let field = scalar_text(&payload, "field selector")?;
1292            match field.as_str() {
1293                FORMAT_FIELD => {
1294                    let text = scalar_text(&rhs, "Format value")?;
1295                    object
1296                        .properties
1297                        .insert(FORMAT_FIELD.to_string(), Value::String(text));
1298                    Ok(Value::Object(object))
1299                }
1300                _ => Err(datetime_error(format!(
1301                    "datetime.subsasgn: unsupported datetime property '{field}'"
1302                ))),
1303            }
1304        }
1305        _ => Err(datetime_error(format!(
1306            "datetime.subsasgn: unsupported indexing kind '{kind}'"
1307        ))),
1308    }
1309}
1310
1311fn datetime_binary_serials(
1312    lhs: Value,
1313    rhs: Value,
1314    context: &str,
1315) -> BuiltinResult<(Tensor, Tensor, Vec<usize>, String)> {
1316    let lhs_serials = serials_from_datetime_value(&lhs)?;
1317    let rhs_serials = match &rhs {
1318        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj)?,
1319        _ => serial_tensor_from_value(rhs, context)?,
1320    };
1321    let (left, right, shape) =
1322        tensor::binary_numeric_tensors(&lhs_serials, &rhs_serials, context, BUILTIN_NAME)?;
1323    let left_tensor = Tensor::new(left, shape.clone())
1324        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
1325    let right_tensor = Tensor::new(right, shape.clone())
1326        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
1327    Ok((
1328        left_tensor,
1329        right_tensor,
1330        shape,
1331        datetime_format_from_value(&lhs),
1332    ))
1333}
1334
1335fn compare_datetime(
1336    lhs: Value,
1337    rhs: Value,
1338    op: &str,
1339    cmp: impl Fn(f64, f64) -> bool,
1340) -> BuiltinResult<Value> {
1341    let (left, right, shape, _) = datetime_binary_serials(lhs, rhs, op)?;
1342    let out = left
1343        .data
1344        .iter()
1345        .zip(right.data.iter())
1346        .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
1347        .collect::<Vec<_>>();
1348    tensor_or_scalar(out, shape)
1349}
1350
1351#[runmat_macros::runtime_builtin(
1352    name = "datetime.eq",
1353    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1354    builtin_path = "crate::builtins::datetime"
1355)]
1356async fn datetime_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1357    compare_datetime(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
1358}
1359
1360#[runmat_macros::runtime_builtin(
1361    name = "datetime.ne",
1362    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1363    builtin_path = "crate::builtins::datetime"
1364)]
1365async fn datetime_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1366    compare_datetime(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
1367}
1368
1369#[runmat_macros::runtime_builtin(
1370    name = "datetime.lt",
1371    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1372    builtin_path = "crate::builtins::datetime"
1373)]
1374async fn datetime_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1375    compare_datetime(lhs, rhs, "lt", |a, b| a < b)
1376}
1377
1378#[runmat_macros::runtime_builtin(
1379    name = "datetime.le",
1380    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1381    builtin_path = "crate::builtins::datetime"
1382)]
1383async fn datetime_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1384    compare_datetime(lhs, rhs, "le", |a, b| a <= b)
1385}
1386
1387#[runmat_macros::runtime_builtin(
1388    name = "datetime.gt",
1389    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1390    builtin_path = "crate::builtins::datetime"
1391)]
1392async fn datetime_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1393    compare_datetime(lhs, rhs, "gt", |a, b| a > b)
1394}
1395
1396#[runmat_macros::runtime_builtin(
1397    name = "datetime.ge",
1398    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1399    builtin_path = "crate::builtins::datetime"
1400)]
1401async fn datetime_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1402    compare_datetime(lhs, rhs, "ge", |a, b| a >= b)
1403}
1404
1405#[runmat_macros::runtime_builtin(
1406    name = "datetime.plus",
1407    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1408    builtin_path = "crate::builtins::datetime"
1409)]
1410async fn datetime_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1411    let lhs_serials = serials_from_datetime_value(&lhs)?;
1412    let rhs_numeric = if crate::builtins::duration::is_duration_object(&rhs) {
1413        crate::builtins::duration::duration_tensor_from_duration_value(&rhs)?
1414    } else {
1415        serial_tensor_from_value(rhs, "plus")?
1416    };
1417    let (left, right, shape) =
1418        tensor::binary_numeric_tensors(&lhs_serials, &rhs_numeric, "plus", BUILTIN_NAME)?;
1419    let serials = left
1420        .iter()
1421        .zip(right.iter())
1422        .map(|(a, b)| a + b)
1423        .collect::<Vec<_>>();
1424    datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
1425}
1426
1427#[runmat_macros::runtime_builtin(
1428    name = "datetime.minus",
1429    descriptor(crate::builtins::datetime::DATETIME_BINARY_DESCRIPTOR),
1430    builtin_path = "crate::builtins::datetime"
1431)]
1432async fn datetime_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1433    let lhs_serials = serials_from_datetime_value(&lhs)?;
1434    match &rhs {
1435        _ if crate::builtins::duration::is_duration_object(&rhs) => {
1436            let rhs_days = crate::builtins::duration::duration_tensor_from_duration_value(&rhs)?;
1437            let (left, right, shape) =
1438                tensor::binary_numeric_tensors(&lhs_serials, &rhs_days, "minus", BUILTIN_NAME)?;
1439            let serials = left
1440                .iter()
1441                .zip(right.iter())
1442                .map(|(a, b)| a - b)
1443                .collect::<Vec<_>>();
1444            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
1445        }
1446        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => {
1447            let rhs_serials = serial_tensor_for_object(obj)?;
1448            let (left, right, shape) =
1449                tensor::binary_numeric_tensors(&lhs_serials, &rhs_serials, "minus", BUILTIN_NAME)?;
1450            let deltas = left
1451                .iter()
1452                .zip(right.iter())
1453                .map(|(a, b)| a - b)
1454                .collect::<Vec<_>>();
1455            tensor_or_scalar(deltas, shape)
1456        }
1457        _ => {
1458            let rhs_numeric = serial_tensor_from_value(rhs, "minus")?;
1459            let (left, right, shape) =
1460                tensor::binary_numeric_tensors(&lhs_serials, &rhs_numeric, "minus", BUILTIN_NAME)?;
1461            let serials = left
1462                .iter()
1463                .zip(right.iter())
1464                .map(|(a, b)| a - b)
1465                .collect::<Vec<_>>();
1466            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
1467        }
1468    }
1469}
1470
1471pub fn datetime_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
1472    let Some(array) = datetime_string_array(value)? else {
1473        return Ok(None);
1474    };
1475    let width = array
1476        .data
1477        .iter()
1478        .map(|s| s.chars().count())
1479        .max()
1480        .unwrap_or(0);
1481    let rows = array.data.len();
1482    let mut data = vec![' '; rows * width];
1483    for (row, text) in array.data.iter().enumerate() {
1484        for (col, ch) in text.chars().enumerate() {
1485            data[row * width + col] = ch;
1486        }
1487    }
1488    let out = CharArray::new(data, rows, width)
1489        .map_err(|err| datetime_error(format!("datetime: {err}")))?;
1490    Ok(Some(out))
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495    use super::*;
1496
1497    fn run_datetime(args: Vec<Value>) -> Value {
1498        futures::executor::block_on(datetime_builtin(args)).expect("datetime")
1499    }
1500
1501    fn as_datetime(value: Value) -> ObjectInstance {
1502        match value {
1503            Value::Object(object) => object,
1504            other => panic!("expected datetime object, got {other:?}"),
1505        }
1506    }
1507
1508    #[test]
1509    fn datetime_descriptor_signatures_cover_constructor_and_methods() {
1510        let labels: Vec<&str> = DATETIME_DESCRIPTOR
1511            .signatures
1512            .iter()
1513            .map(|sig| sig.label)
1514            .collect();
1515        assert!(labels.contains(&"t = datetime()"));
1516        assert!(labels.contains(&"t = datetime(year, month, day, hour, minute, second)"));
1517        assert!(labels.contains(&"t = datetime(serialDateNumbers, \"ConvertFrom\", \"datenum\")"));
1518
1519        assert_eq!(DATETIME_YEAR_DESCRIPTOR.signatures[0].label, "X = year(t)");
1520        assert_eq!(
1521            DATETIME_SUBSREF_DESCRIPTOR.signatures[0].label,
1522            "out = datetime.subsref(obj, kind, payload)"
1523        );
1524        assert_eq!(
1525            DATETIME_BINARY_DESCRIPTOR.signatures[0].label,
1526            "out = datetime.op(lhs, rhs)"
1527        );
1528    }
1529
1530    #[test]
1531    fn datetime_builds_from_components() {
1532        let value = run_datetime(vec![Value::Num(2024.0), Value::Num(3.0), Value::Num(14.0)]);
1533        let object = as_datetime(value);
1534        assert_eq!(object.class_name, DATETIME_CLASS);
1535        assert_eq!(format_for_object(&object), DEFAULT_DATE_FORMAT);
1536        let serials = serial_tensor_for_object(&object).expect("serials");
1537        assert_eq!(serials.data.len(), 1);
1538        let year =
1539            futures::executor::block_on(year_builtin(Value::Object(object.clone()))).expect("year");
1540        assert_eq!(year, Value::Num(2024.0));
1541    }
1542
1543    #[test]
1544    fn datetime_builds_arrays_from_component_vectors() {
1545        let years = Value::Tensor(Tensor::new(vec![2024.0, 2025.0], vec![1, 2]).unwrap());
1546        let months = Value::Tensor(Tensor::new(vec![1.0, 6.0], vec![1, 2]).unwrap());
1547        let days = Value::Tensor(Tensor::new(vec![15.0, 20.0], vec![1, 2]).unwrap());
1548        let value = run_datetime(vec![years, months, days]);
1549        let object = as_datetime(value.clone());
1550        let serials = serial_tensor_for_object(&object).expect("serials");
1551        assert_eq!(serials.shape, vec![1, 2]);
1552        let rendered = datetime_display_text(&value)
1553            .expect("display")
1554            .expect("datetime text");
1555        assert!(rendered.contains("15-Jan-2024"));
1556        assert!(rendered.contains("20-Jun-2025"));
1557    }
1558
1559    #[test]
1560    fn datetime_parses_text_and_converts_to_strings() {
1561        let value = run_datetime(vec![Value::String("2024-03-14 09:26:53".to_string())]);
1562        let rendered = datetime_string_array(&value)
1563            .expect("string array")
1564            .expect("datetime strings");
1565        assert_eq!(rendered.data, vec!["14-Mar-2024 09:26:53".to_string()]);
1566    }
1567
1568    #[test]
1569    fn datetime_supports_format_assignment() {
1570        let value = run_datetime(vec![Value::Num(2024.0), Value::Num(3.0), Value::Num(14.0)]);
1571        let updated = futures::executor::block_on(datetime_subsasgn(
1572            value,
1573            ".".to_string(),
1574            Value::String(FORMAT_FIELD.to_string()),
1575            Value::String("yyyy-MM-dd".to_string()),
1576        ))
1577        .expect("subsasgn");
1578        let rendered = datetime_display_text(&updated)
1579            .expect("display")
1580            .expect("datetime text");
1581        assert_eq!(rendered, "2024-03-14");
1582    }
1583
1584    #[test]
1585    fn datetime_supports_indexing_and_comparison() {
1586        let years = Value::Tensor(Tensor::new(vec![2024.0, 2025.0], vec![1, 2]).unwrap());
1587        let months = Value::Tensor(Tensor::new(vec![1.0, 6.0], vec![1, 2]).unwrap());
1588        let days = Value::Tensor(Tensor::new(vec![15.0, 20.0], vec![1, 2]).unwrap());
1589        let value = run_datetime(vec![years, months, days]);
1590        let payload =
1591            Value::Cell(runmat_builtins::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
1592        let indexed =
1593            futures::executor::block_on(datetime_subsref(value.clone(), "()".to_string(), payload))
1594                .expect("subsref");
1595        let year = futures::executor::block_on(year_builtin(indexed)).expect("year");
1596        assert_eq!(year, Value::Num(2025.0));
1597
1598        let lhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(1.0)]);
1599        let rhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(2.0)]);
1600        let cmp = futures::executor::block_on(datetime_lt(lhs, rhs)).expect("lt");
1601        assert_eq!(cmp, Value::Num(1.0));
1602    }
1603
1604    #[test]
1605    fn datetime_and_duration_interoperate() {
1606        let lhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(1.0)]);
1607        let rhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(2.0)]);
1608        let delta = futures::executor::block_on(datetime_minus(rhs.clone(), lhs.clone()))
1609            .expect("datetime minus datetime");
1610        assert_eq!(delta, Value::Num(1.0));
1611
1612        let duration = crate::builtins::duration::duration_object_from_days_tensor(
1613            Tensor::new(vec![1.0], vec![1, 1]).unwrap(),
1614            crate::builtins::duration::DEFAULT_DURATION_FORMAT,
1615        )
1616        .expect("duration");
1617
1618        let round_trip = futures::executor::block_on(datetime_plus(lhs.clone(), duration.clone()))
1619            .expect("plus");
1620        let round_trip_text = datetime_display_text(&round_trip)
1621            .expect("datetime display")
1622            .expect("datetime text");
1623        assert_eq!(round_trip_text, "02-Jan-2024");
1624
1625        let restored =
1626            futures::executor::block_on(datetime_minus(rhs, duration)).expect("minus duration");
1627        let restored_text = datetime_display_text(&restored)
1628            .expect("datetime display")
1629            .expect("datetime text");
1630        assert_eq!(restored_text, "01-Jan-2024");
1631    }
1632}