Skip to main content

tardis_cli/
core.rs

1//! Core transformation logic for **TARDIS**.
2//!
3//! Converts a natural-language date expression into a formatted string,
4//! applying optional presets and an explicit time-zone/context "now".
5
6use jiff::{Zoned, tz::TimeZone};
7
8use crate::{Result, cli::Command, config::Config, parser, user_input_error};
9
10/// Immutable application context passed to [`process`].
11#[must_use]
12#[non_exhaustive]
13#[derive(Debug)]
14pub struct App {
15    /// Raw human-readable expression (e.g. `"next Friday 10 am"`).
16    pub date: String,
17    /// Either a strftime-style format string *or* the name of a preset.
18    pub format: String,
19    /// Target time-zone for output.
20    pub timezone: TimeZone,
21    /// Optional "now" (useful for deterministic tests).
22    pub now: Option<Zoned>,
23}
24
25/// Pairing of a **named** preset with a strftime format string.
26#[must_use]
27#[non_exhaustive]
28#[derive(Debug, Clone)]
29pub struct Preset {
30    pub name: String,
31    pub format: String,
32}
33
34/// Result of processing a date expression.
35#[must_use]
36#[non_exhaustive]
37#[derive(Debug)]
38pub struct ProcessOutput {
39    /// Formatted date string.
40    pub formatted: String,
41    /// Unix epoch timestamp (seconds).
42    pub epoch: i64,
43}
44
45/// Parse `app.date`, resolve the effective format, and render a string.
46///
47/// * `presets` is passed as a slice to avoid unnecessary allocation.
48/// * All error paths bubble up via [`Result`], ready for unit testing.
49#[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
68/// Format a zoned datetime, handling special "epoch"/"unix" format.
69fn 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
81/// Detect unknown strftime specifiers by checking if any `%X` sequence
82/// in the format string was passed through unchanged to the output.
83///
84/// jiff's strftime passes through unrecognized specifiers as literals,
85/// but we want to error on them (matching chrono's old behavior).
86fn 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
145/// Return the format string corresponding to `input`.
146///
147/// *If* `input` matches the name of a preset, that preset's format is returned;
148/// otherwise `input` itself is treated as the format string.
149fn 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    /// Build an [`App`] from the parsed CLI and loaded configuration.
173    ///
174    /// * CLI values **override** config values.
175    /// * If no time-zone is provided anywhere, falls back to the OS local TZ.
176    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}