1use crate::crontab::CronEntry;
2use anyhow::{Context, Result};
3use chrono::{DateTime, Local, Utc};
4use colored::Colorize;
5use compound_duration::format_dhms;
6use cron_parser::parse;
7use tracing::{debug, info, instrument};
8
9#[instrument(level = "info", skip(comment, command), fields(expression = %expression, verbose = %verbose, color = %color))]
15pub fn display_single(
16 expression: &str,
17 verbose: bool,
18 comment: Option<&str>,
19 command: Option<&str>,
20 color: bool,
21) -> Result<()> {
22 if color {
24 colored::control::set_override(true);
25 }
26
27 let now = Utc::now();
28 let formatted_now = format_datetime(&now);
29 debug!("Current time: {formatted_now}");
30
31 let next = parse(expression, &now)
33 .with_context(|| format!("Failed to parse cron expression: '{expression}'"))?;
34
35 let duration = next.signed_duration_since(now);
37 let seconds = u64::try_from(duration.num_seconds().max(0)).unwrap_or(0);
38
39 info!(
40 next_execution = %format_datetime(&next),
41 seconds_until = %seconds,
42 "Calculated next execution time"
43 );
44
45 if let Some(comment_text) = comment {
47 if color {
48 let formatted = format!("# {comment_text}");
49 println!("{}", formatted.bright_black());
50 } else {
51 println!("# {comment_text}");
52 }
53 }
54
55 if color {
57 println!("{} \"{}\"", "Cron:".green().bold(), expression.yellow());
58 } else {
59 println!("Cron: \"{expression}\"");
60 }
61
62 if let Some(cmd) = command {
64 if color {
65 println!("{} {}", "Command:".red().bold(), cmd.white());
66 } else {
67 println!("Command: {cmd}");
68 }
69 }
70
71 if color {
72 println!("{} {}", "Next:".blue().bold(), format_datetime(&next));
73 println!("{} {}", "Left:".yellow().bold(), format_dhms(seconds));
74 } else {
75 println!("Next: {}", format_datetime(&next));
76 println!("Left: {}", format_dhms(seconds));
77 }
78
79 println!();
81
82 Ok(())
83}
84
85#[instrument(level = "info", fields(entry_count = entries.len(), verbose = %verbose, color = %color))]
91pub fn display_entries(entries: &[CronEntry], verbose: bool, color: bool) -> Result<()> {
92 if color {
94 colored::control::set_override(true);
95 }
96
97 if entries.is_empty() {
98 info!("No cron entries to display");
99 println!("No valid cron entries found");
100 return Ok(());
101 }
102
103 debug!("Displaying {} cron entries", entries.len());
104
105 for (i, entry) in entries.iter().enumerate() {
106 debug!(index = i, expression = %entry.expression, "Processing entry");
107 display_single(
108 &entry.expression,
109 verbose,
110 entry.comment.as_deref(),
111 entry.command.as_deref(),
112 color,
113 )?;
114 }
115
116 Ok(())
117}
118
119#[instrument(level = "info", fields(expression = %expression, count = %count))]
125pub fn display_iterations(expression: &str, count: u32) -> Result<()> {
126 let mut current = Utc::now();
127
128 info!("Calculating {count} iterations");
129
130 println!("Expression: {expression}");
131 println!();
132
133 for i in 1..=count {
134 let next = parse(expression, ¤t)
135 .with_context(|| format!("Failed to parse cron expression: '{expression}'"))?;
136
137 let duration = next.signed_duration_since(Utc::now());
138 let seconds = u64::try_from(duration.num_seconds().max(0)).unwrap_or(0);
139
140 debug!(iteration = i, next_time = %format_datetime(&next), "Calculated iteration");
141
142 println!(
143 "{:3}. {} ({})",
144 i,
145 format_datetime(&next),
146 format_dhms(seconds)
147 );
148
149 current = next;
151 }
152
153 info!("Completed displaying iterations");
154
155 Ok(())
156}
157
158fn format_datetime(dt: &DateTime<Utc>) -> String {
160 let local = dt.with_timezone(&Local);
161 format!(
162 "{} ({} {})",
163 dt.format("%Y-%m-%d %H:%M:%S UTC"),
164 local.format("%H:%M:%S"),
165 local.format("%Z")
166 )
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn test_format_datetime() {
175 let Ok(dt) = DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z") else {
176 return;
177 };
178 let dt = dt.with_timezone(&Utc);
179 let formatted = format_datetime(&dt);
180
181 assert!(formatted.contains("2024-01-15 10:30:00 UTC"));
183
184 assert!(formatted.contains('('));
186 assert!(formatted.contains(')'));
187
188 let parts: Vec<&str> = formatted.split('(').collect();
190 assert_eq!(
191 parts.len(),
192 2,
193 "Format should have UTC and local time parts"
194 );
195 assert!(
196 parts.first().is_some_and(|p| p.ends_with("UTC ")),
197 "First part should end with 'UTC '"
198 );
199 assert!(
200 parts.get(1).is_some_and(|p| p.ends_with(')')),
201 "Second part should end with ')'"
202 );
203 }
204
205 #[test]
206 fn test_format_datetime_includes_timezone() {
207 let Ok(dt) = DateTime::parse_from_rfc3339("2024-06-15T14:30:00Z") else {
208 return;
209 };
210 let dt = dt.with_timezone(&Utc);
211 let formatted = format_datetime(&dt);
212
213 assert!(formatted.starts_with("2024-06-15 14:30:00 UTC"));
215
216 let local_part_start = formatted.find('(');
218 assert!(
219 local_part_start.is_some(),
220 "Should have opening parenthesis"
221 );
222 let local_part_start = local_part_start.unwrap_or_default();
223
224 let local_part_end = formatted.find(')');
225 assert!(local_part_end.is_some(), "Should have closing parenthesis");
226 let local_part_end = local_part_end.unwrap_or_default();
227 assert!(local_part_end > local_part_start);
228
229 let local_part = &formatted[local_part_start + 1..local_part_end];
231 let local_parts: Vec<&str> = local_part.split_whitespace().collect();
232 assert_eq!(
233 local_parts.len(),
234 2,
235 "Local part should have time and timezone"
236 );
237
238 assert_eq!(
240 local_parts.first().map_or(0, |p| p.split(':').count()),
241 3,
242 "Time should have hours, minutes, seconds"
243 );
244 }
245
246 #[test]
247 fn test_display_single_valid() {
248 let result = display_single("*/5 * * * *", false, None, None, false);
249 assert!(result.is_ok());
250 }
251
252 #[test]
253 fn test_display_single_valid_verbose() {
254 let result = display_single("*/5 * * * *", true, None, None, false);
256 assert!(result.is_ok());
257 }
258
259 #[test]
260 fn test_display_single_with_comment() {
261 let result = display_single("0 * * * *", false, Some("Run every hour"), None, false);
262 assert!(result.is_ok());
263 }
264
265 #[test]
266 fn test_display_single_invalid() {
267 let result = display_single("invalid", false, None, None, false);
268 assert!(result.is_err());
269 }
270
271 #[test]
272 fn test_display_single_invalid_expression_with_comment() {
273 let result = display_single("not a cron", false, Some("This will fail"), None, false);
274 assert!(result.is_err());
275 }
276
277 #[test]
278 fn test_display_entries_empty() {
279 let entries = Vec::new();
280 let result = display_entries(&entries, false, false);
281 assert!(result.is_ok());
282 }
283
284 #[test]
285 fn test_display_entries_single() {
286 let entries = vec![CronEntry {
287 expression: "0 * * * *".to_string(),
288 command: None,
289 comment: None,
290 }];
291 let result = display_entries(&entries, false, false);
292 assert!(result.is_ok());
293 }
294
295 #[test]
296 fn test_display_entries_multiple() {
297 let entries = vec![
298 CronEntry {
299 expression: "0 * * * *".to_string(),
300 command: Some("/usr/bin/backup.sh".to_string()),
301 comment: Some("Hourly backup".to_string()),
302 },
303 CronEntry {
304 expression: "0 0 * * *".to_string(),
305 command: Some("/usr/bin/cleanup.sh".to_string()),
306 comment: Some("Daily cleanup".to_string()),
307 },
308 CronEntry {
309 expression: "*/15 * * * *".to_string(),
310 command: None,
311 comment: None,
312 },
313 ];
314 let result = display_entries(&entries, false, false);
315 assert!(result.is_ok());
316 }
317
318 #[test]
319 fn test_display_entries_with_invalid() {
320 let entries = vec![
321 CronEntry {
322 expression: "0 * * * *".to_string(),
323 command: None,
324 comment: None,
325 },
326 CronEntry {
327 expression: "invalid cron".to_string(),
328 command: None,
329 comment: None,
330 },
331 ];
332 let result = display_entries(&entries, false, false);
334 assert!(result.is_err());
335 }
336
337 #[test]
338 fn test_display_iterations_valid() {
339 let result = display_iterations("0 12 * * *", 3);
340 assert!(result.is_ok());
341 }
342
343 #[test]
344 fn test_display_iterations_single() {
345 let result = display_iterations("*/5 * * * *", 1);
346 assert!(result.is_ok());
347 }
348
349 #[test]
350 fn test_display_iterations_invalid() {
351 let result = display_iterations("not valid", 5);
352 assert!(result.is_err());
353 }
354
355 #[test]
356 fn test_display_iterations_many() {
357 let result = display_iterations("0 0 * * 0", 10);
358 assert!(result.is_ok());
359 }
360
361 #[test]
362 fn test_various_cron_expressions() {
363 let expressions = vec![
365 "* * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0", "0 0 1 * *", "*/15 * * * *", "0 9-17 * * 1-5", "30 2 * * *", "0 */2 * * *", "0 0 1 1 *", ];
376
377 for expr in expressions {
378 let result = display_single(expr, false, None, None, false);
379 assert!(result.is_ok(), "Failed to parse cron expression: {expr}");
380 }
381 }
382
383 #[test]
384 fn test_various_invalid_expressions() {
385 let invalid_expressions = vec![
387 "",
388 "invalid",
389 "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 32 * *", "* * * 13 *", "* * * * 7", "xyz * * * *", ];
398
399 for expr in invalid_expressions {
400 let result = display_single(expr, false, None, None, false);
401 assert!(result.is_err(), "Should have failed for: {expr}");
402 }
403 }
404}