Skip to main content

cron_when/
output.rs

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/// Display a single cron expression
11///
12/// # Errors
13///
14/// Returns an error if the cron expression cannot be parsed
15#[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
33/// Display a single cron expression to a specific writer
34///
35/// # Errors
36///
37/// Returns an error if the cron expression cannot be parsed or writing fails
38pub 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    // Force colors on if explicitly requested, regardless of TTY detection
47    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    // Parse the cron expression and get next execution time
67    let next = parse(schedule_expression, &now)
68        .with_context(|| format!("Failed to parse cron expression: '{expression}'"))?;
69
70    // Calculate duration until next execution
71    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    // Format output
81    write_comment(writer, comment, color)?;
82
83    // Always show cron expression with quotes for clarity
84    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    // Show command if available
96    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    // Add separator for multiple entries
123    writeln!(writer)?;
124
125    Ok(())
126}
127
128/// Helper to display @reboot entries
129fn 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/// Display multiple cron entries
182///
183/// # Errors
184///
185/// Returns an error if any cron expression cannot be parsed
186#[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
191/// Display multiple cron entries to a specific writer
192///
193/// # Errors
194///
195/// Returns an error if any cron expression cannot be parsed or writing fails
196pub fn display_entries_with_writer(
197    writer: &mut impl Write,
198    entries: &[CronEntry],
199    verbose: bool,
200    color: bool,
201) -> Result<()> {
202    // Force colors on if explicitly requested, regardless of TTY detection
203    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/// Display next N iterations of a cron expression
231///
232/// # Errors
233///
234/// Returns an error if the cron expression cannot be parsed
235#[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
240/// Display next N iterations of a cron expression to a specific writer
241///
242/// # Errors
243///
244/// Returns an error if the cron expression cannot be parsed or writing fails
245pub 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, &current)
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        // Update current time for next iteration
289        current = next;
290    }
291
292    info!("Completed displaying iterations");
293
294    Ok(())
295}
296
297/// Format a `DateTime` as a human-readable string with both UTC and local timezone
298fn 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        // Check that it contains UTC time in the correct format
321        assert!(formatted.contains("2024-01-15 10:30:00 UTC"));
322
323        // Check that it contains local time in parentheses
324        assert!(formatted.contains('('));
325        assert!(formatted.contains(')'));
326
327        // Verify the format has three parts: UTC datetime, local time, and timezone
328        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        // Should have the pattern: YYYY-MM-DD HH:MM:SS UTC (HH:MM:SS TZ)
353        assert!(formatted.starts_with("2024-06-15 14:30:00 UTC"));
354
355        // The local time part should be in parentheses
356        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        // Extract local time part and verify it has time and timezone
369        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        // Verify time format HH:MM:SS
378        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        // Verbose parameter should not affect output anymore
461        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        // Should fail on the invalid entry
539        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        // Test various valid cron expressions
592        let expressions = vec![
593            "* * * * *",      // Every minute
594            "0 * * * *",      // Every hour
595            "0 0 * * *",      // Daily at midnight
596            "0 0 * * 0",      // Weekly on Sunday
597            "0 0 1 * *",      // Monthly on 1st
598            "*/15 * * * *",   // Every 15 minutes
599            "0 9-17 * * 1-5", // Weekdays 9am-5pm
600            "30 2 * * *",     // Daily at 2:30am
601            "0 */2 * * *",    // Every 2 hours
602            "0 0 1 1 *",      // Yearly on Jan 1st
603            "@daily",         // Daily alias
604            "@hourly",        // Hourly alias
605            "@reboot",        // Reboot alias
606        ];
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        // Test various invalid cron expressions
617        let invalid_expressions = vec![
618            "",
619            "invalid",
620            "* * * *",     // Too few fields
621            "* * * * * *", // Too many fields
622            "60 * * * *",  // Invalid minute
623            "* 24 * * *",  // Invalid hour
624            "* * 32 * *",  // Invalid day
625            "* * * 13 *",  // Invalid month
626            "* * * * 7",   // Invalid day of week
627            "xyz * * * *", // Non-numeric
628        ];
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}