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