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#[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#[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#[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 if trimmed.is_empty() || trimmed.starts_with('#') {
77 continue;
78 }
79
80 if is_env_var(trimmed) {
82 debug!(line = trimmed, "Skipping environment variable");
83 continue;
84 }
85
86 if let Some((expression, command)) = extract_cron_entry(trimmed) {
88 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
104fn 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
122fn extract_cron_entry(line: &str) -> Option<(String, Option<String>)> {
124 let parts: Vec<&str> = line.split_whitespace().collect();
125 if parts.len() >= 6 {
126 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
135fn 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}