Skip to main content

runmat_runtime/builtins/duration/
mod.rs

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