Skip to main content

rusty_ts/
cli.rs

1//! CLI surface — clap-derive `Cli` struct, post-parse validation, and
2//! mode-aware dispatch helpers.
3//!
4//! Per `plan.md` AD-002: clap derive with the `env` feature for
5//! `RUSTY_TS_FORMAT` honoring; same `Cli::command()` is consumed by
6//! `clap_complete` (completions) and `compat_matrix` (drift test). Single
7//! source of truth.
8
9use crate::error::Error;
10use crate::mode::ExplicitChoice;
11use clap::{Parser, Subcommand};
12
13/// `rusty-ts` — prefix each line of stdin with a timestamp. A Rust port of
14/// moreutils `ts`.
15#[derive(Parser, Debug, Clone)]
16#[command(
17    name = "rusty-ts",
18    version,
19    about = "Prefix each line of stdin with a timestamp (Rust port of moreutils ts)",
20    long_about = None,
21)]
22pub struct Cli {
23    // ─────────────────────── Elapsed-time mode flags ───────────────────────
24    /// Render elapsed time since the previous input line instead of absolute time.
25    #[arg(
26        short = 'i',
27        long = "incremental",
28        conflicts_with = "since_start",
29        action = clap::ArgAction::SetTrue
30    )]
31    pub incremental: bool,
32
33    /// Render elapsed time since program start instead of absolute time.
34    #[arg(
35        short = 's',
36        long = "since-start",
37        conflicts_with = "incremental",
38        action = clap::ArgAction::SetTrue
39    )]
40    pub since_start: bool,
41
42    /// Use a monotonic clock source for elapsed-time calculations.
43    /// Has no effect unless `-i` or `-s` is also present.
44    #[arg(short = 'm', long = "monotonic", action = clap::ArgAction::SetTrue)]
45    pub monotonic: bool,
46
47    // ─────────────────────── Relative-mode flag ────────────────────────────
48    /// Convert recognized in-line timestamps to relative form rather than
49    /// prefixing new timestamps. Default mode recognizes ISO-8601, RFC-3339,
50    /// and Unix epoch; Strict mode expands to the full moreutils set.
51    #[arg(short = 'r', long = "relative", action = clap::ArgAction::SetTrue)]
52    pub relative: bool,
53
54    // ─────────────────────── Timezone control ──────────────────────────────
55    /// Force timestamps to be rendered in UTC, overriding system local time
56    /// and the `TZ` env var. Rejected in Strict mode.
57    #[arg(
58        short = 'u',
59        long = "utc",
60        conflicts_with = "tz",
61        action = clap::ArgAction::SetTrue
62    )]
63    pub utc: bool,
64
65    /// Render timestamps in the named IANA timezone (e.g., `America/New_York`).
66    /// Resolved once at startup; per-line render cost is a fixed-offset
67    /// conversion. Rejected in Strict mode.
68    #[arg(long = "tz", value_name = "IANA-NAME", conflicts_with = "utc")]
69    pub tz: Option<String>,
70
71    // ─────────────────────── Compatibility-mode toggles ────────────────────
72    /// Switch into Strict moreutils Compatibility Mode. Rejects `-u`, `--tz`,
73    /// and other Rusty-only flags; expands `-r` to the full moreutils set;
74    /// mirrors moreutils `--help` / `--version` layout; ignores
75    /// `RUSTY_TS_FORMAT`.
76    #[arg(
77        long = "strict",
78        alias = "moreutils-compat",
79        conflicts_with = "no_strict",
80        action = clap::ArgAction::SetTrue
81    )]
82    pub strict: bool,
83
84    /// Force Default mode, overriding `RUSTY_TS_STRICT` env var and argv[0]
85    /// auto-detection.
86    #[arg(
87        long = "no-strict",
88        alias = "no-moreutils-compat",
89        conflicts_with = "strict",
90        action = clap::ArgAction::SetTrue
91    )]
92    pub no_strict: bool,
93
94    // ─────────────────────── Positional format ─────────────────────────────
95    /// Optional strftime format string. If omitted, uses the moreutils
96    /// default format (`%b %d %H:%M:%S`) or the `RUSTY_TS_FORMAT` env var
97    /// (Default mode only). A positional argument always wins over the env var.
98    #[arg(value_name = "FORMAT")]
99    pub format: Option<String>,
100
101    // ─────────────────────── Subcommands ──────────────────────────────────
102    #[command(subcommand)]
103    pub subcommand: Option<CliCommand>,
104}
105
106/// Subcommands. Currently just `completions`; future ports may add more.
107#[derive(Subcommand, Debug, Clone)]
108pub enum CliCommand {
109    /// Generate shell-completion scripts for bash, zsh, fish, or powershell.
110    /// Writes to stdout.
111    Completions {
112        /// Target shell.
113        #[arg(value_enum)]
114        shell: clap_complete::Shell,
115    },
116}
117
118impl Cli {
119    /// Compute the explicit-choice signal for the mode resolver. Returns
120    /// `None` if neither `--strict` nor `--no-strict` was supplied.
121    pub fn explicit_compat_choice(&self) -> Option<ExplicitChoice> {
122        match (self.strict, self.no_strict) {
123            (true, false) => Some(ExplicitChoice::Strict),
124            (false, true) => Some(ExplicitChoice::Default),
125            (false, false) => None,
126            // clap's `conflicts_with` prevents this pair, but defend in
127            // depth.
128            (true, true) => None,
129        }
130    }
131
132    /// Post-parse validation per FR-020 (defense in depth alongside clap's
133    /// `conflicts_with`). Returns `Error::InvalidUtcWithNamedTz` if both `-u`
134    /// and `--tz=...` were supplied.
135    pub fn validate(&self) -> Result<(), Error> {
136        if self.utc {
137            if let Some(tz) = &self.tz {
138                return Err(Error::InvalidUtcWithNamedTz { tz: tz.clone() });
139            }
140        }
141        Ok(())
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use clap::CommandFactory;
149
150    #[test]
151    fn cli_command_factory_builds_without_panic() {
152        // Smoke test: clap derive metadata compiles and yields a valid
153        // Command tree. Used by `completions` and `compat_matrix`.
154        let _cmd = Cli::command();
155    }
156
157    #[test]
158    fn explicit_choice_signals_strict_when_strict_flag_set() {
159        let cli = Cli::parse_from(["rusty-ts", "--strict"]);
160        assert_eq!(cli.explicit_compat_choice(), Some(ExplicitChoice::Strict));
161    }
162
163    #[test]
164    fn explicit_choice_signals_default_when_no_strict_flag_set() {
165        let cli = Cli::parse_from(["rusty-ts", "--no-strict"]);
166        assert_eq!(cli.explicit_compat_choice(), Some(ExplicitChoice::Default));
167    }
168
169    #[test]
170    fn explicit_choice_none_when_neither_flag() {
171        let cli = Cli::parse_from(["rusty-ts"]);
172        assert_eq!(cli.explicit_compat_choice(), None);
173    }
174
175    #[test]
176    fn positional_format_captured() {
177        let cli = Cli::parse_from(["rusty-ts", "%Y-%m-%d %H:%M:%S"]);
178        assert_eq!(cli.format.as_deref(), Some("%Y-%m-%d %H:%M:%S"));
179    }
180
181    #[test]
182    fn utc_flag_parsed() {
183        let cli = Cli::parse_from(["rusty-ts", "-u"]);
184        assert!(cli.utc);
185        assert!(cli.tz.is_none());
186    }
187
188    #[test]
189    fn tz_flag_parsed() {
190        let cli = Cli::parse_from(["rusty-ts", "--tz=Asia/Tokyo"]);
191        assert_eq!(cli.tz.as_deref(), Some("Asia/Tokyo"));
192        assert!(!cli.utc);
193    }
194
195    #[test]
196    fn utc_and_tz_rejected_by_clap_conflict() {
197        let err = Cli::try_parse_from(["rusty-ts", "-u", "--tz=Asia/Tokyo"]);
198        assert!(
199            err.is_err(),
200            "clap should reject -u + --tz=... via conflicts_with"
201        );
202    }
203
204    #[test]
205    fn incremental_and_since_start_rejected_by_clap_conflict() {
206        let err = Cli::try_parse_from(["rusty-ts", "-i", "-s"]);
207        assert!(
208            err.is_err(),
209            "clap should reject -i + -s via conflicts_with"
210        );
211    }
212
213    #[test]
214    fn validate_passes_when_only_utc() {
215        let cli = Cli::parse_from(["rusty-ts", "-u"]);
216        assert!(cli.validate().is_ok());
217    }
218
219    #[test]
220    fn completions_subcommand_parsed() {
221        let cli = Cli::parse_from(["rusty-ts", "completions", "bash"]);
222        assert!(matches!(
223            cli.subcommand,
224            Some(CliCommand::Completions { shell: _ })
225        ));
226    }
227}