vrl/stdlib/
format_timestamp.rs

1use crate::compiler::TimeZone;
2use crate::compiler::prelude::*;
3use chrono::{
4    DateTime, Utc,
5    format::{Item, strftime::StrftimeItems},
6};
7
8fn format_timestamp_with_tz(ts: Value, format: &Value, timezone: Option<Value>) -> Resolved {
9    let ts: DateTime<Utc> = ts.try_timestamp()?;
10
11    let format = format.try_bytes_utf8_lossy()?;
12
13    let timezone_bytes = timezone.map(VrlValueConvert::try_bytes).transpose()?;
14    let timezone = timezone_bytes.as_ref().map(|b| String::from_utf8_lossy(b));
15
16    try_format_with_timezone(ts, &format, timezone.as_deref()).map(Into::into)
17}
18
19#[derive(Clone, Copy, Debug)]
20pub struct FormatTimestamp;
21
22impl Function for FormatTimestamp {
23    fn identifier(&self) -> &'static str {
24        "format_timestamp"
25    }
26
27    fn usage(&self) -> &'static str {
28        "Formats `value` into a string representation of the timestamp."
29    }
30
31    fn category(&self) -> &'static str {
32        Category::Timestamp.as_ref()
33    }
34
35    fn return_kind(&self) -> u16 {
36        kind::BYTES
37    }
38
39    fn parameters(&self) -> &'static [Parameter] {
40        const PARAMETERS: &[Parameter] = &[
41            Parameter::required("value", kind::TIMESTAMP, "The timestamp to format as text."),
42            Parameter::required(
43                "format",
44                kind::BYTES,
45                "The format string as described by the [Chrono library](https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers).",
46            ),
47            Parameter::optional(
48                "timezone",
49                kind::BYTES,
50                "The timezone to use when formatting the timestamp. The parameter uses the TZ identifier or `local`.",
51            ),
52        ];
53        PARAMETERS
54    }
55
56    fn compile(
57        &self,
58        _state: &state::TypeState,
59        _ctx: &mut FunctionCompileContext,
60        arguments: ArgumentList,
61    ) -> Compiled {
62        let value = arguments.required("value");
63        let format = arguments.required("format");
64        let timezone = arguments.optional("timezone");
65
66        Ok(FormatTimestampFn {
67            value,
68            format,
69            timezone,
70        }
71        .as_expr())
72    }
73
74    fn examples(&self) -> &'static [Example] {
75        &[
76            example! {
77                title: "Format a timestamp (ISO8601/RFC 3339)",
78                source: r#"format_timestamp!(t'2020-10-21T16:00:00Z', format: "%+")"#,
79                result: Ok("2020-10-21T16:00:00+00:00"),
80            },
81            example! {
82                title: "Format a timestamp (custom)",
83                source: r#"format_timestamp!(t'2020-10-21T16:00:00Z', format: "%v %R")"#,
84                result: Ok("21-Oct-2020 16:00"),
85            },
86            example! {
87                title: "Format a timestamp with custom format string",
88                source: r#"format_timestamp!(t'2021-02-10T23:32:00+00:00', format: "%d %B %Y %H:%M")"#,
89                result: Ok("10 February 2021 23:32"),
90            },
91            example! {
92                title: "Format a timestamp with timezone conversion",
93                source: r#"format_timestamp!(t'2021-02-10T23:32:00+00:00', format: "%d %B %Y %H:%M", timezone: "Europe/Berlin")"#,
94                result: Ok("11 February 2021 00:32"),
95            },
96        ]
97    }
98}
99
100#[derive(Debug, Clone)]
101struct FormatTimestampFn {
102    value: Box<dyn Expression>,
103    format: Box<dyn Expression>,
104    timezone: Option<Box<dyn Expression>>,
105}
106
107impl FunctionExpression for FormatTimestampFn {
108    fn resolve(&self, ctx: &mut Context) -> Resolved {
109        let bytes = self.format.resolve(ctx)?;
110        let ts = self.value.resolve(ctx)?;
111        let tz = self
112            .timezone
113            .as_ref()
114            .map(|tz| tz.resolve(ctx))
115            .transpose()?;
116
117        format_timestamp_with_tz(ts, &bytes, tz)
118    }
119
120    fn type_def(&self, _: &state::TypeState) -> TypeDef {
121        TypeDef::bytes().fallible()
122    }
123}
124
125fn try_format_with_timezone(
126    dt: DateTime<Utc>,
127    format: &str,
128    timezone: Option<&str>,
129) -> ExpressionResult<String> {
130    let items = StrftimeItems::new(format)
131        .map(|item| match item {
132            Item::Error => Err("invalid format".into()),
133            _ => Ok(item),
134        })
135        .collect::<ExpressionResult<Vec<_>>>()?;
136
137    let timezone = timezone
138        .map(|timezone| {
139            TimeZone::parse(timezone).ok_or(format!("unable to parse timezone: {timezone}"))
140        })
141        .transpose()?;
142
143    match timezone {
144        Some(TimeZone::Named(tz)) => Ok(dt
145            .with_timezone(&tz)
146            .format_with_items(items.into_iter())
147            .to_string()),
148        Some(TimeZone::Local) => Ok(dt
149            .with_timezone(&chrono::Local)
150            .format_with_items(items.into_iter())
151            .to_string()),
152        None => Ok(dt.format_with_items(items.into_iter()).to_string()),
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::value;
160    use chrono::TimeZone;
161
162    test_function![
163        format_timestamp => FormatTimestamp;
164
165        invalid {
166            args: func_args![value: Utc.timestamp_opt(10, 0).single().expect("invalid timestamp"),
167                             format: "%Q INVALID"],
168            want: Err("invalid format"),
169            tdef: TypeDef::bytes().fallible(),
170        }
171
172        valid_secs {
173            args: func_args![value: Utc.timestamp_opt(10, 0).single().expect("invalid timestamp"),
174                             format: "%s"],
175            want: Ok(value!("10")),
176            tdef: TypeDef::bytes().fallible(),
177        }
178
179        date {
180            args: func_args![value: Utc.timestamp_opt(10, 0).single().expect("invalid timestamp"),
181                             format: "%+"],
182            want: Ok(value!("1970-01-01T00:00:10+00:00")),
183            tdef: TypeDef::bytes().fallible(),
184        }
185
186        tz {
187            args: func_args![value: Utc.timestamp_opt(10, 0).single().expect("invalid timestamp"),
188                             format: "%+",
189                             timezone: "Europe/Berlin"],
190            want: Ok(value!("1970-01-01T01:00:10+01:00")),
191            tdef: TypeDef::bytes().fallible(),
192        }
193
194        tz_local {
195            args: func_args![value: Utc.timestamp_opt(10, 0).single().expect("invalid timestamp"),
196                             format: "%s",
197                             timezone: "local"],
198            want: Ok(value!("10")), // Check that there is no error for the local timezone
199            tdef: TypeDef::bytes().fallible(),
200        }
201
202        invalid_tz {
203            args: func_args![value: Utc.timestamp_opt(10, 0).single().expect("invalid timestamp"),
204                             format: "%+",
205                             timezone: "llocal"],
206            want: Err("unable to parse timezone: llocal"),
207            tdef: TypeDef::bytes().fallible(),
208        }
209    ];
210}