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#[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 #[arg(value_name = "FMT", short, long, long_help = FORMAT_HELP)]
120 format: Option<String>,
121 #[arg(value_name = "TZ", short, long, long_help = TIMEZONE_HELP)]
123 timezone: Option<String>,
124 #[arg(value_name = "DATETIME", long, long_help = NOW_HELP)]
126 now: Option<String>,
127}
128
129#[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 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 pub fn parse() -> Result<Self> {
154 Self::parse_from(env::args_os(), io::stdin())
155 }
156
157 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}