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 (((((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 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
235fn parse_clock_duration(s: &str, span: Span) -> Result<Option<i64>, ShellError> {
238 if !s.contains(':') {
239 return Ok(None);
240 }
241
242 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 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 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)] #[case("4\u{03BC}s", 4 * NS_PER_US)] #[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)] #[case("14ns 3hr 17sec", 14 + 3 * NS_PER_HOUR + 17 * NS_PER_SEC)] #[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 #[case("78.797877879789789sec",
554 NS_PER_MINUTE + 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 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}