Skip to main content

nu_command/conversions/into/
duration.rs

1use std::str::FromStr;
2
3use nu_cmd_base::input_handler::{CmdArgument, operate};
4use nu_engine::command_prelude::*;
5use nu_parser::{DURATION_UNIT_GROUPS, parse_unit_value};
6use nu_protocol::{SUPPORTED_DURATION_UNITS, Unit, ast::Expr};
7
8const NS_PER_US: i64 = 1_000;
9const NS_PER_MS: i64 = 1_000_000;
10const NS_PER_SEC: i64 = 1_000_000_000;
11const NS_PER_MINUTE: i64 = 60 * NS_PER_SEC;
12const NS_PER_HOUR: i64 = 60 * NS_PER_MINUTE;
13const NS_PER_DAY: i64 = 24 * NS_PER_HOUR;
14const NS_PER_WEEK: i64 = 7 * NS_PER_DAY;
15
16const ALLOWED_COLUMNS: [&str; 9] = [
17    "week",
18    "day",
19    "hour",
20    "minute",
21    "second",
22    "millisecond",
23    "microsecond",
24    "nanosecond",
25    "sign",
26];
27const ALLOWED_SIGNS: [&str; 2] = ["+", "-"];
28
29#[derive(Clone, Debug)]
30struct Arguments {
31    unit: Option<Spanned<Unit>>,
32    cell_paths: Option<Vec<CellPath>>,
33}
34
35impl CmdArgument for Arguments {
36    fn take_cell_paths(&mut self) -> Option<Vec<CellPath>> {
37        self.cell_paths.take()
38    }
39}
40
41#[derive(Clone)]
42pub struct IntoDuration;
43
44impl Command for IntoDuration {
45    fn name(&self) -> &str {
46        "into duration"
47    }
48
49    fn signature(&self) -> Signature {
50        Signature::build("into duration")
51            .input_output_types(vec![
52                (Type::Int, Type::Duration),
53                (Type::Float, Type::Duration),
54                (Type::String, Type::Duration),
55                (Type::Duration, Type::Duration),
56                (Type::record(), Type::record()),
57                (Type::record(), Type::Duration),
58                (Type::table(), Type::table()),
59            ])
60            .allow_variants_without_examples(true)
61            .param(
62                Flag::new("unit")
63                    .short('u')
64                    .arg(SyntaxShape::String)
65                    .desc(
66                        "Unit to convert number into (will have an effect only with integer input)",
67                    )
68                    .completion(Completion::new_list(SUPPORTED_DURATION_UNITS.as_slice())),
69            )
70            .rest(
71                "rest",
72                SyntaxShape::CellPath,
73                "For a data structure input, convert data at the given cell paths.",
74            )
75            .category(Category::Conversions)
76    }
77
78    fn description(&self) -> &str {
79        "Convert value to a duration."
80    }
81
82    fn extra_description(&self) -> &str {
83        "Max duration value is i64::MAX nanoseconds; max duration time unit is wk (weeks)."
84    }
85
86    fn search_terms(&self) -> Vec<&str> {
87        vec!["convert", "time", "period"]
88    }
89
90    fn run(
91        &self,
92        engine_state: &EngineState,
93        stack: &mut Stack,
94        call: &Call,
95        input: PipelineData,
96    ) -> Result<PipelineData, ShellError> {
97        let cell_paths = call.rest(engine_state, stack, 0)?;
98        let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths);
99
100        let unit = match call.get_flag::<Spanned<String>>(engine_state, stack, "unit")? {
101            Some(spanned_unit) => match Unit::from_str(&spanned_unit.item) {
102                Ok(u) => match u {
103                    Unit::Filesize(_) => {
104                        return Err(ShellError::InvalidUnit {
105                            span: spanned_unit.span,
106                            supported_units: SUPPORTED_DURATION_UNITS.join(", "),
107                        });
108                    }
109                    _ => Some(Spanned {
110                        item: u,
111                        span: spanned_unit.span,
112                    }),
113                },
114                Err(_) => {
115                    return Err(ShellError::InvalidUnit {
116                        span: spanned_unit.span,
117                        supported_units: SUPPORTED_DURATION_UNITS.join(", "),
118                    });
119                }
120            },
121            None => None,
122        };
123        let args = Arguments { unit, cell_paths };
124        operate(action, args, input, call.head, engine_state.signals())
125    }
126
127    fn examples(&self) -> Vec<Example<'_>> {
128        vec![
129            Example {
130                description: "Convert duration string to duration value.",
131                example: "'7min' | into duration",
132                result: Some(Value::test_duration(7 * 60 * NS_PER_SEC)),
133            },
134            Example {
135                description: "Convert compound duration string to duration value.",
136                example: "'1day 2hr 3min 4sec' | into duration",
137                result: Some(Value::test_duration(
138                    (((((/* 1 * */24) + 2) * 60) + 3) * 60 + 4) * NS_PER_SEC,
139                )),
140            },
141            Example {
142                description: "Convert table of duration strings to table of duration values.",
143                example: "[[value]; ['1sec'] ['2min'] ['3hr'] ['4day'] ['5wk']] | into duration value",
144                result: Some(Value::test_list(vec![
145                    Value::test_record(record! {
146                        "value" => Value::test_duration(NS_PER_SEC),
147                    }),
148                    Value::test_record(record! {
149                        "value" => Value::test_duration(2 * 60 * NS_PER_SEC),
150                    }),
151                    Value::test_record(record! {
152                        "value" => Value::test_duration(3 * 60 * 60 * NS_PER_SEC),
153                    }),
154                    Value::test_record(record! {
155                        "value" => Value::test_duration(4 * 24 * 60 * 60 * NS_PER_SEC),
156                    }),
157                    Value::test_record(record! {
158                        "value" => Value::test_duration(5 * 7 * 24 * 60 * 60 * NS_PER_SEC),
159                    }),
160                ])),
161            },
162            Example {
163                description: "Convert duration to duration.",
164                example: "420sec | into duration",
165                result: Some(Value::test_duration(7 * 60 * NS_PER_SEC)),
166            },
167            Example {
168                description: "Convert `hh:mm:ss`-style string to duration",
169                example: "'3:34:00' | into duration",
170                result: Some(Value::test_duration(3 * NS_PER_HOUR + 34 * NS_PER_MINUTE)),
171            },
172            Example {
173                description: "Convert `hh:mm:ss.f`-style string to duration",
174                example: "'2:45:31.2' | into duration",
175                result: Some(Value::test_duration(
176                    2 * NS_PER_HOUR + 45 * NS_PER_MINUTE + 31 * NS_PER_SEC + 200 * NS_PER_MS,
177                )),
178            },
179            Example {
180                description: "Convert a number of ns to duration.",
181                example: "1_234_567 | into duration",
182                result: Some(Value::test_duration(1_234_567)),
183            },
184            Example {
185                description: "Convert a number of an arbitrary unit to duration.",
186                example: "1_234 | into duration --unit ms",
187                result: Some(Value::test_duration(1_234 * 1_000_000)),
188            },
189            Example {
190                description: "Convert a floating point number of an arbitrary unit to duration.",
191                example: "1.234 | into duration --unit sec",
192                result: Some(Value::test_duration(1_234 * 1_000_000)),
193            },
194            Example {
195                description: "Convert a record to a duration.",
196                example: "{day: 10, hour: 2, minute: 6, second: 50, sign: '+'} | into duration",
197                result: Some(Value::duration(
198                    10 * NS_PER_DAY + 2 * NS_PER_HOUR + 6 * NS_PER_MINUTE + 50 * NS_PER_SEC,
199                    Span::test_data(),
200                )),
201            },
202        ]
203    }
204}
205
206fn split_whitespace_indices(s: &str, span: Span) -> impl Iterator<Item = (&str, Span)> {
207    s.split_whitespace().map(move |sub| {
208        // Gets the offset of the `sub` substring inside the string `s`.
209        // `wrapping_` operations are necessary because the pointers can
210        // overflow on 32-bit platforms.  The result will not overflow, because
211        // `sub` is within `s`, and the end of `s` has to be a valid memory
212        // address.
213        //
214        // XXX: this should be replaced with `str::substr_range` from the
215        // standard library when it's stabilized.
216        let start_offset = span
217            .start
218            .wrapping_add(sub.as_ptr() as usize)
219            .wrapping_sub(s.as_ptr() as usize);
220        (sub, Span::new(start_offset, start_offset + sub.len()))
221    })
222}
223
224fn compound_to_duration(s: &str, span: Span) -> Result<i64, ShellError> {
225    let mut duration_ns: i64 = 0;
226
227    for (substring, substring_span) in split_whitespace_indices(s, span) {
228        let sub_ns = string_to_duration(substring, substring_span)?;
229        duration_ns += sub_ns;
230    }
231
232    Ok(duration_ns)
233}
234
235// Try to parse a string formatted as `hh:mm:ss` with an optional fractional
236// seconds component using 1 to 9 digits of sub-second precision.
237fn parse_clock_duration(s: &str, span: Span) -> Result<Option<i64>, ShellError> {
238    if !s.contains(':') {
239        return Ok(None);
240    }
241
242    // helper for consistent error messaging
243    fn clock_format_error(span: Span) -> ShellError {
244        ShellError::IncorrectValue {
245            msg: "invalid clock-style duration; please use hh:mm:ss with optional .f up to .fffffffff"
246                .to_string(),
247            val_span: span,
248            call_span: span,
249        }
250    }
251
252    fn clock_range_error(span: Span) -> ShellError {
253        ShellError::IncorrectValue {
254            msg: "invalid clock-style duration; hours must be >= 0 and minutes/seconds must be >= 0 and < 60"
255                .to_string(),
256            val_span: span,
257            call_span: span,
258        }
259    }
260
261    let parts: Vec<&str> = s.split(':').collect();
262
263    if parts.len() != 3 {
264        return Err(clock_format_error(span));
265    }
266
267    let hours = parts[0]
268        .parse::<i64>()
269        .map_err(|_| clock_format_error(span))?;
270    let minutes = parts[1]
271        .parse::<i64>()
272        .map_err(|_| clock_format_error(span))?;
273
274    let (seconds_part, fractional_part) = match parts[2].split_once('.') {
275        Some((seconds, fractional)) => (seconds, Some(fractional)),
276        None => (parts[2], None),
277    };
278
279    let seconds = seconds_part
280        .parse::<i64>()
281        .map_err(|_| clock_format_error(span))?;
282
283    let fractional_ns = match fractional_part {
284        Some(fractional) if fractional.chars().all(|c| c.is_ascii_digit()) => {
285            if fractional.is_empty() || fractional.len() > 9 {
286                return Err(clock_format_error(span));
287            }
288
289            let scale = 10_i64.pow((9 - fractional.len()) as u32);
290            fractional
291                .parse::<i64>()
292                .map(|value| value * scale)
293                .map_err(|_| clock_format_error(span))?
294        }
295        Some(_) => return Err(clock_format_error(span)),
296        None => 0,
297    };
298
299    if hours < 0 || minutes >= 60 || seconds >= 60 || minutes < 0 || seconds < 0 {
300        return Err(clock_range_error(span));
301    }
302
303    Ok(Some(
304        hours * NS_PER_HOUR + minutes * NS_PER_MINUTE + seconds * NS_PER_SEC + fractional_ns,
305    ))
306}
307
308fn string_to_duration(s: &str, span: Span) -> Result<i64, ShellError> {
309    // first try the newly added clock-style parser
310    if let Some(parsed) = parse_clock_duration(s, span)? {
311        return Ok(parsed);
312    }
313
314    if let Some(Ok(expression)) = parse_unit_value(
315        s.as_bytes(),
316        span,
317        DURATION_UNIT_GROUPS,
318        Type::Duration,
319        |x| x,
320    ) && let Expr::ValueWithUnit(value) = expression.expr
321        && let Expr::Int(x) = value.expr.expr
322    {
323        match value.unit.item {
324            Unit::Nanosecond => return Ok(x),
325            Unit::Microsecond => return Ok(x * 1000),
326            Unit::Millisecond => return Ok(x * 1000 * 1000),
327            Unit::Second => return Ok(x * NS_PER_SEC),
328            Unit::Minute => return Ok(x * 60 * NS_PER_SEC),
329            Unit::Hour => return Ok(x * 60 * 60 * NS_PER_SEC),
330            Unit::Day => return Ok(x * 24 * 60 * 60 * NS_PER_SEC),
331            Unit::Week => return Ok(x * 7 * 24 * 60 * 60 * NS_PER_SEC),
332            _ => {}
333        }
334    }
335
336    Err(ShellError::InvalidUnit {
337        span,
338        supported_units: SUPPORTED_DURATION_UNITS.join(", "),
339    })
340}
341
342fn action(input: &Value, args: &Arguments, head: Span) -> Value {
343    let value_span = input.span();
344    let unit_option = &args.unit;
345
346    if let Value::Record { .. } | Value::Duration { .. } = input
347        && let Some(unit) = unit_option
348    {
349        return Value::error(
350            ShellError::IncompatibleParameters {
351                left_message: "got a record as input".into(),
352                left_span: head,
353                right_message: "the units should be included in the record".into(),
354                right_span: unit.span,
355            },
356            head,
357        );
358    }
359
360    let unit = match unit_option {
361        Some(unit) => &unit.item,
362        None => &Unit::Nanosecond,
363    };
364
365    match input {
366        Value::Duration { .. } => input.clone(),
367        Value::Record { val, .. } => {
368            merge_record(val, head, value_span).unwrap_or_else(|err| Value::error(err, value_span))
369        }
370        Value::String { val, .. } => {
371            if let Ok(num) = val.parse::<f64>() {
372                let ns = unit_to_ns_factor(unit);
373                return Value::duration((num * (ns as f64)) as i64, head);
374            }
375            match compound_to_duration(val, value_span) {
376                Ok(val) => Value::duration(val, head),
377                Err(error) => Value::error(error, head),
378            }
379        }
380        Value::Float { val, .. } => {
381            let ns = unit_to_ns_factor(unit);
382            Value::duration((*val * (ns as f64)) as i64, head)
383        }
384        Value::Int { val, .. } => {
385            let ns = unit_to_ns_factor(unit);
386            Value::duration(*val * ns, head)
387        }
388        // Propagate errors by explicitly matching them before the final case.
389        Value::Error { .. } => input.clone(),
390        other => Value::error(
391            ShellError::OnlySupportsThisInputType {
392                exp_input_type: "string or duration".into(),
393                wrong_type: other.get_type().to_string(),
394                dst_span: head,
395                src_span: other.span(),
396            },
397            head,
398        ),
399    }
400}
401
402fn merge_record(record: &Record, head: Span, span: Span) -> Result<Value, ShellError> {
403    if let Some(invalid_col) = record
404        .columns()
405        .find(|key| !ALLOWED_COLUMNS.contains(&key.as_str()))
406    {
407        let allowed_cols = ALLOWED_COLUMNS.join(", ");
408        return Err(ShellError::UnsupportedInput {
409            msg: format!(
410                "Column '{invalid_col}' is not valid for a structured duration. Allowed columns are: {allowed_cols}"
411            ),
412            input: "value originates from here".into(),
413            msg_span: head,
414            input_span: span,
415        });
416    };
417
418    let mut duration: i64 = 0;
419
420    if let Some(col_val) = record.get("week") {
421        let week = parse_number_from_record(col_val, &head)?;
422        duration += week * NS_PER_WEEK;
423    };
424    if let Some(col_val) = record.get("day") {
425        let day = parse_number_from_record(col_val, &head)?;
426        duration += day * NS_PER_DAY;
427    };
428    if let Some(col_val) = record.get("hour") {
429        let hour = parse_number_from_record(col_val, &head)?;
430        duration += hour * NS_PER_HOUR;
431    };
432    if let Some(col_val) = record.get("minute") {
433        let minute = parse_number_from_record(col_val, &head)?;
434        duration += minute * NS_PER_MINUTE;
435    };
436    if let Some(col_val) = record.get("second") {
437        let second = parse_number_from_record(col_val, &head)?;
438        duration += second * NS_PER_SEC;
439    };
440    if let Some(col_val) = record.get("millisecond") {
441        let millisecond = parse_number_from_record(col_val, &head)?;
442        duration += millisecond * NS_PER_MS;
443    };
444    if let Some(col_val) = record.get("microsecond") {
445        let microsecond = parse_number_from_record(col_val, &head)?;
446        duration += microsecond * NS_PER_US;
447    };
448    if let Some(col_val) = record.get("nanosecond") {
449        let nanosecond = parse_number_from_record(col_val, &head)?;
450        duration += nanosecond;
451    };
452
453    if let Some(sign) = record.get("sign") {
454        match sign {
455            Value::String { val, .. } => {
456                if !ALLOWED_SIGNS.contains(&val.as_str()) {
457                    let allowed_signs = ALLOWED_SIGNS.join(", ");
458                    return Err(ShellError::IncorrectValue {
459                        msg: format!("Invalid sign. Allowed signs are {allowed_signs}").to_string(),
460                        val_span: sign.span(),
461                        call_span: head,
462                    });
463                }
464                if val == "-" {
465                    duration = -duration;
466                }
467            }
468            other => {
469                return Err(ShellError::OnlySupportsThisInputType {
470                    exp_input_type: "int".to_string(),
471                    wrong_type: other.get_type().to_string(),
472                    dst_span: head,
473                    src_span: other.span(),
474                });
475            }
476        }
477    };
478
479    Ok(Value::duration(duration, span))
480}
481
482fn parse_number_from_record(col_val: &Value, head: &Span) -> Result<i64, ShellError> {
483    let value = match col_val {
484        Value::Int { val, .. } => {
485            if *val < 0 {
486                return Err(ShellError::IncorrectValue {
487                    msg: "number should be positive".to_string(),
488                    val_span: col_val.span(),
489                    call_span: *head,
490                });
491            }
492            *val
493        }
494        other => {
495            return Err(ShellError::OnlySupportsThisInputType {
496                exp_input_type: "int".to_string(),
497                wrong_type: other.get_type().to_string(),
498                dst_span: *head,
499                src_span: other.span(),
500            });
501        }
502    };
503    Ok(value)
504}
505
506fn unit_to_ns_factor(unit: &Unit) -> i64 {
507    match unit {
508        Unit::Nanosecond => 1,
509        Unit::Microsecond => NS_PER_US,
510        Unit::Millisecond => NS_PER_MS,
511        Unit::Second => NS_PER_SEC,
512        Unit::Minute => NS_PER_MINUTE,
513        Unit::Hour => NS_PER_HOUR,
514        Unit::Day => NS_PER_DAY,
515        Unit::Week => NS_PER_WEEK,
516        _ => 0,
517    }
518}
519
520#[cfg(test)]
521mod test {
522    use super::*;
523    use rstest::rstest;
524
525    #[test]
526    fn test_examples() -> nu_test_support::Result {
527        nu_test_support::test().examples(IntoDuration)
528    }
529
530    const NS_PER_SEC: i64 = 1_000_000_000;
531
532    #[rstest]
533    #[case("3ns", 3)]
534    #[case("4us", 4 * NS_PER_US)]
535    #[case("4\u{00B5}s", 4 * NS_PER_US)] // micro sign
536    #[case("4\u{03BC}s", 4 * NS_PER_US)] // mu symbol
537    #[case("5ms", 5 * NS_PER_MS)]
538    #[case("1sec", NS_PER_SEC)]
539    #[case("7min", 7 * NS_PER_MINUTE)]
540    #[case("42hr", 42 * NS_PER_HOUR)]
541    #[case("123day", 123 * NS_PER_DAY)]
542    #[case("3wk", 3 * NS_PER_WEEK)]
543    #[case("86hr 26ns", 86 * 3600 * NS_PER_SEC + 26)] // compound duration string
544    #[case("14ns 3hr 17sec", 14 + 3 * NS_PER_HOUR + 17 * NS_PER_SEC)] // compound string with units in random order
545    #[case("3:34:00", 3 * NS_PER_HOUR + 34 * NS_PER_MINUTE)]
546    #[case("2:45:31.2", 2 * NS_PER_HOUR + 45 * NS_PER_MINUTE + 31 * NS_PER_SEC + 200 * NS_PER_MS)]
547    #[case("2:45:31.23", 2 * NS_PER_HOUR + 45 * NS_PER_MINUTE + 31 * NS_PER_SEC + 230 * NS_PER_MS)]
548    #[case("2:45:31.2345", 2 * NS_PER_HOUR + 45 * NS_PER_MINUTE + 31 * NS_PER_SEC + 234 * NS_PER_MS + 500 * NS_PER_US)]
549    #[case("16:59:58.235", 16 * NS_PER_HOUR + 59 * NS_PER_MINUTE + 58 * NS_PER_SEC + 235 * NS_PER_MS)]
550    #[case("16:59:58.235123", 16 * NS_PER_HOUR + 59 * NS_PER_MINUTE + 58 * NS_PER_SEC + 235 * NS_PER_MS + 123 * NS_PER_US)]
551    #[case("16:59:58.235123456", 16 * NS_PER_HOUR + 59 * NS_PER_MINUTE + 58 * NS_PER_SEC + 235 * NS_PER_MS + 123 * NS_PER_US + 456)]
552    // decimal with unit should bypass clock parser and succeed
553    #[case("78.797877879789789sec",
554        NS_PER_MINUTE // 1 * NS_PER_MINUTE
555        + 18 * NS_PER_SEC
556        + 797 * NS_PER_MS
557        + 877 * NS_PER_US
558        + 879)]
559
560    fn turns_string_to_duration(#[case] phrase: &str, #[case] expected_duration_val: i64) {
561        let args = Arguments {
562            unit: Some(Spanned {
563                item: Unit::Nanosecond,
564                span: Span::test_data(),
565            }),
566            cell_paths: None,
567        };
568        let actual = action(&Value::test_string(phrase), &args, Span::test_data());
569        match actual {
570            Value::Duration {
571                val: observed_val, ..
572            } => {
573                assert_eq!(expected_duration_val, observed_val, "expected != observed")
574            }
575            other => {
576                panic!("Expected Value::Duration, observed {other:?}");
577            }
578        }
579    }
580
581    #[test]
582    fn invalid_clock_string() {
583        let args = Arguments {
584            unit: Some(Spanned {
585                item: Unit::Nanosecond,
586                span: Span::test_data(),
587            }),
588            cell_paths: None,
589        };
590
591        // two‑field string must fail with helpful message
592        let actual = action(&Value::test_string("1:02"), &args, Span::test_data());
593        match actual {
594            Value::Error { error, .. } => {
595                if let ShellError::IncorrectValue { msg, .. } = *error {
596                    assert!(msg.contains("hh:mm:ss"), "msg was {msg}");
597                } else {
598                    panic!("wrong error variant: {error:?}");
599                }
600            }
601            other => panic!("expected error, got {other:?}"),
602        }
603    }
604
605    #[test]
606    fn invalid_clock_string_with_out_of_range_fields() {
607        let args = Arguments {
608            unit: Some(Spanned {
609                item: Unit::Nanosecond,
610                span: Span::test_data(),
611            }),
612            cell_paths: None,
613        };
614
615        let actual = action(&Value::test_string("3:99:00"), &args, Span::test_data());
616        match actual {
617            Value::Error { error, .. } => {
618                if let ShellError::IncorrectValue { msg, .. } = *error {
619                    assert!(msg.contains("hours must be >= 0"), "msg was {msg}");
620                } else {
621                    panic!("wrong error variant: {error:?}");
622                }
623            }
624            other => panic!("expected error, got {other:?}"),
625        }
626    }
627
628    #[test]
629    fn clock_parser_nonclock_decimal() {
630        let span = Span::test_data();
631        let parsed = parse_clock_duration("78.797877879789789sec", span).unwrap();
632        assert!(parsed.is_none());
633    }
634
635    #[test]
636    fn invalid_clock_string_with_bad_fraction_precision() {
637        let args = Arguments {
638            unit: Some(Spanned {
639                item: Unit::Nanosecond,
640                span: Span::test_data(),
641            }),
642            cell_paths: None,
643        };
644
645        let actual = action(
646            &Value::test_string("16:59:58.1234567890"),
647            &args,
648            Span::test_data(),
649        );
650        match actual {
651            Value::Error { error, .. } => {
652                if let ShellError::IncorrectValue { msg, .. } = *error {
653                    assert!(msg.contains("hh:mm:ss"), "msg was {msg}");
654                } else {
655                    panic!("wrong error variant: {error:?}");
656                }
657            }
658            other => panic!("expected error, got {other:?}"),
659        }
660    }
661}