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 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 (((((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 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 ) && let Expr::ValueWithUnit(value) = expression.expr
231 && let Expr::Int(x) = value.expr.expr
232 {
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 Err(ShellError::InvalidUnit {
247 span,
248 supported_units: SUPPORTED_DURATION_UNITS.join(", "),
249 })
250}
251
252fn action(input: &Value, args: &Arguments, head: Span) -> Value {
253 let value_span = input.span();
254 let unit_option = &args.unit;
255
256 if let Value::Record { .. } | Value::Duration { .. } = input
257 && let Some(unit) = unit_option
258 {
259 return Value::error(
260 ShellError::IncompatibleParameters {
261 left_message: "got a record as input".into(),
262 left_span: head,
263 right_message: "the units should be included in the record".into(),
264 right_span: unit.span,
265 },
266 head,
267 );
268 }
269
270 let unit = match unit_option {
271 Some(unit) => &unit.item,
272 None => &Unit::Nanosecond,
273 };
274
275 match input {
276 Value::Duration { .. } => input.clone(),
277 Value::Record { val, .. } => {
278 merge_record(val, head, value_span).unwrap_or_else(|err| Value::error(err, value_span))
279 }
280 Value::String { val, .. } => {
281 if let Ok(num) = val.parse::<f64>() {
282 let ns = unit_to_ns_factor(unit);
283 return Value::duration((num * (ns as f64)) as i64, head);
284 }
285 match compound_to_duration(val, value_span) {
286 Ok(val) => Value::duration(val, head),
287 Err(error) => Value::error(error, head),
288 }
289 }
290 Value::Float { val, .. } => {
291 let ns = unit_to_ns_factor(unit);
292 Value::duration((*val * (ns as f64)) as i64, head)
293 }
294 Value::Int { val, .. } => {
295 let ns = unit_to_ns_factor(unit);
296 Value::duration(*val * ns, head)
297 }
298 Value::Error { .. } => input.clone(),
300 other => Value::error(
301 ShellError::OnlySupportsThisInputType {
302 exp_input_type: "string or duration".into(),
303 wrong_type: other.get_type().to_string(),
304 dst_span: head,
305 src_span: other.span(),
306 },
307 head,
308 ),
309 }
310}
311
312fn merge_record(record: &Record, head: Span, span: Span) -> Result<Value, ShellError> {
313 if let Some(invalid_col) = record
314 .columns()
315 .find(|key| !ALLOWED_COLUMNS.contains(&key.as_str()))
316 {
317 let allowed_cols = ALLOWED_COLUMNS.join(", ");
318 return Err(ShellError::UnsupportedInput {
319 msg: format!(
320 "Column '{invalid_col}' is not valid for a structured duration. Allowed columns are: {allowed_cols}"
321 ),
322 input: "value originates from here".into(),
323 msg_span: head,
324 input_span: span,
325 });
326 };
327
328 let mut duration: i64 = 0;
329
330 if let Some(col_val) = record.get("week") {
331 let week = parse_number_from_record(col_val, &head)?;
332 duration += week * NS_PER_WEEK;
333 };
334 if let Some(col_val) = record.get("day") {
335 let day = parse_number_from_record(col_val, &head)?;
336 duration += day * NS_PER_DAY;
337 };
338 if let Some(col_val) = record.get("hour") {
339 let hour = parse_number_from_record(col_val, &head)?;
340 duration += hour * NS_PER_HOUR;
341 };
342 if let Some(col_val) = record.get("minute") {
343 let minute = parse_number_from_record(col_val, &head)?;
344 duration += minute * NS_PER_MINUTE;
345 };
346 if let Some(col_val) = record.get("second") {
347 let second = parse_number_from_record(col_val, &head)?;
348 duration += second * NS_PER_SEC;
349 };
350 if let Some(col_val) = record.get("millisecond") {
351 let millisecond = parse_number_from_record(col_val, &head)?;
352 duration += millisecond * NS_PER_MS;
353 };
354 if let Some(col_val) = record.get("microsecond") {
355 let microsecond = parse_number_from_record(col_val, &head)?;
356 duration += microsecond * NS_PER_US;
357 };
358 if let Some(col_val) = record.get("nanosecond") {
359 let nanosecond = parse_number_from_record(col_val, &head)?;
360 duration += nanosecond;
361 };
362
363 if let Some(sign) = record.get("sign") {
364 match sign {
365 Value::String { val, .. } => {
366 if !ALLOWED_SIGNS.contains(&val.as_str()) {
367 let allowed_signs = ALLOWED_SIGNS.join(", ");
368 return Err(ShellError::IncorrectValue {
369 msg: format!("Invalid sign. Allowed signs are {allowed_signs}").to_string(),
370 val_span: sign.span(),
371 call_span: head,
372 });
373 }
374 if val == "-" {
375 duration = -duration;
376 }
377 }
378 other => {
379 return Err(ShellError::OnlySupportsThisInputType {
380 exp_input_type: "int".to_string(),
381 wrong_type: other.get_type().to_string(),
382 dst_span: head,
383 src_span: other.span(),
384 });
385 }
386 }
387 };
388
389 Ok(Value::duration(duration, span))
390}
391
392fn parse_number_from_record(col_val: &Value, head: &Span) -> Result<i64, ShellError> {
393 let value = match col_val {
394 Value::Int { val, .. } => {
395 if *val < 0 {
396 return Err(ShellError::IncorrectValue {
397 msg: "number should be positive".to_string(),
398 val_span: col_val.span(),
399 call_span: *head,
400 });
401 }
402 *val
403 }
404 other => {
405 return Err(ShellError::OnlySupportsThisInputType {
406 exp_input_type: "int".to_string(),
407 wrong_type: other.get_type().to_string(),
408 dst_span: *head,
409 src_span: other.span(),
410 });
411 }
412 };
413 Ok(value)
414}
415
416fn unit_to_ns_factor(unit: &Unit) -> i64 {
417 match unit {
418 Unit::Nanosecond => 1,
419 Unit::Microsecond => NS_PER_US,
420 Unit::Millisecond => NS_PER_MS,
421 Unit::Second => NS_PER_SEC,
422 Unit::Minute => NS_PER_MINUTE,
423 Unit::Hour => NS_PER_HOUR,
424 Unit::Day => NS_PER_DAY,
425 Unit::Week => NS_PER_WEEK,
426 _ => 0,
427 }
428}
429
430#[cfg(test)]
431mod test {
432 use super::*;
433 use rstest::rstest;
434
435 #[test]
436 fn test_examples() {
437 use crate::test_examples;
438
439 test_examples(IntoDuration {})
440 }
441
442 const NS_PER_SEC: i64 = 1_000_000_000;
443
444 #[rstest]
445 #[case("3ns", 3)]
446 #[case("4us", 4 * NS_PER_US)]
447 #[case("4\u{00B5}s", 4 * NS_PER_US)] #[case("4\u{03BC}s", 4 * NS_PER_US)] #[case("5ms", 5 * NS_PER_MS)]
450 #[case("1sec", NS_PER_SEC)]
451 #[case("7min", 7 * NS_PER_MINUTE)]
452 #[case("42hr", 42 * NS_PER_HOUR)]
453 #[case("123day", 123 * NS_PER_DAY)]
454 #[case("3wk", 3 * NS_PER_WEEK)]
455 #[case("86hr 26ns", 86 * 3600 * NS_PER_SEC + 26)] #[case("14ns 3hr 17sec", 14 + 3 * NS_PER_HOUR + 17 * NS_PER_SEC)] fn turns_string_to_duration(#[case] phrase: &str, #[case] expected_duration_val: i64) {
459 let args = Arguments {
460 unit: Some(Spanned {
461 item: Unit::Nanosecond,
462 span: Span::test_data(),
463 }),
464 cell_paths: None,
465 };
466 let actual = action(&Value::test_string(phrase), &args, Span::test_data());
467 match actual {
468 Value::Duration {
469 val: observed_val, ..
470 } => {
471 assert_eq!(expected_duration_val, observed_val, "expected != observed")
472 }
473 other => {
474 panic!("Expected Value::Duration, observed {other:?}");
475 }
476 }
477 }
478}