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#[derive(Clone, Debug)]
38enum Zone {
39 Utc,
40 Local,
41 East(u8),
42 West(u8),
43 Error, }
45
46impl Zone {
47 const OPTIONS: &[&str] = &["utc", "local"];
48 fn new(i: i64) -> Self {
49 if i.abs() <= 12 {
50 if i >= 0 {
52 Self::East(i as u8) } else {
54 Self::West(-i as u8) }
56 } else {
57 Self::Error }
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 (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 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 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 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 let timestamp = match input {
345 Value::Int { val, .. } => Ok(*val),
346 Value::String { val, .. } => val.parse::<i64>(),
347 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 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 ShellError::TypeMismatch {
408 err_message: "Invalid timezone or offset".to_string(),
409 span: *span,
410 },
411 *span,
412 ),
413 },
414 };
415 };
416
417 let span = input.span();
419
420 let parse_as_string = |val: &str| {
421 match dateformat {
422 Some(dt_format) => {
423 let format_str = dt_format
425 .item
426 .0
427 .replace("%J", "%Y%m%d") .replace("%Q", "%H%M%S"); 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 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 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 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 #[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 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 if let Ok(dt) = NaiveDateTime::parse_from_str(val, fmt) {
814 return Ok(interpret_wall_clock_datetime(dt, timezone, head, val));
815 }
816
817 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 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}