nu_command/strings/format/
date.rs

1use crate::{generate_strftime_list, parse_date_from_string};
2use chrono::{DateTime, Datelike, Locale, TimeZone};
3use nu_engine::command_prelude::*;
4
5use nu_utils::locale::{LOCALE_OVERRIDE_ENV_VAR, get_system_locale_string};
6use std::fmt::{Display, Write};
7
8#[derive(Clone)]
9pub struct FormatDate;
10
11impl Command for FormatDate {
12    fn name(&self) -> &str {
13        "format date"
14    }
15
16    fn signature(&self) -> Signature {
17        Signature::build("format date")
18            .input_output_types(vec![
19                (Type::Date, Type::String),
20                (Type::String, Type::String),
21                (Type::Nothing, Type::table()),
22                // FIXME Type::Any input added to disable pipeline input type checking, as run-time checks can raise undesirable type errors
23                // which aren't caught by the parser. see https://github.com/nushell/nushell/pull/14922 for more details
24                // only applicable for --list flag
25                (Type::Any, Type::table()),
26            ])
27            .allow_variants_without_examples(true) // https://github.com/nushell/nushell/issues/7032
28            .switch("list", "lists strftime cheatsheet", Some('l'))
29            .optional(
30                "format string",
31                SyntaxShape::String,
32                "The desired format date.",
33            )
34            .category(Category::Strings)
35    }
36
37    fn description(&self) -> &str {
38        "Format a given date using a format string."
39    }
40
41    fn search_terms(&self) -> Vec<&str> {
42        vec!["fmt", "strftime"]
43    }
44
45    fn examples(&self) -> Vec<Example<'_>> {
46        vec![
47            Example {
48                description: "Format a given date-time using the default format (RFC 2822).",
49                example: r#"'2021-10-22 20:00:12 +01:00' | into datetime | format date"#,
50                result: Some(Value::string(
51                    "Fri, 22 Oct 2021 20:00:12 +0100".to_string(),
52                    Span::test_data(),
53                )),
54            },
55            Example {
56                description: "Format a given date-time as a string using the default format (RFC 2822).",
57                example: r#""2021-10-22 20:00:12 +01:00" | format date"#,
58                result: Some(Value::string(
59                    "Fri, 22 Oct 2021 20:00:12 +0100".to_string(),
60                    Span::test_data(),
61                )),
62            },
63            Example {
64                description: "Format a given date-time according to the RFC 3339 standard.",
65                example: r#"'2021-10-22 20:00:12 +01:00' | into datetime | format date "%+""#,
66                result: Some(Value::string(
67                    "2021-10-22T20:00:12+01:00".to_string(),
68                    Span::test_data(),
69                )),
70            },
71            Example {
72                description: "Format the current date-time using a given format string.",
73                example: r#"date now | format date "%Y-%m-%d %H:%M:%S""#,
74                result: None,
75            },
76            Example {
77                description: "Format the current date using a given format string.",
78                example: r#"date now | format date "%Y-%m-%d %H:%M:%S""#,
79                result: None,
80            },
81            Example {
82                description: "Format a given date using a given format string.",
83                example: r#""2021-10-22 20:00:12 +01:00" | format date "%Y-%m-%d""#,
84                result: Some(Value::test_string("2021-10-22")),
85            },
86        ]
87    }
88
89    fn is_const(&self) -> bool {
90        true
91    }
92
93    fn run(
94        &self,
95        engine_state: &EngineState,
96        stack: &mut Stack,
97        call: &Call,
98        input: PipelineData,
99    ) -> Result<PipelineData, ShellError> {
100        let list = call.has_flag(engine_state, stack, "list")?;
101        let format = call.opt::<Spanned<String>>(engine_state, stack, 0)?;
102
103        // env var preference is documented at https://www.gnu.org/software/gettext/manual/html_node/Locale-Environment-Variables.html
104        // LC_ALL overrides LC_TIME, LC_TIME overrides LANG
105
106        // get the locale first so we can use the proper get_env_var functions since this is a const command
107        // we can override the locale by setting $env.NU_TEST_LOCALE_OVERRIDE or $env.LC_TIME
108        let locale = if let Some(loc) = engine_state
109            .get_env_var(LOCALE_OVERRIDE_ENV_VAR)
110            .or_else(|| engine_state.get_env_var("LC_ALL"))
111            .or_else(|| engine_state.get_env_var("LC_TIME"))
112            .or_else(|| engine_state.get_env_var("LANG"))
113        {
114            let locale_str = loc.as_str()?.split('.').next().unwrap_or("en_US");
115            locale_str.try_into().unwrap_or(Locale::en_US)
116        } else {
117            get_system_locale_string()
118                .map(|l| l.replace('-', "_"))
119                .unwrap_or_else(|| String::from("en_US"))
120                .as_str()
121                .try_into()
122                .unwrap_or(Locale::en_US)
123        };
124
125        run(engine_state, call, input, list, format, locale)
126    }
127
128    fn run_const(
129        &self,
130        working_set: &StateWorkingSet,
131        call: &Call,
132        input: PipelineData,
133    ) -> Result<PipelineData, ShellError> {
134        let list = call.has_flag_const(working_set, "list")?;
135        let format = call.opt_const::<Spanned<String>>(working_set, 0)?;
136
137        // env var preference is documented at https://www.gnu.org/software/gettext/manual/html_node/Locale-Environment-Variables.html
138        // LC_ALL overrides LC_TIME, LC_TIME overrides LANG
139
140        // get the locale first so we can use the proper get_env_var functions since this is a const command
141        // we can override the locale by setting $env.NU_TEST_LOCALE_OVERRIDE or $env.LC_TIME
142        let locale = if let Some(loc) = working_set
143            .get_env_var(LOCALE_OVERRIDE_ENV_VAR)
144            .or_else(|| working_set.get_env_var("LC_ALL"))
145            .or_else(|| working_set.get_env_var("LC_TIME"))
146            .or_else(|| working_set.get_env_var("LANG"))
147        {
148            let locale_str = loc.as_str()?.split('.').next().unwrap_or("en_US");
149            locale_str.try_into().unwrap_or(Locale::en_US)
150        } else {
151            get_system_locale_string()
152                .map(|l| l.replace('-', "_"))
153                .unwrap_or_else(|| String::from("en_US"))
154                .as_str()
155                .try_into()
156                .unwrap_or(Locale::en_US)
157        };
158
159        run(working_set.permanent(), call, input, list, format, locale)
160    }
161}
162
163fn run(
164    engine_state: &EngineState,
165    call: &Call,
166    input: PipelineData,
167    list: bool,
168    format: Option<Spanned<String>>,
169    locale: Locale,
170) -> Result<PipelineData, ShellError> {
171    let head = call.head;
172    if list {
173        return Ok(PipelineData::value(
174            generate_strftime_list(head, false),
175            None,
176        ));
177    }
178
179    // This doesn't match explicit nulls
180    if let PipelineData::Empty = input {
181        return Err(ShellError::PipelineEmpty { dst_span: head });
182    }
183    input.map(
184        move |value| match &format {
185            Some(format) => format_helper(value, format.item.as_str(), format.span, head, locale),
186            None => format_helper_rfc2822(value, head),
187        },
188        engine_state.signals(),
189    )
190}
191
192fn format_from<Tz: TimeZone>(
193    date_time: DateTime<Tz>,
194    formatter: &str,
195    span: Span,
196    locale: Locale,
197) -> Value
198where
199    Tz::Offset: Display,
200{
201    let mut formatter_buf = String::new();
202    // Handle custom format specifiers for compact formats
203    let processed_formatter = formatter
204        .replace("%J", "%Y%m%d") // %J for joined date (YYYYMMDD)
205        .replace("%Q", "%H%M%S"); // %Q for sequential time (HHMMSS)
206    let format = date_time.format_localized(&processed_formatter, locale);
207
208    match formatter_buf.write_fmt(format_args!("{format}")) {
209        Ok(_) => Value::string(formatter_buf, span),
210        Err(_) => Value::error(
211            ShellError::TypeMismatch {
212                err_message: "invalid format".to_string(),
213                span,
214            },
215            span,
216        ),
217    }
218}
219
220fn format_helper(
221    value: Value,
222    formatter: &str,
223    formatter_span: Span,
224    head_span: Span,
225    locale: Locale,
226) -> Value {
227    match value {
228        Value::Date { val, .. } => format_from(val, formatter, formatter_span, locale),
229        Value::String { val, .. } => {
230            let dt = parse_date_from_string(&val, formatter_span);
231
232            match dt {
233                Ok(x) => format_from(x, formatter, formatter_span, locale),
234                Err(e) => e,
235            }
236        }
237        _ => Value::error(
238            ShellError::OnlySupportsThisInputType {
239                exp_input_type: "date, string (that represents datetime)".into(),
240                wrong_type: value.get_type().to_string(),
241                dst_span: head_span,
242                src_span: value.span(),
243            },
244            head_span,
245        ),
246    }
247}
248
249fn format_helper_rfc2822(value: Value, span: Span) -> Value {
250    let val_span = value.span();
251    match value {
252        Value::Date { val, .. } => Value::string(
253            {
254                if val.year() >= 0 {
255                    val.to_rfc2822()
256                } else {
257                    val.to_rfc3339()
258                }
259            },
260            span,
261        ),
262        Value::String { val, .. } => {
263            let dt = parse_date_from_string(&val, val_span);
264            match dt {
265                Ok(x) => Value::string(
266                    {
267                        if x.year() >= 0 {
268                            x.to_rfc2822()
269                        } else {
270                            x.to_rfc3339()
271                        }
272                    },
273                    span,
274                ),
275                Err(e) => e,
276            }
277        }
278        _ => Value::error(
279            ShellError::OnlySupportsThisInputType {
280                exp_input_type: "date, string (that represents datetime)".into(),
281                wrong_type: value.get_type().to_string(),
282                dst_span: span,
283                src_span: val_span,
284            },
285            span,
286        ),
287    }
288}
289
290#[cfg(test)]
291mod test {
292    use super::*;
293
294    #[test]
295    fn test_examples() {
296        use crate::test_examples;
297
298        test_examples(FormatDate {})
299    }
300}