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")), 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}