Skip to main content

cron_when/cli/commands/
mod.rs

1use clap::{
2    Arg, ArgAction, ColorChoice, Command,
3    builder::styling::{AnsiColor, Effects, Styles},
4};
5
6#[allow(clippy::doc_markdown)]
7pub mod built_info {
8    include!(concat!(env!("OUT_DIR"), "/built.rs"));
9}
10
11/// Build the CLI command structure
12#[must_use]
13pub fn new() -> Command {
14    let styles = Styles::styled()
15        .header(AnsiColor::Yellow.on_default() | Effects::BOLD)
16        .usage(AnsiColor::Green.on_default() | Effects::BOLD)
17        .literal(AnsiColor::Blue.on_default() | Effects::BOLD)
18        .placeholder(AnsiColor::Green.on_default());
19
20    let git_hash = built_info::GIT_COMMIT_HASH.unwrap_or("unknown");
21    let long_version: &'static str =
22        Box::leak(format!("{} - {}", env!("CARGO_PKG_VERSION"), git_hash).into_boxed_str());
23
24    Command::new(env!("CARGO_PKG_NAME"))
25        .version(env!("CARGO_PKG_VERSION"))
26        .long_version(long_version)
27        .author(env!("CARGO_PKG_AUTHORS"))
28        .about(env!("CARGO_PKG_DESCRIPTION"))
29        .long_about(
30            "A CLI tool to parse cron expressions and display next execution times with human-readable durations.\n\n\
31             EXAMPLES:\n  \
32             cron-when \"*/5 * * * *\"\n    \
33             Show next execution time for a cron expression\n\n  \
34             cron-when \"0 0 * * *\" --next 10\n    \
35             Show next 10 execution times\n\n  \
36             cron-when --file /etc/cron.d/jobs\n    \
37             Parse and display all cron jobs from a file\n\n  \
38             cron-when --crontab\n    \
39             Parse current user's crontab\n\n  \
40             cron-when --file /etc/cron.d/jobs --color\n    \
41             Show output with colors"
42        )
43        .color(ColorChoice::Auto)
44        .styles(styles)
45        .arg(arg_cron())
46        .arg(arg_file())
47        .arg(arg_crontab())
48        .arg(arg_verbose())
49        .arg(arg_next())
50        .arg(arg_color())
51        .arg(arg_no_color())
52}
53
54fn arg_cron() -> Arg {
55    Arg::new("cron")
56        .value_name("CRON_EXPRESSION")
57        .help("Cron expression (e.g., \"*/5 * * * *\")")
58        .long_help(
59            "A standard cron expression with 5 fields:\n  \
60             minute hour day month weekday\n\n\
61             Examples:\n  \
62             \"*/5 * * * *\"    - Every 5 minutes\n  \
63             \"0 * * * *\"      - Every hour\n  \
64             \"0 0 * * *\"      - Every day at midnight\n  \
65             \"0 9-17 * * 1-5\" - Weekdays 9am-5pm",
66        )
67        .index(1)
68}
69
70fn arg_file() -> Arg {
71    Arg::new("file")
72        .short('f')
73        .long("file")
74        .value_name("FILE")
75        .help("Read from file (crontab format)")
76        .long_help(
77            "Read cron expressions from a file in standard crontab format.\n\n\
78             The file should contain lines in the format:\n  \
79             <cron-expression> <command>\n\n\
80             Lines starting with '#' are treated as comments.\n\
81             Empty lines and environment variable assignments (e.g., SHELL=/bin/bash) are ignored.",
82        )
83}
84
85fn arg_crontab() -> Arg {
86    Arg::new("crontab")
87        .short('l')
88        .long("crontab")
89        .help("Parse current user's crontab")
90        .long_help(
91            "Parse and display all cron jobs from the current user's crontab.\n\n\
92             This is equivalent to parsing the output of 'crontab -l'.\n\
93             Requires the 'crontab' command to be available on the system.",
94        )
95        .action(ArgAction::SetTrue)
96}
97
98fn arg_verbose() -> Arg {
99    Arg::new("verbose")
100        .short('v')
101        .long("verbose")
102        .help("Show verbose output with cron expression")
103        .long_help(
104            "Enable verbose logging for debugging purposes.\n\n\
105             Can be specified multiple times to increase verbosity:\n  \
106             -v    = INFO level\n  \
107             -vv   = DEBUG level\n  \
108             -vvv  = TRACE level\n\n\
109             Note: Verbose output is sent to stderr via the RUST_LOG environment variable.",
110        )
111        .action(ArgAction::Count)
112}
113
114fn arg_next() -> Arg {
115    Arg::new("next")
116        .short('n')
117        .long("next")
118        .value_name("COUNT")
119        .help("Show next N occurrences of the cron expression")
120        .long_help(
121            "Show the next N execution times for a cron expression.\n\n\
122             This option displays multiple consecutive execution times with their\n\
123             corresponding delays from the current time.\n\n\
124             CONSTRAINTS:\n  \
125             COUNT must be between 1 and 100 (inclusive)\n\n\
126             EXAMPLES:\n  \
127             cron-when \"0 * * * *\" --next 5\n    \
128             Show next 5 hourly executions\n\n  \
129             cron-when \"0 0 * * 0\" -n 10\n    \
130             Show next 10 weekly executions (every Sunday)",
131        )
132        .value_parser(clap::value_parser!(u32).range(1..=100))
133}
134
135fn arg_color() -> Arg {
136    Arg::new("color")
137        .short('c')
138        .long("color")
139        .help("Enable colored output")
140        .long_help(
141            "Explicitly enable colored output. This overrides the NO_COLOR environment variable \
142             and TTY detection.\n\n\
143             When enabled, output labels and values are colored:\n  \
144             - Cron: label (Green bold), expression (Yellow)\n  \
145             - Command: label (Red bold), command (White)\n  \
146             - Next: label (Blue bold), datetime (default)\n  \
147             - Left: label (Yellow bold), duration (default)\n  \
148             - Comments: Gray",
149        )
150        .action(ArgAction::SetTrue)
151        .overrides_with("no-color")
152}
153
154fn arg_no_color() -> Arg {
155    Arg::new("no-color")
156        .long("no-color")
157        .help("Disable colored output")
158        .long_help(
159            "Explicitly disable colored output. This overrides the --color flag, \
160             CLICOLOR_FORCE environment variable, and TTY detection.",
161        )
162        .action(ArgAction::SetTrue)
163        .overrides_with("color")
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_cli_structure() {
172        let cmd = new();
173        assert_eq!(cmd.get_name(), env!("CARGO_PKG_NAME"));
174    }
175
176    #[test]
177    fn test_parse_cron_expression() {
178        let matches = new().get_matches_from(vec!["cron-when", "*/5 * * * *"]);
179        assert!(matches.contains_id("cron"));
180        assert_eq!(
181            matches.get_one::<String>("cron").map(String::as_str),
182            Some("*/5 * * * *")
183        );
184    }
185
186    #[test]
187    fn test_parse_file_flag() {
188        let matches = new().get_matches_from(vec!["cron-when", "-f", "test.crontab"]);
189        assert!(matches.contains_id("file"));
190        assert_eq!(
191            matches.get_one::<String>("file").map(String::as_str),
192            Some("test.crontab")
193        );
194    }
195
196    #[test]
197    fn test_parse_crontab_flag() {
198        let matches = new().get_matches_from(vec!["cron-when", "--crontab"]);
199        assert!(matches.get_flag("crontab"));
200    }
201
202    #[test]
203    fn test_verbose_count() {
204        let matches = new().get_matches_from(vec!["cron-when", "-vvv", "*/5 * * * *"]);
205        assert_eq!(matches.get_count("verbose"), 3);
206    }
207
208    #[test]
209    fn test_parse_next_flag() {
210        let matches = new().get_matches_from(vec!["cron-when", "--next", "5", "*/5 * * * *"]);
211        assert_eq!(matches.get_one::<u32>("next"), Some(&5));
212    }
213
214    #[test]
215    fn test_parse_next_short() {
216        let matches = new().get_matches_from(vec!["cron-when", "-n", "10", "*/5 * * * *"]);
217        assert_eq!(matches.get_one::<u32>("next"), Some(&10));
218    }
219}