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 chrono::{DateTime, TimeZone, Utc};
7use chrono_tz::Tz;
8use human_date_parser::{ParseResult, from_human_time};
9
10use crate::{Error, Result, errors::UserInputError, user_input_error};
11
12/// Immutable application context passed to [`process`].
13#[derive(Debug)]
14pub struct App {
15    /// Raw human-readable expression (e.g. `"next Friday 10 am"`).
16    pub date: String,
17    /// Either a chrono-style format string *or* the name of a preset.
18    pub format: String,
19    /// Target time-zone for output.
20    pub timezone: Tz,
21    /// Optional “now” (useful for deterministic tests).
22    pub now: Option<DateTime<Tz>>,
23}
24
25/// Pairing of a **named** preset with a chrono format string.
26#[derive(Debug, Clone)]
27pub struct Preset {
28    pub name: String,
29    pub format: String,
30}
31
32/// Parse `app.date`, resolve the effective format, and render a string.
33///
34/// * `presets` is passed as a slice to avoid unnecessary allocation.
35/// * All error paths bubble up via [`Result`], ready for unit testing.
36pub fn process(app: &App, presets: &[Preset]) -> Result<String> {
37    let now = app
38        .now
39        .unwrap_or_else(|| app.timezone.from_utc_datetime(&Utc::now().naive_utc()));
40
41    let parsed = from_human_time(&app.date, now.naive_local()).map_err(|e| {
42        user_input_error!(
43            InvalidDateFormat,
44            "failed to parse human date '{}': {}",
45            app.date,
46            e
47        )
48    })?;
49
50    let fmt = resolve_format(&app.format, presets)?;
51
52    render_datetime(parsed, &fmt, now, app.timezone)
53}
54
55/// Return the chrono format corresponding to `input`.
56///
57/// *If* `input` matches the name of a preset, that preset’s format is returned;
58/// otherwise `input` itself is treated as the format string.
59fn resolve_format(input: &str, presets: &[Preset]) -> Result<String> {
60    if input.is_empty() {
61        return Err(user_input_error!(MissingArgument, "empty --format"));
62    }
63
64    Ok(presets
65        .iter()
66        .find(|p| p.name == input)
67        .map(|p| p.format.clone())
68        .unwrap_or_else(|| input.to_owned()))
69}
70
71/// Convert the parsed result into a `DateTime<Tz>` and format it.
72///
73/// Any failure in `chrono`’s formatting machinery is converted into a
74/// user-visible error.
75fn render_datetime(parsed: ParseResult, fmt: &str, now: DateTime<Tz>, tz: Tz) -> Result<String> {
76    use std::fmt::Write;
77
78    let naive = match parsed {
79        ParseResult::Date(d) => d.and_hms_opt(0, 0, 0).ok_or(std::fmt::Error)?,
80        ParseResult::DateTime(dt) => dt,
81        ParseResult::Time(t) => chrono::NaiveDateTime::new(now.date_naive(), t),
82    };
83
84    let zoned = tz
85        .from_local_datetime(&naive)
86        .single()
87        .ok_or(std::fmt::Error)?;
88
89    // HACK: Safe formatting (captures chrono’s formatting errors as `fmt::Error`)
90    let mut out = String::new();
91    write!(&mut out, "{}", zoned.format(fmt))?;
92    Ok(out)
93}
94
95impl App {
96    #[inline]
97    pub fn new(date: String, format: String, timezone: Tz, now: Option<DateTime<Tz>>) -> Self {
98        Self {
99            date,
100            format,
101            timezone,
102            now,
103        }
104    }
105}
106
107impl Preset {
108    #[inline]
109    pub fn new(name: String, format: String) -> Self {
110        Self { name, format }
111    }
112}
113
114impl From<std::fmt::Error> for Error {
115    fn from(err: std::fmt::Error) -> Self {
116        Error::UserInput(UserInputError::UnsupportedFormat(err))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
124
125    #[test]
126    fn resolve_format_returns_preset_when_found() {
127        let presets = [
128            Preset::new("iso".into(), "%Y-%m-%d".into()),
129            Preset::new("time".into(), "%H:%M".into()),
130        ];
131        let out = super::resolve_format("iso", &presets).unwrap();
132        assert_eq!(out, "%Y-%m-%d");
133    }
134
135    #[test]
136    fn resolve_format_returns_raw_when_not_preset() {
137        let presets = [Preset::new("iso".into(), "%Y-%m-%d".into())];
138        let out = super::resolve_format("%H:%M", &presets).unwrap();
139        assert_eq!(out, "%H:%M");
140    }
141
142    #[test]
143    fn resolve_format_fails_on_empty() {
144        let presets: [Preset; 0] = [];
145        assert!(super::resolve_format("", &presets).is_err());
146    }
147
148    #[test]
149    fn render_datetime_from_date() {
150        let ny = chrono_tz::UTC;
151        let now = ny.with_ymd_and_hms(2025, 6, 24, 12, 0, 0).unwrap();
152        let parsed = ParseResult::Date(NaiveDate::from_ymd_opt(2025, 6, 30).unwrap());
153        let out = super::render_datetime(parsed, "%Y-%m-%d", now, ny).unwrap();
154        assert_eq!(out, "2025-06-30");
155    }
156
157    #[test]
158    fn render_datetime_from_time() {
159        let tz = chrono_tz::UTC;
160        let now = tz.with_ymd_and_hms(2025, 6, 24, 0, 0, 0).unwrap();
161        let parsed = ParseResult::Time(NaiveTime::from_hms_opt(15, 30, 0).unwrap());
162        let out = super::render_datetime(parsed, "%Y-%m-%dT%H:%M:%S", now, tz).unwrap();
163        assert_eq!(out, "2025-06-24T15:30:00");
164    }
165
166    #[test]
167    fn render_datetime_handles_datetime_directly() {
168        let tz = chrono_tz::UTC;
169        let now = tz.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
170        let parsed_dt = NaiveDateTime::new(
171            NaiveDate::from_ymd_opt(2030, 1, 15).unwrap(),
172            NaiveTime::from_hms_opt(5, 45, 0).unwrap(),
173        );
174        let parsed = ParseResult::DateTime(parsed_dt);
175        let out = super::render_datetime(parsed, "%Y-%m-%d %H:%M", now, tz).unwrap();
176        assert_eq!(out, "2030-01-15 05:45");
177    }
178
179    #[test]
180    fn render_datetime_fails_on_ambiguous_local_time() {
181        let tz = chrono_tz::America::New_York;
182        let now = tz.with_ymd_and_hms(2025, 11, 1, 12, 0, 0).unwrap();
183        let ambiguous = NaiveDateTime::new(
184            NaiveDate::from_ymd_opt(2025, 11, 2).unwrap(),
185            NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
186        );
187        let parsed = ParseResult::DateTime(ambiguous);
188
189        let err = super::render_datetime(parsed, "%Y-%m-%d %H:%M", now, tz).unwrap_err();
190        assert!(matches!(err, Error::UserInput(_)));
191    }
192
193    #[test]
194    fn process_with_preset_full_flow() {
195        let tz = chrono_tz::UTC;
196        let app = App::new("2025-06-24 10:00".into(), "iso".into(), tz, None);
197        let presets = [Preset::new("iso".into(), "%Y-%m-%dT%H:%M:%S".into())];
198        let out = process(&app, &presets).unwrap();
199        assert_eq!(out, "2025-06-24T10:00:00");
200    }
201
202    #[test]
203    fn process_with_raw_format() {
204        let tz = chrono_tz::UTC;
205        let now = tz.with_ymd_and_hms(2025, 6, 24, 0, 0, 0).unwrap();
206        let app = App::new("tomorrow".into(), "%Y-%m-%d".into(), tz, Some(now));
207        let out = process(&app, &[]).unwrap();
208        assert_eq!(out, "2025-06-25");
209    }
210
211    #[test]
212    fn process_errors_on_bad_date_expression() {
213        let tz = chrono_tz::UTC;
214        let app = App::new("???".into(), "%Y".into(), tz, None);
215        assert!(process(&app, &[]).is_err());
216    }
217
218    #[test]
219    fn process_errors_on_empty_format() {
220        let tz = chrono_tz::UTC;
221        let app = App::new("today".into(), "".into(), tz, None);
222        let err = process(&app, &[]).unwrap_err();
223        assert!(matches!(err, Error::UserInput(_)));
224    }
225}