cron_when/
output.rs

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/// Display a single cron expression
10///
11/// # Errors
12///
13/// Returns an error if the cron expression cannot be parsed
14#[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    // Force colors on if explicitly requested, regardless of TTY detection
23    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    // Parse the cron expression and get next execution time
32    let next = parse(expression, &now)
33        .with_context(|| format!("Failed to parse cron expression: '{expression}'"))?;
34
35    // Calculate duration until next execution
36    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    // Format output
46    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    // Always show cron expression with quotes for clarity
56    if color {
57        println!("{} \"{}\"", "Cron:".green().bold(), expression.yellow());
58    } else {
59        println!("Cron: \"{expression}\"");
60    }
61
62    // Show command if available
63    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    // Add separator for multiple entries
80    println!();
81
82    Ok(())
83}
84
85/// Display multiple cron entries
86///
87/// # Errors
88///
89/// Returns an error if any cron expression cannot be parsed
90#[instrument(level = "info", fields(entry_count = entries.len(), verbose = %verbose, color = %color))]
91pub fn display_entries(entries: &[CronEntry], verbose: bool, color: bool) -> Result<()> {
92    // Force colors on if explicitly requested, regardless of TTY detection
93    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/// Display next N iterations of a cron expression
120///
121/// # Errors
122///
123/// Returns an error if the cron expression cannot be parsed
124#[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, &current)
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        // Update current time for next iteration
150        current = next;
151    }
152
153    info!("Completed displaying iterations");
154
155    Ok(())
156}
157
158/// Format a `DateTime` as a human-readable string with both UTC and local timezone
159fn 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        // Check that it contains UTC time in the correct format
182        assert!(formatted.contains("2024-01-15 10:30:00 UTC"));
183
184        // Check that it contains local time in parentheses
185        assert!(formatted.contains('('));
186        assert!(formatted.contains(')'));
187
188        // Verify the format has three parts: UTC datetime, local time, and timezone
189        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        // Should have the pattern: YYYY-MM-DD HH:MM:SS UTC (HH:MM:SS TZ)
214        assert!(formatted.starts_with("2024-06-15 14:30:00 UTC"));
215
216        // The local time part should be in parentheses
217        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        // Extract local time part and verify it has time and timezone
230        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        // Verify time format HH:MM:SS
239        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        // Verbose parameter should not affect output anymore
255        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        // Should fail on the invalid entry
333        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        // Test various valid cron expressions
364        let expressions = vec![
365            "* * * * *",      // Every minute
366            "0 * * * *",      // Every hour
367            "0 0 * * *",      // Daily at midnight
368            "0 0 * * 0",      // Weekly on Sunday
369            "0 0 1 * *",      // Monthly on 1st
370            "*/15 * * * *",   // Every 15 minutes
371            "0 9-17 * * 1-5", // Weekdays 9am-5pm
372            "30 2 * * *",     // Daily at 2:30am
373            "0 */2 * * *",    // Every 2 hours
374            "0 0 1 1 *",      // Yearly on Jan 1st
375        ];
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        // Test various invalid cron expressions
386        let invalid_expressions = vec![
387            "",
388            "invalid",
389            "* * * *",     // Too few fields
390            "* * * * * *", // Too many fields
391            "60 * * * *",  // Invalid minute
392            "* 24 * * *",  // Invalid hour
393            "* * 32 * *",  // Invalid day
394            "* * * 13 *",  // Invalid month
395            "* * * * 7",   // Invalid day of week
396            "xyz * * * *", // Non-numeric
397        ];
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}