Skip to main content

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