Skip to main content

nu_command/conversions/into/
datetime.rs

1use crate::{generate_strftime_list, parse_date_from_string};
2use chrono::{
3    DateTime, Datelike, FixedOffset, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone,
4    Timelike, Utc,
5};
6use nu_cmd_base::input_handler::{CmdArgument, operate};
7use nu_engine::command_prelude::*;
8
9const HOUR: i32 = 60 * 60;
10const ALLOWED_COLUMNS: [&str; 10] = [
11    "year",
12    "month",
13    "day",
14    "hour",
15    "minute",
16    "second",
17    "millisecond",
18    "microsecond",
19    "nanosecond",
20    "timezone",
21];
22
23#[derive(Clone, Debug)]
24struct Arguments {
25    zone_options: Option<Spanned<Zone>>,
26    format_options: Option<Spanned<DatetimeFormat>>,
27    cell_paths: Option<Vec<CellPath>>,
28}
29
30impl CmdArgument for Arguments {
31    fn take_cell_paths(&mut self) -> Option<Vec<CellPath>> {
32        self.cell_paths.take()
33    }
34}
35
36// In case it may be confused with chrono::TimeZone
37#[derive(Clone, Debug)]
38enum Zone {
39    Utc,
40    Local,
41    East(u8),
42    West(u8),
43    Error, // we want Nushell to cast it instead of Rust
44}
45
46impl Zone {
47    const OPTIONS: &[&str] = &["utc", "local"];
48    fn new(i: i64) -> Self {
49        if i.abs() <= 12 {
50            // guaranteed here
51            if i >= 0 {
52                Self::East(i as u8) // won't go out of range
53            } else {
54                Self::West(-i as u8) // same here
55            }
56        } else {
57            Self::Error // Out of range
58        }
59    }
60    fn from_string(s: &str) -> Self {
61        match s.to_ascii_lowercase().as_str() {
62            "utc" | "u" => Self::Utc,
63            "local" | "l" => Self::Local,
64            _ => Self::Error,
65        }
66    }
67}
68
69#[derive(Clone)]
70pub struct IntoDatetime;
71
72impl Command for IntoDatetime {
73    fn name(&self) -> &str {
74        "into datetime"
75    }
76
77    fn signature(&self) -> Signature {
78        Signature::build("into datetime")
79            .input_output_types(vec![
80                (Type::Date, Type::Date),
81                (Type::Int, Type::Date),
82                (Type::String, Type::Date),
83                (
84                    Type::List(Box::new(Type::String)),
85                    Type::List(Box::new(Type::Date)),
86                ),
87                (Type::table(), Type::table()),
88                (Type::Nothing, Type::table()),
89                (Type::record(), Type::record()),
90                (Type::record(), Type::Date),
91                // FIXME Type::Any input added to disable pipeline input type checking, as run-time checks can raise undesirable type errors
92                // which aren't caught by the parser. see https://github.com/nushell/nushell/pull/14922 for more details
93                // only applicable for --list flag
94                (Type::Any, Type::table()),
95            ])
96            .allow_variants_without_examples(true)
97            .param(
98                Flag::new("timezone")
99                    .short('z')
100                    .arg(SyntaxShape::String)
101                    .desc(
102                        "Specify timezone if the input is a Unix timestamp. Valid options: 'UTC' ('u') or 'LOCAL' ('l').",
103                    )
104                    .completion(Completion::new_list(Zone::OPTIONS)),
105            )
106            .named(
107                "offset",
108                SyntaxShape::Int,
109                "Specify timezone by offset from UTC if the input is a Unix timestamp, like '+8', '-4'.",
110                Some('o'),
111            )
112            .named(
113                "format",
114                SyntaxShape::String,
115                "Specify expected format of INPUT string to parse to datetime. Use --list to see options.",
116                Some('f'),
117            )
118            .switch(
119                "list",
120                "Show all possible variables for use in --format flag.",
121                Some('l'),
122            )
123            .rest(
124                "rest",
125                SyntaxShape::CellPath,
126                "For a data structure input, convert data at the given cell paths.",
127            )
128            .category(Category::Conversions)
129    }
130
131    fn run(
132        &self,
133        engine_state: &EngineState,
134        stack: &mut Stack,
135        call: &Call,
136        input: PipelineData,
137    ) -> Result<PipelineData, ShellError> {
138        if call.has_flag(engine_state, stack, "list")? {
139            Ok(generate_strftime_list(call.head, true).into_pipeline_data())
140        } else {
141            let cell_paths = call.rest(engine_state, stack, 0)?;
142            let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths);
143
144            // if zone-offset is specified, then zone will be neglected
145            let timezone = call.get_flag::<Spanned<String>>(engine_state, stack, "timezone")?;
146            let zone_options =
147                match &call.get_flag::<Spanned<i64>>(engine_state, stack, "offset")? {
148                    Some(zone_offset) => Some(Spanned {
149                        item: Zone::new(zone_offset.item),
150                        span: zone_offset.span,
151                    }),
152                    None => timezone.as_ref().map(|zone| Spanned {
153                        item: Zone::from_string(&zone.item),
154                        span: zone.span,
155                    }),
156                };
157
158            let format_options = call
159                .get_flag::<Spanned<String>>(engine_state, stack, "format")?
160                .as_ref()
161                .map(|fmt| Spanned {
162                    item: DatetimeFormat(fmt.item.to_string()),
163                    span: fmt.span,
164                });
165
166            let args = Arguments {
167                zone_options,
168                format_options,
169                cell_paths,
170            };
171            operate(action, args, input, call.head, engine_state.signals())
172        }
173    }
174
175    fn description(&self) -> &str {
176        "Convert text or timestamp into a datetime."
177    }
178
179    fn search_terms(&self) -> Vec<&str> {
180        vec!["convert", "timezone", "UTC"]
181    }
182
183    fn examples(&self) -> Vec<Example<'_>> {
184        let example_result_1 = |nanos: i64| {
185            Some(Value::date(
186                Utc.timestamp_nanos(nanos).into(),
187                Span::test_data(),
188            ))
189        };
190        vec![
191            Example {
192                description: "Convert timestamp string to datetime with timezone offset.",
193                example: "'27.02.2021 1:55 pm +0000' | into datetime",
194                #[allow(clippy::inconsistent_digit_grouping)]
195                result: example_result_1(1614434100_000000000),
196            },
197            Example {
198                description: "Convert standard timestamp string to datetime with timezone offset.",
199                example: "'2021-02-27T13:55:40.2246+00:00' | into datetime",
200                #[allow(clippy::inconsistent_digit_grouping)]
201                result: example_result_1(1614434140_224600000),
202            },
203            Example {
204                description: "Convert non-standard timestamp string, with timezone offset, to \
205                              datetime using a custom format.",
206                example: "'20210227_135540+0000' | into datetime --format '%Y%m%d_%H%M%S%z'",
207                #[allow(clippy::inconsistent_digit_grouping)]
208                result: example_result_1(1614434140_000000000),
209            },
210            Example {
211                description: "Convert non-standard timestamp string, without timezone offset, to \
212                              datetime with custom formatting.",
213                example: "'16.11.1984 8:00 am' | into datetime --format '%d.%m.%Y %H:%M %P'",
214                #[allow(clippy::inconsistent_digit_grouping)]
215                result: Some(Value::date(
216                    Local
217                        .from_local_datetime(
218                            &NaiveDateTime::parse_from_str(
219                                "16.11.1984 8:00 am",
220                                "%d.%m.%Y %H:%M %P",
221                            )
222                            .expect("date calculation should not fail in test"),
223                        )
224                        .unwrap()
225                        .with_timezone(Local::now().offset()),
226                    Span::test_data(),
227                )),
228            },
229            Example {
230                description: "Convert nanosecond-precision unix timestamp to a datetime with \
231                              offset from UTC.",
232                example: "1614434140123456789 | into datetime --offset -5",
233                #[allow(clippy::inconsistent_digit_grouping)]
234                result: example_result_1(1614434140_123456789),
235            },
236            Example {
237                description: "Convert standard (seconds) unix timestamp to a UTC datetime.",
238                example: "1614434140 | into datetime -f '%s'",
239                #[allow(clippy::inconsistent_digit_grouping)]
240                result: example_result_1(1614434140_000000000),
241            },
242            Example {
243                description: "Using a datetime as input simply returns the value.",
244                example: "2021-02-27T13:55:40 | into datetime",
245                #[allow(clippy::inconsistent_digit_grouping)]
246                result: example_result_1(1614434140_000000000),
247            },
248            Example {
249                description: "Using a record as input.",
250                example: "{year: 2025, month: 3, day: 30, hour: 12, minute: 15, second: 59, \
251                          timezone: '+02:00'} | into datetime",
252                #[allow(clippy::inconsistent_digit_grouping)]
253                result: example_result_1(1743329759_000000000),
254            },
255            Example {
256                description: "Convert list of timestamps to datetimes.",
257                example: r#"["2023-03-30 10:10:07 -05:00", "2023-05-05 13:43:49 -05:00", "2023-06-05 01:37:42 -05:00"] | into datetime"#,
258                result: Some(Value::list(
259                    vec![
260                        Value::date(
261                            DateTime::parse_from_str(
262                                "2023-03-30 10:10:07 -05:00",
263                                "%Y-%m-%d %H:%M:%S %z",
264                            )
265                            .expect("date calculation should not fail in test"),
266                            Span::test_data(),
267                        ),
268                        Value::date(
269                            DateTime::parse_from_str(
270                                "2023-05-05 13:43:49 -05:00",
271                                "%Y-%m-%d %H:%M:%S %z",
272                            )
273                            .expect("date calculation should not fail in test"),
274                            Span::test_data(),
275                        ),
276                        Value::date(
277                            DateTime::parse_from_str(
278                                "2023-06-05 01:37:42 -05:00",
279                                "%Y-%m-%d %H:%M:%S %z",
280                            )
281                            .expect("date calculation should not fail in test"),
282                            Span::test_data(),
283                        ),
284                    ],
285                    Span::test_data(),
286                )),
287            },
288        ]
289    }
290}
291
292#[derive(Clone, Debug)]
293struct DatetimeFormat(String);
294
295fn action(input: &Value, args: &Arguments, head: Span) -> Value {
296    let timezone = &args.zone_options;
297    let dateformat = &args.format_options;
298
299    // noop if the input is already a datetime
300    if let Value::Date { .. } = input {
301        return input.clone();
302    }
303
304    if let Value::Record { val: record, .. } = input {
305        if let Some(tz) = timezone {
306            return Value::error(
307                ShellError::IncompatibleParameters {
308                    left_message: "got a record as input".into(),
309                    left_span: head,
310                    right_message: "the timezone should be included in the record".into(),
311                    right_span: tz.span,
312                },
313                head,
314            );
315        }
316
317        if let Some(dt) = dateformat {
318            return Value::error(
319                ShellError::IncompatibleParameters {
320                    left_message: "got a record as input".into(),
321                    left_span: head,
322                    right_message: "cannot be used with records".into(),
323                    right_span: dt.span,
324                },
325                head,
326            );
327        }
328
329        let span = input.span();
330        return merge_record(record, head, span).unwrap_or_else(|err| Value::error(err, span));
331    }
332
333    // Let's try dtparse first
334    if matches!(input, Value::String { .. }) && dateformat.is_none() {
335        let span = input.span();
336        if let Ok(input_val) = input.coerce_str()
337            && let Ok(date) = parse_date_from_string(&input_val, span)
338        {
339            return Value::date(date, span);
340        }
341    }
342
343    // Check to see if input looks like a Unix timestamp (i.e. can it be parsed to an int?)
344    let timestamp = match input {
345        Value::Int { val, .. } => Ok(*val),
346        Value::String { val, .. } => val.parse::<i64>(),
347        // Propagate errors by explicitly matching them before the final case.
348        Value::Error { .. } => return input.clone(),
349        other => {
350            return Value::error(
351                ShellError::OnlySupportsThisInputType {
352                    exp_input_type: "string and int".into(),
353                    wrong_type: other.get_type().to_string(),
354                    dst_span: head,
355                    src_span: other.span(),
356                },
357                head,
358            );
359        }
360    };
361
362    if dateformat.is_none()
363        && let Ok(ts) = timestamp
364    {
365        return match timezone {
366            // note all these `.timestamp_nanos()` could overflow if we didn't check range in `<date> | into int`.
367
368            // default to UTC
369            None => Value::date(Utc.timestamp_nanos(ts).into(), head),
370            Some(Spanned { item, span }) => match item {
371                Zone::Utc => {
372                    let dt = Utc.timestamp_nanos(ts);
373                    Value::date(dt.into(), *span)
374                }
375                Zone::Local => {
376                    let dt = Local.timestamp_nanos(ts);
377                    Value::date(dt.into(), *span)
378                }
379                Zone::East(i) => match FixedOffset::east_opt((*i as i32) * HOUR) {
380                    Some(eastoffset) => {
381                        let dt = eastoffset.timestamp_nanos(ts);
382                        Value::date(dt, *span)
383                    }
384                    None => Value::error(
385                        ShellError::DatetimeParseError {
386                            msg: input.to_abbreviated_string(&nu_protocol::Config::default()),
387                            span: *span,
388                        },
389                        *span,
390                    ),
391                },
392                Zone::West(i) => match FixedOffset::west_opt((*i as i32) * HOUR) {
393                    Some(westoffset) => {
394                        let dt = westoffset.timestamp_nanos(ts);
395                        Value::date(dt, *span)
396                    }
397                    None => Value::error(
398                        ShellError::DatetimeParseError {
399                            msg: input.to_abbreviated_string(&nu_protocol::Config::default()),
400                            span: *span,
401                        },
402                        *span,
403                    ),
404                },
405                Zone::Error => Value::error(
406                    // This is an argument error, not an input error
407                    ShellError::TypeMismatch {
408                        err_message: "Invalid timezone or offset".to_string(),
409                        span: *span,
410                    },
411                    *span,
412                ),
413            },
414        };
415    };
416
417    // If input is not a timestamp, try parsing it as a string
418    let span = input.span();
419
420    let parse_as_string = |val: &str| {
421        match dateformat {
422            Some(dt_format) => {
423                // Handle custom format specifiers for compact formats
424                let format_str = dt_format
425                    .item
426                    .0
427                    .replace("%J", "%Y%m%d") // %J for joined date (YYYYMMDD)
428                    .replace("%Q", "%H%M%S"); // %Q for sequential time (HHMMSS)
429                match DateTime::parse_from_str(val, &format_str) {
430                    Ok(dt) => match timezone {
431                        None => Value::date(dt, head),
432                        Some(Spanned { item, span }) => match item {
433                            Zone::Utc => Value::date(dt, head),
434                            Zone::Local => Value::date(dt.with_timezone(&Local).into(), *span),
435                            Zone::East(i) => match FixedOffset::east_opt((*i as i32) * HOUR) {
436                                Some(eastoffset) => {
437                                    Value::date(dt.with_timezone(&eastoffset), *span)
438                                }
439                                None => Value::error(
440                                    ShellError::DatetimeParseError {
441                                        msg: input
442                                            .to_abbreviated_string(&nu_protocol::Config::default()),
443                                        span: *span,
444                                    },
445                                    *span,
446                                ),
447                            },
448                            Zone::West(i) => match FixedOffset::west_opt((*i as i32) * HOUR) {
449                                Some(westoffset) => {
450                                    Value::date(dt.with_timezone(&westoffset), *span)
451                                }
452                                None => Value::error(
453                                    ShellError::DatetimeParseError {
454                                        msg: input
455                                            .to_abbreviated_string(&nu_protocol::Config::default()),
456                                        span: *span,
457                                    },
458                                    *span,
459                                ),
460                            },
461                            Zone::Error => Value::error(
462                                // This is an argument error, not an input error
463                                ShellError::TypeMismatch {
464                                    err_message: "Invalid timezone or offset".to_string(),
465                                    span: *span,
466                                },
467                                *span,
468                            ),
469                        },
470                    },
471                    Err(reason) => parse_with_format(val, &format_str, head).unwrap_or_else(|_| {
472                        Value::error(
473                            ShellError::CantConvert {
474                                to_type: format!(
475                                    "could not parse as datetime using format '{}'",
476                                    dt_format.item.0
477                                ),
478                                from_type: reason.to_string(),
479                                span: head,
480                                help: Some(
481                                    "you can use `into datetime` without a format string to \
482                                         enable flexible parsing"
483                                        .to_string(),
484                                ),
485                            },
486                            head,
487                        )
488                    }),
489                }
490            }
491
492            // Tries to automatically parse the date
493            // (i.e. without a format string)
494            // and assumes the system's local timezone if none is specified
495            None => match parse_date_from_string(val, span) {
496                Ok(date) => Value::date(date, span),
497                Err(err) => err,
498            },
499        }
500    };
501
502    match input {
503        Value::String { val, .. } => parse_as_string(val),
504        Value::Int { val, .. } => parse_as_string(&val.to_string()),
505
506        // Propagate errors by explicitly matching them before the final case.
507        Value::Error { .. } => input.clone(),
508        other => Value::error(
509            ShellError::OnlySupportsThisInputType {
510                exp_input_type: "string".into(),
511                wrong_type: other.get_type().to_string(),
512                dst_span: head,
513                src_span: other.span(),
514            },
515            head,
516        ),
517    }
518}
519
520fn merge_record(record: &Record, head: Span, span: Span) -> Result<Value, ShellError> {
521    if let Some(invalid_col) = record
522        .columns()
523        .find(|key| !ALLOWED_COLUMNS.contains(&key.as_str()))
524    {
525        let allowed_cols = ALLOWED_COLUMNS.join(", ");
526        return Err(ShellError::UnsupportedInput {
527            msg: format!(
528                "Column '{invalid_col}' is not valid for a structured datetime. Allowed columns \
529                 are: {allowed_cols}"
530            ),
531            input: "value originates from here".into(),
532            msg_span: head,
533            input_span: span,
534        });
535    };
536
537    // Empty fields are filled in a specific way: the time units bigger than the biggest provided fields are assumed to be current and smaller ones are zeroed.
538    // And local timezone is used if not provided.
539    #[derive(Debug)]
540    enum RecordColumnDefault {
541        Now,
542        Zero,
543    }
544    let mut record_column_default = RecordColumnDefault::Now;
545
546    let now = Local::now();
547    let mut now_nanosecond = now.nanosecond();
548    let now_millisecond = now_nanosecond / 1_000_000;
549    now_nanosecond %= 1_000_000;
550    let now_microsecond = now_nanosecond / 1_000;
551    now_nanosecond %= 1_000;
552
553    let year: i32 = match record.get("year") {
554        Some(val) => {
555            record_column_default = RecordColumnDefault::Zero;
556            match val {
557                Value::Int { val, .. } => *val as i32,
558                other => {
559                    return Err(ShellError::OnlySupportsThisInputType {
560                        exp_input_type: "int".to_string(),
561                        wrong_type: other.get_type().to_string(),
562                        dst_span: head,
563                        src_span: other.span(),
564                    });
565                }
566            }
567        }
568        None => now.year(),
569    };
570    let month = match record.get("month") {
571        Some(col_val) => {
572            record_column_default = RecordColumnDefault::Zero;
573            parse_value_from_record_as_u32("month", col_val, &head, &span)?
574        }
575        None => match record_column_default {
576            RecordColumnDefault::Now => now.month(),
577            RecordColumnDefault::Zero => 1,
578        },
579    };
580    let day = match record.get("day") {
581        Some(col_val) => {
582            record_column_default = RecordColumnDefault::Zero;
583            parse_value_from_record_as_u32("day", col_val, &head, &span)?
584        }
585        None => match record_column_default {
586            RecordColumnDefault::Now => now.day(),
587            RecordColumnDefault::Zero => 1,
588        },
589    };
590    let hour = match record.get("hour") {
591        Some(col_val) => {
592            record_column_default = RecordColumnDefault::Zero;
593            parse_value_from_record_as_u32("hour", col_val, &head, &span)?
594        }
595        None => match record_column_default {
596            RecordColumnDefault::Now => now.hour(),
597            RecordColumnDefault::Zero => 0,
598        },
599    };
600    let minute = match record.get("minute") {
601        Some(col_val) => {
602            record_column_default = RecordColumnDefault::Zero;
603            parse_value_from_record_as_u32("minute", col_val, &head, &span)?
604        }
605        None => match record_column_default {
606            RecordColumnDefault::Now => now.minute(),
607            RecordColumnDefault::Zero => 0,
608        },
609    };
610    let second = match record.get("second") {
611        Some(col_val) => {
612            record_column_default = RecordColumnDefault::Zero;
613            parse_value_from_record_as_u32("second", col_val, &head, &span)?
614        }
615        None => match record_column_default {
616            RecordColumnDefault::Now => now.second(),
617            RecordColumnDefault::Zero => 0,
618        },
619    };
620    let millisecond = match record.get("millisecond") {
621        Some(col_val) => {
622            record_column_default = RecordColumnDefault::Zero;
623            parse_value_from_record_as_u32("millisecond", col_val, &head, &span)?
624        }
625        None => match record_column_default {
626            RecordColumnDefault::Now => now_millisecond,
627            RecordColumnDefault::Zero => 0,
628        },
629    };
630    let microsecond = match record.get("microsecond") {
631        Some(col_val) => {
632            record_column_default = RecordColumnDefault::Zero;
633            parse_value_from_record_as_u32("microsecond", col_val, &head, &span)?
634        }
635        None => match record_column_default {
636            RecordColumnDefault::Now => now_microsecond,
637            RecordColumnDefault::Zero => 0,
638        },
639    };
640
641    let nanosecond = match record.get("nanosecond") {
642        Some(col_val) => parse_value_from_record_as_u32("nanosecond", col_val, &head, &span)?,
643        None => match record_column_default {
644            RecordColumnDefault::Now => now_nanosecond,
645            RecordColumnDefault::Zero => 0,
646        },
647    };
648
649    let offset: FixedOffset = match record.get("timezone") {
650        Some(timezone) => parse_timezone_from_record(timezone, &head, &timezone.span())?,
651        None => now.offset().to_owned(),
652    };
653
654    let total_nanoseconds = nanosecond + microsecond * 1_000 + millisecond * 1_000_000;
655
656    let date = match NaiveDate::from_ymd_opt(year, month, day) {
657        Some(d) => d,
658        None => {
659            return Err(ShellError::IncorrectValue {
660                msg: "one of more values are incorrect and do not represent valid date".to_string(),
661                val_span: head,
662                call_span: span,
663            });
664        }
665    };
666    let time = match NaiveTime::from_hms_nano_opt(hour, minute, second, total_nanoseconds) {
667        Some(t) => t,
668        None => {
669            return Err(ShellError::IncorrectValue {
670                msg: "one of more values are incorrect and do not represent valid time".to_string(),
671                val_span: head,
672                call_span: span,
673            });
674        }
675    };
676    let date_time = NaiveDateTime::new(date, time);
677
678    let date_time_fixed = match offset.from_local_datetime(&date_time).single() {
679        Some(d) => d,
680        None => {
681            return Err(ShellError::IncorrectValue {
682                msg: "Ambiguous or invalid timezone conversion".to_string(),
683                val_span: head,
684                call_span: span,
685            });
686        }
687    };
688    Ok(Value::date(date_time_fixed, span))
689}
690
691fn parse_value_from_record_as_u32(
692    col: &str,
693    col_val: &Value,
694    head: &Span,
695    span: &Span,
696) -> Result<u32, ShellError> {
697    let value: u32 = match col_val {
698        Value::Int { val, .. } => {
699            if *val < 0 || *val > u32::MAX as i64 {
700                return Err(ShellError::IncorrectValue {
701                    msg: format!("incorrect value for {col}"),
702                    val_span: *head,
703                    call_span: *span,
704                });
705            }
706            *val as u32
707        }
708        other => {
709            return Err(ShellError::OnlySupportsThisInputType {
710                exp_input_type: "int".to_string(),
711                wrong_type: other.get_type().to_string(),
712                dst_span: *head,
713                src_span: other.span(),
714            });
715        }
716    };
717    Ok(value)
718}
719
720fn parse_timezone_from_record(
721    timezone: &Value,
722    head: &Span,
723    span: &Span,
724) -> Result<FixedOffset, ShellError> {
725    match timezone {
726        Value::String { val, .. } => {
727            let offset: FixedOffset = match val.parse() {
728                Ok(offset) => offset,
729                Err(_) => {
730                    return Err(ShellError::IncorrectValue {
731                        msg: "invalid timezone".to_string(),
732                        val_span: *span,
733                        call_span: *head,
734                    });
735                }
736            };
737            Ok(offset)
738        }
739        other => Err(ShellError::OnlySupportsThisInputType {
740            exp_input_type: "string".to_string(),
741            wrong_type: other.get_type().to_string(),
742            dst_span: *head,
743            src_span: other.span(),
744        }),
745    }
746}
747
748fn parse_with_format(val: &str, fmt: &str, head: Span) -> Result<Value, ()> {
749    // try parsing at date + time
750    if let Ok(dt) = NaiveDateTime::parse_from_str(val, fmt) {
751        let dt_native = Local.from_local_datetime(&dt).single().unwrap_or_default();
752        return Ok(Value::date(dt_native.into(), head));
753    }
754
755    // try parsing at date only
756    if let Ok(date) = NaiveDate::parse_from_str(val, fmt)
757        && let Some(dt) = date.and_hms_opt(0, 0, 0)
758    {
759        let dt_native = Local.from_local_datetime(&dt).single().unwrap_or_default();
760        return Ok(Value::date(dt_native.into(), head));
761    }
762
763    // try parsing at time only
764    if let Ok(time) = NaiveTime::parse_from_str(val, fmt) {
765        let now = Local::now().naive_local().date();
766        let dt_native = Local
767            .from_local_datetime(&now.and_time(time))
768            .single()
769            .unwrap_or_default();
770        return Ok(Value::date(dt_native.into(), head));
771    }
772
773    Err(())
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use super::{DatetimeFormat, IntoDatetime, Zone, action};
780    use nu_protocol::Type::Error;
781
782    #[test]
783    fn test_examples() {
784        use crate::test_examples;
785
786        test_examples(IntoDatetime {})
787    }
788
789    #[test]
790    fn takes_a_date_format_with_timezone() {
791        let date_str = Value::test_string("16.11.1984 8:00 am +0000");
792        let fmt_options = Some(Spanned {
793            item: DatetimeFormat("%d.%m.%Y %H:%M %P %z".to_string()),
794            span: Span::test_data(),
795        });
796        let args = Arguments {
797            zone_options: None,
798            format_options: fmt_options,
799            cell_paths: None,
800        };
801        let actual = action(&date_str, &args, Span::test_data());
802        let expected = Value::date(
803            DateTime::parse_from_str("16.11.1984 8:00 am +0000", "%d.%m.%Y %H:%M %P %z").unwrap(),
804            Span::test_data(),
805        );
806        assert_eq!(actual, expected)
807    }
808
809    #[test]
810    fn takes_a_date_format_without_timezone() {
811        let date_str = Value::test_string("16.11.1984 8:00 am");
812        let fmt_options = Some(Spanned {
813            item: DatetimeFormat("%d.%m.%Y %H:%M %P".to_string()),
814            span: Span::test_data(),
815        });
816        let args = Arguments {
817            zone_options: None,
818            format_options: fmt_options,
819            cell_paths: None,
820        };
821        let actual = action(&date_str, &args, Span::test_data());
822        let expected = Value::date(
823            Local
824                .from_local_datetime(
825                    &NaiveDateTime::parse_from_str("16.11.1984 8:00 am", "%d.%m.%Y %H:%M %P")
826                        .unwrap(),
827                )
828                .unwrap()
829                .with_timezone(Local::now().offset()),
830            Span::test_data(),
831        );
832
833        assert_eq!(actual, expected)
834    }
835
836    #[test]
837    fn takes_iso8601_date_format() {
838        let date_str = Value::test_string("2020-08-04T16:39:18+00:00");
839        let args = Arguments {
840            zone_options: None,
841            format_options: None,
842            cell_paths: None,
843        };
844        let actual = action(&date_str, &args, Span::test_data());
845        let expected = Value::date(
846            DateTime::parse_from_str("2020-08-04T16:39:18+00:00", "%Y-%m-%dT%H:%M:%S%z").unwrap(),
847            Span::test_data(),
848        );
849        assert_eq!(actual, expected)
850    }
851
852    #[test]
853    fn takes_timestamp_offset() {
854        let date_str = Value::test_string("1614434140000000000");
855        let timezone_option = Some(Spanned {
856            item: Zone::East(8),
857            span: Span::test_data(),
858        });
859        let args = Arguments {
860            zone_options: timezone_option,
861            format_options: None,
862            cell_paths: None,
863        };
864        let actual = action(&date_str, &args, Span::test_data());
865        let expected = Value::date(
866            DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z").unwrap(),
867            Span::test_data(),
868        );
869
870        assert_eq!(actual, expected)
871    }
872
873    #[test]
874    fn takes_timestamp_offset_as_int() {
875        let date_int = Value::test_int(1_614_434_140_000_000_000);
876        let timezone_option = Some(Spanned {
877            item: Zone::East(8),
878            span: Span::test_data(),
879        });
880        let args = Arguments {
881            zone_options: timezone_option,
882            format_options: None,
883            cell_paths: None,
884        };
885        let actual = action(&date_int, &args, Span::test_data());
886        let expected = Value::date(
887            DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z").unwrap(),
888            Span::test_data(),
889        );
890
891        assert_eq!(actual, expected)
892    }
893
894    #[test]
895    fn takes_int_with_formatstring() {
896        let date_int = Value::test_int(1_614_434_140);
897        let fmt_options = Some(Spanned {
898            item: DatetimeFormat("%s".to_string()),
899            span: Span::test_data(),
900        });
901        let args = Arguments {
902            zone_options: None,
903            format_options: fmt_options,
904            cell_paths: None,
905        };
906        let actual = action(&date_int, &args, Span::test_data());
907        let expected = Value::date(
908            DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z").unwrap(),
909            Span::test_data(),
910        );
911
912        assert_eq!(actual, expected)
913    }
914
915    #[test]
916    fn takes_timestamp_offset_as_int_with_formatting() {
917        let date_int = Value::test_int(1_614_434_140);
918        let timezone_option = Some(Spanned {
919            item: Zone::East(8),
920            span: Span::test_data(),
921        });
922        let fmt_options = Some(Spanned {
923            item: DatetimeFormat("%s".to_string()),
924            span: Span::test_data(),
925        });
926        let args = Arguments {
927            zone_options: timezone_option,
928            format_options: fmt_options,
929            cell_paths: None,
930        };
931        let actual = action(&date_int, &args, Span::test_data());
932        let expected = Value::date(
933            DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z").unwrap(),
934            Span::test_data(),
935        );
936
937        assert_eq!(actual, expected)
938    }
939
940    #[test]
941    fn takes_timestamp_offset_as_int_with_local_timezone() {
942        let date_int = Value::test_int(1_614_434_140);
943        let timezone_option = Some(Spanned {
944            item: Zone::Local,
945            span: Span::test_data(),
946        });
947        let fmt_options = Some(Spanned {
948            item: DatetimeFormat("%s".to_string()),
949            span: Span::test_data(),
950        });
951        let args = Arguments {
952            zone_options: timezone_option,
953            format_options: fmt_options,
954            cell_paths: None,
955        };
956        let actual = action(&date_int, &args, Span::test_data());
957        let expected = Value::date(
958            Utc.timestamp_opt(1_614_434_140, 0).unwrap().into(),
959            Span::test_data(),
960        );
961        assert_eq!(actual, expected)
962    }
963
964    #[test]
965    fn takes_timestamp() {
966        let date_str = Value::test_string("1614434140000000000");
967        let timezone_option = Some(Spanned {
968            item: Zone::Local,
969            span: Span::test_data(),
970        });
971        let args = Arguments {
972            zone_options: timezone_option,
973            format_options: None,
974            cell_paths: None,
975        };
976        let actual = action(&date_str, &args, Span::test_data());
977        let expected = Value::date(
978            Local.timestamp_opt(1_614_434_140, 0).unwrap().into(),
979            Span::test_data(),
980        );
981
982        assert_eq!(actual, expected)
983    }
984
985    #[test]
986    fn takes_datetime() {
987        let timezone_option = Some(Spanned {
988            item: Zone::Local,
989            span: Span::test_data(),
990        });
991        let args = Arguments {
992            zone_options: timezone_option,
993            format_options: None,
994            cell_paths: None,
995        };
996        let expected = Value::date(
997            Local.timestamp_opt(1_614_434_140, 0).unwrap().into(),
998            Span::test_data(),
999        );
1000        let actual = action(&expected, &args, Span::test_data());
1001
1002        assert_eq!(actual, expected)
1003    }
1004
1005    #[test]
1006    fn takes_timestamp_without_timezone() {
1007        let date_str = Value::test_string("1614434140000000000");
1008        let args = Arguments {
1009            zone_options: None,
1010            format_options: None,
1011            cell_paths: None,
1012        };
1013        let actual = action(&date_str, &args, Span::test_data());
1014
1015        let expected = Value::date(
1016            Utc.timestamp_opt(1_614_434_140, 0).unwrap().into(),
1017            Span::test_data(),
1018        );
1019
1020        assert_eq!(actual, expected)
1021    }
1022
1023    #[test]
1024    fn communicates_parsing_error_given_an_invalid_datetimelike_string() {
1025        let date_str = Value::test_string("16.11.1984 8:00 am Oops0000");
1026        let fmt_options = Some(Spanned {
1027            item: DatetimeFormat("%d.%m.%Y %H:%M %P %z".to_string()),
1028            span: Span::test_data(),
1029        });
1030        let args = Arguments {
1031            zone_options: None,
1032            format_options: fmt_options,
1033            cell_paths: None,
1034        };
1035        let actual = action(&date_str, &args, Span::test_data());
1036
1037        assert_eq!(actual.get_type(), Error);
1038    }
1039}