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 zone_options = {
145 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 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 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 let timestamp = match input {
337 Value::Int { val, .. } => Ok(*val),
338 Value::String { val, .. } => val.parse::<i64>(),
339 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 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 ShellError::TypeMismatch {
400 err_message: "Invalid timezone or offset".to_string(),
401 span: *span,
402 },
403 *span,
404 ),
405 },
406 };
407 };
408
409 let span = input.span();
411
412 let parse_as_string = |val: &str| {
413 match dateformat {
414 Some(dt_format) => {
415 let format_str = dt_format
417 .item
418 .0
419 .replace("%J", "%Y%m%d") .replace("%Q", "%H%M%S"); 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 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 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 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 #[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 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 if let Ok(dt) = NaiveDateTime::parse_from_str(val, fmt) {
806 return Ok(interpret_wall_clock_datetime(dt, timezone, head, val));
807 }
808
809 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 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}