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