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            // 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.with_timezone(&Utc).into(), *span),
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, timezone.as_ref())
472                        .unwrap_or_else(|_| {
473                            Value::error(
474                                ShellError::CantConvert {
475                                    to_type: format!(
476                                        "could not parse as datetime using format '{}'",
477                                        dt_format.item.0
478                                    ),
479                                    from_type: reason.to_string(),
480                                    span: head,
481                                    help: Some(
482                                        "you can use `into datetime` without a format string to \
483                                         enable flexible parsing"
484                                            .to_string(),
485                                    ),
486                                },
487                                head,
488                            )
489                        }),
490                }
491            }
492
493            // Tries to automatically parse the date
494            // (i.e. without a format string)
495            // and assumes the system's local timezone if none is specified
496            None => match parse_date_from_string(val, span) {
497                Ok(date) => Value::date(date, span),
498                Err(err) => err,
499            },
500        }
501    };
502
503    match input {
504        Value::String { val, .. } => parse_as_string(val),
505        Value::Int { val, .. } => parse_as_string(&val.to_string()),
506
507        // Propagate errors by explicitly matching them before the final case.
508        Value::Error { .. } => input.clone(),
509        other => Value::error(
510            ShellError::OnlySupportsThisInputType {
511                exp_input_type: "string".into(),
512                wrong_type: other.get_type().to_string(),
513                dst_span: head,
514                src_span: other.span(),
515            },
516            head,
517        ),
518    }
519}
520
521fn merge_record(record: &Record, head: Span, span: Span) -> Result<Value, ShellError> {
522    if let Some(invalid_col) = record
523        .columns()
524        .find(|key| !ALLOWED_COLUMNS.contains(&key.as_str()))
525    {
526        let allowed_cols = ALLOWED_COLUMNS.join(", ");
527        return Err(ShellError::UnsupportedInput {
528            msg: format!(
529                "Column '{invalid_col}' is not valid for a structured datetime. Allowed columns \
530                 are: {allowed_cols}"
531            ),
532            input: "value originates from here".into(),
533            msg_span: head,
534            input_span: span,
535        });
536    };
537
538    // 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.
539    // And local timezone is used if not provided.
540    #[derive(Debug)]
541    enum RecordColumnDefault {
542        Now,
543        Zero,
544    }
545    let mut record_column_default = RecordColumnDefault::Now;
546
547    let now = Local::now();
548    let mut now_nanosecond = now.nanosecond();
549    let now_millisecond = now_nanosecond / 1_000_000;
550    now_nanosecond %= 1_000_000;
551    let now_microsecond = now_nanosecond / 1_000;
552    now_nanosecond %= 1_000;
553
554    let year: i32 = match record.get("year") {
555        Some(val) => {
556            record_column_default = RecordColumnDefault::Zero;
557            match val {
558                Value::Int { val, .. } => *val as i32,
559                other => {
560                    return Err(ShellError::OnlySupportsThisInputType {
561                        exp_input_type: "int".to_string(),
562                        wrong_type: other.get_type().to_string(),
563                        dst_span: head,
564                        src_span: other.span(),
565                    });
566                }
567            }
568        }
569        None => now.year(),
570    };
571    let month = match record.get("month") {
572        Some(col_val) => {
573            record_column_default = RecordColumnDefault::Zero;
574            parse_value_from_record_as_u32("month", col_val, &head, &span)?
575        }
576        None => match record_column_default {
577            RecordColumnDefault::Now => now.month(),
578            RecordColumnDefault::Zero => 1,
579        },
580    };
581    let day = match record.get("day") {
582        Some(col_val) => {
583            record_column_default = RecordColumnDefault::Zero;
584            parse_value_from_record_as_u32("day", col_val, &head, &span)?
585        }
586        None => match record_column_default {
587            RecordColumnDefault::Now => now.day(),
588            RecordColumnDefault::Zero => 1,
589        },
590    };
591    let hour = match record.get("hour") {
592        Some(col_val) => {
593            record_column_default = RecordColumnDefault::Zero;
594            parse_value_from_record_as_u32("hour", col_val, &head, &span)?
595        }
596        None => match record_column_default {
597            RecordColumnDefault::Now => now.hour(),
598            RecordColumnDefault::Zero => 0,
599        },
600    };
601    let minute = match record.get("minute") {
602        Some(col_val) => {
603            record_column_default = RecordColumnDefault::Zero;
604            parse_value_from_record_as_u32("minute", col_val, &head, &span)?
605        }
606        None => match record_column_default {
607            RecordColumnDefault::Now => now.minute(),
608            RecordColumnDefault::Zero => 0,
609        },
610    };
611    let second = match record.get("second") {
612        Some(col_val) => {
613            record_column_default = RecordColumnDefault::Zero;
614            parse_value_from_record_as_u32("second", col_val, &head, &span)?
615        }
616        None => match record_column_default {
617            RecordColumnDefault::Now => now.second(),
618            RecordColumnDefault::Zero => 0,
619        },
620    };
621    let millisecond = match record.get("millisecond") {
622        Some(col_val) => {
623            record_column_default = RecordColumnDefault::Zero;
624            parse_value_from_record_as_u32("millisecond", col_val, &head, &span)?
625        }
626        None => match record_column_default {
627            RecordColumnDefault::Now => now_millisecond,
628            RecordColumnDefault::Zero => 0,
629        },
630    };
631    let microsecond = match record.get("microsecond") {
632        Some(col_val) => {
633            record_column_default = RecordColumnDefault::Zero;
634            parse_value_from_record_as_u32("microsecond", col_val, &head, &span)?
635        }
636        None => match record_column_default {
637            RecordColumnDefault::Now => now_microsecond,
638            RecordColumnDefault::Zero => 0,
639        },
640    };
641
642    let nanosecond = match record.get("nanosecond") {
643        Some(col_val) => parse_value_from_record_as_u32("nanosecond", col_val, &head, &span)?,
644        None => match record_column_default {
645            RecordColumnDefault::Now => now_nanosecond,
646            RecordColumnDefault::Zero => 0,
647        },
648    };
649
650    let offset: FixedOffset = match record.get("timezone") {
651        Some(timezone) => parse_timezone_from_record(timezone, &head, &timezone.span())?,
652        None => now.offset().to_owned(),
653    };
654
655    let total_nanoseconds = nanosecond + microsecond * 1_000 + millisecond * 1_000_000;
656
657    let date = match NaiveDate::from_ymd_opt(year, month, day) {
658        Some(d) => d,
659        None => {
660            return Err(ShellError::IncorrectValue {
661                msg: "one of more values are incorrect and do not represent valid date".to_string(),
662                val_span: head,
663                call_span: span,
664            });
665        }
666    };
667    let time = match NaiveTime::from_hms_nano_opt(hour, minute, second, total_nanoseconds) {
668        Some(t) => t,
669        None => {
670            return Err(ShellError::IncorrectValue {
671                msg: "one of more values are incorrect and do not represent valid time".to_string(),
672                val_span: head,
673                call_span: span,
674            });
675        }
676    };
677    let date_time = NaiveDateTime::new(date, time);
678
679    let date_time_fixed = match offset.from_local_datetime(&date_time).single() {
680        Some(d) => d,
681        None => {
682            return Err(ShellError::IncorrectValue {
683                msg: "Ambiguous or invalid timezone conversion".to_string(),
684                val_span: head,
685                call_span: span,
686            });
687        }
688    };
689    Ok(Value::date(date_time_fixed, span))
690}
691
692fn parse_value_from_record_as_u32(
693    col: &str,
694    col_val: &Value,
695    head: &Span,
696    span: &Span,
697) -> Result<u32, ShellError> {
698    let value: u32 = match col_val {
699        Value::Int { val, .. } => {
700            if *val < 0 || *val > u32::MAX as i64 {
701                return Err(ShellError::IncorrectValue {
702                    msg: format!("incorrect value for {col}"),
703                    val_span: *head,
704                    call_span: *span,
705                });
706            }
707            *val as u32
708        }
709        other => {
710            return Err(ShellError::OnlySupportsThisInputType {
711                exp_input_type: "int".to_string(),
712                wrong_type: other.get_type().to_string(),
713                dst_span: *head,
714                src_span: other.span(),
715            });
716        }
717    };
718    Ok(value)
719}
720
721fn parse_timezone_from_record(
722    timezone: &Value,
723    head: &Span,
724    span: &Span,
725) -> Result<FixedOffset, ShellError> {
726    match timezone {
727        Value::String { val, .. } => {
728            let offset: FixedOffset = match val.parse() {
729                Ok(offset) => offset,
730                Err(_) => {
731                    return Err(ShellError::IncorrectValue {
732                        msg: "invalid timezone".to_string(),
733                        val_span: *span,
734                        call_span: *head,
735                    });
736                }
737            };
738            Ok(offset)
739        }
740        other => Err(ShellError::OnlySupportsThisInputType {
741            exp_input_type: "string".to_string(),
742            wrong_type: other.get_type().to_string(),
743            dst_span: *head,
744            src_span: other.span(),
745        }),
746    }
747}
748
749fn datetime_parse_error_value(val: &str, span: Span) -> Value {
750    Value::error(
751        ShellError::DatetimeParseError {
752            msg: val.to_string(),
753            span,
754        },
755        span,
756    )
757}
758
759fn invalid_timezone_value(span: Span) -> Value {
760    Value::error(
761        // This is an argument error, not an input error
762        ShellError::TypeMismatch {
763            err_message: "Invalid timezone or offset".to_string(),
764            span,
765        },
766        span,
767    )
768}
769
770fn interpret_wall_clock_datetime(
771    dt: NaiveDateTime,
772    timezone: Option<&Spanned<Zone>>,
773    head: Span,
774    val: &str,
775) -> Value {
776    match timezone {
777        None => match Local.from_local_datetime(&dt).single() {
778            Some(dt_native) => Value::date(dt_native.into(), head),
779            None => datetime_parse_error_value(val, head),
780        },
781        Some(Spanned { item, span }) => match item {
782            Zone::Utc => Value::date(Utc.from_utc_datetime(&dt).into(), *span),
783            Zone::Local => match Local.from_local_datetime(&dt).single() {
784                Some(dt_native) => Value::date(dt_native.into(), *span),
785                None => datetime_parse_error_value(val, *span),
786            },
787            Zone::East(i) => match FixedOffset::east_opt((*i as i32) * HOUR) {
788                Some(eastoffset) => match eastoffset.from_local_datetime(&dt).single() {
789                    Some(dt_native) => Value::date(dt_native, *span),
790                    None => datetime_parse_error_value(val, *span),
791                },
792                None => datetime_parse_error_value(val, *span),
793            },
794            Zone::West(i) => match FixedOffset::west_opt((*i as i32) * HOUR) {
795                Some(westoffset) => match westoffset.from_local_datetime(&dt).single() {
796                    Some(dt_native) => Value::date(dt_native, *span),
797                    None => datetime_parse_error_value(val, *span),
798                },
799                None => datetime_parse_error_value(val, *span),
800            },
801            Zone::Error => invalid_timezone_value(*span),
802        },
803    }
804}
805
806fn parse_with_format(
807    val: &str,
808    fmt: &str,
809    head: Span,
810    timezone: Option<&Spanned<Zone>>,
811) -> Result<Value, ()> {
812    // try parsing at date + time
813    if let Ok(dt) = NaiveDateTime::parse_from_str(val, fmt) {
814        return Ok(interpret_wall_clock_datetime(dt, timezone, head, val));
815    }
816
817    // try parsing at date only
818    if let Ok(date) = NaiveDate::parse_from_str(val, fmt)
819        && let Some(dt) = date.and_hms_opt(0, 0, 0)
820    {
821        return Ok(interpret_wall_clock_datetime(dt, timezone, head, val));
822    }
823
824    // try parsing at time only
825    if let Ok(time) = NaiveTime::parse_from_str(val, fmt) {
826        let now = Local::now().naive_local().date();
827        return Ok(interpret_wall_clock_datetime(
828            now.and_time(time),
829            timezone,
830            head,
831            val,
832        ));
833    }
834
835    Err(())
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use super::{DatetimeFormat, IntoDatetime, Zone, action};
842    use nu_protocol::Type::Error;
843
844    #[test]
845    fn test_examples() -> nu_test_support::Result {
846        nu_test_support::test().examples(IntoDatetime)
847    }
848
849    #[test]
850    fn takes_a_date_format_with_timezone() {
851        let date_str = Value::test_string("16.11.1984 8:00 am +0000");
852        let fmt_options = Some(Spanned {
853            item: DatetimeFormat("%d.%m.%Y %H:%M %P %z".to_string()),
854            span: Span::test_data(),
855        });
856        let args = Arguments {
857            zone_options: None,
858            format_options: fmt_options,
859            cell_paths: None,
860        };
861        let actual = action(&date_str, &args, Span::test_data());
862        let expected = Value::date(
863            DateTime::parse_from_str("16.11.1984 8:00 am +0000", "%d.%m.%Y %H:%M %P %z").unwrap(),
864            Span::test_data(),
865        );
866        assert_eq!(actual, expected)
867    }
868
869    #[test]
870    fn takes_a_date_format_without_timezone() {
871        let date_str = Value::test_string("16.11.1984 8:00 am");
872        let fmt_options = Some(Spanned {
873            item: DatetimeFormat("%d.%m.%Y %H:%M %P".to_string()),
874            span: Span::test_data(),
875        });
876        let args = Arguments {
877            zone_options: None,
878            format_options: fmt_options,
879            cell_paths: None,
880        };
881        let actual = action(&date_str, &args, Span::test_data());
882        let expected = Value::date(
883            Local
884                .from_local_datetime(
885                    &NaiveDateTime::parse_from_str("16.11.1984 8:00 am", "%d.%m.%Y %H:%M %P")
886                        .unwrap(),
887                )
888                .unwrap()
889                .with_timezone(Local::now().offset()),
890            Span::test_data(),
891        );
892
893        assert_eq!(actual, expected)
894    }
895
896    #[test]
897    fn takes_a_date_format_without_timezone_with_utc_timezone() {
898        let date_str = Value::test_string("2026-03-21_00:25");
899        let timezone_option = Some(Spanned {
900            item: Zone::Utc,
901            span: Span::test_data(),
902        });
903        let fmt_options = Some(Spanned {
904            item: DatetimeFormat("%F_%R".to_string()),
905            span: Span::test_data(),
906        });
907        let args = Arguments {
908            zone_options: timezone_option,
909            format_options: fmt_options,
910            cell_paths: None,
911        };
912        let actual = action(&date_str, &args, Span::test_data());
913        let expected = Value::date(
914            Utc.from_utc_datetime(
915                &NaiveDateTime::parse_from_str("2026-03-21_00:25", "%F_%R").unwrap(),
916            )
917            .into(),
918            Span::test_data(),
919        );
920
921        assert_eq!(actual, expected)
922    }
923
924    #[test]
925    fn takes_a_date_format_without_timezone_with_offset_timezone() {
926        let date_str = Value::test_string("29 Aug 2025 19:30:07");
927        let timezone_option = Some(Spanned {
928            item: Zone::East(3),
929            span: Span::test_data(),
930        });
931        let fmt_options = Some(Spanned {
932            item: DatetimeFormat("%d %b %Y %H:%M:%S".to_string()),
933            span: Span::test_data(),
934        });
935        let args = Arguments {
936            zone_options: timezone_option,
937            format_options: fmt_options,
938            cell_paths: None,
939        };
940        let actual = action(&date_str, &args, Span::test_data());
941
942        let dt =
943            NaiveDateTime::parse_from_str("29 Aug 2025 19:30:07", "%d %b %Y %H:%M:%S").unwrap();
944        let eastoffset = FixedOffset::east_opt(3 * HOUR).unwrap();
945        let expected = Value::date(
946            eastoffset.from_local_datetime(&dt).single().unwrap(),
947            Span::test_data(),
948        );
949
950        assert_eq!(actual, expected)
951    }
952
953    #[test]
954    fn takes_a_date_format_without_timezone_applies_timezone_and_offset_differently() {
955        let date_str = Value::test_string("2026-03-21_00:25");
956        let fmt_options = Some(Spanned {
957            item: DatetimeFormat("%F_%R".to_string()),
958            span: Span::test_data(),
959        });
960
961        let utc_args = Arguments {
962            zone_options: Some(Spanned {
963                item: Zone::Utc,
964                span: Span::test_data(),
965            }),
966            format_options: fmt_options.clone(),
967            cell_paths: None,
968        };
969        let east_args = Arguments {
970            zone_options: Some(Spanned {
971                item: Zone::East(1),
972                span: Span::test_data(),
973            }),
974            format_options: fmt_options,
975            cell_paths: None,
976        };
977
978        let utc_actual = action(&date_str, &utc_args, Span::test_data());
979        let east_actual = action(&date_str, &east_args, Span::test_data());
980
981        assert_ne!(utc_actual, east_actual)
982    }
983
984    #[test]
985    fn takes_iso8601_date_format() {
986        let date_str = Value::test_string("2020-08-04T16:39:18+00:00");
987        let args = Arguments {
988            zone_options: None,
989            format_options: None,
990            cell_paths: None,
991        };
992        let actual = action(&date_str, &args, Span::test_data());
993        let expected = Value::date(
994            DateTime::parse_from_str("2020-08-04T16:39:18+00:00", "%Y-%m-%dT%H:%M:%S%z").unwrap(),
995            Span::test_data(),
996        );
997        assert_eq!(actual, expected)
998    }
999
1000    #[test]
1001    fn takes_timestamp_offset() {
1002        let date_str = Value::test_string("1614434140000000000");
1003        let timezone_option = Some(Spanned {
1004            item: Zone::East(8),
1005            span: Span::test_data(),
1006        });
1007        let args = Arguments {
1008            zone_options: timezone_option,
1009            format_options: None,
1010            cell_paths: None,
1011        };
1012        let actual = action(&date_str, &args, Span::test_data());
1013        let expected = Value::date(
1014            DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z").unwrap(),
1015            Span::test_data(),
1016        );
1017
1018        assert_eq!(actual, expected)
1019    }
1020
1021    #[test]
1022    fn takes_timestamp_offset_as_int() {
1023        let date_int = Value::test_int(1_614_434_140_000_000_000);
1024        let timezone_option = Some(Spanned {
1025            item: Zone::East(8),
1026            span: Span::test_data(),
1027        });
1028        let args = Arguments {
1029            zone_options: timezone_option,
1030            format_options: None,
1031            cell_paths: None,
1032        };
1033        let actual = action(&date_int, &args, Span::test_data());
1034        let expected = Value::date(
1035            DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z").unwrap(),
1036            Span::test_data(),
1037        );
1038
1039        assert_eq!(actual, expected)
1040    }
1041
1042    #[test]
1043    fn takes_int_with_formatstring() {
1044        let date_int = Value::test_int(1_614_434_140);
1045        let fmt_options = Some(Spanned {
1046            item: DatetimeFormat("%s".to_string()),
1047            span: Span::test_data(),
1048        });
1049        let args = Arguments {
1050            zone_options: None,
1051            format_options: fmt_options,
1052            cell_paths: None,
1053        };
1054        let actual = action(&date_int, &args, Span::test_data());
1055        let expected = Value::date(
1056            DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z").unwrap(),
1057            Span::test_data(),
1058        );
1059
1060        assert_eq!(actual, expected)
1061    }
1062
1063    #[test]
1064    fn takes_timestamp_offset_as_int_with_formatting() {
1065        let date_int = Value::test_int(1_614_434_140);
1066        let timezone_option = Some(Spanned {
1067            item: Zone::East(8),
1068            span: Span::test_data(),
1069        });
1070        let fmt_options = Some(Spanned {
1071            item: DatetimeFormat("%s".to_string()),
1072            span: Span::test_data(),
1073        });
1074        let args = Arguments {
1075            zone_options: timezone_option,
1076            format_options: fmt_options,
1077            cell_paths: None,
1078        };
1079        let actual = action(&date_int, &args, Span::test_data());
1080        let expected = Value::date(
1081            DateTime::parse_from_str("2021-02-27 21:55:40 +08:00", "%Y-%m-%d %H:%M:%S %z").unwrap(),
1082            Span::test_data(),
1083        );
1084
1085        assert_eq!(actual, expected)
1086    }
1087
1088    #[test]
1089    fn takes_timestamp_offset_as_int_with_local_timezone() {
1090        let date_int = Value::test_int(1_614_434_140);
1091        let timezone_option = Some(Spanned {
1092            item: Zone::Local,
1093            span: Span::test_data(),
1094        });
1095        let fmt_options = Some(Spanned {
1096            item: DatetimeFormat("%s".to_string()),
1097            span: Span::test_data(),
1098        });
1099        let args = Arguments {
1100            zone_options: timezone_option,
1101            format_options: fmt_options,
1102            cell_paths: None,
1103        };
1104        let actual = action(&date_int, &args, Span::test_data());
1105        let expected = Value::date(
1106            Utc.timestamp_opt(1_614_434_140, 0).unwrap().into(),
1107            Span::test_data(),
1108        );
1109        assert_eq!(actual, expected)
1110    }
1111
1112    #[test]
1113    fn takes_timestamp() {
1114        let date_str = Value::test_string("1614434140000000000");
1115        let timezone_option = Some(Spanned {
1116            item: Zone::Local,
1117            span: Span::test_data(),
1118        });
1119        let args = Arguments {
1120            zone_options: timezone_option,
1121            format_options: None,
1122            cell_paths: None,
1123        };
1124        let actual = action(&date_str, &args, Span::test_data());
1125        let expected = Value::date(
1126            Local.timestamp_opt(1_614_434_140, 0).unwrap().into(),
1127            Span::test_data(),
1128        );
1129
1130        assert_eq!(actual, expected)
1131    }
1132
1133    #[test]
1134    fn takes_datetime() {
1135        let timezone_option = Some(Spanned {
1136            item: Zone::Local,
1137            span: Span::test_data(),
1138        });
1139        let args = Arguments {
1140            zone_options: timezone_option,
1141            format_options: None,
1142            cell_paths: None,
1143        };
1144        let expected = Value::date(
1145            Local.timestamp_opt(1_614_434_140, 0).unwrap().into(),
1146            Span::test_data(),
1147        );
1148        let actual = action(&expected, &args, Span::test_data());
1149
1150        assert_eq!(actual, expected)
1151    }
1152
1153    #[test]
1154    fn takes_timestamp_without_timezone() {
1155        let date_str = Value::test_string("1614434140000000000");
1156        let args = Arguments {
1157            zone_options: None,
1158            format_options: None,
1159            cell_paths: None,
1160        };
1161        let actual = action(&date_str, &args, Span::test_data());
1162
1163        let expected = Value::date(
1164            Utc.timestamp_opt(1_614_434_140, 0).unwrap().into(),
1165            Span::test_data(),
1166        );
1167
1168        assert_eq!(actual, expected)
1169    }
1170
1171    #[test]
1172    fn communicates_parsing_error_given_an_invalid_datetimelike_string() {
1173        let date_str = Value::test_string("16.11.1984 8:00 am Oops0000");
1174        let fmt_options = Some(Spanned {
1175            item: DatetimeFormat("%d.%m.%Y %H:%M %P %z".to_string()),
1176            span: Span::test_data(),
1177        });
1178        let args = Arguments {
1179            zone_options: None,
1180            format_options: fmt_options,
1181            cell_paths: None,
1182        };
1183        let actual = action(&date_str, &args, Span::test_data());
1184
1185        assert_eq!(actual.get_type(), Error);
1186    }
1187}