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}