cron_when/cli/commands/
mod.rs1use 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#[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}