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