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#[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#[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#[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 if trimmed.is_empty() || trimmed.starts_with('#') {
68 continue;
69 }
70
71 if is_env_var(trimmed) {
73 debug!(line = trimmed, "Skipping environment variable");
74 continue;
75 }
76
77 if let Some(expression) = extract_cron_expression(trimmed) {
79 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
94fn 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
108fn extract_cron_expression(line: &str) -> Option<String> {
110 let parts: Vec<&str> = line.split_whitespace().collect();
111 if parts.len() >= 6 {
112 Some(parts[0..5].join(" "))
114 } else {
115 None
116 }
117}
118
119fn 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}