Files
nushell/crates/nu-command/src/conversions/into/duration.rs
Loïc Riegel 5c59611083 feat: duration from record (#15600)
Closes #15543

# Description

1. Simplify code in ``datetime.rs`` based on a suggestion in my last PR
on "datetime from record"
1. Make ``into duration`` work with durations inside a record, provided
as a cell path
1. Make ``into duration`` work with durations as record

# User-Facing Changes

```nushell
# Happy paths
~> {d: '1hr'} | into duration d
╭───┬─────╮
│ d │ 1hr │
╰───┴─────╯

~> {week: 10, day: 2, sign: '+'} | into duration
10wk 2day

# Error paths and invalid usage
~> {week: 10, day: 2, sign: 'x'} | into duration
Error: nu:🐚:incorrect_value

  × Incorrect value.
   ╭─[entry #4:1:26]
 1 │ {week: 10, day: 2, sign: 'x'} | into duration
   ·                          ─┬─    ──────┬──────
   ·                           │           ╰── encountered here
   ·                           ╰── Invalid sign. Allowed signs are +, -
   ╰────

~> {week: 10, day: -2, sign: '+'} | into duration
Error: nu:🐚:incorrect_value

  × Incorrect value.
   ╭─[entry #5:1:17]
 1 │ {week: 10, day: -2, sign: '+'} | into duration
   ·                 ─┬               ──────┬──────
   ·                  │                     ╰── encountered here
   ·                  ╰── number should be positive
   ╰────

~> {week: 10, day: '2', sign: '+'} | into duration
Error: nu:🐚:only_supports_this_input_type

  × Input type not supported.
   ╭─[entry #6:1:17]
 1 │ {week: 10, day: '2', sign: '+'} | into duration
   ·                 ─┬─               ──────┬──────
   ·                  │                      ╰── only int input data is supported
   ·                  ╰── input type: string
   ╰────

~> {week: 10, unknown: 1} | into duration
Error: nu:🐚:unsupported_input

  × Unsupported input
   ╭─[entry #7:1:1]
 1 │ {week: 10, unknown: 1} | into duration
   · ───────────┬──────────   ──────┬──────
   ·            │                   ╰── Column 'unknown' is not valid for a structured duration. Allowed columns are: week, day, hour, minute, second, millisecond, microsecond, nanosecond, sign
   ·            ╰── value originates from here
   ╰────

~> {week: 10, day: 2, sign: '+'} | into duration --unit sec
Error: nu:🐚:incompatible_parameters

  × Incompatible parameters.
   ╭─[entry #2:1:33]
 1 │ {week: 10, day: 2, sign: '+'} | into duration --unit sec
   ·                                 ──────┬────── ─────┬────
   ·                                       │            ╰── the units should be included in the record
   ·                                       ╰── got a record as input
   ╰────
```

# Tests + Formatting
- Add examples and integration tests for ``into duration``
- Add one test for ``into duration``

# After Submitting
If this is merged in time, I'll update my PR on the "datetime handling
highlights" for the release notes.
2025-04-19 18:29:12 -05:00

485 lines
17 KiB
Rust

use nu_cmd_base::input_handler::{operate, CmdArgument};
use nu_engine::command_prelude::*;
use nu_parser::{parse_unit_value, DURATION_UNIT_GROUPS};
use nu_protocol::{ast::Expr, Unit};
const NS_PER_US: i64 = 1_000;
const NS_PER_MS: i64 = 1_000_000;
const NS_PER_SEC: i64 = 1_000_000_000;
const NS_PER_MINUTE: i64 = 60 * NS_PER_SEC;
const NS_PER_HOUR: i64 = 60 * NS_PER_MINUTE;
const NS_PER_DAY: i64 = 24 * NS_PER_HOUR;
const NS_PER_WEEK: i64 = 7 * NS_PER_DAY;
const ALLOWED_COLUMNS: [&str; 9] = [
"week",
"day",
"hour",
"minute",
"second",
"millisecond",
"microsecond",
"nanosecond",
"sign",
];
const ALLOWED_SIGNS: [&str; 2] = ["+", "-"];
#[derive(Clone, Debug)]
struct Arguments {
unit: Option<Spanned<String>>,
cell_paths: Option<Vec<CellPath>>,
}
impl CmdArgument for Arguments {
fn take_cell_paths(&mut self) -> Option<Vec<CellPath>> {
self.cell_paths.take()
}
}
#[derive(Clone)]
pub struct IntoDuration;
impl Command for IntoDuration {
fn name(&self) -> &str {
"into duration"
}
fn signature(&self) -> Signature {
Signature::build("into duration")
.input_output_types(vec![
(Type::Int, Type::Duration),
(Type::Float, Type::Duration),
(Type::String, Type::Duration),
(Type::Duration, Type::Duration),
// FIXME: https://github.com/nushell/nushell/issues/15485
// 'record -> any' was added as a temporary workaround to avoid type inference issues. The Any arm needs to be appear first.
(Type::record(), Type::Any),
(Type::record(), Type::record()),
(Type::record(), Type::Duration),
(Type::table(), Type::table()),
])
.allow_variants_without_examples(true)
.named(
"unit",
SyntaxShape::String,
"Unit to convert number into (will have an effect only with integer input)",
Some('u'),
)
.rest(
"rest",
SyntaxShape::CellPath,
"For a data structure input, convert data at the given cell paths.",
)
.category(Category::Conversions)
}
fn description(&self) -> &str {
"Convert value to duration."
}
fn extra_description(&self) -> &str {
"Max duration value is i64::MAX nanoseconds; max duration time unit is wk (weeks)."
}
fn search_terms(&self) -> Vec<&str> {
vec!["convert", "time", "period"]
}
fn run(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
input: PipelineData,
) -> Result<PipelineData, ShellError> {
let cell_paths = call.rest(engine_state, stack, 0)?;
let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths);
let span = match input.span() {
Some(t) => t,
None => call.head,
};
let unit = match call.get_flag::<Spanned<String>>(engine_state, stack, "unit")? {
Some(spanned_unit) => {
if ["ns", "us", "µs", "ms", "sec", "min", "hr", "day", "wk"]
.contains(&spanned_unit.item.as_str())
{
Some(spanned_unit)
} else {
return Err(ShellError::CantConvertToDuration {
details: spanned_unit.item,
dst_span: span,
src_span: span,
help: Some(
"supported units are ns, us/µs, ms, sec, min, hr, day, and wk"
.to_string(),
),
});
}
}
None => None,
};
let args = Arguments { unit, cell_paths };
operate(action, args, input, call.head, engine_state.signals())
}
fn examples(&self) -> Vec<Example> {
vec![
Example {
description: "Convert duration string to duration value",
example: "'7min' | into duration",
result: Some(Value::test_duration(7 * 60 * NS_PER_SEC)),
},
Example {
description: "Convert compound duration string to duration value",
example: "'1day 2hr 3min 4sec' | into duration",
result: Some(Value::test_duration(
(((((/* 1 * */24) + 2) * 60) + 3) * 60 + 4) * NS_PER_SEC,
)),
},
Example {
description: "Convert table of duration strings to table of duration values",
example:
"[[value]; ['1sec'] ['2min'] ['3hr'] ['4day'] ['5wk']] | into duration value",
result: Some(Value::test_list(vec![
Value::test_record(record! {
"value" => Value::test_duration(NS_PER_SEC),
}),
Value::test_record(record! {
"value" => Value::test_duration(2 * 60 * NS_PER_SEC),
}),
Value::test_record(record! {
"value" => Value::test_duration(3 * 60 * 60 * NS_PER_SEC),
}),
Value::test_record(record! {
"value" => Value::test_duration(4 * 24 * 60 * 60 * NS_PER_SEC),
}),
Value::test_record(record! {
"value" => Value::test_duration(5 * 7 * 24 * 60 * 60 * NS_PER_SEC),
}),
])),
},
Example {
description: "Convert duration to duration",
example: "420sec | into duration",
result: Some(Value::test_duration(7 * 60 * NS_PER_SEC)),
},
Example {
description: "Convert a number of ns to duration",
example: "1_234_567 | into duration",
result: Some(Value::test_duration(1_234_567)),
},
Example {
description: "Convert a number of an arbitrary unit to duration",
example: "1_234 | into duration --unit ms",
result: Some(Value::test_duration(1_234 * 1_000_000)),
},
Example {
description: "Convert a floating point number of an arbitrary unit to duration",
example: "1.234 | into duration --unit sec",
result: Some(Value::test_duration(1_234 * 1_000_000)),
},
Example {
description: "Convert a record to a duration",
example: "{day: 10, hour: 2, minute: 6, second: 50, sign: '+'} | into duration",
result: Some(Value::duration(
10 * NS_PER_DAY + 2 * NS_PER_HOUR + 6 * NS_PER_MINUTE + 50 * NS_PER_SEC,
Span::test_data(),
)),
},
]
}
}
fn split_whitespace_indices(s: &str, span: Span) -> impl Iterator<Item = (&str, Span)> {
s.split_whitespace().map(move |sub| {
// Gets the offset of the `sub` substring inside the string `s`.
// `wrapping_` operations are necessary because the pointers can
// overflow on 32-bit platforms. The result will not overflow, because
// `sub` is within `s`, and the end of `s` has to be a valid memory
// address.
//
// XXX: this should be replaced with `str::substr_range` from the
// standard library when it's stabilized.
let start_offset = span
.start
.wrapping_add(sub.as_ptr() as usize)
.wrapping_sub(s.as_ptr() as usize);
(sub, Span::new(start_offset, start_offset + sub.len()))
})
}
fn compound_to_duration(s: &str, span: Span) -> Result<i64, ShellError> {
let mut duration_ns: i64 = 0;
for (substring, substring_span) in split_whitespace_indices(s, span) {
let sub_ns = string_to_duration(substring, substring_span)?;
duration_ns += sub_ns;
}
Ok(duration_ns)
}
fn string_to_duration(s: &str, span: Span) -> Result<i64, ShellError> {
if let Some(Ok(expression)) = parse_unit_value(
s.as_bytes(),
span,
DURATION_UNIT_GROUPS,
Type::Duration,
|x| x,
) {
if let Expr::ValueWithUnit(value) = expression.expr {
if let Expr::Int(x) = value.expr.expr {
match value.unit.item {
Unit::Nanosecond => return Ok(x),
Unit::Microsecond => return Ok(x * 1000),
Unit::Millisecond => return Ok(x * 1000 * 1000),
Unit::Second => return Ok(x * NS_PER_SEC),
Unit::Minute => return Ok(x * 60 * NS_PER_SEC),
Unit::Hour => return Ok(x * 60 * 60 * NS_PER_SEC),
Unit::Day => return Ok(x * 24 * 60 * 60 * NS_PER_SEC),
Unit::Week => return Ok(x * 7 * 24 * 60 * 60 * NS_PER_SEC),
_ => {}
}
}
}
}
Err(ShellError::CantConvertToDuration {
details: s.to_string(),
dst_span: span,
src_span: span,
help: Some("supported units are ns, us/µs, ms, sec, min, hr, day, and wk".to_string()),
})
}
fn action(input: &Value, args: &Arguments, head: Span) -> Value {
let value_span = input.span();
let unit_option = &args.unit;
if let Value::Record { .. } | Value::Duration { .. } = input {
if let Some(unit) = unit_option {
return Value::error(
ShellError::IncompatibleParameters {
left_message: "got a record as input".into(),
left_span: head,
right_message: "the units should be included in the record".into(),
right_span: unit.span,
},
head,
);
}
}
let unit: &str = match unit_option {
Some(unit) => &unit.item,
None => "ns",
};
match input {
Value::Duration { .. } => input.clone(),
Value::Record { val, .. } => {
merge_record(val, head, value_span).unwrap_or_else(|err| Value::error(err, value_span))
}
Value::String { val, .. } => {
if let Ok(num) = val.parse::<f64>() {
let ns = unit_to_ns_factor(unit);
return Value::duration((num * (ns as f64)) as i64, head);
}
match compound_to_duration(val, value_span) {
Ok(val) => Value::duration(val, head),
Err(error) => Value::error(error, head),
}
}
Value::Float { val, .. } => {
let ns = unit_to_ns_factor(unit);
Value::duration((*val * (ns as f64)) as i64, head)
}
Value::Int { val, .. } => {
let ns = unit_to_ns_factor(unit);
Value::duration(*val * ns, head)
}
// Propagate errors by explicitly matching them before the final case.
Value::Error { .. } => input.clone(),
other => Value::error(
ShellError::OnlySupportsThisInputType {
exp_input_type: "string or duration".into(),
wrong_type: other.get_type().to_string(),
dst_span: head,
src_span: other.span(),
},
head,
),
}
}
fn merge_record(record: &Record, head: Span, span: Span) -> Result<Value, ShellError> {
if let Some(invalid_col) = record
.columns()
.find(|key| !ALLOWED_COLUMNS.contains(&key.as_str()))
{
let allowed_cols = ALLOWED_COLUMNS.join(", ");
return Err(ShellError::UnsupportedInput {
msg: format!(
"Column '{invalid_col}' is not valid for a structured duration. Allowed columns are: {allowed_cols}"
),
input: "value originates from here".into(),
msg_span: head,
input_span: span
}
);
};
let mut duration: i64 = 0;
if let Some(col_val) = record.get("week") {
let week = parse_number_from_record(col_val, &head)?;
duration += week * NS_PER_WEEK;
};
if let Some(col_val) = record.get("day") {
let day = parse_number_from_record(col_val, &head)?;
duration += day * NS_PER_DAY;
};
if let Some(col_val) = record.get("hour") {
let hour = parse_number_from_record(col_val, &head)?;
duration += hour * NS_PER_HOUR;
};
if let Some(col_val) = record.get("minute") {
let minute = parse_number_from_record(col_val, &head)?;
duration += minute * NS_PER_MINUTE;
};
if let Some(col_val) = record.get("second") {
let second = parse_number_from_record(col_val, &head)?;
duration += second * NS_PER_SEC;
};
if let Some(col_val) = record.get("millisecond") {
let millisecond = parse_number_from_record(col_val, &head)?;
duration += millisecond * NS_PER_MS;
};
if let Some(col_val) = record.get("microsecond") {
let microsecond = parse_number_from_record(col_val, &head)?;
duration += microsecond * NS_PER_US;
};
if let Some(col_val) = record.get("nanosecond") {
let nanosecond = parse_number_from_record(col_val, &head)?;
duration += nanosecond;
};
if let Some(sign) = record.get("sign") {
match sign {
Value::String { val, .. } => {
if !ALLOWED_SIGNS.contains(&val.as_str()) {
let allowed_signs = ALLOWED_SIGNS.join(", ");
return Err(ShellError::IncorrectValue {
msg: format!("Invalid sign. Allowed signs are {}", allowed_signs)
.to_string(),
val_span: sign.span(),
call_span: head,
});
}
if val == "-" {
duration = -duration;
}
}
other => {
return Err(ShellError::OnlySupportsThisInputType {
exp_input_type: "int".to_string(),
wrong_type: other.get_type().to_string(),
dst_span: head,
src_span: other.span(),
});
}
}
};
Ok(Value::duration(duration, span))
}
fn parse_number_from_record(col_val: &Value, head: &Span) -> Result<i64, ShellError> {
let value = match col_val {
Value::Int { val, .. } => {
if *val < 0 {
return Err(ShellError::IncorrectValue {
msg: "number should be positive".to_string(),
val_span: col_val.span(),
call_span: *head,
});
}
*val
}
other => {
return Err(ShellError::OnlySupportsThisInputType {
exp_input_type: "int".to_string(),
wrong_type: other.get_type().to_string(),
dst_span: *head,
src_span: other.span(),
});
}
};
Ok(value)
}
fn unit_to_ns_factor(unit: &str) -> i64 {
match unit {
"ns" => 1,
"us" | "µs" => NS_PER_US,
"ms" => NS_PER_MS,
"sec" => NS_PER_SEC,
"min" => NS_PER_MINUTE,
"hr" => NS_PER_HOUR,
"day" => NS_PER_DAY,
"wk" => NS_PER_WEEK,
_ => 0,
}
}
#[cfg(test)]
mod test {
use super::*;
use rstest::rstest;
#[test]
fn test_examples() {
use crate::test_examples;
test_examples(IntoDuration {})
}
const NS_PER_SEC: i64 = 1_000_000_000;
#[rstest]
#[case("3ns", 3)]
#[case("4us", 4 * NS_PER_US)]
#[case("4\u{00B5}s", 4 * NS_PER_US)] // micro sign
#[case("4\u{03BC}s", 4 * NS_PER_US)] // mu symbol
#[case("5ms", 5 * NS_PER_MS)]
#[case("1sec", NS_PER_SEC)]
#[case("7min", 7 * NS_PER_MINUTE)]
#[case("42hr", 42 * NS_PER_HOUR)]
#[case("123day", 123 * NS_PER_DAY)]
#[case("3wk", 3 * NS_PER_WEEK)]
#[case("86hr 26ns", 86 * 3600 * NS_PER_SEC + 26)] // compound duration string
#[case("14ns 3hr 17sec", 14 + 3 * NS_PER_HOUR + 17 * NS_PER_SEC)] // compound string with units in random order
fn turns_string_to_duration(#[case] phrase: &str, #[case] expected_duration_val: i64) {
let args = Arguments {
unit: Some(Spanned {
item: "ns".to_string(),
span: Span::test_data(),
}),
cell_paths: None,
};
let actual = action(&Value::test_string(phrase), &args, Span::test_data());
match actual {
Value::Duration {
val: observed_val, ..
} => {
assert_eq!(expected_duration_val, observed_val, "expected != observed")
}
other => {
panic!("Expected Value::Duration, observed {other:?}");
}
}
}
}