1use anyhow::{Context, Result};
2use chrono::Utc;
3use cron_parser::parse;
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7use tracing::{debug, info, instrument, warn};
8
9#[derive(Debug, Clone)]
10pub struct CronEntry {
11 pub expression: String,
12 pub command: Option<String>,
13 pub comment: Option<String>,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub(crate) enum ScheduleExpression<'a> {
18 Reboot,
19 Standard(&'a str),
20}
21
22#[instrument(level = "info")]
28pub fn parse_current() -> Result<Vec<CronEntry>> {
29 debug!("Executing 'crontab -l' command");
30
31 let output = Command::new("crontab")
32 .arg("-l")
33 .output()
34 .context("Failed to execute 'crontab -l'. Make sure crontab is installed.")?;
35
36 if !output.status.success() {
37 let stderr = String::from_utf8_lossy(&output.stderr);
38 if stderr.contains("no crontab") {
39 info!("No crontab found for current user");
40 return Ok(Vec::new());
41 }
42 anyhow::bail!("crontab -l failed: {stderr}");
43 }
44
45 let content =
46 String::from_utf8(output.stdout).context("Failed to parse crontab output as UTF-8")?;
47
48 let entries = parse_content(&content);
49 info!(entry_count = entries.len(), "Parsed crontab entries");
50
51 Ok(entries)
52}
53
54#[instrument(level = "info", fields(path = %path.display()))]
60pub fn parse_file(path: &Path) -> Result<Vec<CronEntry>> {
61 debug!("Reading crontab file");
62
63 let content = fs::read_to_string(path)
64 .with_context(|| format!("Failed to read file: {path}", path = path.display()))?;
65
66 let entries = parse_content(&content);
67 info!(entry_count = entries.len(), "Parsed crontab file entries");
68
69 Ok(entries)
70}
71
72#[instrument(level = "debug", skip(content))]
74fn parse_content(content: &str) -> Vec<CronEntry> {
75 let mut entries = Vec::new();
76 let lines: Vec<&str> = content.lines().collect();
77 let mut current_comments = Vec::new();
78 let now = Utc::now();
79
80 debug!(line_count = lines.len(), "Parsing crontab content");
81
82 for line in lines {
83 let trimmed = line.trim();
84
85 if trimmed.is_empty() {
87 current_comments.clear();
88 continue;
89 }
90
91 if trimmed.starts_with('#') {
92 current_comments.push(trimmed.trim_start_matches('#').trim().to_string());
93 continue;
94 }
95
96 if is_env_var(trimmed) {
98 debug!(line = trimmed, "Skipping environment variable");
99 current_comments.clear();
100 continue;
101 }
102
103 if let Some((expression, command, inline_comment)) = extract_cron_entry(trimmed) {
105 let Some(schedule) = normalized_schedule_expression(&expression) else {
106 warn!(line = trimmed, "Unsupported cron alias");
107 current_comments.clear();
108 continue;
109 };
110
111 if let ScheduleExpression::Standard(schedule_expression) = schedule
112 && parse(schedule_expression, &now).is_err()
113 {
114 warn!(line = trimmed, expression = %expression, "Invalid cron expression");
115 current_comments.clear();
116 continue;
117 }
118
119 let comment = merge_comments(¤t_comments, inline_comment);
120 debug!(expression = %expression, has_comment = comment.is_some(), "Found cron entry");
121 entries.push(CronEntry {
122 expression,
123 command: Some(command),
124 comment,
125 });
126 } else {
127 warn!(line = trimmed, "Could not parse as cron expression");
128 }
129
130 current_comments.clear();
131 }
132
133 entries
134}
135
136fn is_env_var(line: &str) -> bool {
138 if let Some((key, _)) = line.split_once('=') {
139 return !key.contains(char::is_whitespace);
140 }
141 false
142}
143
144fn extract_cron_entry(line: &str) -> Option<(String, String, Option<String>)> {
146 let (expression, command_tail) = split_expression_and_command(line)?;
147 let (command, inline_comment) = split_command_and_inline_comment(command_tail);
148 let command = command.trim();
149
150 if command.is_empty() {
151 return None;
152 }
153
154 Some((expression, command.to_string(), inline_comment))
155}
156
157fn split_expression_and_command(line: &str) -> Option<(String, &str)> {
158 let trimmed = line.trim();
159 let mut fields = trimmed.split_whitespace();
160 let first = fields.next()?;
161
162 if first.starts_with('@') {
163 let alias_end = first.len();
164 let command_tail = trimmed.get(alias_end..)?.trim_start();
165 return Some((first.to_string(), command_tail));
166 }
167
168 let mut field_count = 1usize;
169 let mut end_index = first.len();
170
171 while field_count < 5 {
172 let remainder = trimmed.get(end_index..)?;
173 let whitespace_len = remainder
174 .chars()
175 .take_while(|ch| ch.is_whitespace())
176 .map(char::len_utf8)
177 .sum::<usize>();
178
179 if whitespace_len == 0 {
180 return None;
181 }
182
183 end_index += whitespace_len;
184
185 let remainder = trimmed.get(end_index..)?;
186 let field_len = remainder
187 .chars()
188 .take_while(|ch| !ch.is_whitespace())
189 .map(char::len_utf8)
190 .sum::<usize>();
191
192 if field_len == 0 {
193 return None;
194 }
195
196 end_index += field_len;
197 field_count += 1;
198 }
199
200 let expression = trimmed.get(..end_index)?.to_string();
201 let command_tail = trimmed.get(end_index..)?.trim_start();
202 Some((expression, command_tail))
203}
204
205fn split_command_and_inline_comment(command: &str) -> (&str, Option<String>) {
206 let mut in_single_quotes = false;
207 let mut in_double_quotes = false;
208 let mut escaped = false;
209 let mut previous = None;
210
211 for (index, ch) in command.char_indices() {
212 if escaped {
213 escaped = false;
214 previous = Some(ch);
215 continue;
216 }
217
218 match ch {
219 '\\' => {
220 escaped = true;
221 }
222 '\'' if !in_double_quotes => {
223 in_single_quotes = !in_single_quotes;
224 }
225 '"' if !in_single_quotes => {
226 in_double_quotes = !in_double_quotes;
227 }
228 '#' if !in_single_quotes
229 && !in_double_quotes
230 && previous.is_some_and(char::is_whitespace) =>
231 {
232 let command_part = command[..index].trim_end();
233 let inline_comment = command[index + ch.len_utf8()..].trim();
234 let inline_comment = if inline_comment.is_empty() {
235 None
236 } else {
237 Some(inline_comment.to_string())
238 };
239
240 return (command_part, inline_comment);
241 }
242 _ => {}
243 }
244
245 previous = Some(ch);
246 }
247
248 (command.trim_end(), None)
249}
250
251pub(crate) fn normalized_schedule_expression(expression: &str) -> Option<ScheduleExpression<'_>> {
252 match expression {
253 "@reboot" => Some(ScheduleExpression::Reboot),
254 "@yearly" | "@annually" => Some(ScheduleExpression::Standard("0 0 1 1 *")),
255 "@monthly" => Some(ScheduleExpression::Standard("0 0 1 * *")),
256 "@weekly" => Some(ScheduleExpression::Standard("0 0 * * 0")),
257 "@daily" | "@midnight" => Some(ScheduleExpression::Standard("0 0 * * *")),
258 "@hourly" => Some(ScheduleExpression::Standard("0 * * * *")),
259 _ if expression.starts_with('@') => None,
260 _ => Some(ScheduleExpression::Standard(expression)),
261 }
262}
263
264fn merge_comments(block_comments: &[String], inline_comment: Option<String>) -> Option<String> {
265 let mut comments = block_comments.to_vec();
266 if let Some(inline_comment) = inline_comment {
267 comments.push(inline_comment);
268 }
269
270 if comments.is_empty() {
271 None
272 } else {
273 Some(comments.join("\n"))
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn test_parse_content() {
283 let content = r"
284# Run every 5 minutes
285*/5 * * * * /usr/bin/script1.sh
286
287# Daily backup at midnight
2880 0 * * * /usr/bin/backup.sh
289
290# Environment variable
291SHELL=/bin/bash
292
293# Another comment without entry
294
29530 2 * * 1 /usr/bin/weekly.sh
296";
297
298 let entries = parse_content(content);
299 assert_eq!(entries.len(), 3);
300
301 assert_eq!(
302 entries.first().map(|e| e.expression.as_str()),
303 Some("*/5 * * * *")
304 );
305 assert_eq!(
306 entries.first().and_then(|e| e.command.as_deref()),
307 Some("/usr/bin/script1.sh")
308 );
309 assert_eq!(
310 entries.first().and_then(|e| e.comment.as_deref()),
311 Some("Run every 5 minutes")
312 );
313
314 assert_eq!(
315 entries.get(1).map(|e| e.expression.as_str()),
316 Some("0 0 * * *")
317 );
318 assert_eq!(
319 entries.get(1).and_then(|e| e.command.as_deref()),
320 Some("/usr/bin/backup.sh")
321 );
322 assert_eq!(
323 entries.get(1).and_then(|e| e.comment.as_deref()),
324 Some("Daily backup at midnight")
325 );
326
327 assert_eq!(
328 entries.get(2).map(|e| e.expression.as_str()),
329 Some("30 2 * * 1")
330 );
331 assert_eq!(
332 entries.get(2).and_then(|e| e.command.as_deref()),
333 Some("/usr/bin/weekly.sh")
334 );
335 assert_eq!(entries.get(2).and_then(|e| e.comment.as_deref()), None);
336 }
337
338 #[test]
339 fn test_parse_content_complex() {
340 let content = r"
341# First comment line
342# Second comment line
343*/5 * * * * /usr/bin/script1.sh
344
345# This is a daily job
346@daily /usr/bin/daily.sh
347
348# Job with inline comment
3490 0 * * * /usr/bin/backup.sh # backup now
350";
351
352 let entries = parse_content(content);
353
354 assert_eq!(entries.len(), 3);
355
356 assert_eq!(
358 entries.first().map(|e| e.expression.as_str()),
359 Some("*/5 * * * *")
360 );
361 assert_eq!(
362 entries.first().and_then(|e| e.comment.as_deref()),
363 Some("First comment line\nSecond comment line")
364 );
365
366 assert_eq!(
368 entries.get(1).map(|e| e.expression.as_str()),
369 Some("@daily")
370 );
371 assert_eq!(
372 entries.get(1).and_then(|e| e.command.as_deref()),
373 Some("/usr/bin/daily.sh")
374 );
375 assert_eq!(
376 entries.get(1).and_then(|e| e.comment.as_deref()),
377 Some("This is a daily job")
378 );
379
380 assert_eq!(
382 entries.get(2).map(|e| e.expression.as_str()),
383 Some("0 0 * * *")
384 );
385 assert_eq!(
386 entries.get(2).and_then(|e| e.command.as_deref()),
387 Some("/usr/bin/backup.sh")
388 );
389 assert_eq!(
390 entries.get(2).and_then(|e| e.comment.as_deref()),
391 Some("Job with inline comment\nbackup now")
392 );
393 }
394
395 #[test]
396 fn test_parse_content_alias_with_inline_comment() {
397 let content = "@hourly /usr/bin/backup.sh # rotate logs\n";
398
399 let entries = parse_content(content);
400 assert_eq!(entries.len(), 1);
401 assert_eq!(
402 entries.first().map(|entry| entry.expression.as_str()),
403 Some("@hourly")
404 );
405 assert_eq!(
406 entries.first().and_then(|entry| entry.command.as_deref()),
407 Some("/usr/bin/backup.sh")
408 );
409 assert_eq!(
410 entries.first().and_then(|entry| entry.comment.as_deref()),
411 Some("rotate logs")
412 );
413 }
414
415 #[test]
416 fn test_parse_content_preserves_literal_hash_in_command() {
417 let content = "0 0 * * * echo foo#bar\n";
418
419 let entries = parse_content(content);
420 assert_eq!(entries.len(), 1);
421 assert_eq!(
422 entries.first().and_then(|entry| entry.command.as_deref()),
423 Some("echo foo#bar")
424 );
425 assert_eq!(
426 entries.first().and_then(|entry| entry.comment.as_deref()),
427 None
428 );
429 }
430
431 #[test]
432 fn test_parse_content_splits_inline_comment_when_preceded_by_whitespace() {
433 let content = "0 0 * * * echo foo # backup\n";
434
435 let entries = parse_content(content);
436 assert_eq!(entries.len(), 1);
437 assert_eq!(
438 entries.first().and_then(|entry| entry.command.as_deref()),
439 Some("echo foo")
440 );
441 assert_eq!(
442 entries.first().and_then(|entry| entry.comment.as_deref()),
443 Some("backup")
444 );
445 }
446
447 #[test]
448 fn test_parse_content_preserves_escaped_and_quoted_hashes() {
449 let content = r#"
4500 0 * * * echo foo\#bar
4510 0 * * * echo "foo # not-comment"
4520 0 * * * echo 'foo # still-not-comment'
453"#;
454
455 let entries = parse_content(content);
456 assert_eq!(entries.len(), 3);
457 assert_eq!(
458 entries.first().and_then(|entry| entry.command.as_deref()),
459 Some(r"echo foo\#bar")
460 );
461 assert_eq!(
462 entries.get(1).and_then(|entry| entry.command.as_deref()),
463 Some(r#"echo "foo # not-comment""#)
464 );
465 assert_eq!(
466 entries.get(2).and_then(|entry| entry.command.as_deref()),
467 Some("echo 'foo # still-not-comment'")
468 );
469 }
470
471 #[test]
472 fn test_parse_content_merges_block_and_inline_comments() {
473 let content = r"
474# first
475# second
4760 0 * * * /bin/true # third
477";
478
479 let entries = parse_content(content);
480 assert_eq!(entries.len(), 1);
481 assert_eq!(
482 entries.first().and_then(|entry| entry.comment.as_deref()),
483 Some("first\nsecond\nthird")
484 );
485 }
486
487 #[test]
488 fn test_parse_content_skips_alias_without_command() {
489 let content = "@daily\n";
490
491 let entries = parse_content(content);
492 assert!(entries.is_empty());
493 }
494
495 #[test]
496 fn test_parse_content_skips_unknown_alias_without_aborting() {
497 let content = r"
498@unknown /bin/true
4990 0 * * * /bin/echo ok
500";
501
502 let entries = parse_content(content);
503 assert_eq!(entries.len(), 1);
504 assert_eq!(
505 entries.first().map(|entry| entry.expression.as_str()),
506 Some("0 0 * * *")
507 );
508 }
509
510 #[test]
511 fn test_parse_content_skips_invalid_standard_expression_without_aborting() {
512 let content = r"
51361 * * * * /bin/false
5140 0 * * * /bin/echo ok
515";
516
517 let entries = parse_content(content);
518 assert_eq!(entries.len(), 1);
519 assert_eq!(
520 entries.first().and_then(|entry| entry.command.as_deref()),
521 Some("/bin/echo ok")
522 );
523 }
524
525 #[test]
526 fn test_normalized_schedule_expression() {
527 assert_eq!(
528 normalized_schedule_expression("@daily"),
529 Some(ScheduleExpression::Standard("0 0 * * *"))
530 );
531 assert_eq!(
532 normalized_schedule_expression("@reboot"),
533 Some(ScheduleExpression::Reboot)
534 );
535 assert_eq!(
536 normalized_schedule_expression("*/5 * * * *"),
537 Some(ScheduleExpression::Standard("*/5 * * * *"))
538 );
539 assert_eq!(normalized_schedule_expression("@unknown"), None);
540 }
541
542 #[test]
543 fn test_is_env_var() {
544 assert!(is_env_var("SHELL=/bin/bash"));
545 assert!(is_env_var("PATH=/usr/bin:/bin"));
546 assert!(!is_env_var("0 0 * * * command"));
547 assert!(!is_env_var("# comment"));
548 }
549
550 #[test]
551 fn test_extract_cron_entry() {
552 assert_eq!(
553 extract_cron_entry("*/5 * * * * /usr/bin/script.sh"),
554 Some((
555 "*/5 * * * *".to_string(),
556 "/usr/bin/script.sh".to_string(),
557 None
558 ))
559 );
560 assert_eq!(
561 extract_cron_entry("0 0 * * * command with args"),
562 Some((
563 "0 0 * * *".to_string(),
564 "command with args".to_string(),
565 None
566 ))
567 );
568 assert_eq!(
569 extract_cron_entry("0 0 * * * command # note"),
570 Some((
571 "0 0 * * *".to_string(),
572 "command".to_string(),
573 Some("note".to_string())
574 ))
575 );
576 assert_eq!(extract_cron_entry("invalid"), None);
577 assert_eq!(extract_cron_entry("0 0 * *"), None);
578 }
579}