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")]
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#[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#[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 if trimmed.is_empty() || trimmed.starts_with('#') {
69 continue;
70 }
71
72 if is_env_var(trimmed) {
74 debug!(line = trimmed, "Skipping environment variable");
75 continue;
76 }
77
78 if let Some((expression, command)) = extract_cron_entry(trimmed) {
80 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
96fn 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
110fn extract_cron_entry(line: &str) -> Option<(String, Option<String>)> {
112 let parts: Vec<&str> = line.split_whitespace().collect();
113 if parts.len() >= 6 {
114 let expression = parts[0..5].join(" ");
116 let command = Some(parts[5..].join(" "));
117 Some((expression, command))
118 } else {
119 None
120 }
121}
122
123fn 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}