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