cron_when/
crontab.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::Path;
4use std::process::Command;
5use tracing::{debug, info, instrument, warn};
6
7#[derive(Debug, Clone)]
8pub struct CronEntry {
9    pub expression: String,
10    pub comment: Option<String>,
11}
12
13/// Parse crontab from current user
14#[instrument(level = "info")]
15pub fn parse_current() -> Result<Vec<CronEntry>> {
16    debug!("Executing 'crontab -l' command");
17
18    let output = Command::new("crontab")
19        .arg("-l")
20        .output()
21        .context("Failed to execute 'crontab -l'. Make sure crontab is installed.")?;
22
23    if !output.status.success() {
24        let stderr = String::from_utf8_lossy(&output.stderr);
25        if stderr.contains("no crontab") {
26            info!("No crontab found for current user");
27            return Ok(Vec::new());
28        }
29        anyhow::bail!("crontab -l failed: {}", stderr);
30    }
31
32    let content =
33        String::from_utf8(output.stdout).context("Failed to parse crontab output as UTF-8")?;
34
35    let entries = parse_content(&content);
36    info!(entry_count = entries.len(), "Parsed crontab entries");
37
38    Ok(entries)
39}
40
41/// Parse crontab from file
42#[instrument(level = "info", fields(path = %path.display()))]
43pub fn parse_file(path: &Path) -> Result<Vec<CronEntry>> {
44    debug!("Reading crontab file");
45
46    let content = fs::read_to_string(path)
47        .with_context(|| format!("Failed to read file: {}", path.display()))?;
48
49    let entries = parse_content(&content);
50    info!(entry_count = entries.len(), "Parsed crontab file entries");
51
52    Ok(entries)
53}
54
55/// Parse crontab content and extract entries
56#[instrument(level = "debug", skip(content))]
57fn parse_content(content: &str) -> Vec<CronEntry> {
58    let mut entries = Vec::new();
59    let lines: Vec<&str> = content.lines().collect();
60
61    debug!(line_count = lines.len(), "Parsing crontab content");
62
63    for (i, line) in lines.iter().enumerate() {
64        let trimmed = line.trim();
65
66        // Skip empty lines and comments
67        if trimmed.is_empty() || trimmed.starts_with('#') {
68            continue;
69        }
70
71        // Skip environment variable assignments
72        if is_env_var(trimmed) {
73            debug!(line = trimmed, "Skipping environment variable");
74            continue;
75        }
76
77        // Try to parse as cron entry
78        if let Some(expression) = extract_cron_expression(trimmed) {
79            // Check if previous line was a comment
80            let comment = extract_previous_comment(&lines, i);
81            debug!(expression = %expression, has_comment = comment.is_some(), "Found cron entry");
82            entries.push(CronEntry {
83                expression,
84                comment,
85            });
86        } else {
87            warn!(line = trimmed, "Could not parse as cron expression");
88        }
89    }
90
91    entries
92}
93
94/// Check if line is an environment variable assignment
95fn is_env_var(line: &str) -> bool {
96    if !line.contains('=') {
97        return false;
98    }
99
100    let parts: Vec<&str> = line.splitn(2, '=').collect();
101    if parts.len() == 2 && !parts[0].contains(char::is_whitespace) {
102        return true;
103    }
104
105    false
106}
107
108/// Extract cron expression from a line (first 5 fields)
109fn extract_cron_expression(line: &str) -> Option<String> {
110    let parts: Vec<&str> = line.split_whitespace().collect();
111    if parts.len() >= 6 {
112        // Standard cron: 5 time fields + command
113        Some(parts[0..5].join(" "))
114    } else {
115        None
116    }
117}
118
119/// Extract comment from previous line if it exists
120fn extract_previous_comment(lines: &[&str], current_index: usize) -> Option<String> {
121    if current_index > 0 {
122        let prev_line = lines[current_index - 1].trim();
123        if prev_line.starts_with('#') {
124            return Some(prev_line.trim_start_matches('#').trim().to_string());
125        }
126    }
127    None
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_parse_content() {
136        let content = r#"
137# Run every 5 minutes
138*/5 * * * * /usr/bin/script1.sh
139
140# Daily backup at midnight
1410 0 * * * /usr/bin/backup.sh
142
143# Environment variable
144SHELL=/bin/bash
145
146# Another comment without entry
147
14830 2 * * 1 /usr/bin/weekly.sh
149"#;
150
151        let entries = parse_content(content);
152        assert_eq!(entries.len(), 3);
153
154        assert_eq!(entries[0].expression, "*/5 * * * *");
155        assert_eq!(entries[0].comment, Some("Run every 5 minutes".to_string()));
156
157        assert_eq!(entries[1].expression, "0 0 * * *");
158        assert_eq!(
159            entries[1].comment,
160            Some("Daily backup at midnight".to_string())
161        );
162
163        assert_eq!(entries[2].expression, "30 2 * * 1");
164        assert_eq!(entries[2].comment, None);
165    }
166
167    #[test]
168    fn test_parse_content_empty() {
169        let content = r#"
170# Just comments
171# No actual entries
172SHELL=/bin/bash
173"#;
174
175        let entries = parse_content(content);
176        assert_eq!(entries.len(), 0);
177    }
178
179    #[test]
180    fn test_is_env_var() {
181        assert!(is_env_var("SHELL=/bin/bash"));
182        assert!(is_env_var("PATH=/usr/bin:/bin"));
183        assert!(!is_env_var("0 0 * * * command"));
184        assert!(!is_env_var("# comment"));
185    }
186
187    #[test]
188    fn test_extract_cron_expression() {
189        assert_eq!(
190            extract_cron_expression("*/5 * * * * /usr/bin/script.sh"),
191            Some("*/5 * * * *".to_string())
192        );
193        assert_eq!(
194            extract_cron_expression("0 0 * * * command with args"),
195            Some("0 0 * * *".to_string())
196        );
197        assert_eq!(extract_cron_expression("invalid"), None);
198        assert_eq!(extract_cron_expression("0 0 * *"), None);
199    }
200}