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, CharArray, ClassDef, MethodDef, ObjectInstance, PropertyDef, StringArray, Tensor, Value,
7};
8
9use crate::builtins::common::tensor;
10use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
11
12const BUILTIN_NAME: &str = "datetime";
13const DATETIME_CLASS: &str = "datetime";
14const SERIAL_FIELD: &str = "__serial";
15const FORMAT_FIELD: &str = "Format";
16const DEFAULT_DATE_FORMAT: &str = "dd-MMM-yyyy";
17const DEFAULT_DATETIME_FORMAT: &str = "dd-MMM-yyyy HH:mm:ss";
18const UNIX_DATENUM: f64 = 719_529.0;
19const SECONDS_PER_DAY: f64 = 86_400.0;
20
21static DATETIME_CLASS_REGISTERED: OnceLock<()> = OnceLock::new();
22
23fn datetime_error(message: impl Into<String>) -> RuntimeError {
24    build_runtime_error(message)
25        .with_builtin(BUILTIN_NAME)
26        .build()
27}
28
29fn ensure_datetime_class_registered() {
30    DATETIME_CLASS_REGISTERED.get_or_init(|| {
31        let mut properties = HashMap::new();
32        properties.insert(
33            FORMAT_FIELD.to_string(),
34            PropertyDef {
35                name: FORMAT_FIELD.to_string(),
36                is_static: false,
37                is_dependent: false,
38                get_access: Access::Public,
39                set_access: Access::Public,
40                default_value: Some(Value::String(DEFAULT_DATETIME_FORMAT.to_string())),
41            },
42        );
43
44        let mut methods = HashMap::new();
45        for name in [
46            "subsref", "subsasgn", "plus", "minus", "eq", "ne", "lt", "le", "gt", "ge",
47        ] {
48            methods.insert(
49                name.to_string(),
50                MethodDef {
51                    name: name.to_string(),
52                    is_static: false,
53                    access: Access::Public,
54                    function_name: format!("{DATETIME_CLASS}.{name}"),
55                },
56            );
57        }
58
59        runmat_builtins::register_class(ClassDef {
60            name: DATETIME_CLASS.to_string(),
61            parent: None,
62            properties,
63            methods,
64        });
65    });
66}
67
68async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
69    let mut out = Vec::with_capacity(args.len());
70    for arg in args {
71        out.push(
72            gather_if_needed_async(arg)
73                .await
74                .map_err(|err| datetime_error(format!("datetime: {}", err.message())))?,
75        );
76    }
77    Ok(out)
78}
79
80fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
81    match value {
82        Value::String(text) => Ok(text.clone()),
83        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
84        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
85        _ => Err(datetime_error(format!(
86            "datetime: {context} must be a string scalar or character vector"
87        ))),
88    }
89}
90
91fn parse_trailing_options(
92    args: &[Value],
93) -> BuiltinResult<(usize, Option<String>, Option<String>)> {
94    let mut positional_end = args.len();
95    let mut format = None;
96    let mut convert_from = None;
97
98    while positional_end >= 2 {
99        let name = match scalar_text(&args[positional_end - 2], "option name") {
100            Ok(text) => text,
101            Err(_) => break,
102        };
103        let lowered = name.trim().to_ascii_lowercase();
104        let value = scalar_text(&args[positional_end - 1], &format!("{name} option"))?;
105        match lowered.as_str() {
106            "format" => format = Some(value),
107            "convertfrom" => convert_from = Some(value),
108            _ => break,
109        }
110        positional_end -= 2;
111    }
112
113    Ok((positional_end, format, convert_from))
114}
115
116fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
117    tensor::value_into_tensor_for(context, value)
118        .map_err(|message| datetime_error(format!("datetime: {message}")))
119}
120
121fn serial_tensor_from_value(value: Value, context: &str) -> BuiltinResult<Tensor> {
122    let tensor = tensor_from_numeric(value, context)?;
123    Tensor::new(
124        tensor.data.clone(),
125        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
126    )
127    .map_err(|err| datetime_error(format!("datetime: {err}")))
128}
129
130fn format_for_object(obj: &ObjectInstance) -> String {
131    match obj.properties.get(FORMAT_FIELD) {
132        Some(Value::String(text)) => text.clone(),
133        Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
134        Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
135        _ => DEFAULT_DATETIME_FORMAT.to_string(),
136    }
137}
138
139fn serial_tensor_for_object(obj: &ObjectInstance) -> BuiltinResult<Tensor> {
140    match obj.properties.get(SERIAL_FIELD) {
141        Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
142        Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
143            .map_err(|err| datetime_error(format!("datetime: {err}"))),
144        Some(other) => Err(datetime_error(format!(
145            "datetime: invalid internal serial storage {other:?}"
146        ))),
147        None => Err(datetime_error("datetime: missing internal serial storage")),
148    }
149}
150
151pub(crate) fn datetime_object_from_serial_tensor(
152    serials: Tensor,
153    format: impl Into<String>,
154) -> BuiltinResult<Value> {
155    ensure_datetime_class_registered();
156    let mut object = ObjectInstance::new(DATETIME_CLASS.to_string());
157    object
158        .properties
159        .insert(SERIAL_FIELD.to_string(), Value::Tensor(serials));
160    object
161        .properties
162        .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
163    Ok(Value::Object(object))
164}
165
166fn datetime_object_from_serials(
167    serials: Vec<f64>,
168    shape: Vec<usize>,
169    format: impl Into<String>,
170) -> BuiltinResult<Value> {
171    let tensor =
172        Tensor::new(serials, shape).map_err(|err| datetime_error(format!("datetime: {err}")))?;
173    datetime_object_from_serial_tensor(tensor, format)
174}
175
176fn format_token_to_strftime(format: &str) -> String {
177    let mut out = format.to_string();
178    for (src, dst) in [
179        ("yyyy", "%Y"),
180        ("MMM", "%b"),
181        ("MM", "%m"),
182        ("dd", "%d"),
183        ("HH", "%H"),
184        ("mm", "%M"),
185        ("ss", "%S"),
186    ] {
187        out = out.replace(src, dst);
188    }
189    out
190}
191
192fn datenum_from_naive(datetime: NaiveDateTime) -> f64 {
193    let base = NaiveDate::from_ymd_opt(1970, 1, 1)
194        .unwrap()
195        .and_hms_opt(0, 0, 0)
196        .unwrap();
197    let duration = datetime - base;
198    let seconds = duration.num_seconds();
199    let nanos = (duration - Duration::seconds(seconds))
200        .num_nanoseconds()
201        .unwrap_or(0);
202    let total_seconds = seconds as f64 + nanos as f64 / 1_000_000_000.0;
203    total_seconds / SECONDS_PER_DAY + UNIX_DATENUM
204}
205
206fn naive_from_datenum(serial: f64) -> BuiltinResult<NaiveDateTime> {
207    if !serial.is_finite() {
208        return Err(datetime_error(
209            "datetime: serial date numbers must be finite",
210        ));
211    }
212    let total_seconds = (serial - UNIX_DATENUM) * SECONDS_PER_DAY;
213    let whole_seconds = total_seconds.floor();
214    let mut nanos = ((total_seconds - whole_seconds) * 1_000_000_000.0).round() as i64;
215    let mut seconds = whole_seconds as i64;
216    if nanos == 1_000_000_000 {
217        seconds += 1;
218        nanos = 0;
219    }
220    let base = NaiveDate::from_ymd_opt(1970, 1, 1)
221        .unwrap()
222        .and_hms_opt(0, 0, 0)
223        .unwrap();
224    Ok(base + Duration::seconds(seconds) + Duration::nanoseconds(nanos))
225}
226
227fn format_serial(serial: f64, format: &str) -> BuiltinResult<String> {
228    let naive = naive_from_datenum(serial)?;
229    let chrono_format = format_token_to_strftime(format);
230    Ok(naive.format(&chrono_format).to_string())
231}
232
233fn parse_datetime_text(text: &str) -> Option<(NaiveDateTime, bool)> {
234    let trimmed = text.trim();
235    if trimmed.is_empty() {
236        return None;
237    }
238
239    if let Ok(value) = DateTime::parse_from_rfc3339(trimmed) {
240        return Some((value.with_timezone(&Local).naive_local(), true));
241    }
242
243    for (pattern, has_time) in [
244        ("%Y-%m-%d %H:%M:%S", true),
245        ("%Y-%m-%d", false),
246        ("%d-%b-%Y %H:%M:%S", true),
247        ("%d-%b-%Y", false),
248        ("%m/%d/%Y %H:%M:%S", true),
249        ("%m/%d/%Y", false),
250    ] {
251        if has_time {
252            if let Ok(value) = NaiveDateTime::parse_from_str(trimmed, pattern) {
253                return Some((value, true));
254            }
255        } else if let Ok(value) = NaiveDate::parse_from_str(trimmed, pattern) {
256            return Some((value.and_hms_opt(0, 0, 0).unwrap(), false));
257        }
258    }
259
260    None
261}
262
263fn parse_text_input(value: Value) -> BuiltinResult<(Vec<f64>, Vec<usize>, String)> {
264    match value {
265        Value::String(text) => {
266            if text.trim().eq_ignore_ascii_case("now") {
267                let now = Local::now().naive_local();
268                return Ok((
269                    vec![datenum_from_naive(now)],
270                    vec![1, 1],
271                    DEFAULT_DATETIME_FORMAT.to_string(),
272                ));
273            }
274            let (naive, has_time) = parse_datetime_text(&text).ok_or_else(|| {
275                datetime_error(format!("datetime: unable to parse date/time text '{text}'"))
276            })?;
277            Ok((
278                vec![datenum_from_naive(naive)],
279                vec![1, 1],
280                if has_time {
281                    DEFAULT_DATETIME_FORMAT.to_string()
282                } else {
283                    DEFAULT_DATE_FORMAT.to_string()
284                },
285            ))
286        }
287        Value::StringArray(array) => {
288            let mut serials = Vec::with_capacity(array.data.len());
289            let mut has_time = false;
290            for text in &array.data {
291                let (naive, parsed_has_time) = parse_datetime_text(text).ok_or_else(|| {
292                    datetime_error(format!("datetime: unable to parse date/time text '{text}'"))
293                })?;
294                serials.push(datenum_from_naive(naive));
295                has_time |= parsed_has_time;
296            }
297            Ok((
298                serials,
299                tensor::default_shape_for(&array.shape, array.data.len()),
300                if has_time {
301                    DEFAULT_DATETIME_FORMAT.to_string()
302                } else {
303                    DEFAULT_DATE_FORMAT.to_string()
304                },
305            ))
306        }
307        Value::CharArray(array) => {
308            let mut texts = Vec::with_capacity(array.rows);
309            for row in 0..array.rows {
310                let start = row * array.cols;
311                let end = start + array.cols;
312                texts.push(
313                    array.data[start..end]
314                        .iter()
315                        .collect::<String>()
316                        .trim_end()
317                        .to_string(),
318                );
319            }
320            parse_text_input(Value::StringArray(
321                StringArray::new(texts, vec![array.rows, 1])
322                    .map_err(|err| datetime_error(format!("datetime: {err}")))?,
323            ))
324        }
325        _ => Err(datetime_error(
326            "datetime: text input must be a string scalar, string array, or character array",
327        )),
328    }
329}
330
331fn round_component(value: f64, label: &str, min: i64, max: i64) -> BuiltinResult<i64> {
332    if !value.is_finite() {
333        return Err(datetime_error(format!(
334            "datetime: {label} values must be finite"
335        )));
336    }
337    let rounded = value.round();
338    if (rounded - value).abs() > 1e-9 {
339        return Err(datetime_error(format!(
340            "datetime: {label} values must be integers"
341        )));
342    }
343    let integer = rounded as i64;
344    if integer < min || integer > max {
345        return Err(datetime_error(format!(
346            "datetime: {label} values must be in the range [{min}, {max}]"
347        )));
348    }
349    Ok(integer)
350}
351
352fn naive_from_components(
353    year: f64,
354    month: f64,
355    day: f64,
356    hour: f64,
357    minute: f64,
358    second: f64,
359) -> BuiltinResult<NaiveDateTime> {
360    let year = round_component(year, "year", -262_000, 262_000)? as i32;
361    let month = round_component(month, "month", 1, 12)? as u32;
362    let day = round_component(day, "day", 1, 31)? as u32;
363    let hour = round_component(hour, "hour", 0, 23)? as u32;
364    let minute = round_component(minute, "minute", 0, 59)? as u32;
365    if !second.is_finite() {
366        return Err(datetime_error("datetime: second values must be finite"));
367    }
368    if !(0.0..60.0).contains(&second) {
369        return Err(datetime_error(
370            "datetime: second values must be in the range [0, 60)",
371        ));
372    }
373
374    let base_date = NaiveDate::from_ymd_opt(year, month, day)
375        .ok_or_else(|| datetime_error("datetime: invalid calendar date"))?;
376    let whole_second = second.floor();
377    let mut nanos = ((second - whole_second) * 1_000_000_000.0).round() as u32;
378    let mut secs = whole_second as u32;
379    if nanos == 1_000_000_000 {
380        secs += 1;
381        nanos = 0;
382    }
383    let time = base_date
384        .and_hms_nano_opt(hour, minute, secs, nanos)
385        .ok_or_else(|| datetime_error("datetime: invalid time components"))?;
386    Ok(time)
387}
388
389fn broadcast_component_data(
390    arrays: &[Tensor],
391    labels: &[&str],
392) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
393    let mut target_shape = vec![1, 1];
394    let mut target_len = 1usize;
395
396    for array in arrays {
397        let len = array.data.len();
398        if len > 1 {
399            let shape = tensor::default_shape_for(&array.shape, len);
400            if target_len == 1 {
401                target_len = len;
402                target_shape = shape;
403            } else if len != target_len || shape != target_shape {
404                return Err(datetime_error(
405                    "datetime: non-scalar component inputs must have matching sizes",
406                ));
407            }
408        }
409    }
410
411    let mut broadcasted = Vec::with_capacity(arrays.len());
412    for (idx, array) in arrays.iter().enumerate() {
413        if array.data.len() == 1 {
414            broadcasted.push(vec![array.data[0]; target_len]);
415        } else if array.data.len() == target_len {
416            broadcasted.push(array.data.clone());
417        } else {
418            return Err(datetime_error(format!(
419                "datetime: {} input size does not match the other components",
420                labels[idx]
421            )));
422        }
423    }
424
425    Ok((broadcasted, target_shape))
426}
427
428fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
429    let tensor = tensor_from_numeric(value, context)?;
430    Tensor::new(
431        tensor.data.clone(),
432        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
433    )
434    .map_err(|err| datetime_error(format!("datetime: {err}")))
435}
436
437fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
438    let labels = ["year", "month", "day", "hour", "minute", "second"];
439    let input_count = args.len();
440    let mut arrays = Vec::with_capacity(args.len());
441    for (idx, arg) in args.into_iter().enumerate() {
442        arrays.push(component_tensor(arg, labels[idx])?);
443    }
444    while arrays.len() < 6 {
445        arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
446    }
447
448    let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
449    let len = broadcasted[0].len();
450    let mut serials = Vec::with_capacity(len);
451    for idx in 0..len {
452        let naive = naive_from_components(
453            broadcasted[0][idx],
454            broadcasted[1][idx],
455            broadcasted[2][idx],
456            broadcasted[3][idx],
457            broadcasted[4][idx],
458            broadcasted[5][idx],
459        )?;
460        serials.push(datenum_from_naive(naive));
461    }
462
463    let default_format = if let Some(format) = format {
464        format
465    } else if input_count > 3 {
466        DEFAULT_DATETIME_FORMAT.to_string()
467    } else {
468        DEFAULT_DATE_FORMAT.to_string()
469    };
470    datetime_object_from_serials(serials, shape, default_format)
471}
472
473fn numeric_value_to_datetime(value: Value, format: Option<String>) -> BuiltinResult<Value> {
474    let serials = serial_tensor_from_value(value, "datetime")?;
475    datetime_object_from_serial_tensor(
476        serials,
477        format.unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string()),
478    )
479}
480
481pub fn is_datetime_object(value: &Value) -> bool {
482    matches!(value, Value::Object(obj) if obj.is_class(DATETIME_CLASS))
483}
484
485pub(crate) fn serials_from_datetime_value(value: &Value) -> BuiltinResult<Tensor> {
486    match value {
487        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj),
488        _ => Err(datetime_error("datetime: expected a datetime value")),
489    }
490}
491
492pub(crate) fn datetime_format_from_value(value: &Value) -> String {
493    match value {
494        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => format_for_object(obj),
495        _ => DEFAULT_DATETIME_FORMAT.to_string(),
496    }
497}
498
499pub fn datetime_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
500    let Value::Object(obj) = value else {
501        return Ok(None);
502    };
503    if !obj.is_class(DATETIME_CLASS) {
504        return Ok(None);
505    }
506    let serials = serial_tensor_for_object(obj)?;
507    let format = format_for_object(obj);
508    let mut strings = Vec::with_capacity(serials.data.len());
509    for serial in &serials.data {
510        strings.push(format_serial(*serial, &format)?);
511    }
512    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
513    let array = StringArray::new(strings, shape)
514        .map_err(|err| datetime_error(format!("datetime: {err}")))?;
515    Ok(Some(array))
516}
517
518pub fn datetime_display_text(value: &Value) -> BuiltinResult<Option<String>> {
519    let Some(array) = datetime_string_array(value)? else {
520        return Ok(None);
521    };
522    if array.data.len() == 1 {
523        return Ok(Some(array.data[0].clone()));
524    }
525
526    let rows = array.rows;
527    let cols = array.cols;
528    let mut widths = vec![0usize; cols];
529    for col in 0..cols {
530        for row in 0..rows {
531            let idx = row + col * rows;
532            widths[col] = widths[col].max(array.data[idx].chars().count());
533        }
534    }
535
536    let mut lines = Vec::with_capacity(rows);
537    for row in 0..rows {
538        let mut line = String::new();
539        for col in 0..cols {
540            if col > 0 {
541                line.push_str("  ");
542            }
543            let idx = row + col * rows;
544            let text = &array.data[idx];
545            line.push_str(text);
546            let padding = widths[col].saturating_sub(text.chars().count());
547            if padding > 0 {
548                line.push_str(&" ".repeat(padding));
549            }
550        }
551        lines.push(line);
552    }
553    Ok(Some(lines.join("\n")))
554}
555
556pub fn datetime_summary(value: &Value) -> BuiltinResult<Option<String>> {
557    let Value::Object(obj) = value else {
558        return Ok(None);
559    };
560    if !obj.is_class(DATETIME_CLASS) {
561        return Ok(None);
562    }
563    let serials = serial_tensor_for_object(obj)?;
564    if serials.data.len() == 1 {
565        return datetime_display_text(value);
566    }
567    let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
568    Ok(Some(format!(
569        "[{} datetime]",
570        shape
571            .iter()
572            .map(|dim| dim.to_string())
573            .collect::<Vec<_>>()
574            .join("x")
575    )))
576}
577
578fn component_tensor_from_datetime(
579    value: &Value,
580    label: &str,
581    extractor: impl Fn(&NaiveDateTime) -> f64,
582) -> BuiltinResult<Value> {
583    let serials = serials_from_datetime_value(value)?;
584    let mut out = Vec::with_capacity(serials.data.len());
585    for serial in &serials.data {
586        let naive = naive_from_datenum(*serial)?;
587        out.push(extractor(&naive));
588    }
589    if out.len() == 1 {
590        Ok(Value::Num(out[0]))
591    } else {
592        let shape = tensor::default_shape_for(&serials.shape, serials.data.len());
593        let tensor =
594            Tensor::new(out, shape).map_err(|err| datetime_error(format!("{label}: {err}")))?;
595        Ok(Value::Tensor(tensor))
596    }
597}
598
599fn tensor_or_scalar(data: Vec<f64>, shape: Vec<usize>) -> BuiltinResult<Value> {
600    if data.len() == 1 {
601        Ok(Value::Num(data[0]))
602    } else {
603        Ok(Value::Tensor(Tensor::new(data, shape).map_err(|err| {
604            datetime_error(format!("datetime: {err}"))
605        })?))
606    }
607}
608
609async fn datetime_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
610    let Value::Object(object) = obj else {
611        return Err(datetime_error(
612            "datetime.subsref: receiver must be a datetime object",
613        ));
614    };
615    let format = format_for_object(&object);
616    let serials = serial_tensor_for_object(&object)?;
617
618    let Value::Cell(cell) = payload else {
619        return Err(datetime_error(
620            "datetime.subsref: indexing payload must be a cell array",
621        ));
622    };
623    if cell.data.is_empty() {
624        return datetime_object_from_serial_tensor(serials, format);
625    }
626    if cell.data.len() != 1 {
627        return Err(datetime_error(
628            "datetime.subsref: only linear datetime indexing is currently supported",
629        ));
630    }
631    let selector = (*cell.data[0]).clone();
632    let selector = match selector {
633        Value::Tensor(tensor) => tensor,
634        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
635            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
636        Value::Int(value) => Tensor::new(vec![value.to_f64()], vec![1, 1])
637            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
638        Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
639            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
640        other => {
641            return Err(datetime_error(format!(
642                "datetime.subsref: unsupported index value {other:?}"
643            )))
644        }
645    };
646    let indexed = crate::perform_indexing(&Value::Tensor(serials), &selector.data)
647        .await
648        .map_err(|err| datetime_error(format!("datetime.subsref: {}", err.message())))?;
649    let indexed_serials = match indexed {
650        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
651            .map_err(|err| datetime_error(format!("datetime.subsref: {err}")))?,
652        Value::Tensor(tensor) => tensor,
653        other => {
654            return Err(datetime_error(format!(
655                "datetime.subsref: unexpected indexing result {other:?}"
656            )))
657        }
658    };
659    datetime_object_from_serial_tensor(indexed_serials, format)
660}
661
662#[runmat_macros::runtime_builtin(
663    name = "datetime",
664    builtin_path = "crate::builtins::datetime",
665    category = "datetime",
666    summary = "Create MATLAB-compatible datetime arrays from text, components, or serial date numbers.",
667    keywords = "datetime,date,time,datenum,Format",
668    related = "year,month,day,hour,minute,second,string,char,disp",
669    examples = "t = datetime(2024, 4, 9, 13, 30, 0);"
670)]
671async fn datetime_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
672    ensure_datetime_class_registered();
673    let args = gather_args(&args).await?;
674    let (positional_end, format, convert_from) = parse_trailing_options(&args)?;
675    let positional = args[..positional_end].to_vec();
676
677    if let Some(convert_from) = convert_from {
678        if !convert_from.eq_ignore_ascii_case("datenum") {
679            return Err(datetime_error(format!(
680                "datetime: unsupported ConvertFrom value '{convert_from}'"
681            )));
682        }
683        if positional.len() != 1 {
684            return Err(datetime_error(
685                "datetime: ConvertFrom='datenum' expects exactly one numeric input",
686            ));
687        }
688        return numeric_value_to_datetime(positional[0].clone(), format);
689    }
690
691    match positional.len() {
692        0 => {
693            let now = Local::now().naive_local();
694            datetime_object_from_serials(
695                vec![datenum_from_naive(now)],
696                vec![1, 1],
697                format.unwrap_or_else(|| DEFAULT_DATETIME_FORMAT.to_string()),
698            )
699        }
700        1 => match &positional[0] {
701            Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
702                let (serials, shape, inferred_format) = parse_text_input(positional[0].clone())?;
703                datetime_object_from_serials(serials, shape, format.unwrap_or(inferred_format))
704            }
705            _ => numeric_value_to_datetime(positional[0].clone(), format),
706        },
707        3..=6 => build_from_components(positional, format),
708        _ => Err(datetime_error(
709            "datetime: unsupported argument pattern; use text, serial dates, or Y/M/D component inputs",
710        )),
711    }
712}
713
714#[runmat_macros::runtime_builtin(
715    name = "year",
716    builtin_path = "crate::builtins::datetime",
717    category = "datetime",
718    summary = "Extract year numbers from datetime arrays.",
719    keywords = "year,datetime,date component"
720)]
721async fn year_builtin(value: Value) -> crate::BuiltinResult<Value> {
722    component_tensor_from_datetime(&value, "year", |naive| naive.year() as f64)
723}
724
725#[runmat_macros::runtime_builtin(
726    name = "month",
727    builtin_path = "crate::builtins::datetime",
728    category = "datetime",
729    summary = "Extract month numbers from datetime arrays.",
730    keywords = "month,datetime,date component"
731)]
732async fn month_builtin(value: Value) -> crate::BuiltinResult<Value> {
733    component_tensor_from_datetime(&value, "month", |naive| naive.month() as f64)
734}
735
736#[runmat_macros::runtime_builtin(
737    name = "day",
738    builtin_path = "crate::builtins::datetime",
739    category = "datetime",
740    summary = "Extract day-of-month numbers from datetime arrays.",
741    keywords = "day,datetime,date component"
742)]
743async fn day_builtin(value: Value) -> crate::BuiltinResult<Value> {
744    component_tensor_from_datetime(&value, "day", |naive| naive.day() as f64)
745}
746
747#[runmat_macros::runtime_builtin(
748    name = "hour",
749    builtin_path = "crate::builtins::datetime",
750    category = "datetime",
751    summary = "Extract hour numbers from datetime arrays.",
752    keywords = "hour,datetime,time component"
753)]
754async fn hour_builtin(value: Value) -> crate::BuiltinResult<Value> {
755    component_tensor_from_datetime(&value, "hour", |naive| naive.hour() as f64)
756}
757
758#[runmat_macros::runtime_builtin(
759    name = "minute",
760    builtin_path = "crate::builtins::datetime",
761    category = "datetime",
762    summary = "Extract minute numbers from datetime arrays.",
763    keywords = "minute,datetime,time component"
764)]
765async fn minute_builtin(value: Value) -> crate::BuiltinResult<Value> {
766    component_tensor_from_datetime(&value, "minute", |naive| naive.minute() as f64)
767}
768
769#[runmat_macros::runtime_builtin(
770    name = "second",
771    builtin_path = "crate::builtins::datetime",
772    category = "datetime",
773    summary = "Extract second values from datetime arrays.",
774    keywords = "second,datetime,time component"
775)]
776async fn second_builtin(value: Value) -> crate::BuiltinResult<Value> {
777    component_tensor_from_datetime(&value, "second", |naive| {
778        naive.second() as f64 + f64::from(naive.nanosecond()) / 1_000_000_000.0
779    })
780}
781
782#[runmat_macros::runtime_builtin(
783    name = "datetime.subsref",
784    builtin_path = "crate::builtins::datetime"
785)]
786async fn datetime_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
787    match kind.as_str() {
788        "()" => datetime_indexing(obj, payload).await,
789        "." => {
790            let Value::Object(object) = obj else {
791                return Err(datetime_error(
792                    "datetime.subsref: receiver must be a datetime object",
793                ));
794            };
795            let field = scalar_text(&payload, "field selector")?;
796            match field.as_str() {
797                FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
798                _ => Err(datetime_error(format!(
799                    "datetime.subsref: unsupported datetime property '{field}'"
800                ))),
801            }
802        }
803        other => Err(datetime_error(format!(
804            "datetime.subsref: unsupported indexing kind '{other}'"
805        ))),
806    }
807}
808
809#[runmat_macros::runtime_builtin(
810    name = "datetime.subsasgn",
811    builtin_path = "crate::builtins::datetime"
812)]
813async fn datetime_subsasgn(
814    obj: Value,
815    kind: String,
816    payload: Value,
817    rhs: Value,
818) -> crate::BuiltinResult<Value> {
819    let Value::Object(mut object) = obj else {
820        return Err(datetime_error(
821            "datetime.subsasgn: receiver must be a datetime object",
822        ));
823    };
824    match kind.as_str() {
825        "." => {
826            let field = scalar_text(&payload, "field selector")?;
827            match field.as_str() {
828                FORMAT_FIELD => {
829                    let text = scalar_text(&rhs, "Format value")?;
830                    object
831                        .properties
832                        .insert(FORMAT_FIELD.to_string(), Value::String(text));
833                    Ok(Value::Object(object))
834                }
835                _ => Err(datetime_error(format!(
836                    "datetime.subsasgn: unsupported datetime property '{field}'"
837                ))),
838            }
839        }
840        _ => Err(datetime_error(format!(
841            "datetime.subsasgn: unsupported indexing kind '{kind}'"
842        ))),
843    }
844}
845
846fn datetime_binary_serials(
847    lhs: Value,
848    rhs: Value,
849    context: &str,
850) -> BuiltinResult<(Tensor, Tensor, Vec<usize>, String)> {
851    let lhs_serials = serials_from_datetime_value(&lhs)?;
852    let rhs_serials = match &rhs {
853        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => serial_tensor_for_object(obj)?,
854        _ => serial_tensor_from_value(rhs, context)?,
855    };
856    let (left, right, shape) =
857        tensor::binary_numeric_tensors(&lhs_serials, &rhs_serials, context, BUILTIN_NAME)?;
858    let left_tensor = Tensor::new(left, shape.clone())
859        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
860    let right_tensor = Tensor::new(right, shape.clone())
861        .map_err(|err| datetime_error(format!("{context}: {err}")))?;
862    Ok((
863        left_tensor,
864        right_tensor,
865        shape,
866        datetime_format_from_value(&lhs),
867    ))
868}
869
870fn compare_datetime(
871    lhs: Value,
872    rhs: Value,
873    op: &str,
874    cmp: impl Fn(f64, f64) -> bool,
875) -> BuiltinResult<Value> {
876    let (left, right, shape, _) = datetime_binary_serials(lhs, rhs, op)?;
877    let out = left
878        .data
879        .iter()
880        .zip(right.data.iter())
881        .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
882        .collect::<Vec<_>>();
883    tensor_or_scalar(out, shape)
884}
885
886#[runmat_macros::runtime_builtin(name = "datetime.eq", builtin_path = "crate::builtins::datetime")]
887async fn datetime_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
888    compare_datetime(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
889}
890
891#[runmat_macros::runtime_builtin(name = "datetime.ne", builtin_path = "crate::builtins::datetime")]
892async fn datetime_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
893    compare_datetime(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
894}
895
896#[runmat_macros::runtime_builtin(name = "datetime.lt", builtin_path = "crate::builtins::datetime")]
897async fn datetime_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
898    compare_datetime(lhs, rhs, "lt", |a, b| a < b)
899}
900
901#[runmat_macros::runtime_builtin(name = "datetime.le", builtin_path = "crate::builtins::datetime")]
902async fn datetime_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
903    compare_datetime(lhs, rhs, "le", |a, b| a <= b)
904}
905
906#[runmat_macros::runtime_builtin(name = "datetime.gt", builtin_path = "crate::builtins::datetime")]
907async fn datetime_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
908    compare_datetime(lhs, rhs, "gt", |a, b| a > b)
909}
910
911#[runmat_macros::runtime_builtin(name = "datetime.ge", builtin_path = "crate::builtins::datetime")]
912async fn datetime_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
913    compare_datetime(lhs, rhs, "ge", |a, b| a >= b)
914}
915
916#[runmat_macros::runtime_builtin(
917    name = "datetime.plus",
918    builtin_path = "crate::builtins::datetime"
919)]
920async fn datetime_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
921    let lhs_serials = serials_from_datetime_value(&lhs)?;
922    let rhs_numeric = if crate::builtins::duration::is_duration_object(&rhs) {
923        crate::builtins::duration::duration_tensor_from_duration_value(&rhs)?
924    } else {
925        serial_tensor_from_value(rhs, "plus")?
926    };
927    let (left, right, shape) =
928        tensor::binary_numeric_tensors(&lhs_serials, &rhs_numeric, "plus", BUILTIN_NAME)?;
929    let serials = left
930        .iter()
931        .zip(right.iter())
932        .map(|(a, b)| a + b)
933        .collect::<Vec<_>>();
934    datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
935}
936
937#[runmat_macros::runtime_builtin(
938    name = "datetime.minus",
939    builtin_path = "crate::builtins::datetime"
940)]
941async fn datetime_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
942    let lhs_serials = serials_from_datetime_value(&lhs)?;
943    match &rhs {
944        _ if crate::builtins::duration::is_duration_object(&rhs) => {
945            let rhs_days = crate::builtins::duration::duration_tensor_from_duration_value(&rhs)?;
946            let (left, right, shape) =
947                tensor::binary_numeric_tensors(&lhs_serials, &rhs_days, "minus", BUILTIN_NAME)?;
948            let serials = left
949                .iter()
950                .zip(right.iter())
951                .map(|(a, b)| a - b)
952                .collect::<Vec<_>>();
953            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
954        }
955        Value::Object(obj) if obj.is_class(DATETIME_CLASS) => {
956            let rhs_serials = serial_tensor_for_object(obj)?;
957            let (left, right, shape) =
958                tensor::binary_numeric_tensors(&lhs_serials, &rhs_serials, "minus", BUILTIN_NAME)?;
959            let deltas = left
960                .iter()
961                .zip(right.iter())
962                .map(|(a, b)| a - b)
963                .collect::<Vec<_>>();
964            tensor_or_scalar(deltas, shape)
965        }
966        _ => {
967            let rhs_numeric = serial_tensor_from_value(rhs, "minus")?;
968            let (left, right, shape) =
969                tensor::binary_numeric_tensors(&lhs_serials, &rhs_numeric, "minus", BUILTIN_NAME)?;
970            let serials = left
971                .iter()
972                .zip(right.iter())
973                .map(|(a, b)| a - b)
974                .collect::<Vec<_>>();
975            datetime_object_from_serials(serials, shape, datetime_format_from_value(&lhs))
976        }
977    }
978}
979
980pub fn datetime_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
981    let Some(array) = datetime_string_array(value)? else {
982        return Ok(None);
983    };
984    let width = array
985        .data
986        .iter()
987        .map(|s| s.chars().count())
988        .max()
989        .unwrap_or(0);
990    let rows = array.data.len();
991    let mut data = vec![' '; rows * width];
992    for (row, text) in array.data.iter().enumerate() {
993        for (col, ch) in text.chars().enumerate() {
994            data[row * width + col] = ch;
995        }
996    }
997    let out = CharArray::new(data, rows, width)
998        .map_err(|err| datetime_error(format!("datetime: {err}")))?;
999    Ok(Some(out))
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    fn run_datetime(args: Vec<Value>) -> Value {
1007        futures::executor::block_on(datetime_builtin(args)).expect("datetime")
1008    }
1009
1010    fn as_datetime(value: Value) -> ObjectInstance {
1011        match value {
1012            Value::Object(object) => object,
1013            other => panic!("expected datetime object, got {other:?}"),
1014        }
1015    }
1016
1017    #[test]
1018    fn datetime_builds_from_components() {
1019        let value = run_datetime(vec![Value::Num(2024.0), Value::Num(3.0), Value::Num(14.0)]);
1020        let object = as_datetime(value);
1021        assert_eq!(object.class_name, DATETIME_CLASS);
1022        assert_eq!(format_for_object(&object), DEFAULT_DATE_FORMAT);
1023        let serials = serial_tensor_for_object(&object).expect("serials");
1024        assert_eq!(serials.data.len(), 1);
1025        let year =
1026            futures::executor::block_on(year_builtin(Value::Object(object.clone()))).expect("year");
1027        assert_eq!(year, Value::Num(2024.0));
1028    }
1029
1030    #[test]
1031    fn datetime_builds_arrays_from_component_vectors() {
1032        let years = Value::Tensor(Tensor::new(vec![2024.0, 2025.0], vec![1, 2]).unwrap());
1033        let months = Value::Tensor(Tensor::new(vec![1.0, 6.0], vec![1, 2]).unwrap());
1034        let days = Value::Tensor(Tensor::new(vec![15.0, 20.0], vec![1, 2]).unwrap());
1035        let value = run_datetime(vec![years, months, days]);
1036        let object = as_datetime(value.clone());
1037        let serials = serial_tensor_for_object(&object).expect("serials");
1038        assert_eq!(serials.shape, vec![1, 2]);
1039        let rendered = datetime_display_text(&value)
1040            .expect("display")
1041            .expect("datetime text");
1042        assert!(rendered.contains("15-Jan-2024"));
1043        assert!(rendered.contains("20-Jun-2025"));
1044    }
1045
1046    #[test]
1047    fn datetime_parses_text_and_converts_to_strings() {
1048        let value = run_datetime(vec![Value::String("2024-03-14 09:26:53".to_string())]);
1049        let rendered = datetime_string_array(&value)
1050            .expect("string array")
1051            .expect("datetime strings");
1052        assert_eq!(rendered.data, vec!["14-Mar-2024 09:26:53".to_string()]);
1053    }
1054
1055    #[test]
1056    fn datetime_supports_format_assignment() {
1057        let value = run_datetime(vec![Value::Num(2024.0), Value::Num(3.0), Value::Num(14.0)]);
1058        let updated = futures::executor::block_on(datetime_subsasgn(
1059            value,
1060            ".".to_string(),
1061            Value::String(FORMAT_FIELD.to_string()),
1062            Value::String("yyyy-MM-dd".to_string()),
1063        ))
1064        .expect("subsasgn");
1065        let rendered = datetime_display_text(&updated)
1066            .expect("display")
1067            .expect("datetime text");
1068        assert_eq!(rendered, "2024-03-14");
1069    }
1070
1071    #[test]
1072    fn datetime_supports_indexing_and_comparison() {
1073        let years = Value::Tensor(Tensor::new(vec![2024.0, 2025.0], vec![1, 2]).unwrap());
1074        let months = Value::Tensor(Tensor::new(vec![1.0, 6.0], vec![1, 2]).unwrap());
1075        let days = Value::Tensor(Tensor::new(vec![15.0, 20.0], vec![1, 2]).unwrap());
1076        let value = run_datetime(vec![years, months, days]);
1077        let payload =
1078            Value::Cell(runmat_builtins::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
1079        let indexed =
1080            futures::executor::block_on(datetime_subsref(value.clone(), "()".to_string(), payload))
1081                .expect("subsref");
1082        let year = futures::executor::block_on(year_builtin(indexed)).expect("year");
1083        assert_eq!(year, Value::Num(2025.0));
1084
1085        let lhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(1.0)]);
1086        let rhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(2.0)]);
1087        let cmp = futures::executor::block_on(datetime_lt(lhs, rhs)).expect("lt");
1088        assert_eq!(cmp, Value::Num(1.0));
1089    }
1090
1091    #[test]
1092    fn datetime_and_duration_interoperate() {
1093        let lhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(1.0)]);
1094        let rhs = run_datetime(vec![Value::Num(2024.0), Value::Num(1.0), Value::Num(2.0)]);
1095        let delta = futures::executor::block_on(datetime_minus(rhs.clone(), lhs.clone()))
1096            .expect("datetime minus datetime");
1097        assert_eq!(delta, Value::Num(1.0));
1098
1099        let duration = crate::builtins::duration::duration_object_from_days_tensor(
1100            Tensor::new(vec![1.0], vec![1, 1]).unwrap(),
1101            crate::builtins::duration::DEFAULT_DURATION_FORMAT,
1102        )
1103        .expect("duration");
1104
1105        let round_trip = futures::executor::block_on(datetime_plus(lhs.clone(), duration.clone()))
1106            .expect("plus");
1107        let round_trip_text = datetime_display_text(&round_trip)
1108            .expect("datetime display")
1109            .expect("datetime text");
1110        assert_eq!(round_trip_text, "02-Jan-2024");
1111
1112        let restored =
1113            futures::executor::block_on(datetime_minus(rhs, duration)).expect("minus duration");
1114        let restored_text = datetime_display_text(&restored)
1115            .expect("datetime display")
1116            .expect("datetime text");
1117        assert_eq!(restored_text, "01-Jan-2024");
1118    }
1119}