1use jiff::{Zoned, tz::TimeZone};
7
8use crate::{Result, cli::Command, config::Config, parser, user_input_error};
9
10#[must_use]
12#[non_exhaustive]
13#[derive(Debug)]
14pub struct App {
15 pub date: String,
17 pub format: String,
19 pub timezone: TimeZone,
21 pub now: Option<Zoned>,
23}
24
25#[must_use]
27#[non_exhaustive]
28#[derive(Debug, Clone)]
29pub struct Preset {
30 pub name: String,
31 pub format: String,
32}
33
34#[must_use]
36#[non_exhaustive]
37#[derive(Debug)]
38pub struct ProcessOutput {
39 pub formatted: String,
41 pub epoch: i64,
43}
44
45#[must_use = "process returns a ProcessOutput that should not be discarded"]
50pub fn process(app: &App, presets: &[Preset]) -> Result<ProcessOutput> {
51 let now = app
52 .now
53 .clone()
54 .unwrap_or_else(|| Zoned::now().with_time_zone(app.timezone.clone()));
55
56 let fmt = resolve_format(&app.format, presets)?;
57
58 let zoned = parser::parse(&app.date, &now)
59 .map_err(|e| user_input_error!(InvalidDateFormat, "{}", e.format_message()))?;
60
61 let formatted = format_output(&zoned, &fmt)?;
62 Ok(ProcessOutput {
63 formatted,
64 epoch: zoned.timestamp().as_second(),
65 })
66}
67
68fn format_output(zoned: &Zoned, fmt: &str) -> Result<String> {
70 if fmt == "epoch" || fmt == "unix" {
71 return Ok(zoned.timestamp().as_second().to_string());
72 }
73
74 let output = zoned.strftime(fmt).to_string();
75
76 validate_format_output(fmt, &output)?;
77
78 Ok(output)
79}
80
81fn validate_format_output(fmt: &str, output: &str) -> Result<()> {
87 const KNOWN_SPECIFIERS: &[char] = &[
88 'A', 'a', 'B', 'b', 'C', 'c', 'D', 'd', 'e', 'F', 'G', 'g', 'H', 'h', 'I', 'j', 'k', 'l',
89 'M', 'm', 'N', 'n', 'P', 'p', 'R', 'r', 'S', 's', 'T', 't', 'U', 'u', 'V', 'v', 'W', 'w',
90 'X', 'x', 'Y', 'y', 'Z', 'z', 'f', '-', '0', '_', ':', '%',
91 ];
92
93 let bytes = fmt.as_bytes();
94 let mut i = 0;
95 while i < bytes.len() {
96 if bytes[i] == b'%' {
97 i += 1;
98 if i >= bytes.len() {
99 return Err(user_input_error!(
100 UnsupportedFormat,
101 "invalid format string: {}",
102 fmt
103 ));
104 }
105 while i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'0' || bytes[i] == b'_') {
106 i += 1;
107 }
108 if i >= bytes.len() {
109 return Err(user_input_error!(
110 UnsupportedFormat,
111 "invalid format string: {}",
112 fmt
113 ));
114 }
115 if bytes[i] == b':' {
116 i += 1;
117 while i < bytes.len() && bytes[i] == b':' {
118 i += 1;
119 }
120 if i < bytes.len() && bytes[i] == b'z' {
121 i += 1;
122 continue;
123 }
124 return Err(user_input_error!(
125 UnsupportedFormat,
126 "invalid format string: {}",
127 fmt
128 ));
129 }
130 let c = bytes[i] as char;
131 if !KNOWN_SPECIFIERS.contains(&c) {
132 return Err(user_input_error!(
133 UnsupportedFormat,
134 "invalid format string: {}",
135 fmt
136 ));
137 }
138 }
139 i += 1;
140 }
141 let _ = output;
142 Ok(())
143}
144
145fn resolve_format(input: &str, presets: &[Preset]) -> Result<String> {
150 if input.is_empty() {
151 return Err(user_input_error!(MissingArgument, "empty --format"));
152 }
153
154 Ok(presets
155 .iter()
156 .find(|p| p.name == input)
157 .map(|p| p.format.clone())
158 .unwrap_or_else(|| input.to_owned()))
159}
160
161impl App {
162 #[inline]
163 pub fn new(date: String, format: String, timezone: TimeZone, now: Option<Zoned>) -> Self {
164 Self {
165 date,
166 format,
167 timezone,
168 now,
169 }
170 }
171
172 pub fn from_cli(cmd: &Command, cfg: &Config) -> Result<Self> {
177 let format = cmd.format.clone().unwrap_or_else(|| cfg.format.clone());
178
179 if format.trim().is_empty() {
180 return Err(user_input_error!(
181 MissingArgument,
182 "no output format specified"
183 ));
184 }
185
186 let tz_raw = cmd
187 .timezone
188 .clone()
189 .unwrap_or_else(|| cfg.timezone.clone())
190 .trim()
191 .to_owned();
192
193 let timezone: TimeZone = if tz_raw.is_empty() {
194 TimeZone::system()
195 } else {
196 TimeZone::get(&tz_raw).map_err(|_| {
197 user_input_error!(UnsupportedTimezone, "invalid timezone ID: {}", tz_raw)
198 })?
199 };
200
201 let now = cmd.now.map(|ts| ts.to_zoned(timezone.clone()));
202
203 Ok(Self {
204 date: cmd.input.clone(),
205 format,
206 timezone,
207 now,
208 })
209 }
210}
211
212impl Preset {
213 #[inline]
214 pub fn new(name: String, format: String) -> Self {
215 Self { name, format }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 #![allow(clippy::unwrap_used, clippy::expect_used)]
222 use super::*;
223 use crate::Error;
224 use jiff::{Timestamp, civil, tz::TimeZone};
225 use pretty_assertions::assert_eq;
226 use std::collections::HashMap;
227
228 fn utc() -> TimeZone {
229 TimeZone::get("UTC").unwrap()
230 }
231
232 fn zoned_utc(year: i16, month: i8, day: i8, hour: i8, min: i8, sec: i8) -> Zoned {
233 let dt = civil::date(year, month, day).at(hour, min, sec, 0);
234 utc().to_ambiguous_zoned(dt).compatible().unwrap()
235 }
236
237 #[test]
238 fn resolve_format_returns_preset_when_found() {
239 let presets = [
240 Preset::new("iso".into(), "%Y-%m-%d".into()),
241 Preset::new("time".into(), "%H:%M".into()),
242 ];
243 let out = super::resolve_format("iso", &presets).unwrap();
244 assert_eq!(out, "%Y-%m-%d");
245 }
246
247 #[test]
248 fn resolve_format_returns_raw_when_not_preset() {
249 let presets = [Preset::new("iso".into(), "%Y-%m-%d".into())];
250 let out = super::resolve_format("%H:%M", &presets).unwrap();
251 assert_eq!(out, "%H:%M");
252 }
253
254 #[test]
255 fn resolve_format_fails_on_empty() {
256 let presets: [Preset; 0] = [];
257 assert!(super::resolve_format("", &presets).is_err());
258 }
259
260 #[test]
261 fn process_with_preset_full_flow() {
262 let tz = utc();
263 let app = App::new("2025-06-24 10:00".into(), "iso".into(), tz, None);
264 let presets = [Preset::new("iso".into(), "%Y-%m-%dT%H:%M:%S".into())];
265 let out = process(&app, &presets).unwrap();
266 assert_eq!(out.formatted, "2025-06-24T10:00:00");
267 }
268
269 #[test]
270 fn process_with_raw_format() {
271 let tz = utc();
272 let now = zoned_utc(2025, 6, 24, 0, 0, 0);
273 let app = App::new("tomorrow".into(), "%Y-%m-%d".into(), tz, Some(now));
274 let out = process(&app, &[]).unwrap();
275 assert_eq!(out.formatted, "2025-06-25");
276 }
277
278 #[test]
279 fn process_errors_on_bad_date_expression() {
280 let tz = utc();
281 let app = App::new("???".into(), "%Y".into(), tz, None);
282 assert!(process(&app, &[]).is_err());
283 }
284
285 #[test]
286 fn process_errors_on_empty_format() {
287 let tz = utc();
288 let app = App::new("today".into(), "".into(), tz, None);
289 let err = process(&app, &[]).unwrap_err();
290 assert!(matches!(err, Error::UserInput(_)));
291 }
292
293 fn make_cmd(
294 input: &str,
295 format: Option<&str>,
296 timezone: Option<&str>,
297 now: Option<&str>,
298 ) -> Command {
299 Command {
300 input: input.to_string(),
301 format: format.map(|s| s.to_string()),
302 timezone: timezone.map(|s| s.to_string()),
303
304 now: now.map(|s| s.parse::<Timestamp>().unwrap()),
305 json: false,
306 no_newline: false,
307 verbose: false,
308 skip_errors: false,
309 }
310 }
311
312 fn make_cfg(format: &str, timezone: &str) -> Config {
313 Config {
314 format: format.to_string(),
315 timezone: timezone.to_string(),
316
317 formats: None,
318 }
319 }
320
321 fn tz_name(tz: &TimeZone) -> &str {
322 tz.iana_name().unwrap_or("Unknown")
323 }
324
325 #[test]
326 fn cli_overrides_config_format() {
327 let cli = make_cmd("2025-01-01", Some("%Y"), None, None);
328 let cfg = make_cfg("%F", "UTC");
329 let app = App::from_cli(&cli, &cfg).unwrap();
330 assert_eq!(app.format, "%Y");
331 assert_eq!(tz_name(&app.timezone), "UTC");
332 }
333
334 #[test]
335 fn empty_format_is_error() {
336 let cli = make_cmd("2025-01-01", Some(" "), None, None);
337 let cfg = make_cfg("%F", "UTC");
338 let err = App::from_cli(&cli, &cfg).unwrap_err();
339 assert!(matches!(
340 err,
341 Error::UserInput(crate::errors::UserInputError::MissingArgument { .. })
342 ));
343 }
344
345 #[test]
346 fn cli_overrides_config_timezone() {
347 let cli = make_cmd("2025-01-01", Some("%Y"), Some("Europe/London"), None);
348 let cfg = make_cfg("%Y", "UTC");
349 let app = App::from_cli(&cli, &cfg).unwrap();
350 assert_eq!(tz_name(&app.timezone), "Europe/London");
351 }
352
353 #[test]
354 fn invalid_timezone_returns_error() {
355 let cli = make_cmd("2025-01-01", Some("%Y"), Some("Mars/Olympus"), None);
356 let cfg = make_cfg("%Y", "UTC");
357 let err = App::from_cli(&cli, &cfg).unwrap_err();
358 assert!(matches!(
359 err,
360 Error::UserInput(crate::errors::UserInputError::UnsupportedTimezone { .. })
361 ));
362 }
363
364 #[test]
365 fn preset_name_kept_in_app() {
366 let cli = make_cmd("2030-12-31", Some("br"), None, None);
367 let mut fmts = HashMap::new();
368 fmts.insert("br".into(), "%d/%m/%Y".into());
369 let cfg = Config {
370 format: "%F".into(),
371 timezone: "UTC".into(),
372
373 formats: Some(fmts),
374 };
375 let app = App::from_cli(&cli, &cfg).unwrap();
376 assert_eq!(app.format, "br");
377 }
378
379 #[test]
380 fn from_cli_with_now_override() {
381 let cli = make_cmd(
382 "today",
383 Some("%Y"),
384 Some("UTC"),
385 Some("2025-06-24T12:00:00Z"),
386 );
387 let cfg = make_cfg("%Y", "UTC");
388 let app = App::from_cli(&cli, &cfg).unwrap();
389 assert!(app.now.is_some());
390 }
391
392 #[test]
393 fn epoch_input_valid() {
394 let tz = utc();
395 let app = App::new("@1735689600".into(), "%Y-%m-%d".into(), tz, None);
396 let out = process(&app, &[]).unwrap();
397 assert_eq!(out.formatted, "2025-01-01");
398 assert_eq!(out.epoch, 1735689600);
399 }
400
401 #[test]
402 fn epoch_input_invalid_not_a_number() {
403 let tz = utc();
404 let app = App::new("@abc".into(), "%Y".into(), tz, None);
405 let err = process(&app, &[]).unwrap_err();
406 assert!(matches!(
407 err,
408 Error::UserInput(crate::errors::UserInputError::InvalidDateFormat(_))
409 ));
410 }
411
412 #[test]
413 fn epoch_input_smart_precision() {
414 let tz = utc();
415 let app = App::new("@99999999999999999".into(), "%Y".into(), tz, None);
416 let out = process(&app, &[]).unwrap();
417 assert!(!out.formatted.is_empty());
418 }
419
420 #[test]
421 fn epoch_output_format() {
422 let tz = utc();
423 let now = zoned_utc(2025, 1, 1, 0, 0, 0);
424 let app = App::new("today".into(), "epoch".into(), tz, Some(now));
425 let out = process(&app, &[]).unwrap();
426 assert_eq!(out.formatted, "1735689600");
427 }
428
429 #[test]
430 fn unix_output_format() {
431 let tz = utc();
432 let now = zoned_utc(2025, 1, 1, 0, 0, 0);
433 let app = App::new("today".into(), "unix".into(), tz, Some(now));
434 let out = process(&app, &[]).unwrap();
435 assert_eq!(out.formatted, "1735689600");
436 }
437
438 #[test]
439 fn epoch_input_with_epoch_output() {
440 let tz = utc();
441 let app = App::new("@1735689600".into(), "epoch".into(), tz, None);
442 let out = process(&app, &[]).unwrap();
443 assert_eq!(out.formatted, "1735689600");
444 assert_eq!(out.epoch, 1735689600);
445 }
446
447 #[test]
448 fn process_output_includes_epoch() {
449 let tz = utc();
450 let now = zoned_utc(2025, 6, 24, 0, 0, 0);
451 let app = App::new("tomorrow".into(), "%Y-%m-%d".into(), tz, Some(now));
452 let out = process(&app, &[]).unwrap();
453 assert_eq!(out.formatted, "2025-06-25");
454 assert_eq!(out.epoch, 1750809600);
455 }
456
457 #[test]
458 fn epoch_negative_timestamp() {
459 let tz = utc();
460 let app = App::new("@-86400".into(), "%Y-%m-%d".into(), tz, None);
461 let out = process(&app, &[]).unwrap();
462 assert_eq!(out.formatted, "1969-12-31");
463 }
464
465 #[test]
466 fn format_output_with_literal_text() {
467 let zoned = zoned_utc(2025, 1, 1, 0, 0, 0);
468 let out = super::format_output(&zoned, "Year: %Y").unwrap();
469 assert_eq!(out, "Year: 2025");
470 }
471
472 #[test]
473 fn format_output_epoch() {
474 let zoned = zoned_utc(2025, 1, 1, 0, 0, 0);
475 let out = super::format_output(&zoned, "epoch").unwrap();
476 assert_eq!(out, "1735689600");
477 }
478
479 #[test]
480 fn format_output_unix() {
481 let zoned = zoned_utc(2025, 1, 1, 0, 0, 0);
482 let out = super::format_output(&zoned, "unix").unwrap();
483 assert_eq!(out, "1735689600");
484 }
485}