Skip to main content

cron_when/
crontab.rs

1use anyhow::{Context, Result};
2use chrono::Utc;
3use cron_parser::parse;
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7use tracing::{debug, info, instrument, warn};
8
9#[derive(Debug, Clone)]
10pub struct CronEntry {
11    pub expression: String,
12    pub command: Option<String>,
13    pub comment: Option<String>,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub(crate) enum ScheduleExpression<'a> {
18    Reboot,
19    Standard(&'a str),
20}
21
22/// Parse crontab from current user
23///
24/// # Errors
25///
26/// Returns an error if crontab command execution or parsing fails
27#[instrument(level = "info")]
28pub fn parse_current() -> Result<Vec<CronEntry>> {
29    debug!("Executing 'crontab -l' command");
30
31    let output = Command::new("crontab")
32        .arg("-l")
33        .output()
34        .context("Failed to execute 'crontab -l'. Make sure crontab is installed.")?;
35
36    if !output.status.success() {
37        let stderr = String::from_utf8_lossy(&output.stderr);
38        if stderr.contains("no crontab") {
39            info!("No crontab found for current user");
40            return Ok(Vec::new());
41        }
42        anyhow::bail!("crontab -l failed: {stderr}");
43    }
44
45    let content =
46        String::from_utf8(output.stdout).context("Failed to parse crontab output as UTF-8")?;
47
48    let entries = parse_content(&content);
49    info!(entry_count = entries.len(), "Parsed crontab entries");
50
51    Ok(entries)
52}
53
54/// Parse crontab from file
55///
56/// # Errors
57///
58/// Returns an error if file reading or parsing fails
59#[instrument(level = "info", fields(path = %path.display()))]
60pub fn parse_file(path: &Path) -> Result<Vec<CronEntry>> {
61    debug!("Reading crontab file");
62
63    let content = fs::read_to_string(path)
64        .with_context(|| format!("Failed to read file: {path}", path = path.display()))?;
65
66    let entries = parse_content(&content);
67    info!(entry_count = entries.len(), "Parsed crontab file entries");
68
69    Ok(entries)
70}
71
72/// Parse crontab content and extract entries
73#[instrument(level = "debug", skip(content))]
74fn parse_content(content: &str) -> Vec<CronEntry> {
75    let mut entries = Vec::new();
76    let lines: Vec<&str> = content.lines().collect();
77    let mut current_comments = Vec::new();
78    let now = Utc::now();
79
80    debug!(line_count = lines.len(), "Parsing crontab content");
81
82    for line in lines {
83        let trimmed = line.trim();
84
85        // Skip empty lines and collect comments
86        if trimmed.is_empty() {
87            current_comments.clear();
88            continue;
89        }
90
91        if trimmed.starts_with('#') {
92            current_comments.push(trimmed.trim_start_matches('#').trim().to_string());
93            continue;
94        }
95
96        // Skip environment variable assignments
97        if is_env_var(trimmed) {
98            debug!(line = trimmed, "Skipping environment variable");
99            current_comments.clear();
100            continue;
101        }
102
103        // Try to parse as cron entry
104        if let Some((expression, command, inline_comment)) = extract_cron_entry(trimmed) {
105            let Some(schedule) = normalized_schedule_expression(&expression) else {
106                warn!(line = trimmed, "Unsupported cron alias");
107                current_comments.clear();
108                continue;
109            };
110
111            if let ScheduleExpression::Standard(schedule_expression) = schedule
112                && parse(schedule_expression, &now).is_err()
113            {
114                warn!(line = trimmed, expression = %expression, "Invalid cron expression");
115                current_comments.clear();
116                continue;
117            }
118
119            let comment = merge_comments(&current_comments, inline_comment);
120            debug!(expression = %expression, has_comment = comment.is_some(), "Found cron entry");
121            entries.push(CronEntry {
122                expression,
123                command: Some(command),
124                comment,
125            });
126        } else {
127            warn!(line = trimmed, "Could not parse as cron expression");
128        }
129
130        current_comments.clear();
131    }
132
133    entries
134}
135
136/// Check if line is an environment variable assignment
137fn is_env_var(line: &str) -> bool {
138    if let Some((key, _)) = line.split_once('=') {
139        return !key.contains(char::is_whitespace);
140    }
141    false
142}
143
144/// Extract cron expression and command from a line
145fn extract_cron_entry(line: &str) -> Option<(String, String, Option<String>)> {
146    let (expression, command_tail) = split_expression_and_command(line)?;
147    let (command, inline_comment) = split_command_and_inline_comment(command_tail);
148    let command = command.trim();
149
150    if command.is_empty() {
151        return None;
152    }
153
154    Some((expression, command.to_string(), inline_comment))
155}
156
157fn split_expression_and_command(line: &str) -> Option<(String, &str)> {
158    let trimmed = line.trim();
159    let mut fields = trimmed.split_whitespace();
160    let first = fields.next()?;
161
162    if first.starts_with('@') {
163        let alias_end = first.len();
164        let command_tail = trimmed.get(alias_end..)?.trim_start();
165        return Some((first.to_string(), command_tail));
166    }
167
168    let mut field_count = 1usize;
169    let mut end_index = first.len();
170
171    while field_count < 5 {
172        let remainder = trimmed.get(end_index..)?;
173        let whitespace_len = remainder
174            .chars()
175            .take_while(|ch| ch.is_whitespace())
176            .map(char::len_utf8)
177            .sum::<usize>();
178
179        if whitespace_len == 0 {
180            return None;
181        }
182
183        end_index += whitespace_len;
184
185        let remainder = trimmed.get(end_index..)?;
186        let field_len = remainder
187            .chars()
188            .take_while(|ch| !ch.is_whitespace())
189            .map(char::len_utf8)
190            .sum::<usize>();
191
192        if field_len == 0 {
193            return None;
194        }
195
196        end_index += field_len;
197        field_count += 1;
198    }
199
200    let expression = trimmed.get(..end_index)?.to_string();
201    let command_tail = trimmed.get(end_index..)?.trim_start();
202    Some((expression, command_tail))
203}
204
205fn split_command_and_inline_comment(command: &str) -> (&str, Option<String>) {
206    let mut in_single_quotes = false;
207    let mut in_double_quotes = false;
208    let mut escaped = false;
209    let mut previous = None;
210
211    for (index, ch) in command.char_indices() {
212        if escaped {
213            escaped = false;
214            previous = Some(ch);
215            continue;
216        }
217
218        match ch {
219            '\\' => {
220                escaped = true;
221            }
222            '\'' if !in_double_quotes => {
223                in_single_quotes = !in_single_quotes;
224            }
225            '"' if !in_single_quotes => {
226                in_double_quotes = !in_double_quotes;
227            }
228            '#' if !in_single_quotes
229                && !in_double_quotes
230                && previous.is_some_and(char::is_whitespace) =>
231            {
232                let command_part = command[..index].trim_end();
233                let inline_comment = command[index + ch.len_utf8()..].trim();
234                let inline_comment = if inline_comment.is_empty() {
235                    None
236                } else {
237                    Some(inline_comment.to_string())
238                };
239
240                return (command_part, inline_comment);
241            }
242            _ => {}
243        }
244
245        previous = Some(ch);
246    }
247
248    (command.trim_end(), None)
249}
250
251pub(crate) fn normalized_schedule_expression(expression: &str) -> Option<ScheduleExpression<'_>> {
252    match expression {
253        "@reboot" => Some(ScheduleExpression::Reboot),
254        "@yearly" | "@annually" => Some(ScheduleExpression::Standard("0 0 1 1 *")),
255        "@monthly" => Some(ScheduleExpression::Standard("0 0 1 * *")),
256        "@weekly" => Some(ScheduleExpression::Standard("0 0 * * 0")),
257        "@daily" | "@midnight" => Some(ScheduleExpression::Standard("0 0 * * *")),
258        "@hourly" => Some(ScheduleExpression::Standard("0 * * * *")),
259        _ if expression.starts_with('@') => None,
260        _ => Some(ScheduleExpression::Standard(expression)),
261    }
262}
263
264fn merge_comments(block_comments: &[String], inline_comment: Option<String>) -> Option<String> {
265    let mut comments = block_comments.to_vec();
266    if let Some(inline_comment) = inline_comment {
267        comments.push(inline_comment);
268    }
269
270    if comments.is_empty() {
271        None
272    } else {
273        Some(comments.join("\n"))
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_parse_content() {
283        let content = r"
284# Run every 5 minutes
285*/5 * * * * /usr/bin/script1.sh
286
287# Daily backup at midnight
2880 0 * * * /usr/bin/backup.sh
289
290# Environment variable
291SHELL=/bin/bash
292
293# Another comment without entry
294
29530 2 * * 1 /usr/bin/weekly.sh
296";
297
298        let entries = parse_content(content);
299        assert_eq!(entries.len(), 3);
300
301        assert_eq!(
302            entries.first().map(|e| e.expression.as_str()),
303            Some("*/5 * * * *")
304        );
305        assert_eq!(
306            entries.first().and_then(|e| e.command.as_deref()),
307            Some("/usr/bin/script1.sh")
308        );
309        assert_eq!(
310            entries.first().and_then(|e| e.comment.as_deref()),
311            Some("Run every 5 minutes")
312        );
313
314        assert_eq!(
315            entries.get(1).map(|e| e.expression.as_str()),
316            Some("0 0 * * *")
317        );
318        assert_eq!(
319            entries.get(1).and_then(|e| e.command.as_deref()),
320            Some("/usr/bin/backup.sh")
321        );
322        assert_eq!(
323            entries.get(1).and_then(|e| e.comment.as_deref()),
324            Some("Daily backup at midnight")
325        );
326
327        assert_eq!(
328            entries.get(2).map(|e| e.expression.as_str()),
329            Some("30 2 * * 1")
330        );
331        assert_eq!(
332            entries.get(2).and_then(|e| e.command.as_deref()),
333            Some("/usr/bin/weekly.sh")
334        );
335        assert_eq!(entries.get(2).and_then(|e| e.comment.as_deref()), None);
336    }
337
338    #[test]
339    fn test_parse_content_complex() {
340        let content = r"
341# First comment line
342# Second comment line
343*/5 * * * * /usr/bin/script1.sh
344
345# This is a daily job
346@daily /usr/bin/daily.sh
347
348# Job with inline comment
3490 0 * * * /usr/bin/backup.sh # backup now
350";
351
352        let entries = parse_content(content);
353
354        assert_eq!(entries.len(), 3);
355
356        // Check first entry (multi-line comment)
357        assert_eq!(
358            entries.first().map(|e| e.expression.as_str()),
359            Some("*/5 * * * *")
360        );
361        assert_eq!(
362            entries.first().and_then(|e| e.comment.as_deref()),
363            Some("First comment line\nSecond comment line")
364        );
365
366        // Check second entry (alias)
367        assert_eq!(
368            entries.get(1).map(|e| e.expression.as_str()),
369            Some("@daily")
370        );
371        assert_eq!(
372            entries.get(1).and_then(|e| e.command.as_deref()),
373            Some("/usr/bin/daily.sh")
374        );
375        assert_eq!(
376            entries.get(1).and_then(|e| e.comment.as_deref()),
377            Some("This is a daily job")
378        );
379
380        // Check third entry (inline comment merged into comment block)
381        assert_eq!(
382            entries.get(2).map(|e| e.expression.as_str()),
383            Some("0 0 * * *")
384        );
385        assert_eq!(
386            entries.get(2).and_then(|e| e.command.as_deref()),
387            Some("/usr/bin/backup.sh")
388        );
389        assert_eq!(
390            entries.get(2).and_then(|e| e.comment.as_deref()),
391            Some("Job with inline comment\nbackup now")
392        );
393    }
394
395    #[test]
396    fn test_parse_content_alias_with_inline_comment() {
397        let content = "@hourly /usr/bin/backup.sh # rotate logs\n";
398
399        let entries = parse_content(content);
400        assert_eq!(entries.len(), 1);
401        assert_eq!(
402            entries.first().map(|entry| entry.expression.as_str()),
403            Some("@hourly")
404        );
405        assert_eq!(
406            entries.first().and_then(|entry| entry.command.as_deref()),
407            Some("/usr/bin/backup.sh")
408        );
409        assert_eq!(
410            entries.first().and_then(|entry| entry.comment.as_deref()),
411            Some("rotate logs")
412        );
413    }
414
415    #[test]
416    fn test_parse_content_preserves_literal_hash_in_command() {
417        let content = "0 0 * * * echo foo#bar\n";
418
419        let entries = parse_content(content);
420        assert_eq!(entries.len(), 1);
421        assert_eq!(
422            entries.first().and_then(|entry| entry.command.as_deref()),
423            Some("echo foo#bar")
424        );
425        assert_eq!(
426            entries.first().and_then(|entry| entry.comment.as_deref()),
427            None
428        );
429    }
430
431    #[test]
432    fn test_parse_content_splits_inline_comment_when_preceded_by_whitespace() {
433        let content = "0 0 * * * echo foo # backup\n";
434
435        let entries = parse_content(content);
436        assert_eq!(entries.len(), 1);
437        assert_eq!(
438            entries.first().and_then(|entry| entry.command.as_deref()),
439            Some("echo foo")
440        );
441        assert_eq!(
442            entries.first().and_then(|entry| entry.comment.as_deref()),
443            Some("backup")
444        );
445    }
446
447    #[test]
448    fn test_parse_content_preserves_escaped_and_quoted_hashes() {
449        let content = r#"
4500 0 * * * echo foo\#bar
4510 0 * * * echo "foo # not-comment"
4520 0 * * * echo 'foo # still-not-comment'
453"#;
454
455        let entries = parse_content(content);
456        assert_eq!(entries.len(), 3);
457        assert_eq!(
458            entries.first().and_then(|entry| entry.command.as_deref()),
459            Some(r"echo foo\#bar")
460        );
461        assert_eq!(
462            entries.get(1).and_then(|entry| entry.command.as_deref()),
463            Some(r#"echo "foo # not-comment""#)
464        );
465        assert_eq!(
466            entries.get(2).and_then(|entry| entry.command.as_deref()),
467            Some("echo 'foo # still-not-comment'")
468        );
469    }
470
471    #[test]
472    fn test_parse_content_merges_block_and_inline_comments() {
473        let content = r"
474# first
475# second
4760 0 * * * /bin/true # third
477";
478
479        let entries = parse_content(content);
480        assert_eq!(entries.len(), 1);
481        assert_eq!(
482            entries.first().and_then(|entry| entry.comment.as_deref()),
483            Some("first\nsecond\nthird")
484        );
485    }
486
487    #[test]
488    fn test_parse_content_skips_alias_without_command() {
489        let content = "@daily\n";
490
491        let entries = parse_content(content);
492        assert!(entries.is_empty());
493    }
494
495    #[test]
496    fn test_parse_content_skips_unknown_alias_without_aborting() {
497        let content = r"
498@unknown /bin/true
4990 0 * * * /bin/echo ok
500";
501
502        let entries = parse_content(content);
503        assert_eq!(entries.len(), 1);
504        assert_eq!(
505            entries.first().map(|entry| entry.expression.as_str()),
506            Some("0 0 * * *")
507        );
508    }
509
510    #[test]
511    fn test_parse_content_skips_invalid_standard_expression_without_aborting() {
512        let content = r"
51361 * * * * /bin/false
5140 0 * * * /bin/echo ok
515";
516
517        let entries = parse_content(content);
518        assert_eq!(entries.len(), 1);
519        assert_eq!(
520            entries.first().and_then(|entry| entry.command.as_deref()),
521            Some("/bin/echo ok")
522        );
523    }
524
525    #[test]
526    fn test_normalized_schedule_expression() {
527        assert_eq!(
528            normalized_schedule_expression("@daily"),
529            Some(ScheduleExpression::Standard("0 0 * * *"))
530        );
531        assert_eq!(
532            normalized_schedule_expression("@reboot"),
533            Some(ScheduleExpression::Reboot)
534        );
535        assert_eq!(
536            normalized_schedule_expression("*/5 * * * *"),
537            Some(ScheduleExpression::Standard("*/5 * * * *"))
538        );
539        assert_eq!(normalized_schedule_expression("@unknown"), None);
540    }
541
542    #[test]
543    fn test_is_env_var() {
544        assert!(is_env_var("SHELL=/bin/bash"));
545        assert!(is_env_var("PATH=/usr/bin:/bin"));
546        assert!(!is_env_var("0 0 * * * command"));
547        assert!(!is_env_var("# comment"));
548    }
549
550    #[test]
551    fn test_extract_cron_entry() {
552        assert_eq!(
553            extract_cron_entry("*/5 * * * * /usr/bin/script.sh"),
554            Some((
555                "*/5 * * * *".to_string(),
556                "/usr/bin/script.sh".to_string(),
557                None
558            ))
559        );
560        assert_eq!(
561            extract_cron_entry("0 0 * * * command with args"),
562            Some((
563                "0 0 * * *".to_string(),
564                "command with args".to_string(),
565                None
566            ))
567        );
568        assert_eq!(
569            extract_cron_entry("0 0 * * * command # note"),
570            Some((
571                "0 0 * * *".to_string(),
572                "command".to_string(),
573                Some("note".to_string())
574            ))
575        );
576        assert_eq!(extract_cron_entry("invalid"), None);
577        assert_eq!(extract_cron_entry("0 0 * *"), None);
578    }
579}