diff --git a/crates/nu-command/src/commands/str_/to_datetime.rs b/crates/nu-command/src/commands/str_/to_datetime.rs index 6605c16a6b..057ac7e2c5 100644 --- a/crates/nu-command/src/commands/str_/to_datetime.rs +++ b/crates/nu-command/src/commands/str_/to_datetime.rs @@ -8,14 +8,48 @@ use nu_protocol::{ use nu_source::{Tag, Tagged}; use nu_value_ext::ValueExt; -use chrono::{DateTime, FixedOffset, LocalResult, Offset, TimeZone}; +use chrono::{DateTime, FixedOffset, Local, LocalResult, Offset, TimeZone, Utc}; #[derive(Deserialize)] struct Arguments { + timezone: Option>, + offset: Option>, format: Option>, rest: Vec, } +// In case it may be confused with chrono::TimeZone +#[derive(Clone)] +enum Zone { + Utc, + Local, + East(u8), + West(u8), + Error, // we want the nullshell to cast it instead of rust +} + +impl Zone { + fn new(i: i16) -> Self { + if i.abs() <= 12 { + // guanranteed here + if i >= 0 { + Self::East(i as u8) // won't go out of range + } else { + Self::West(-i as u8) // same here + } + } else { + Self::Error // Out of range + } + } + fn from_string(s: String) -> Self { + match s.to_lowercase().as_str() { + "utc" | "u" => Self::Utc, + "local" | "l" => Self::Local, + _ => Self::Error, + } + } +} + pub struct SubCommand; #[async_trait] @@ -26,6 +60,18 @@ impl WholeStreamCommand for SubCommand { fn signature(&self) -> Signature { Signature::build("str to-datetime") + .named( + "timezone", + SyntaxShape::String, + "Specify timezone if the input is timestamp, like 'UTC/u' or 'LOCAL/l'", + Some('z'), + ) + .named( + "offset", + SyntaxShape::Int, + "Specify timezone by offset if the input is timestamp, like '+8', '-4', prior than timezone", + Some('o'), + ) .named( "format", SyntaxShape::String, @@ -63,6 +109,17 @@ impl WholeStreamCommand for SubCommand { example: "echo '20200904_163918+0000' | str to-datetime -f '%Y%m%d_%H%M%S%z'", result: None, }, + Example { + description: "Convert to datetime using a specified timezone", + example: "echo '1614434140' | str to-datetime -z 'UTC'", + result: None, + }, + Example { + description: + "Convert to datetime using a specified timezone offset (between -12 and 12)", + example: "echo '1614434140' | str to-datetime -o '+9'", + result: None, + }, ] } } @@ -71,11 +128,42 @@ impl WholeStreamCommand for SubCommand { struct DatetimeFormat(String); async fn operate(args: CommandArgs) -> Result { - let (Arguments { format, rest }, input) = args.process().await?; + let ( + Arguments { + timezone, + offset, + format, + rest, + }, + input, + ) = args.process().await?; let column_paths: Vec<_> = rest; - let options = if let Some(Tagged { item: fmt, .. }) = format { + // if zone-offset is specified, then zone will be neglected + let zone_options = if let Some(Tagged { + item: zone_offset, + tag: _tag, + }) = offset + { + Some(Tagged { + item: Zone::new(zone_offset), + tag: _tag, + }) + } else if let Some(Tagged { + item: zone, + tag: _tag, + }) = timezone + { + Some(Tagged { + item: Zone::from_string(zone), + tag: _tag, + }) + } else { + None + }; + + let format_options = if let Some(Tagged { item: fmt, .. }) = format { Some(DatetimeFormat(fmt)) } else { None @@ -84,16 +172,17 @@ async fn operate(args: CommandArgs) -> Result { Ok(input .map(move |v| { if column_paths.is_empty() { - ReturnSuccess::value(action(&v, &options, v.tag())?) + ReturnSuccess::value(action(&v, &zone_options, &format_options, v.tag())?) } else { let mut ret = v; for path in &column_paths { - let options = options.clone(); + let zone_options = zone_options.clone(); + let format_options = format_options.clone(); ret = ret.swap_data_by_column_path( path, - Box::new(move |old| action(old, &options, old.tag())), + Box::new(move |old| action(old, &zone_options, &format_options, old.tag())), )?; } @@ -105,12 +194,41 @@ async fn operate(args: CommandArgs) -> Result { fn action( input: &Value, - options: &Option, + timezone: &Option>, + dateformat: &Option, tag: impl Into, ) -> Result { match &input.value { UntaggedValue::Primitive(Primitive::String(s)) => { - let out = match options { + let ts = s.parse::(); + // if timezone if specified, first check if the input is a timestamp. + if let Some(tz) = timezone { + if let Ok(t) = ts { + const HOUR: i32 = 3600; + let stampout = match tz.item { + Zone::Utc => UntaggedValue::date(Utc.timestamp(t, 0)), + Zone::Local => UntaggedValue::date(Local.timestamp(t, 0)), + Zone::East(i) => { + let eastoffset = FixedOffset::east((i as i32) * HOUR); + UntaggedValue::date(eastoffset.timestamp(t, 0)) + } + Zone::West(i) => { + let westoffset = FixedOffset::west((i as i32) * HOUR); + UntaggedValue::date(westoffset.timestamp(t, 0)) + } + Zone::Error => { + return Err(ShellError::labeled_error( + "could not continue to convert timestamp", + "given timezone or offset is invalid", + tz.tag().span, + )); + } + }; + return Ok(stampout.into_value(tag)); + } + }; + // if it's not, continue and negelect the timezone option. + let out = match dateformat { Some(dt) => match DateTime::parse_from_str(s, &dt.0) { Ok(d) => UntaggedValue::date(d), Err(reason) => { @@ -165,9 +283,9 @@ fn action( #[cfg(test)] mod tests { use super::ShellError; - use super::{action, DatetimeFormat, SubCommand}; + use super::{action, DatetimeFormat, SubCommand, Zone}; use nu_protocol::{Primitive, UntaggedValue}; - use nu_source::Tag; + use nu_source::{Tag, Tagged}; use nu_test_support::value::string; #[test] @@ -183,7 +301,7 @@ mod tests { let fmt_options = Some(DatetimeFormat("%d.%m.%Y %H:%M %P %z".to_string())); - let actual = action(&date_str, &fmt_options, Tag::unknown()).unwrap(); + let actual = action(&date_str, &None, &fmt_options, Tag::unknown()).unwrap(); match actual.value { UntaggedValue::Primitive(Primitive::Date(_)) => {} @@ -194,7 +312,35 @@ mod tests { #[test] fn takes_iso8601_date_format() { let date_str = string("2020-08-04T16:39:18+00:00"); - let actual = action(&date_str, &None, Tag::unknown()).unwrap(); + let actual = action(&date_str, &None, &None, Tag::unknown()).unwrap(); + match actual.value { + UntaggedValue::Primitive(Primitive::Date(_)) => {} + _ => panic!("Didn't convert to date"), + } + } + + #[test] + fn takes_timestamp_offset() { + let date_str = string("1614434140"); + let timezone_option = Some(Tagged { + item: Zone::East(8), + tag: Tag::unknown(), + }); + let actual = action(&date_str, &timezone_option, &None, Tag::unknown()).unwrap(); + match actual.value { + UntaggedValue::Primitive(Primitive::Date(_)) => {} + _ => panic!("Didn't convert to date"), + } + } + + #[test] + fn takes_timestamp() { + let date_str = string("1614434140"); + let timezone_option = Some(Tagged { + item: Zone::Local, + tag: Tag::unknown(), + }); + let actual = action(&date_str, &timezone_option, &None, Tag::unknown()).unwrap(); match actual.value { UntaggedValue::Primitive(Primitive::Date(_)) => {} _ => panic!("Didn't convert to date"), @@ -207,7 +353,7 @@ mod tests { let fmt_options = Some(DatetimeFormat("%d.%m.%Y %H:%M %P %z".to_string())); - let actual = action(&date_str, &fmt_options, Tag::unknown()); + let actual = action(&date_str, &None, &fmt_options, Tag::unknown()); assert!(actual.is_err()); }