Skip to main content

runmat_runtime/builtins/duration/
mod.rs

1use std::cell::Cell;
2use std::collections::HashMap;
3
4use runmat_builtins::{
5    Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7    CharArray, ClassDef, MethodDef, ObjectInstance, PropertyDef, StringArray, Tensor, Value,
8};
9
10use crate::builtins::common::tensor;
11use crate::{
12    build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError, OBJECT_INDEX_MEMBER,
13    OBJECT_INDEX_PAREN, OBJECT_SUBSASGN_METHOD, OBJECT_SUBSREF_METHOD,
14};
15
16const BUILTIN_NAME: &str = "duration";
17const DURATION_CLASS: &str = "duration";
18const DAYS_FIELD: &str = "__days";
19const FORMAT_FIELD: &str = "Format";
20pub(crate) const DEFAULT_DURATION_FORMAT: &str = "hh:mm:ss";
21const SECONDS_PER_DAY: f64 = 86_400.0;
22
23thread_local! {
24    static DURATION_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
25}
26
27const DURATION_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
28    code: "RM.DURATION.INVALID_ARGUMENT",
29    identifier: Some("RunMat:duration:InvalidArgument"),
30    when: "Arguments or option grammar do not match supported duration forms.",
31    message: "duration: invalid argument",
32};
33const DURATION_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
34    code: "RM.DURATION.INVALID_INPUT",
35    identifier: Some("RunMat:duration:InvalidInput"),
36    when: "Input values cannot be converted/broadcast/formatted to a valid duration result.",
37    message: "duration: invalid input",
38};
39const DURATION_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
40    code: "RM.DURATION.INTERNAL",
41    identifier: Some("RunMat:duration:Internal"),
42    when: "Internal duration state or indexing/evaluation failed unexpectedly.",
43    message: "duration: internal operation failed",
44};
45const DURATION_ERRORS: [BuiltinErrorDescriptor; 3] = [
46    DURATION_ERROR_INVALID_ARGUMENT,
47    DURATION_ERROR_INVALID_INPUT,
48    DURATION_ERROR_INTERNAL,
49];
50
51const OUT_DURATION: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
52    name: "t",
53    ty: BuiltinParamType::Any,
54    arity: BuiltinParamArity::Required,
55    default: None,
56    description: "Duration object result.",
57}];
58const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
59    name: "out",
60    ty: BuiltinParamType::Any,
61    arity: BuiltinParamArity::Required,
62    default: None,
63    description: "Method result.",
64}];
65const DURATION_ARGS_ONLY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
66    name: "args",
67    ty: BuiltinParamType::Any,
68    arity: BuiltinParamArity::Variadic,
69    default: None,
70    description: "Duration constructor arguments.",
71}];
72const DURATION_BINARY_INPUTS: [BuiltinParamDescriptor; 2] = [
73    BuiltinParamDescriptor {
74        name: "lhs",
75        ty: BuiltinParamType::Any,
76        arity: BuiltinParamArity::Required,
77        default: None,
78        description: "Left duration operand.",
79    },
80    BuiltinParamDescriptor {
81        name: "rhs",
82        ty: BuiltinParamType::Any,
83        arity: BuiltinParamArity::Required,
84        default: None,
85        description: "Right duration/datetime operand.",
86    },
87];
88const DURATION_SUBSREF_INPUTS: [BuiltinParamDescriptor; 3] = [
89    BuiltinParamDescriptor {
90        name: "obj",
91        ty: BuiltinParamType::Any,
92        arity: BuiltinParamArity::Required,
93        default: None,
94        description: "Duration receiver object.",
95    },
96    BuiltinParamDescriptor {
97        name: "kind",
98        ty: BuiltinParamType::StringScalar,
99        arity: BuiltinParamArity::Required,
100        default: None,
101        description: "Indexing kind token.",
102    },
103    BuiltinParamDescriptor {
104        name: "payload",
105        ty: BuiltinParamType::Any,
106        arity: BuiltinParamArity::Required,
107        default: None,
108        description: "Index/member payload.",
109    },
110];
111const DURATION_SUBSASGN_INPUTS: [BuiltinParamDescriptor; 4] = [
112    BuiltinParamDescriptor {
113        name: "obj",
114        ty: BuiltinParamType::Any,
115        arity: BuiltinParamArity::Required,
116        default: None,
117        description: "Duration receiver object.",
118    },
119    BuiltinParamDescriptor {
120        name: "kind",
121        ty: BuiltinParamType::StringScalar,
122        arity: BuiltinParamArity::Required,
123        default: None,
124        description: "Indexing kind token.",
125    },
126    BuiltinParamDescriptor {
127        name: "payload",
128        ty: BuiltinParamType::Any,
129        arity: BuiltinParamArity::Required,
130        default: None,
131        description: "Index/member payload.",
132    },
133    BuiltinParamDescriptor {
134        name: "rhs",
135        ty: BuiltinParamType::Any,
136        arity: BuiltinParamArity::Required,
137        default: None,
138        description: "Assigned value.",
139    },
140];
141
142const DURATION_SIGNATURES: [BuiltinSignatureDescriptor; 5] = [
143    BuiltinSignatureDescriptor {
144        label: "t = duration(hours)",
145        inputs: &[BuiltinParamDescriptor {
146            name: "hours",
147            ty: BuiltinParamType::NumericArray,
148            arity: BuiltinParamArity::Required,
149            default: None,
150            description: "Hour component.",
151        }],
152        outputs: &OUT_DURATION,
153    },
154    BuiltinSignatureDescriptor {
155        label: "t = duration(hours, minutes)",
156        inputs: &[
157            BuiltinParamDescriptor {
158                name: "hours",
159                ty: BuiltinParamType::NumericArray,
160                arity: BuiltinParamArity::Required,
161                default: None,
162                description: "Hour component.",
163            },
164            BuiltinParamDescriptor {
165                name: "minutes",
166                ty: BuiltinParamType::NumericArray,
167                arity: BuiltinParamArity::Required,
168                default: None,
169                description: "Minute component.",
170            },
171        ],
172        outputs: &OUT_DURATION,
173    },
174    BuiltinSignatureDescriptor {
175        label: "t = duration(hours, minutes, seconds)",
176        inputs: &[
177            BuiltinParamDescriptor {
178                name: "hours",
179                ty: BuiltinParamType::NumericArray,
180                arity: BuiltinParamArity::Required,
181                default: None,
182                description: "Hour component.",
183            },
184            BuiltinParamDescriptor {
185                name: "minutes",
186                ty: BuiltinParamType::NumericArray,
187                arity: BuiltinParamArity::Required,
188                default: None,
189                description: "Minute component.",
190            },
191            BuiltinParamDescriptor {
192                name: "seconds",
193                ty: BuiltinParamType::NumericArray,
194                arity: BuiltinParamArity::Required,
195                default: None,
196                description: "Second component.",
197            },
198        ],
199        outputs: &OUT_DURATION,
200    },
201    BuiltinSignatureDescriptor {
202        label: "t = duration(___, \"Format\", format)",
203        inputs: &DURATION_ARGS_ONLY,
204        outputs: &OUT_DURATION,
205    },
206    BuiltinSignatureDescriptor {
207        label: "t = duration(___, Name, Value, ...)",
208        inputs: &DURATION_ARGS_ONLY,
209        outputs: &OUT_DURATION,
210    },
211];
212const DURATION_SUBSREF_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
213    label: "out = duration.subsref(obj, kind, payload)",
214    inputs: &DURATION_SUBSREF_INPUTS,
215    outputs: &OUT_ANY,
216}];
217const DURATION_SUBSASGN_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
218    [BuiltinSignatureDescriptor {
219        label: "out = duration.subsasgn(obj, kind, payload, rhs)",
220        inputs: &DURATION_SUBSASGN_INPUTS,
221        outputs: &OUT_ANY,
222    }];
223const DURATION_BINARY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
224    label: "out = duration.op(lhs, rhs)",
225    inputs: &DURATION_BINARY_INPUTS,
226    outputs: &OUT_ANY,
227}];
228
229pub const DURATION_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
230    signatures: &DURATION_SIGNATURES,
231    output_mode: BuiltinOutputMode::Fixed,
232    completion_policy: BuiltinCompletionPolicy::Public,
233    errors: &DURATION_ERRORS,
234};
235pub const DURATION_SUBSREF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
236    signatures: &DURATION_SUBSREF_SIGNATURES,
237    output_mode: BuiltinOutputMode::Fixed,
238    completion_policy: BuiltinCompletionPolicy::MethodOnly,
239    errors: &DURATION_ERRORS,
240};
241pub const DURATION_SUBSASGN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
242    signatures: &DURATION_SUBSASGN_SIGNATURES,
243    output_mode: BuiltinOutputMode::Fixed,
244    completion_policy: BuiltinCompletionPolicy::MethodOnly,
245    errors: &DURATION_ERRORS,
246};
247pub const DURATION_BINARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
248    signatures: &DURATION_BINARY_SIGNATURES,
249    output_mode: BuiltinOutputMode::Fixed,
250    completion_policy: BuiltinCompletionPolicy::MethodOnly,
251    errors: &DURATION_ERRORS,
252};
253
254fn duration_error(message: impl Into<String>) -> RuntimeError {
255    build_runtime_error(message)
256        .with_builtin(BUILTIN_NAME)
257        .build()
258}
259
260fn ensure_duration_class_registered() {
261    DURATION_CLASS_REGISTERED.with(|registered| {
262        if registered.get() {
263            return;
264        }
265        let mut properties = HashMap::new();
266        properties.insert(
267            FORMAT_FIELD.to_string(),
268            PropertyDef {
269                name: FORMAT_FIELD.to_string(),
270                is_static: false,
271                is_constant: false,
272                is_dependent: false,
273                get_access: Access::Public,
274                set_access: Access::Public,
275                default_value: Some(Value::String(DEFAULT_DURATION_FORMAT.to_string())),
276            },
277        );
278
279        let mut methods = HashMap::new();
280        for name in [
281            OBJECT_SUBSREF_METHOD,
282            OBJECT_SUBSASGN_METHOD,
283            "plus",
284            "minus",
285            "eq",
286            "ne",
287            "lt",
288            "le",
289            "gt",
290            "ge",
291        ] {
292            methods.insert(
293                name.to_string(),
294                MethodDef {
295                    name: name.to_string(),
296                    is_static: false,
297                    is_abstract: false,
298                    is_sealed: false,
299                    access: Access::Public,
300                    function_name: format!("{DURATION_CLASS}.{name}"),
301                    implicit_class_argument: None,
302                },
303            );
304        }
305
306        runmat_builtins::register_class(ClassDef {
307            name: DURATION_CLASS.to_string(),
308            parent: None,
309            properties,
310            methods,
311        });
312        registered.set(true);
313    });
314}
315
316pub fn is_duration_object(value: &Value) -> bool {
317    matches!(value, Value::Object(obj) if obj.is_class(DURATION_CLASS))
318}
319
320async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
321    let mut out = Vec::with_capacity(args.len());
322    for arg in args {
323        out.push(
324            gather_if_needed_async(arg)
325                .await
326                .map_err(|err| duration_error(format!("duration: {}", err.message())))?,
327        );
328    }
329    Ok(out)
330}
331
332fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
333    match value {
334        Value::String(text) => Ok(text.clone()),
335        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
336        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
337        _ => Err(duration_error(format!(
338            "duration: {context} must be a string scalar or character vector"
339        ))),
340    }
341}
342
343fn parse_trailing_format(args: &[Value]) -> BuiltinResult<(usize, Option<String>)> {
344    let mut positional_end = args.len();
345    let mut format = None;
346
347    while positional_end >= 2 {
348        let name = match scalar_text(&args[positional_end - 2], "option name") {
349            Ok(text) => text,
350            Err(_) => break,
351        };
352        if !name.trim().eq_ignore_ascii_case("format") {
353            break;
354        }
355        format = Some(scalar_text(&args[positional_end - 1], "Format option")?);
356        positional_end -= 2;
357    }
358
359    Ok((positional_end, format))
360}
361
362fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
363    tensor::value_into_tensor_for(context, value)
364        .map_err(|message| duration_error(format!("duration: {message}")))
365}
366
367fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
368    let tensor = tensor_from_numeric(value, context)?;
369    Tensor::new(
370        tensor.data.clone(),
371        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
372    )
373    .map_err(|err| duration_error(format!("duration: {err}")))
374}
375
376fn format_for_object(obj: &ObjectInstance) -> String {
377    match obj.properties.get(FORMAT_FIELD) {
378        Some(Value::String(text)) => text.clone(),
379        Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
380        Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
381        _ => DEFAULT_DURATION_FORMAT.to_string(),
382    }
383}
384
385pub(crate) fn duration_tensor_from_duration_value(value: &Value) -> BuiltinResult<Tensor> {
386    match value {
387        Value::Object(obj) if obj.is_class(DURATION_CLASS) => {
388            match obj.properties.get(DAYS_FIELD) {
389                Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
390                Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
391                    .map_err(|err| duration_error(format!("duration: {err}"))),
392                Some(other) => Err(duration_error(format!(
393                    "duration: invalid internal day storage {other:?}"
394                ))),
395                None => Err(duration_error("duration: missing internal day storage")),
396            }
397        }
398        _ => Err(duration_error("duration: expected a duration value")),
399    }
400}
401
402pub(crate) fn duration_format_from_value(value: &Value) -> String {
403    match value {
404        Value::Object(obj) if obj.is_class(DURATION_CLASS) => format_for_object(obj),
405        _ => DEFAULT_DURATION_FORMAT.to_string(),
406    }
407}
408
409pub(crate) fn duration_object_from_days_tensor(
410    days: Tensor,
411    format: impl Into<String>,
412) -> BuiltinResult<Value> {
413    ensure_duration_class_registered();
414    let mut object = ObjectInstance::new(DURATION_CLASS.to_string());
415    object
416        .properties
417        .insert(DAYS_FIELD.to_string(), Value::Tensor(days));
418    object
419        .properties
420        .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
421    Ok(Value::Object(object))
422}
423
424fn duration_object_from_days(
425    days: Vec<f64>,
426    shape: Vec<usize>,
427    format: impl Into<String>,
428) -> BuiltinResult<Value> {
429    let tensor =
430        Tensor::new(days, shape).map_err(|err| duration_error(format!("duration: {err}")))?;
431    duration_object_from_days_tensor(tensor, format)
432}
433
434async fn duration_unit_value(
435    value: Value,
436    unit_name: &str,
437    days_per_unit: f64,
438) -> BuiltinResult<Value> {
439    let value = gather_if_needed_async(&value)
440        .await
441        .map_err(|err| duration_error(format!("{unit_name}: {}", err.message())))?;
442    if is_duration_object(&value) {
443        let days = duration_tensor_from_duration_value(&value)?;
444        let data = days
445            .data
446            .iter()
447            .map(|day| day / days_per_unit)
448            .collect::<Vec<_>>();
449        return if data.len() == 1 {
450            Ok(Value::Num(data[0]))
451        } else {
452            Ok(Value::Tensor(
453                Tensor::new(
454                    data,
455                    tensor::default_shape_for(&days.shape, days.data.len()),
456                )
457                .map_err(|err| duration_error(format!("{unit_name}: {err}")))?,
458            ))
459        };
460    }
461    let numeric = component_tensor(value, unit_name)?;
462    let days = numeric
463        .data
464        .iter()
465        .map(|value| {
466            if !value.is_finite() {
467                Err(duration_error(format!(
468                    "{unit_name}: values must be finite"
469                )))
470            } else {
471                let days = value * days_per_unit;
472                if days.is_finite() {
473                    Ok(days)
474                } else {
475                    Err(duration_error(format!(
476                        "{unit_name}: resulting duration is outside supported range"
477                    )))
478                }
479            }
480        })
481        .collect::<BuiltinResult<Vec<_>>>()?;
482    duration_object_from_days(
483        days,
484        tensor::default_shape_for(&numeric.shape, numeric.data.len()),
485        DEFAULT_DURATION_FORMAT,
486    )
487}
488
489fn broadcast_component_data(
490    arrays: &[Tensor],
491    labels: &[&str],
492) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
493    let mut target_shape = vec![1, 1];
494    let mut target_len = 1usize;
495
496    for array in arrays {
497        let len = array.data.len();
498        if len > 1 {
499            let shape = tensor::default_shape_for(&array.shape, len);
500            if target_len == 1 {
501                target_len = len;
502                target_shape = shape;
503            } else if len != target_len || shape != target_shape {
504                return Err(duration_error(
505                    "duration: non-scalar component inputs must have matching sizes",
506                ));
507            }
508        }
509    }
510
511    let mut broadcasted = Vec::with_capacity(arrays.len());
512    for (idx, array) in arrays.iter().enumerate() {
513        if array.data.len() == 1 {
514            broadcasted.push(vec![array.data[0]; target_len]);
515        } else if array.data.len() == target_len {
516            broadcasted.push(array.data.clone());
517        } else {
518            return Err(duration_error(format!(
519                "duration: {} input size does not match the other components",
520                labels[idx]
521            )));
522        }
523    }
524
525    Ok((broadcasted, target_shape))
526}
527
528fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
529    let labels = ["hours", "minutes", "seconds"];
530    let mut arrays = Vec::with_capacity(args.len());
531    for (idx, arg) in args.into_iter().enumerate() {
532        arrays.push(component_tensor(arg, labels[idx])?);
533    }
534    while arrays.len() < 3 {
535        arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
536    }
537
538    let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
539    let len = broadcasted[0].len();
540    let mut days = Vec::with_capacity(len);
541    for idx in 0..len {
542        let total_seconds =
543            broadcasted[0][idx] * 3600.0 + broadcasted[1][idx] * 60.0 + broadcasted[2][idx];
544        if !total_seconds.is_finite() {
545            return Err(duration_error("duration: component values must be finite"));
546        }
547        days.push(total_seconds / SECONDS_PER_DAY);
548    }
549
550    duration_object_from_days(
551        days,
552        shape,
553        format.unwrap_or_else(|| DEFAULT_DURATION_FORMAT.to_string()),
554    )
555}
556
557fn format_seconds_field(seconds: f64) -> String {
558    let whole = seconds.floor();
559    let fractional = seconds - whole;
560    if fractional.abs() <= 1e-9 {
561        format!("{:02}", whole as i64)
562    } else {
563        let mut text = format!("{:06.3}", seconds);
564        while text.contains('.') && text.ends_with('0') {
565            text.pop();
566        }
567        if text.ends_with('.') {
568            text.pop();
569        }
570        text
571    }
572}
573
574fn format_duration_value(days: f64, format: &str) -> BuiltinResult<String> {
575    if days.is_nan() {
576        return Ok("NaN".to_string());
577    }
578    if !days.is_finite() {
579        return Err(duration_error("duration: values must be finite"));
580    }
581
582    let total_seconds = days * SECONDS_PER_DAY;
583    let sign = if total_seconds < 0.0 { "-" } else { "" };
584    let total_seconds = total_seconds.abs();
585    let total_hours = (total_seconds / 3600.0).floor();
586    let total_minutes = (total_seconds / 60.0).floor();
587    let hours = total_hours as i64;
588    let minutes_component = ((total_seconds / 60.0).floor() as i64) % 60;
589    let seconds_component =
590        total_seconds - (hours as f64 * 3600.0) - (minutes_component as f64 * 60.0);
591
592    let rendered = match format {
593        "hh:mm:ss" => format!(
594            "{sign}{hours:02}:{minutes_component:02}:{}",
595            format_seconds_field(seconds_component)
596        ),
597        "hh:mm" => format!("{sign}{hours:02}:{minutes_component:02}"),
598        "mm:ss" => format!(
599            "{sign}{:02}:{}",
600            total_minutes as i64,
601            format_seconds_field(total_seconds - total_minutes * 60.0)
602        ),
603        "s" | "ss" => {
604            let mut text = format!("{:.3}", total_seconds);
605            while text.contains('.') && text.ends_with('0') {
606                text.pop();
607            }
608            if text.ends_with('.') {
609                text.pop();
610            }
611            format!("{sign}{text}")
612        }
613        other => {
614            return Err(duration_error(format!(
615                "duration: unsupported Format value '{other}'"
616            )))
617        }
618    };
619
620    Ok(rendered)
621}
622
623pub fn duration_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
624    let Value::Object(obj) = value else {
625        return Ok(None);
626    };
627    if !obj.is_class(DURATION_CLASS) {
628        return Ok(None);
629    }
630    let days = duration_tensor_from_duration_value(value)?;
631    let format = format_for_object(obj);
632    let mut strings = Vec::with_capacity(days.data.len());
633    for value in &days.data {
634        strings.push(format_duration_value(*value, &format)?);
635    }
636    let shape = tensor::default_shape_for(&days.shape, days.data.len());
637    let array = StringArray::new(strings, shape)
638        .map_err(|err| duration_error(format!("duration: {err}")))?;
639    Ok(Some(array))
640}
641
642pub fn duration_display_text(value: &Value) -> BuiltinResult<Option<String>> {
643    let Some(array) = duration_string_array(value)? else {
644        return Ok(None);
645    };
646    if array.data.len() == 1 {
647        return Ok(Some(array.data[0].clone()));
648    }
649
650    let rows = array.rows;
651    let cols = array.cols;
652    let mut widths = vec![0usize; cols];
653    for col in 0..cols {
654        for row in 0..rows {
655            let idx = row + col * rows;
656            widths[col] = widths[col].max(array.data[idx].len());
657        }
658    }
659
660    let mut lines = Vec::with_capacity(rows);
661    for row in 0..rows {
662        let mut line = String::new();
663        for col in 0..cols {
664            if col > 0 {
665                line.push_str("  ");
666            }
667            let idx = row + col * rows;
668            let text = &array.data[idx];
669            line.push_str(text);
670            let padding = widths[col].saturating_sub(text.len());
671            if padding > 0 {
672                line.push_str(&" ".repeat(padding));
673            }
674        }
675        lines.push(line);
676    }
677
678    Ok(Some(lines.join("\n")))
679}
680
681pub fn duration_summary(value: &Value) -> BuiltinResult<Option<String>> {
682    let Value::Object(obj) = value else {
683        return Ok(None);
684    };
685    if !obj.is_class(DURATION_CLASS) {
686        return Ok(None);
687    }
688    let days = duration_tensor_from_duration_value(value)?;
689    if days.data.len() == 1 {
690        return duration_display_text(value);
691    }
692    let shape = tensor::default_shape_for(&days.shape, days.data.len());
693    Ok(Some(format!(
694        "[{} duration]",
695        shape
696            .iter()
697            .map(|dim| dim.to_string())
698            .collect::<Vec<_>>()
699            .join("x")
700    )))
701}
702
703pub fn duration_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
704    let Some(array) = duration_string_array(value)? else {
705        return Ok(None);
706    };
707    let width = array.data.iter().map(String::len).max().unwrap_or(0);
708    let rows = array.data.len();
709    let mut data = vec![' '; rows * width];
710    for (row, text) in array.data.iter().enumerate() {
711        for (col, ch) in text.chars().enumerate() {
712            data[row * width + col] = ch;
713        }
714    }
715    let out = CharArray::new(data, rows, width)
716        .map_err(|err| duration_error(format!("duration: {err}")))?;
717    Ok(Some(out))
718}
719
720fn compare_duration(
721    lhs: Value,
722    rhs: Value,
723    op: &str,
724    cmp: impl Fn(f64, f64) -> bool,
725) -> BuiltinResult<Value> {
726    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
727    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
728    let (left, right, shape) =
729        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, op, BUILTIN_NAME)?;
730    let out = left
731        .iter()
732        .zip(right.iter())
733        .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
734        .collect::<Vec<_>>();
735    if out.len() == 1 {
736        Ok(Value::Num(out[0]))
737    } else {
738        Ok(Value::Tensor(Tensor::new(out, shape).map_err(|err| {
739            duration_error(format!("duration: {err}"))
740        })?))
741    }
742}
743
744async fn duration_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
745    let Value::Object(object) = obj else {
746        return Err(duration_error(
747            "duration.subsref: receiver must be a duration object",
748        ));
749    };
750    let format = format_for_object(&object);
751    let days = duration_tensor_from_duration_value(&Value::Object(object.clone()))?;
752
753    let Value::Cell(cell) = payload else {
754        return Err(duration_error(
755            "duration.subsref: indexing payload must be a cell array",
756        ));
757    };
758    if cell.data.is_empty() {
759        return duration_object_from_days_tensor(days, format);
760    }
761    if cell.data.len() != 1 {
762        return Err(duration_error(
763            "duration.subsref: only linear duration indexing is currently supported",
764        ));
765    }
766    let selector = cell.data[0].clone();
767    let selector = match selector {
768        Value::Tensor(tensor) => tensor,
769        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
770            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
771        Value::Int(value) => Tensor::new(vec![value.to_f64()], vec![1, 1])
772            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
773        Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
774            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
775        other => {
776            return Err(duration_error(format!(
777                "duration.subsref: unsupported index value {other:?}"
778            )))
779        }
780    };
781    let indexed = crate::perform_indexing(&Value::Tensor(days), &selector.data)
782        .await
783        .map_err(|err| duration_error(format!("duration.subsref: {}", err.message())))?;
784    let indexed_days = match indexed {
785        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
786            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
787        Value::Tensor(tensor) => tensor,
788        other => {
789            return Err(duration_error(format!(
790                "duration.subsref: unexpected indexing result {other:?}"
791            )))
792        }
793    };
794    duration_object_from_days_tensor(indexed_days, format)
795}
796
797#[runmat_macros::runtime_builtin(
798    name = "duration",
799    descriptor(crate::builtins::duration::DURATION_DESCRIPTOR),
800    builtin_path = "crate::builtins::duration",
801    category = "datetime",
802    summary = "Create duration arrays from hour, minute, and second components.",
803    keywords = "duration,time span,elapsed time,Format",
804    related = "datetime,string,char,disp",
805    examples = "t = duration(1, 30, 45);"
806)]
807async fn duration_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
808    ensure_duration_class_registered();
809    let args = gather_args(&args).await?;
810    let (positional_end, format) = parse_trailing_format(&args)?;
811    let positional = args[..positional_end].to_vec();
812
813    match positional.len() {
814        1..=3 => build_from_components(positional, format),
815        _ => Err(duration_error(
816            "duration: unsupported argument pattern; use H/M/S numeric component inputs",
817        )),
818    }
819}
820
821#[runmat_macros::runtime_builtin(
822    name = "days",
823    builtin_path = "crate::builtins::duration",
824    category = "datetime",
825    summary = "Create duration values from days or convert duration values to day counts.",
826    keywords = "days,duration,datetime"
827)]
828async fn days_builtin(value: Value) -> crate::BuiltinResult<Value> {
829    duration_unit_value(value, "days", 1.0).await
830}
831
832#[runmat_macros::runtime_builtin(
833    name = "hours",
834    builtin_path = "crate::builtins::duration",
835    category = "datetime",
836    summary = "Create duration values from hours or convert duration values to hour counts.",
837    keywords = "hours,duration,datetime"
838)]
839async fn hours_builtin(value: Value) -> crate::BuiltinResult<Value> {
840    duration_unit_value(value, "hours", 1.0 / 24.0).await
841}
842
843#[runmat_macros::runtime_builtin(
844    name = "minutes",
845    builtin_path = "crate::builtins::duration",
846    category = "datetime",
847    summary = "Create duration values from minutes or convert duration values to minute counts.",
848    keywords = "minutes,duration,datetime"
849)]
850async fn minutes_builtin(value: Value) -> crate::BuiltinResult<Value> {
851    duration_unit_value(value, "minutes", 1.0 / (24.0 * 60.0)).await
852}
853
854#[runmat_macros::runtime_builtin(
855    name = "seconds",
856    builtin_path = "crate::builtins::duration",
857    category = "datetime",
858    summary = "Create duration values from seconds or convert duration values to second counts.",
859    keywords = "seconds,duration,datetime"
860)]
861async fn seconds_builtin(value: Value) -> crate::BuiltinResult<Value> {
862    duration_unit_value(value, "seconds", 1.0 / SECONDS_PER_DAY).await
863}
864
865#[runmat_macros::runtime_builtin(
866    name = "milliseconds",
867    builtin_path = "crate::builtins::duration",
868    category = "datetime",
869    summary = "Create duration values from milliseconds or convert duration values to millisecond counts.",
870    keywords = "milliseconds,duration,datetime"
871)]
872async fn milliseconds_builtin(value: Value) -> crate::BuiltinResult<Value> {
873    duration_unit_value(value, "milliseconds", 1.0 / (SECONDS_PER_DAY * 1000.0)).await
874}
875
876#[runmat_macros::runtime_builtin(
877    name = "years",
878    builtin_path = "crate::builtins::duration",
879    category = "datetime",
880    summary = "Create fixed-length duration values from years or convert durations to fixed-length years.",
881    keywords = "years,duration,datetime"
882)]
883async fn years_builtin(value: Value) -> crate::BuiltinResult<Value> {
884    duration_unit_value(value, "years", 365.2425).await
885}
886
887#[runmat_macros::runtime_builtin(
888    name = "isduration",
889    builtin_path = "crate::builtins::duration",
890    category = "datetime",
891    summary = "Return true for duration values.",
892    keywords = "isduration,duration,predicate"
893)]
894fn isduration_builtin(value: Value) -> crate::BuiltinResult<Value> {
895    Ok(Value::Bool(is_duration_object(&value)))
896}
897
898#[runmat_macros::runtime_builtin(
899    name = "duration.subsref",
900    descriptor(crate::builtins::duration::DURATION_SUBSREF_DESCRIPTOR),
901    builtin_path = "crate::builtins::duration"
902)]
903async fn duration_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
904    match kind.as_str() {
905        OBJECT_INDEX_PAREN => duration_indexing(obj, payload).await,
906        OBJECT_INDEX_MEMBER => {
907            let Value::Object(object) = obj else {
908                return Err(duration_error(
909                    "duration.subsref: receiver must be a duration object",
910                ));
911            };
912            let field = scalar_text(&payload, "field selector")?;
913            match field.as_str() {
914                FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
915                _ => Err(duration_error(format!(
916                    "duration.subsref: unsupported duration property '{field}'"
917                ))),
918            }
919        }
920        other => Err(duration_error(format!(
921            "duration.subsref: unsupported indexing kind '{other}'"
922        ))),
923    }
924}
925
926#[runmat_macros::runtime_builtin(
927    name = "duration.subsasgn",
928    descriptor(crate::builtins::duration::DURATION_SUBSASGN_DESCRIPTOR),
929    builtin_path = "crate::builtins::duration"
930)]
931async fn duration_subsasgn(
932    obj: Value,
933    kind: String,
934    payload: Value,
935    rhs: Value,
936) -> crate::BuiltinResult<Value> {
937    let Value::Object(mut object) = obj else {
938        return Err(duration_error(
939            "duration.subsasgn: receiver must be a duration object",
940        ));
941    };
942    match kind.as_str() {
943        OBJECT_INDEX_MEMBER => {
944            let field = scalar_text(&payload, "field selector")?;
945            match field.as_str() {
946                FORMAT_FIELD => {
947                    let text = scalar_text(&rhs, "Format value")?;
948                    object
949                        .properties
950                        .insert(FORMAT_FIELD.to_string(), Value::String(text));
951                    Ok(Value::Object(object))
952                }
953                _ => Err(duration_error(format!(
954                    "duration.subsasgn: unsupported duration property '{field}'"
955                ))),
956            }
957        }
958        _ => Err(duration_error(format!(
959            "duration.subsasgn: unsupported indexing kind '{kind}'"
960        ))),
961    }
962}
963
964#[runmat_macros::runtime_builtin(
965    name = "duration.eq",
966    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
967    builtin_path = "crate::builtins::duration"
968)]
969async fn duration_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
970    compare_duration(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
971}
972
973#[runmat_macros::runtime_builtin(
974    name = "duration.ne",
975    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
976    builtin_path = "crate::builtins::duration"
977)]
978async fn duration_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
979    compare_duration(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
980}
981
982#[runmat_macros::runtime_builtin(
983    name = "duration.lt",
984    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
985    builtin_path = "crate::builtins::duration"
986)]
987async fn duration_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
988    compare_duration(lhs, rhs, "lt", |a, b| a < b)
989}
990
991#[runmat_macros::runtime_builtin(
992    name = "duration.le",
993    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
994    builtin_path = "crate::builtins::duration"
995)]
996async fn duration_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
997    compare_duration(lhs, rhs, "le", |a, b| a <= b)
998}
999
1000#[runmat_macros::runtime_builtin(
1001    name = "duration.gt",
1002    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1003    builtin_path = "crate::builtins::duration"
1004)]
1005async fn duration_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1006    compare_duration(lhs, rhs, "gt", |a, b| a > b)
1007}
1008
1009#[runmat_macros::runtime_builtin(
1010    name = "duration.ge",
1011    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1012    builtin_path = "crate::builtins::duration"
1013)]
1014async fn duration_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1015    compare_duration(lhs, rhs, "ge", |a, b| a >= b)
1016}
1017
1018#[runmat_macros::runtime_builtin(
1019    name = "duration.plus",
1020    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1021    builtin_path = "crate::builtins::duration"
1022)]
1023async fn duration_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1024    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
1025    if crate::builtins::datetime::is_datetime_object(&rhs) {
1026        let rhs_serials = crate::builtins::datetime::serials_from_datetime_value(&rhs)?;
1027        let (left, right, shape) =
1028            tensor::binary_numeric_tensors(&lhs_days, &rhs_serials, "plus", BUILTIN_NAME)?;
1029        let serials = left
1030            .iter()
1031            .zip(right.iter())
1032            .map(|(a, b)| a + b)
1033            .collect::<Vec<_>>();
1034        let tensor =
1035            Tensor::new(serials, shape).map_err(|err| duration_error(format!("plus: {err}")))?;
1036        return crate::builtins::datetime::datetime_object_from_serial_tensor(
1037            tensor,
1038            crate::builtins::datetime::datetime_format_from_value(&rhs),
1039        );
1040    }
1041
1042    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
1043    let (left, right, shape) =
1044        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "plus", BUILTIN_NAME)?;
1045    let days = left
1046        .iter()
1047        .zip(right.iter())
1048        .map(|(a, b)| a + b)
1049        .collect::<Vec<_>>();
1050    duration_object_from_days(days, shape, duration_format_from_value(&lhs))
1051}
1052
1053#[runmat_macros::runtime_builtin(
1054    name = "duration.minus",
1055    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1056    builtin_path = "crate::builtins::duration"
1057)]
1058async fn duration_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1059    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
1060    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
1061    let (left, right, shape) =
1062        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "minus", BUILTIN_NAME)?;
1063    let days = left
1064        .iter()
1065        .zip(right.iter())
1066        .map(|(a, b)| a - b)
1067        .collect::<Vec<_>>();
1068    duration_object_from_days(days, shape, duration_format_from_value(&lhs))
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074
1075    fn run_duration(args: Vec<Value>) -> Value {
1076        futures::executor::block_on(duration_builtin(args)).expect("duration")
1077    }
1078
1079    #[test]
1080    fn duration_descriptor_signatures_cover_constructor_and_methods() {
1081        let labels: Vec<&str> = DURATION_DESCRIPTOR
1082            .signatures
1083            .iter()
1084            .map(|sig| sig.label)
1085            .collect();
1086        assert!(labels.contains(&"t = duration(hours)"));
1087        assert!(labels.contains(&"t = duration(hours, minutes, seconds)"));
1088        assert!(labels.contains(&"t = duration(___, \"Format\", format)"));
1089        assert_eq!(
1090            DURATION_SUBSREF_DESCRIPTOR.signatures[0].label,
1091            "out = duration.subsref(obj, kind, payload)"
1092        );
1093        assert_eq!(
1094            DURATION_BINARY_DESCRIPTOR.signatures[0].label,
1095            "out = duration.op(lhs, rhs)"
1096        );
1097    }
1098
1099    #[test]
1100    fn duration_builds_from_components() {
1101        let value = run_duration(vec![Value::Num(1.0), Value::Num(30.0), Value::Num(45.0)]);
1102        let rendered = duration_display_text(&value)
1103            .expect("display")
1104            .expect("duration text");
1105        assert_eq!(rendered, "01:30:45");
1106    }
1107
1108    #[test]
1109    fn duration_formats_arrays() {
1110        let hours = Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap());
1111        let minutes = Value::Tensor(Tensor::new(vec![15.0, 45.0], vec![1, 2]).unwrap());
1112        let value = run_duration(vec![hours, minutes]);
1113        let rendered = duration_display_text(&value)
1114            .expect("display")
1115            .expect("duration text");
1116        assert!(rendered.contains("01:15:00"));
1117        assert!(rendered.contains("02:45:00"));
1118    }
1119
1120    #[test]
1121    fn duration_missing_days_render_without_error() {
1122        let value = duration_object_from_days_tensor(
1123            Tensor::new(vec![f64::NAN], vec![1, 1]).unwrap(),
1124            DEFAULT_DURATION_FORMAT,
1125        )
1126        .expect("duration object");
1127        let rendered = duration_string_array(&value)
1128            .expect("string array")
1129            .expect("duration strings");
1130        assert_eq!(rendered.data, vec!["NaN".to_string()]);
1131        assert_eq!(
1132            duration_display_text(&value).expect("display"),
1133            Some("NaN".to_string())
1134        );
1135    }
1136
1137    #[test]
1138    fn duration_unit_helpers_create_and_convert_values() {
1139        let one_day = futures::executor::block_on(days_builtin(Value::Num(1.0))).expect("days");
1140        assert!(is_duration_object(&one_day));
1141        let as_hours = futures::executor::block_on(hours_builtin(one_day.clone())).expect("hours");
1142        assert_eq!(as_hours, Value::Num(24.0));
1143        let as_minutes =
1144            futures::executor::block_on(minutes_builtin(one_day.clone())).expect("minutes");
1145        assert_eq!(as_minutes, Value::Num(1440.0));
1146        let as_seconds =
1147            futures::executor::block_on(seconds_builtin(one_day.clone())).expect("seconds");
1148        assert_eq!(as_seconds, Value::Num(86_400.0));
1149        let as_millis =
1150            futures::executor::block_on(milliseconds_builtin(one_day.clone())).expect("millis");
1151        assert_eq!(as_millis, Value::Num(86_400_000.0));
1152
1153        let two_hours = futures::executor::block_on(hours_builtin(Value::Num(2.0))).expect("hours");
1154        let rendered = duration_display_text(&two_hours)
1155            .expect("display")
1156            .expect("duration text");
1157        assert_eq!(rendered, "02:00:00");
1158
1159        let year = futures::executor::block_on(years_builtin(Value::Num(1.0))).expect("years");
1160        let year_days = duration_tensor_from_duration_value(&year).expect("duration tensor");
1161        assert!((year_days.data[0] - 365.2425).abs() < 1e-9);
1162        assert_eq!(
1163            isduration_builtin(year).expect("isduration"),
1164            Value::Bool(true)
1165        );
1166        assert_eq!(
1167            isduration_builtin(Value::Num(1.0)).expect("isduration"),
1168            Value::Bool(false)
1169        );
1170        assert!(futures::executor::block_on(years_builtin(Value::Num(f64::MAX))).is_err());
1171    }
1172
1173    #[test]
1174    fn duration_supports_format_assignment_and_indexing() {
1175        let value = run_duration(vec![Value::Num(1.0), Value::Num(5.0)]);
1176        let updated = futures::executor::block_on(duration_subsasgn(
1177            value.clone(),
1178            ".".to_string(),
1179            Value::String(FORMAT_FIELD.to_string()),
1180            Value::String("hh:mm".to_string()),
1181        ))
1182        .expect("subsasgn");
1183        let rendered = duration_display_text(&updated)
1184            .expect("display")
1185            .expect("duration text");
1186        assert_eq!(rendered, "01:05");
1187
1188        let array = run_duration(vec![
1189            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
1190            Value::Num(0.0),
1191            Value::Num(0.0),
1192        ]);
1193        let payload =
1194            Value::Cell(runmat_builtins::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
1195        let indexed =
1196            futures::executor::block_on(duration_subsref(array, "()".to_string(), payload))
1197                .expect("subsref");
1198        let text = duration_display_text(&indexed)
1199            .expect("display")
1200            .expect("duration text");
1201        assert_eq!(text, "02:00:00");
1202    }
1203}