Skip to main content

tardis_cli/
cli.rs

1//! CLI argument parsing and input normalization for **TARDIS**.
2
3use std::{
4    env,
5    ffi::OsString,
6    io::{self, IsTerminal, Read},
7};
8
9use clap::Parser;
10use jiff::Timestamp;
11
12use crate::{Result, user_input_error};
13
14#[path = "cli_defs.rs"]
15mod cli_defs_mod;
16pub use cli_defs_mod::*;
17
18/// Normalised user command ready for further processing.
19#[must_use]
20#[non_exhaustive]
21#[derive(Debug)]
22pub struct Command {
23    pub input: String,
24    pub format: Option<String>,
25    pub timezone: Option<String>,
26    pub now: Option<Timestamp>,
27    pub json: bool,
28    pub no_newline: bool,
29    pub verbose: bool,
30    pub skip_errors: bool,
31}
32
33impl Command {
34    /// Create a new Command with a different input, preserving all other fields.
35    /// Used in batch mode to avoid manual field cloning.
36    #[must_use = "with_input returns a new Command and does not modify self"]
37    pub fn with_input(&self, input: String) -> Self {
38        Command {
39            input,
40            format: self.format.clone(),
41            timezone: self.timezone.clone(),
42            now: self.now,
43            json: self.json,
44            no_newline: self.no_newline,
45            verbose: self.verbose,
46            skip_errors: self.skip_errors,
47        }
48    }
49}
50
51impl Command {
52    /// Parse from arbitrary arg iterator **and** an arbitrary reader for STDIN.
53    /// The `stdin_is_terminal` flag controls whether we attempt to read from
54    /// the reader when no positional argument is given.
55    pub fn parse_from<I, S, R>(args: I, stdin: R, stdin_is_terminal: bool) -> Result<Self>
56    where
57        I: IntoIterator<Item = S>,
58        S: Into<OsString> + Clone,
59        R: Read,
60    {
61        let cli = Cli::parse_from(args);
62        Self::from_cli(cli, stdin, stdin_is_terminal)
63    }
64
65    /// Parse using the real `env::args_os()` and the real `io::stdin()`.
66    pub fn parse() -> Result<Self> {
67        let is_terminal = io::stdin().is_terminal();
68        Self::parse_from(env::args_os(), io::stdin(), is_terminal)
69    }
70
71    /// Converts a `Cli` into `Command`, reading STDIN if necessary.
72    pub fn from_raw_cli<R: Read>(cli: Cli, stdin: R, stdin_is_terminal: bool) -> Result<Self> {
73        Self::from_cli(cli, stdin, stdin_is_terminal)
74    }
75
76    fn from_cli<R: Read>(cli: Cli, mut stdin: R, stdin_is_terminal: bool) -> Result<Self> {
77        let input = match cli.input {
78            Some(s) if !s.is_empty() => s,
79            None if !stdin_is_terminal => {
80                let mut buf = String::new();
81                stdin.read_to_string(&mut buf).map_err(|e| {
82                    user_input_error!(InvalidDateFormat, "failed to read from stdin: {}", e)
83                })?;
84                let trimmed = buf.trim();
85                if trimmed.is_empty() {
86                    "now".to_owned()
87                } else {
88                    trimmed.to_owned()
89                }
90            }
91            _ => "now".to_owned(),
92        };
93
94        let now_str = cli
95            .now
96            .or_else(|| std::env::var("TARDIS_NOW").ok().filter(|s| !s.is_empty()));
97        let now = now_str
98            .as_deref()
99            .map(|s| s.parse::<Timestamp>())
100            .transpose()
101            .map_err(|e| {
102                user_input_error!(
103                    InvalidNow,
104                    "{} (expect RFC 3339, ex.: 2025-06-24T12:00:00Z)",
105                    e
106                )
107            })?;
108
109        Ok(Command {
110            input,
111            format: cli.format,
112            timezone: cli.timezone,
113            now,
114            json: cli.json,
115            no_newline: cli.no_newline,
116            verbose: cli.verbose,
117            skip_errors: cli.skip_errors,
118        })
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    #![allow(clippy::unwrap_used, clippy::expect_used)]
125
126    use super::*;
127    use pretty_assertions::assert_eq;
128    use std::io::Cursor;
129
130    fn parse_ok(argv: &[&str]) -> Command {
131        Command::parse_from(argv, Cursor::new(""), true).expect("parse should succeed")
132    }
133
134    #[test]
135    fn parses_all_flags() {
136        let cmd = parse_ok(&[
137            "td",
138            "next friday",
139            "-f",
140            "%Y",
141            "-t",
142            "UTC",
143            "--now",
144            "2025-06-24T12:00:00Z",
145        ]);
146
147        assert_eq!(cmd.input, "next friday");
148        assert_eq!(cmd.format.as_deref(), Some("%Y"));
149        assert_eq!(cmd.timezone.as_deref(), Some("UTC"));
150        assert_eq!(
151            cmd.now,
152            Some("2025-06-24T12:00:00Z".parse::<Timestamp>().unwrap())
153        );
154    }
155
156    #[test]
157    fn defaults_none_when_only_input() {
158        let cmd = parse_ok(&["td", "tomorrow"]);
159        assert_eq!(cmd.format, None);
160        assert_eq!(cmd.timezone, None);
161        assert_eq!(cmd.now, None);
162    }
163
164    #[test]
165    fn arg_takes_precedence_over_stdin() {
166        let cmd =
167            Command::parse_from(["td", "next monday"], Cursor::new("ignored"), false).unwrap();
168        assert_eq!(cmd.input, "next monday");
169    }
170
171    #[test]
172    fn no_args_terminal_defaults_to_now() {
173        let cmd = Command::parse_from(["td"], Cursor::new(""), true).unwrap();
174        assert_eq!(cmd.input, "now");
175    }
176
177    #[test]
178    fn empty_stdin_defaults_to_now() {
179        let cmd = Command::parse_from(["td"], Cursor::new(""), false).unwrap();
180        assert_eq!(cmd.input, "now");
181    }
182
183    #[test]
184    fn stdin_with_content_is_read() {
185        let cmd = Command::parse_from(["td"], Cursor::new("tomorrow\n"), false).unwrap();
186        assert_eq!(cmd.input, "tomorrow");
187    }
188
189    #[test]
190    fn json_flag_parsed() {
191        let cmd = parse_ok(&["td", "now", "--json"]);
192        assert!(cmd.json);
193    }
194
195    #[test]
196    fn no_newline_flag_parsed() {
197        let cmd = parse_ok(&["td", "now", "-n"]);
198        assert!(cmd.no_newline);
199    }
200
201    #[test]
202    fn skip_errors_flag_parsed() {
203        let cmd = parse_ok(&["td", "now", "--skip-errors"]);
204        assert!(cmd.skip_errors);
205    }
206
207    #[test]
208    fn with_input_preserves_fields() {
209        let cmd = parse_ok(&["td", "original", "-f", "%Y", "-t", "UTC", "--json", "-n"]);
210        let new_cmd = cmd.with_input("replaced".to_string());
211        assert_eq!(new_cmd.input, "replaced");
212        assert_eq!(new_cmd.format.as_deref(), Some("%Y"));
213        assert_eq!(new_cmd.timezone.as_deref(), Some("UTC"));
214        assert!(new_cmd.json);
215        assert!(new_cmd.no_newline);
216    }
217}