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