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