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                // FIXME: https://github.com/nushell/nushell/issues/15485
57                // 'record -> any' was added as a temporary workaround to avoid type inference issues. The Any arm needs to be appear first.
58                (Type::record(), Type::Any),
59                (Type::record(), Type::record()),
60                (Type::record(), Type::Duration),
61                (Type::table(), Type::table()),
62            ])
63            .allow_variants_without_examples(true)
64            .named(
65                "unit",
66                SyntaxShape::String,
67                "Unit to convert number into (will have an effect only with integer input)",
68                Some('u'),
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 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 a number of ns to duration",
169                example: "1_234_567 | into duration",
170                result: Some(Value::test_duration(1_234_567)),
171            },
172            Example {
173                description: "Convert a number of an arbitrary unit to duration",
174                example: "1_234 | into duration --unit ms",
175                result: Some(Value::test_duration(1_234 * 1_000_000)),
176            },
177            Example {
178                description: "Convert a floating point number of an arbitrary unit to duration",
179                example: "1.234 | into duration --unit sec",
180                result: Some(Value::test_duration(1_234 * 1_000_000)),
181            },
182            Example {
183                description: "Convert a record to a duration",
184                example: "{day: 10, hour: 2, minute: 6, second: 50, sign: '+'} | into duration",
185                result: Some(Value::duration(
186                    10 * NS_PER_DAY + 2 * NS_PER_HOUR + 6 * NS_PER_MINUTE + 50 * NS_PER_SEC,
187                    Span::test_data(),
188                )),
189            },
190        ]
191    }
192}
193
194fn split_whitespace_indices(s: &str, span: Span) -> impl Iterator<Item = (&str, Span)> {
195    s.split_whitespace().map(move |sub| {
196        // Gets the offset of the `sub` substring inside the string `s`.
197        // `wrapping_` operations are necessary because the pointers can
198        // overflow on 32-bit platforms.  The result will not overflow, because
199        // `sub` is within `s`, and the end of `s` has to be a valid memory
200        // address.
201        //
202        // XXX: this should be replaced with `str::substr_range` from the
203        // standard library when it's stabilized.
204        let start_offset = span
205            .start
206            .wrapping_add(sub.as_ptr() as usize)
207            .wrapping_sub(s.as_ptr() as usize);
208        (sub, Span::new(start_offset, start_offset + sub.len()))
209    })
210}
211
212fn compound_to_duration(s: &str, span: Span) -> Result<i64, ShellError> {
213    let mut duration_ns: i64 = 0;
214
215    for (substring, substring_span) in split_whitespace_indices(s, span) {
216        let sub_ns = string_to_duration(substring, substring_span)?;
217        duration_ns += sub_ns;
218    }
219
220    Ok(duration_ns)
221}
222
223fn string_to_duration(s: &str, span: Span) -> Result<i64, ShellError> {
224    if let Some(Ok(expression)) = parse_unit_value(
225        s.as_bytes(),
226        span,
227        DURATION_UNIT_GROUPS,
228        Type::Duration,
229        |x| x,
230    ) {
231        if let Expr::ValueWithUnit(value) = expression.expr {
232            if let Expr::Int(x) = value.expr.expr {
233                match value.unit.item {
234                    Unit::Nanosecond => return Ok(x),
235                    Unit::Microsecond => return Ok(x * 1000),
236                    Unit::Millisecond => return Ok(x * 1000 * 1000),
237                    Unit::Second => return Ok(x * NS_PER_SEC),
238                    Unit::Minute => return Ok(x * 60 * NS_PER_SEC),
239                    Unit::Hour => return Ok(x * 60 * 60 * NS_PER_SEC),
240                    Unit::Day => return Ok(x * 24 * 60 * 60 * NS_PER_SEC),
241                    Unit::Week => return Ok(x * 7 * 24 * 60 * 60 * NS_PER_SEC),
242                    _ => {}
243                }
244            }
245        }
246    }
247
248    Err(ShellError::InvalidUnit {
249        span,
250        supported_units: SUPPORTED_DURATION_UNITS.join(", "),
251    })
252}
253
254fn action(input: &Value, args: &Arguments, head: Span) -> Value {
255    let value_span = input.span();
256    let unit_option = &args.unit;
257
258    if let Value::Record { .. } | Value::Duration { .. } = input {
259        if let Some(unit) = unit_option {
260            return Value::error(
261                ShellError::IncompatibleParameters {
262                    left_message: "got a record as input".into(),
263                    left_span: head,
264                    right_message: "the units should be included in the record".into(),
265                    right_span: unit.span,
266                },
267                head,
268            );
269        }
270    }
271
272    let unit = match unit_option {
273        Some(unit) => &unit.item,
274        None => &Unit::Nanosecond,
275    };
276
277    match input {
278        Value::Duration { .. } => input.clone(),
279        Value::Record { val, .. } => {
280            merge_record(val, head, value_span).unwrap_or_else(|err| Value::error(err, value_span))
281        }
282        Value::String { val, .. } => {
283            if let Ok(num) = val.parse::<f64>() {
284                let ns = unit_to_ns_factor(unit);
285                return Value::duration((num * (ns as f64)) as i64, head);
286            }
287            match compound_to_duration(val, value_span) {
288                Ok(val) => Value::duration(val, head),
289                Err(error) => Value::error(error, head),
290            }
291        }
292        Value::Float { val, .. } => {
293            let ns = unit_to_ns_factor(unit);
294            Value::duration((*val * (ns as f64)) as i64, head)
295        }
296        Value::Int { val, .. } => {
297            let ns = unit_to_ns_factor(unit);
298            Value::duration(*val * ns, head)
299        }
300        // Propagate errors by explicitly matching them before the final case.
301        Value::Error { .. } => input.clone(),
302        other => Value::error(
303            ShellError::OnlySupportsThisInputType {
304                exp_input_type: "string or duration".into(),
305                wrong_type: other.get_type().to_string(),
306                dst_span: head,
307                src_span: other.span(),
308            },
309            head,
310        ),
311    }
312}
313
314fn merge_record(record: &Record, head: Span, span: Span) -> Result<Value, ShellError> {
315    if let Some(invalid_col) = record
316        .columns()
317        .find(|key| !ALLOWED_COLUMNS.contains(&key.as_str()))
318    {
319        let allowed_cols = ALLOWED_COLUMNS.join(", ");
320        return Err(ShellError::UnsupportedInput {
321            msg: format!(
322                "Column '{invalid_col}' is not valid for a structured duration. Allowed columns are: {allowed_cols}"
323            ),
324            input: "value originates from here".into(),
325            msg_span: head,
326            input_span: span,
327        });
328    };
329
330    let mut duration: i64 = 0;
331
332    if let Some(col_val) = record.get("week") {
333        let week = parse_number_from_record(col_val, &head)?;
334        duration += week * NS_PER_WEEK;
335    };
336    if let Some(col_val) = record.get("day") {
337        let day = parse_number_from_record(col_val, &head)?;
338        duration += day * NS_PER_DAY;
339    };
340    if let Some(col_val) = record.get("hour") {
341        let hour = parse_number_from_record(col_val, &head)?;
342        duration += hour * NS_PER_HOUR;
343    };
344    if let Some(col_val) = record.get("minute") {
345        let minute = parse_number_from_record(col_val, &head)?;
346        duration += minute * NS_PER_MINUTE;
347    };
348    if let Some(col_val) = record.get("second") {
349        let second = parse_number_from_record(col_val, &head)?;
350        duration += second * NS_PER_SEC;
351    };
352    if let Some(col_val) = record.get("millisecond") {
353        let millisecond = parse_number_from_record(col_val, &head)?;
354        duration += millisecond * NS_PER_MS;
355    };
356    if let Some(col_val) = record.get("microsecond") {
357        let microsecond = parse_number_from_record(col_val, &head)?;
358        duration += microsecond * NS_PER_US;
359    };
360    if let Some(col_val) = record.get("nanosecond") {
361        let nanosecond = parse_number_from_record(col_val, &head)?;
362        duration += nanosecond;
363    };
364
365    if let Some(sign) = record.get("sign") {
366        match sign {
367            Value::String { val, .. } => {
368                if !ALLOWED_SIGNS.contains(&val.as_str()) {
369                    let allowed_signs = ALLOWED_SIGNS.join(", ");
370                    return Err(ShellError::IncorrectValue {
371                        msg: format!("Invalid sign. Allowed signs are {}", allowed_signs)
372                            .to_string(),
373                        val_span: sign.span(),
374                        call_span: head,
375                    });
376                }
377                if val == "-" {
378                    duration = -duration;
379                }
380            }
381            other => {
382                return Err(ShellError::OnlySupportsThisInputType {
383                    exp_input_type: "int".to_string(),
384                    wrong_type: other.get_type().to_string(),
385                    dst_span: head,
386                    src_span: other.span(),
387                });
388            }
389        }
390    };
391
392    Ok(Value::duration(duration, span))
393}
394
395fn parse_number_from_record(col_val: &Value, head: &Span) -> Result<i64, ShellError> {
396    let value = match col_val {
397        Value::Int { val, .. } => {
398            if *val < 0 {
399                return Err(ShellError::IncorrectValue {
400                    msg: "number should be positive".to_string(),
401                    val_span: col_val.span(),
402                    call_span: *head,
403                });
404            }
405            *val
406        }
407        other => {
408            return Err(ShellError::OnlySupportsThisInputType {
409                exp_input_type: "int".to_string(),
410                wrong_type: other.get_type().to_string(),
411                dst_span: *head,
412                src_span: other.span(),
413            });
414        }
415    };
416    Ok(value)
417}
418
419fn unit_to_ns_factor(unit: &Unit) -> i64 {
420    match unit {
421        Unit::Nanosecond => 1,
422        Unit::Microsecond => NS_PER_US,
423        Unit::Millisecond => NS_PER_MS,
424        Unit::Second => NS_PER_SEC,
425        Unit::Minute => NS_PER_MINUTE,
426        Unit::Hour => NS_PER_HOUR,
427        Unit::Day => NS_PER_DAY,
428        Unit::Week => NS_PER_WEEK,
429        _ => 0,
430    }
431}
432
433#[cfg(test)]
434mod test {
435    use super::*;
436    use rstest::rstest;
437
438    #[test]
439    fn test_examples() {
440        use crate::test_examples;
441
442        test_examples(IntoDuration {})
443    }
444
445    const NS_PER_SEC: i64 = 1_000_000_000;
446
447    #[rstest]
448    #[case("3ns", 3)]
449    #[case("4us", 4 * NS_PER_US)]
450    #[case("4\u{00B5}s", 4 * NS_PER_US)] // micro sign
451    #[case("4\u{03BC}s", 4 * NS_PER_US)] // mu symbol
452    #[case("5ms", 5 * NS_PER_MS)]
453    #[case("1sec", NS_PER_SEC)]
454    #[case("7min", 7 * NS_PER_MINUTE)]
455    #[case("42hr", 42 * NS_PER_HOUR)]
456    #[case("123day", 123 * NS_PER_DAY)]
457    #[case("3wk", 3 * NS_PER_WEEK)]
458    #[case("86hr 26ns", 86 * 3600 * NS_PER_SEC + 26)] // compound duration string
459    #[case("14ns 3hr 17sec", 14 + 3 * NS_PER_HOUR + 17 * NS_PER_SEC)] // compound string with units in random order
460
461    fn turns_string_to_duration(#[case] phrase: &str, #[case] expected_duration_val: i64) {
462        let args = Arguments {
463            unit: Some(Spanned {
464                item: Unit::Nanosecond,
465                span: Span::test_data(),
466            }),
467            cell_paths: None,
468        };
469        let actual = action(&Value::test_string(phrase), &args, Span::test_data());
470        match actual {
471            Value::Duration {
472                val: observed_val, ..
473            } => {
474                assert_eq!(expected_duration_val, observed_val, "expected != observed")
475            }
476            other => {
477                panic!("Expected Value::Duration, observed {other:?}");
478            }
479        }
480    }
481}