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 (((((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 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
222fn parse_clock_duration(s: &str, span: Span) -> Result<Option<i64>, ShellError> {
225 if !s.contains(':') {
226 return Ok(None);
227 }
228
229 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 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 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)] #[case("4\u{03BC}s", 4 * NS_PER_US)] #[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)] #[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)]
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 #[case("78.797877879789789sec",
541 NS_PER_MINUTE + 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 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}