tardis_cli/
cli.rs

1use std::{
2    env,
3    ffi::OsString,
4    io::{self, IsTerminal, Read},
5};
6
7use chrono::{DateTime, FixedOffset};
8use clap::{
9    Parser,
10    builder::styling::{AnsiColor, Styles},
11};
12use color_print::cstr;
13
14use crate::{Result, user_input_error};
15
16pub const STYLES: Styles = Styles::styled()
17    .header(AnsiColor::Green.on_default().bold())
18    .usage(AnsiColor::Green.on_default().bold())
19    .literal(AnsiColor::Blue.on_default().bold())
20    .placeholder(AnsiColor::Cyan.on_default());
21
22pub const AFTER_LONG_HELP: &str = cstr!(
23    r#"
24<green><bold>Environment Variables:</bold></green>
25  <bold><blue>TARDIS_FORMAT</blue></bold>     Default output format or preset name.
26  <bold><blue>TARDIS_TIMEZONE</blue></bold>   Default IANA time zone (e.g. America/Sao_Paulo).
27
28<green><bold>Configuration File:</bold></green>
29  <blue><bold>$XDG_CONFIG_HOME</bold>/tardis/config.toml</blue>
30
31  if XDG_CONFIG_HOME is unset:
32        • Linux:   ~/.config/tardis/config.toml
33        • macOS:   ~/Library/Application Support/tardis/config.toml
34        • Windows: %APPDATA%\tardis\config.toml
35
36  The file is created automatically on first run and contains commented
37  examples for every field.
38
39
40<green><bold>Precedence:</bold></green>
41  CLI flags → env vars → config file
42
43For more info, visit <underline>https://github.com/hvpaiva/tardis</underline>
44"#
45);
46
47pub const INPUT_HELP: &str = cstr!(
48    r#"
49<bold>A natural-language expression</bold> like <underline>"next Friday at 9:30"</underline>.
50If omitted, the value is read from <bold>STDIN</bold>.
51
52Supported formats:
53<underline>https://github.com/technologicalMayhem/human-date-parser?tab=readme-ov-file#formats</underline>
54"#
55);
56
57const FORMAT_HELP: &str = cstr!(
58    r#"
59<bold>Output format.</bold>
60
61Accepts chrono‑style strftime patterns (e.g. <bold>"%Y‑%m‑%d"</bold>) or a named
62preset defined in the config file.
63
64Reference:
65<underline>https://docs.rs/chrono/latest/chrono/format/strftime/index.html</underline>
66
67If not provided, tries to read from <bold><blue>TARDIS_FORMAT</blue></bold> and
68falls back to the default format defined in the config file.
69"#
70);
71
72pub const TIMEZONE_HELP: &str = cstr!(
73    r#"
74<bold>Time‑zone to apply</bold> (IANA/Olson ID). If not provided, uses system local time.
75
76Examples: <italic>"UTC", "America/Sao_Paulo", "Europe/London".</italic>
77
78Reference:
79<underline>https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html</underline>
80
81If not provided, tries to read from <bold><blue>TARDIS_TIMEZONE</blue></bold> and
82falls back to the default time zone defined in the config file.
83"#
84);
85
86pub const NOW_HELP: &str = cstr!(
87    r#"
88Override “now”. Format <bold>RFC 3339</bold>, e.g. <italic>2025‑06‑24T09:00:00Z</italic>.
89"#
90);
91
92pub const ABOUT_HELP: &str = cstr!(
93    r#"
94<magenta>TARDIS — Time And Relative Date Input Simplifier</magenta>
95
96Translates natural-language time expressions into formatted datetimes.
97
98A lightweight CLI tool for converting human-readable date and time phrases
99like <bold>"next Friday at 2:00"</bold> or <bold>"in 3 days"</bold> into machine-usable output.
100"#
101);
102
103/// TARDIS — Time And Relative Date Input Simplifier
104#[derive(Debug, Parser)]
105#[command(
106    name = "td",
107    about,
108    long_about = ABOUT_HELP,
109    version,
110    color = clap::ColorChoice::Auto,
111    after_long_help = AFTER_LONG_HELP,
112    after_help = cstr!("For more information, visit <underline>https://github.com/hvpaiva/tardis-cli</underline>"),
113    styles=STYLES,
114)]
115pub struct Cli {
116    #[arg(help = INPUT_HELP)]
117    input: Option<String>,
118    /// Output format.
119    #[arg(value_name = "FMT", short, long, long_help = FORMAT_HELP)]
120    format: Option<String>,
121    /// Time-zone to apply (IANA/Olson ID). If not provided, uses system local time.
122    #[arg(value_name = "TZ", short, long, long_help = TIMEZONE_HELP)]
123    timezone: Option<String>,
124    /// Override “now”. Format **RFC 3339**, e.g. 2025-06-24T09:00:00Z.
125    #[arg(value_name = "DATETIME", long, long_help = NOW_HELP)]
126    now: Option<String>,
127}
128
129/// Normalised user command ready for further processing.
130#[derive(Debug)]
131pub struct Command {
132    pub input: String,
133    pub format: Option<String>,
134    pub timezone: Option<String>,
135    pub now: Option<DateTime<FixedOffset>>,
136}
137
138impl Command {
139    /// Parse from arbitrary arg iterator **and** an arbitrary reader for STDIN.
140    /// Makes unit-testing easier by allowing injection of fake inputs.
141    pub fn parse_from<I, S, R>(args: I, mut stdin: R) -> Result<Self>
142    where
143        I: IntoIterator<Item = S>,
144        S: Into<OsString> + Clone,
145        R: Read,
146    {
147        let cli = Cli::parse_from(args);
148        Self::from_cli(cli, &mut stdin)
149    }
150
151    /// Parse using the real `env::args_os()` and the real `io::stdin()`.
152    /// This is what the binary calls from `main`.
153    pub fn parse() -> Result<Self> {
154        Self::parse_from(env::args_os(), io::stdin())
155    }
156
157    /// Internal helper that converts a `Cli` into `Command`,
158    /// reading STDIN if necessary.
159    fn from_cli<R: Read>(cli: Cli, mut stdin: R) -> Result<Self> {
160        let input = match cli.input {
161            Some(s) if !s.is_empty() => s,
162            None if !io::stdin().is_terminal() => {
163                let mut buf = String::new();
164                stdin.read_to_string(&mut buf).map_err(|e| {
165                    user_input_error!(InvalidDateFormat, "failed to read from stdin: {}", e)
166                })?;
167                let trimmed = buf.trim();
168                if trimmed.is_empty() {
169                    return Err(user_input_error!(
170                        InvalidDateFormat,
171                        "no input provided in stdin; pass an argument or pipe data"
172                    ));
173                }
174                trimmed.to_owned()
175            }
176            _ => {
177                return Err(user_input_error!(
178                    InvalidDateFormat,
179                    "no input provided; pass an argument or pipe data"
180                ));
181            }
182        };
183
184        let now = cli
185            .now
186            .as_deref()
187            .map(DateTime::parse_from_rfc3339)
188            .transpose()
189            .map_err(|e| {
190                user_input_error!(
191                    InvalidNow,
192                    "{} (expect RFC 3339, ex.: 2025-06-24T12:00:00Z)",
193                    e
194                )
195            })?;
196
197        Ok(Command {
198            input,
199            format: cli.format,
200            timezone: cli.timezone,
201            now,
202        })
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use pretty_assertions::assert_eq;
210    use std::io::Cursor;
211
212    fn parse_ok(argv: &[&str]) -> Command {
213        Command::parse_from(argv, Cursor::new("")).expect("parse should succeed")
214    }
215
216    #[test]
217    fn parses_all_flags() {
218        let cmd = parse_ok(&[
219            "td",
220            "next friday",
221            "-f",
222            "%Y",
223            "-t",
224            "UTC",
225            "--now",
226            "2025-06-24T12:00:00Z",
227        ]);
228
229        assert_eq!(cmd.input, "next friday");
230        assert_eq!(cmd.format.as_deref(), Some("%Y"));
231        assert_eq!(cmd.timezone.as_deref(), Some("UTC"));
232        assert_eq!(
233            cmd.now,
234            Some(DateTime::parse_from_rfc3339("2025-06-24T12:00:00Z").unwrap())
235        );
236    }
237
238    #[test]
239    fn defaults_none_when_only_input() {
240        let cmd = parse_ok(&["td", "tomorrow"]);
241        assert_eq!(cmd.format, None);
242        assert_eq!(cmd.timezone, None);
243        assert_eq!(cmd.now, None);
244    }
245
246    #[test]
247    fn arg_takes_precedence_over_stdin() {
248        let cmd = Command::parse_from(["td", "next monday"], Cursor::new("ignored")).unwrap();
249        assert_eq!(cmd.input, "next monday");
250    }
251
252    #[test]
253    fn stdin_empty_in_unit_path_gives_missing_input() {
254        let err = Command::parse_from(["td"], Cursor::new("")).unwrap_err();
255        use crate::{Error, errors::UserInputError};
256        assert!(matches!(
257            err,
258            Error::UserInput(UserInputError::InvalidDateFormat(_))
259        ));
260    }
261}