use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::process::Command;
use crate::cron_jobs::CronJob;
pub fn read_all() -> Vec<CronJob> {
let mut jobs = Vec::new();
jobs.extend(read_user_crontab());
jobs.extend(read_etc_crontab());
jobs.extend(read_cron_d());
jobs
}
fn read_user_crontab() -> Vec<CronJob> {
let output = match Command::new("crontab").arg("-l").output() {
Ok(o) if o.status.success() => o,
_ => return vec![],
};
let text = String::from_utf8_lossy(&output.stdout);
parse_text(&text, "system:user-crontab", false)
}
fn read_etc_crontab() -> Vec<CronJob> {
let path = Path::new("/etc/crontab");
if !path.exists() {
return vec![];
}
match std::fs::read_to_string(path) {
Ok(text) => parse_text(&text, "system:etc-crontab", true),
Err(_) => vec![],
}
}
fn read_cron_d() -> Vec<CronJob> {
let dir = Path::new("/etc/cron.d");
if !dir.is_dir() {
return vec![];
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return vec![],
};
let mut jobs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let source = format!("system:cron.d/{}", name);
if let Ok(text) = std::fs::read_to_string(&path) {
jobs.extend(parse_text(&text, &source, true));
}
}
jobs
}
fn stable_id(source: &str, schedule: &str, command: &str) -> String {
let mut h = DefaultHasher::new();
source.hash(&mut h);
schedule.hash(&mut h);
command.hash(&mut h);
format!("sys-{:016x}", h.finish())
}
fn is_env_var_line(line: &str) -> bool {
if let Some(eq_pos) = line.find('=') {
let key = &line[..eq_pos];
!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
} else {
false
}
}
fn parse_text(text: &str, source: &str, has_user_field: bool) -> Vec<CronJob> {
text.lines()
.filter_map(|line| parse_line(line, source, has_user_field))
.collect()
}
fn parse_line(line: &str, source: &str, has_user_field: bool) -> Option<CronJob> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || is_env_var_line(line) {
return None;
}
let (schedule, command) = if let Some(rest) = line.strip_prefix('@') {
let kw_end = rest
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(rest.len());
let keyword = &rest[..kw_end];
let after = rest[kw_end..].trim_start();
let cmd = if has_user_field {
let user_end = after
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(after.len());
after[user_end..].trim_start()
} else {
after
};
if cmd.is_empty() {
return None;
}
(format!("@{}", keyword), cmd.to_string())
} else {
let tokens: Vec<&str> = line.split_ascii_whitespace().collect();
let min_fields = if has_user_field { 7 } else { 6 };
if tokens.len() < min_fields {
return None;
}
let schedule = tokens[..5].join(" ");
let cmd_start = if has_user_field { 6 } else { 5 };
let command = tokens[cmd_start..].join(" ");
(schedule, command)
};
Some(CronJob {
id: stable_id(source, &schedule, &command),
schedule,
handler: command,
metadata: serde_json::json!({}),
enabled: true,
source: source.to_string(),
created_at: 0,
updated_at: 0,
last_triggered_at: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_standard_line() {
let job = parse_line("30 9 * * 1-5 /usr/bin/backup.sh", "test", false).unwrap();
assert_eq!(job.schedule, "30 9 * * 1-5");
assert_eq!(job.handler, "/usr/bin/backup.sh");
assert_eq!(job.source, "test");
}
#[test]
fn parses_at_syntax() {
let job = parse_line("@daily /usr/bin/cleanup.sh", "test", false).unwrap();
assert_eq!(job.schedule, "@daily");
assert_eq!(job.handler, "/usr/bin/cleanup.sh");
}
#[test]
fn parses_etc_crontab_with_user() {
let job = parse_line("* * * * * root /usr/sbin/ntpdate", "etc", true).unwrap();
assert_eq!(job.schedule, "* * * * *");
assert_eq!(job.handler, "/usr/sbin/ntpdate");
}
#[test]
fn parses_at_syntax_with_user() {
let job = parse_line("@reboot root /usr/sbin/cron-startup", "etc", true).unwrap();
assert_eq!(job.schedule, "@reboot");
assert_eq!(job.handler, "/usr/sbin/cron-startup");
}
#[test]
fn skips_comments() {
assert!(parse_line("# this is a comment", "test", false).is_none());
}
#[test]
fn skips_env_vars() {
assert!(parse_line("MAILTO=\"\"", "test", false).is_none());
assert!(parse_line("PATH=/usr/bin:/usr/sbin", "test", false).is_none());
}
#[test]
fn skips_blank_lines() {
assert!(parse_line(" ", "test", false).is_none());
assert!(parse_line("", "test", false).is_none());
}
#[test]
fn stable_id_is_deterministic() {
let id1 = stable_id("system:user-crontab", "@daily", "/usr/bin/backup.sh");
let id2 = stable_id("system:user-crontab", "@daily", "/usr/bin/backup.sh");
assert_eq!(id1, id2);
assert!(id1.starts_with("sys-"));
}
}