1use crate::crontab::{CronEntry, ScheduleExpression, normalized_schedule_expression};
2use anyhow::{Context, Result};
3use chrono::{DateTime, Local, Utc};
4use colored::Colorize;
5use compound_duration::format_dhms;
6use cron_parser::parse;
7use std::io::Write;
8use tracing::{debug, info, instrument};
9
10#[instrument(level = "info", skip(comment, command), fields(expression = %expression, verbose = %verbose, color = %color))]
16pub fn display_single(
17 expression: &str,
18 verbose: bool,
19 comment: Option<&str>,
20 command: Option<&str>,
21 color: bool,
22) -> Result<()> {
23 display_single_with_writer(
24 &mut std::io::stdout(),
25 expression,
26 verbose,
27 comment,
28 command,
29 color,
30 )
31}
32
33pub fn display_single_with_writer(
39 writer: &mut impl Write,
40 expression: &str,
41 _verbose: bool,
42 comment: Option<&str>,
43 command: Option<&str>,
44 color: bool,
45) -> Result<()> {
46 if color {
48 colored::control::set_override(true);
49 }
50
51 let schedule = normalized_schedule_expression(expression)
52 .with_context(|| format!("Failed to parse cron expression: '{expression}'"))?;
53
54 if schedule == ScheduleExpression::Reboot {
55 return display_reboot(writer, expression, comment, command, color);
56 }
57
58 let now = Utc::now();
59 let formatted_now = format_datetime(&now);
60 debug!("Current time: {formatted_now}");
61
62 let ScheduleExpression::Standard(schedule_expression) = schedule else {
63 unreachable!("reboot expressions return early");
64 };
65
66 let next = parse(schedule_expression, &now)
68 .with_context(|| format!("Failed to parse cron expression: '{expression}'"))?;
69
70 let duration = next.signed_duration_since(now);
72 let seconds = u64::try_from(duration.num_seconds().max(0)).unwrap_or(0);
73
74 info!(
75 next_execution = %format_datetime(&next),
76 seconds_until = %seconds,
77 "Calculated next execution time"
78 );
79
80 write_comment(writer, comment, color)?;
82
83 if color {
85 writeln!(
86 writer,
87 "{} \"{}\"",
88 "Cron:".green().bold(),
89 expression.yellow()
90 )?;
91 } else {
92 writeln!(writer, "Cron: \"{expression}\"")?;
93 }
94
95 if let Some(cmd) = command {
97 if color {
98 writeln!(writer, "{} {}", "Command:".red().bold(), cmd.white())?;
99 } else {
100 writeln!(writer, "Command: {cmd}")?;
101 }
102 }
103
104 if color {
105 writeln!(
106 writer,
107 "{} {}",
108 "Next:".blue().bold(),
109 format_datetime(&next)
110 )?;
111 writeln!(
112 writer,
113 "{} {}",
114 "Left:".yellow().bold(),
115 format_dhms(seconds)
116 )?;
117 } else {
118 writeln!(writer, "Next: {}", format_datetime(&next))?;
119 writeln!(writer, "Left: {}", format_dhms(seconds))?;
120 }
121
122 writeln!(writer)?;
124
125 Ok(())
126}
127
128fn display_reboot(
130 writer: &mut impl Write,
131 expression: &str,
132 comment: Option<&str>,
133 command: Option<&str>,
134 color: bool,
135) -> Result<()> {
136 write_comment(writer, comment, color)?;
137 if color {
138 writeln!(
139 writer,
140 "{} \"{}\"",
141 "Cron:".green().bold(),
142 expression.yellow()
143 )?;
144 } else {
145 writeln!(writer, "Cron: \"{expression}\"")?;
146 }
147 if let Some(cmd) = command {
148 if color {
149 writeln!(writer, "{} {}", "Command:".red().bold(), cmd.white())?;
150 } else {
151 writeln!(writer, "Command: {cmd}")?;
152 }
153 }
154 if color {
155 writeln!(writer, "{} System Startup", "Next:".blue().bold())?;
156 writeln!(writer, "{} N/A", "Left:".yellow().bold())?;
157 } else {
158 writeln!(writer, "Next: System Startup")?;
159 writeln!(writer, "Left: N/A")?;
160 }
161 writeln!(writer)?;
162 Ok(())
163}
164
165fn write_comment(writer: &mut impl Write, comment: Option<&str>, color: bool) -> Result<()> {
166 let Some(comment) = comment else {
167 return Ok(());
168 };
169
170 for line in comment.lines() {
171 if color {
172 writeln!(writer, "{}", format!("# {line}").bright_black())?;
173 } else {
174 writeln!(writer, "# {line}")?;
175 }
176 }
177
178 Ok(())
179}
180
181#[instrument(level = "info", fields(entry_count = entries.len(), verbose = %verbose, color = %color))]
187pub fn display_entries(entries: &[CronEntry], verbose: bool, color: bool) -> Result<()> {
188 display_entries_with_writer(&mut std::io::stdout(), entries, verbose, color)
189}
190
191pub fn display_entries_with_writer(
197 writer: &mut impl Write,
198 entries: &[CronEntry],
199 verbose: bool,
200 color: bool,
201) -> Result<()> {
202 if color {
204 colored::control::set_override(true);
205 }
206
207 if entries.is_empty() {
208 info!("No cron entries to display");
209 writeln!(writer, "No valid cron entries found")?;
210 return Ok(());
211 }
212
213 debug!("Displaying {} cron entries", entries.len());
214
215 for (i, entry) in entries.iter().enumerate() {
216 debug!(index = i, expression = %entry.expression, "Processing entry");
217 display_single_with_writer(
218 writer,
219 &entry.expression,
220 verbose,
221 entry.comment.as_deref(),
222 entry.command.as_deref(),
223 color,
224 )?;
225 }
226
227 Ok(())
228}
229
230#[instrument(level = "info", fields(expression = %expression, count = %count))]
236pub fn display_iterations(expression: &str, count: u32) -> Result<()> {
237 display_iterations_with_writer(&mut std::io::stdout(), expression, count)
238}
239
240pub fn display_iterations_with_writer(
246 writer: &mut impl Write,
247 expression: &str,
248 count: u32,
249) -> Result<()> {
250 let schedule = normalized_schedule_expression(expression)
251 .with_context(|| format!("Failed to parse cron expression: '{expression}'"))?;
252
253 if schedule == ScheduleExpression::Reboot {
254 writeln!(writer, "Expression: {expression}")?;
255 writeln!(writer)?;
256 writeln!(writer, " 1. System Startup (Runs once at boot)")?;
257 return Ok(());
258 }
259
260 let ScheduleExpression::Standard(schedule_expression) = schedule else {
261 unreachable!("reboot expressions return early");
262 };
263
264 let mut current = Utc::now();
265
266 info!("Calculating {count} iterations");
267
268 writeln!(writer, "Expression: {expression}")?;
269 writeln!(writer)?;
270
271 for i in 1..=count {
272 let next = parse(schedule_expression, ¤t)
273 .with_context(|| format!("Failed to parse cron expression: '{expression}'"))?;
274
275 let duration = next.signed_duration_since(Utc::now());
276 let seconds = u64::try_from(duration.num_seconds().max(0)).unwrap_or(0);
277
278 debug!(iteration = i, next_time = %format_datetime(&next), "Calculated iteration");
279
280 writeln!(
281 writer,
282 "{:3}. {} ({})",
283 i,
284 format_datetime(&next),
285 format_dhms(seconds)
286 )?;
287
288 current = next;
290 }
291
292 info!("Completed displaying iterations");
293
294 Ok(())
295}
296
297fn format_datetime(dt: &DateTime<Utc>) -> String {
299 let local = dt.with_timezone(&Local);
300 format!(
301 "{} ({} {})",
302 dt.format("%Y-%m-%d %H:%M:%S UTC"),
303 local.format("%H:%M:%S"),
304 local.format("%Z")
305 )
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn test_format_datetime() {
314 let Ok(dt) = DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z") else {
315 return;
316 };
317 let dt = dt.with_timezone(&Utc);
318 let formatted = format_datetime(&dt);
319
320 assert!(formatted.contains("2024-01-15 10:30:00 UTC"));
322
323 assert!(formatted.contains('('));
325 assert!(formatted.contains(')'));
326
327 let parts: Vec<&str> = formatted.split('(').collect();
329 assert_eq!(
330 parts.len(),
331 2,
332 "Format should have UTC and local time parts"
333 );
334 assert!(
335 parts.first().is_some_and(|p| p.ends_with("UTC ")),
336 "First part should end with 'UTC '"
337 );
338 assert!(
339 parts.get(1).is_some_and(|p| p.ends_with(')')),
340 "Second part should end with ')'"
341 );
342 }
343
344 #[test]
345 fn test_format_datetime_includes_timezone() {
346 let Ok(dt) = DateTime::parse_from_rfc3339("2024-06-15T14:30:00Z") else {
347 return;
348 };
349 let dt = dt.with_timezone(&Utc);
350 let formatted = format_datetime(&dt);
351
352 assert!(formatted.starts_with("2024-06-15 14:30:00 UTC"));
354
355 let local_part_start = formatted.find('(');
357 assert!(
358 local_part_start.is_some(),
359 "Should have opening parenthesis"
360 );
361 let local_part_start = local_part_start.unwrap_or_default();
362
363 let local_part_end = formatted.find(')');
364 assert!(local_part_end.is_some(), "Should have closing parenthesis");
365 let local_part_end = local_part_end.unwrap_or_default();
366 assert!(local_part_end > local_part_start);
367
368 let local_part = &formatted[local_part_start + 1..local_part_end];
370 let local_parts: Vec<&str> = local_part.split_whitespace().collect();
371 assert_eq!(
372 local_parts.len(),
373 2,
374 "Local part should have time and timezone"
375 );
376
377 assert_eq!(
379 local_parts.first().map_or(0, |p| p.split(':').count()),
380 3,
381 "Time should have hours, minutes, seconds"
382 );
383 }
384
385 #[test]
386 fn test_display_single_with_writer() -> Result<()> {
387 let mut buf = Vec::new();
388 display_single_with_writer(
389 &mut buf,
390 "*/5 * * * *",
391 false,
392 Some("test comment"),
393 Some("test command"),
394 false,
395 )?;
396
397 let output = String::from_utf8(buf).context("Failed to parse output as UTF-8")?;
398 assert!(output.contains("# test comment"));
399 assert!(output.contains("Cron: \"*/5 * * * *\""));
400 assert!(output.contains("Command: test command"));
401 assert!(output.contains("Next:"));
402 assert!(output.contains("Left:"));
403 Ok(())
404 }
405
406 #[test]
407 fn test_display_single_preserves_alias_in_output() -> Result<()> {
408 let mut buf = Vec::new();
409 display_single_with_writer(&mut buf, "@daily", false, None, Some("/bin/true"), false)?;
410
411 let output = String::from_utf8(buf).context("Failed to parse output as UTF-8")?;
412 assert!(output.contains("Cron: \"@daily\""));
413 assert!(output.contains("Command: /bin/true"));
414 Ok(())
415 }
416
417 #[test]
418 fn test_display_single_renders_multiline_comments_line_by_line() -> Result<()> {
419 let mut buf = Vec::new();
420 display_single_with_writer(
421 &mut buf,
422 "0 * * * *",
423 false,
424 Some("first\nsecond"),
425 None,
426 false,
427 )?;
428
429 let output = String::from_utf8(buf).context("Failed to parse output as UTF-8")?;
430 assert!(output.contains("# first\n# second\n"));
431 Ok(())
432 }
433
434 #[test]
435 fn test_display_entries_with_writer_preserves_alias_and_comment_formatting() -> Result<()> {
436 let entries = vec![CronEntry {
437 expression: "@hourly".to_string(),
438 command: Some("/usr/bin/backup.sh".to_string()),
439 comment: Some("first\nsecond".to_string()),
440 }];
441
442 let mut buf = Vec::new();
443 display_entries_with_writer(&mut buf, &entries, false, false)?;
444
445 let output = String::from_utf8(buf).context("Failed to parse output as UTF-8")?;
446 assert!(output.contains("# first\n# second\n"));
447 assert!(output.contains("Cron: \"@hourly\""));
448 assert!(output.contains("Command: /usr/bin/backup.sh"));
449 Ok(())
450 }
451
452 #[test]
453 fn test_display_single_valid() {
454 let result = display_single("*/5 * * * *", false, None, None, false);
455 assert!(result.is_ok());
456 }
457
458 #[test]
459 fn test_display_single_valid_verbose() {
460 let result = display_single("*/5 * * * *", true, None, None, false);
462 assert!(result.is_ok());
463 }
464
465 #[test]
466 fn test_display_single_with_comment() {
467 let result = display_single("0 * * * *", false, Some("Run every hour"), None, false);
468 assert!(result.is_ok());
469 }
470
471 #[test]
472 fn test_display_single_invalid() {
473 let result = display_single("invalid", false, None, None, false);
474 assert!(result.is_err());
475 }
476
477 #[test]
478 fn test_display_single_invalid_expression_with_comment() {
479 let result = display_single("not a cron", false, Some("This will fail"), None, false);
480 assert!(result.is_err());
481 }
482
483 #[test]
484 fn test_display_entries_empty() {
485 let entries = Vec::new();
486 let result = display_entries(&entries, false, false);
487 assert!(result.is_ok());
488 }
489
490 #[test]
491 fn test_display_entries_single() {
492 let entries = vec![CronEntry {
493 expression: "0 * * * *".to_string(),
494 command: None,
495 comment: None,
496 }];
497 let result = display_entries(&entries, false, false);
498 assert!(result.is_ok());
499 }
500
501 #[test]
502 fn test_display_entries_multiple() {
503 let entries = vec![
504 CronEntry {
505 expression: "0 * * * *".to_string(),
506 command: Some("/usr/bin/backup.sh".to_string()),
507 comment: Some("Hourly backup".to_string()),
508 },
509 CronEntry {
510 expression: "0 0 * * *".to_string(),
511 command: Some("/usr/bin/cleanup.sh".to_string()),
512 comment: Some("Daily cleanup".to_string()),
513 },
514 CronEntry {
515 expression: "*/15 * * * *".to_string(),
516 command: None,
517 comment: None,
518 },
519 ];
520 let result = display_entries(&entries, false, false);
521 assert!(result.is_ok());
522 }
523
524 #[test]
525 fn test_display_entries_with_invalid() {
526 let entries = vec![
527 CronEntry {
528 expression: "0 * * * *".to_string(),
529 command: None,
530 comment: None,
531 },
532 CronEntry {
533 expression: "invalid cron".to_string(),
534 command: None,
535 comment: None,
536 },
537 ];
538 let result = display_entries(&entries, false, false);
540 assert!(result.is_err());
541 }
542
543 #[test]
544 fn test_display_iterations_valid() {
545 let result = display_iterations("0 12 * * *", 3);
546 assert!(result.is_ok());
547 }
548
549 #[test]
550 fn test_display_iterations_single() {
551 let result = display_iterations("*/5 * * * *", 1);
552 assert!(result.is_ok());
553 }
554
555 #[test]
556 fn test_display_iterations_reboot() -> Result<()> {
557 let mut buf = Vec::new();
558 display_iterations_with_writer(&mut buf, "@reboot", 5)?;
559
560 let output = String::from_utf8(buf).context("Failed to parse output as UTF-8")?;
561 assert!(output.contains("Expression: @reboot"));
562 assert!(output.contains("System Startup"));
563 Ok(())
564 }
565
566 #[test]
567 fn test_display_iterations_preserve_alias_in_header() -> Result<()> {
568 let mut buf = Vec::new();
569 display_iterations_with_writer(&mut buf, "@daily", 1)?;
570
571 let output = String::from_utf8(buf).context("Failed to parse output as UTF-8")?;
572 assert!(output.contains("Expression: @daily"));
573 assert!(output.contains(" 1. "));
574 Ok(())
575 }
576
577 #[test]
578 fn test_display_iterations_invalid() {
579 let result = display_iterations("not valid", 5);
580 assert!(result.is_err());
581 }
582
583 #[test]
584 fn test_display_iterations_many() {
585 let result = display_iterations("0 0 * * 0", 10);
586 assert!(result.is_ok());
587 }
588
589 #[test]
590 fn test_various_cron_expressions() {
591 let expressions = vec![
593 "* * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0", "0 0 1 * *", "*/15 * * * *", "0 9-17 * * 1-5", "30 2 * * *", "0 */2 * * *", "0 0 1 1 *", "@daily", "@hourly", "@reboot", ];
607
608 for expr in expressions {
609 let result = display_single(expr, false, None, None, false);
610 assert!(result.is_ok(), "Failed to parse cron expression: {expr}");
611 }
612 }
613
614 #[test]
615 fn test_various_invalid_expressions() {
616 let invalid_expressions = vec![
618 "",
619 "invalid",
620 "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 32 * *", "* * * 13 *", "* * * * 7", "xyz * * * *", ];
629
630 for expr in invalid_expressions {
631 let result = display_single(expr, false, None, None, false);
632 assert!(result.is_err(), "Should have failed for: {expr}");
633 }
634 }
635}