use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::cron_jobs::{CronJob, CronStore};
use crate::paths::handlers_dir;
use crate::storage::write_job;
use crate::utils::time::now_secs;
pub mod routines;
const BLOCK_BEGIN: &str = "# BEGIN MOADIM";
const BLOCK_END: &str = "# END MOADIM";
const BLOCK_HEADER: &str =
"# Managed by moadim — manual edits to this block sync back automatically";
#[derive(Debug)]
pub enum SyncError {
CrontabCommand(String),
Io(std::io::Error),
}
impl std::fmt::Display for SyncError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SyncError::CrontabCommand(msg) => write!(f, "crontab: {msg}"),
SyncError::Io(e) => write!(f, "io: {e}"),
}
}
}
impl From<std::io::Error> for SyncError {
fn from(e: std::io::Error) -> Self {
SyncError::Io(e)
}
}
pub(crate) fn to_os_schedule(schedule: &str) -> String {
let s = schedule.trim();
if s.starts_with('@') {
return s.to_string();
}
let fields: Vec<&str> = s.split_ascii_whitespace().collect();
match fields.len() {
7 => fields[1..6].join(" "),
5 => s.to_string(),
_ => s.to_string(),
}
}
#[allow(dead_code)]
pub(crate) fn to_moadim_schedule(schedule: &str) -> String {
schedule.trim().to_string()
}
pub(crate) fn resolve_handler_path(handler: &str, dir: &Path) -> PathBuf {
let exact = dir.join(handler);
if exact.exists() {
return exact;
}
for ext in ["sh", "py", "js", "rb", "pl", "bash", "zsh"] {
let candidate = dir.join(format!("{handler}.{ext}"));
if candidate.exists() {
return candidate;
}
}
exact
}
#[allow(dead_code)]
pub(crate) fn handler_from_command(command: &str, dir: &Path) -> String {
let p = Path::new(command.trim());
let stem = if let Ok(rel) = p.strip_prefix(dir) {
rel.file_stem().and_then(|s| s.to_str()).map(str::to_string)
} else {
p.file_stem().and_then(|s| s.to_str()).map(str::to_string)
};
stem.unwrap_or_else(|| command.trim().to_string())
}
pub(crate) fn read_crontab() -> Result<String, SyncError> {
let out = Command::new("crontab")
.arg("-l")
.output()
.map_err(|e| SyncError::CrontabCommand(format!("failed to run crontab -l: {e}")))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("no crontab") {
return Ok(String::new());
}
Err(SyncError::CrontabCommand(stderr.into_owned()))
}
pub(crate) fn write_crontab(content: &str) -> Result<(), SyncError> {
let mut child = Command::new("crontab")
.arg("-")
.stdin(Stdio::piped())
.spawn()
.map_err(|e| SyncError::CrontabCommand(format!("failed to spawn crontab: {e}")))?;
child
.stdin
.take()
.expect("stdin is piped")
.write_all(content.as_bytes())?;
let status = child
.wait()
.map_err(|e| SyncError::CrontabCommand(format!("crontab wait failed: {e}")))?;
if !status.success() {
return Err(SyncError::CrontabCommand(format!(
"crontab - exited with {status}"
)));
}
Ok(())
}
pub(crate) fn format_crontab_line(job: &CronJob, handlers: &Path) -> String {
let schedule = to_os_schedule(&job.schedule);
let path = resolve_handler_path(&job.handler, handlers);
format!("{} {} # moadim:{}", schedule, path.display(), job.id)
}
fn build_block(store: &CronStore) -> String {
let dir = handlers_dir();
let mut jobs: Vec<CronJob> = {
let lock = store.lock().unwrap();
lock.values()
.filter(|j| j.source == "managed" && j.enabled)
.cloned()
.collect()
};
jobs.sort_by_key(|j| j.created_at);
let lines: Vec<String> = jobs.iter().map(|j| format_crontab_line(j, &dir)).collect();
if lines.is_empty() {
format!("{BLOCK_BEGIN}\n{BLOCK_HEADER}\n{BLOCK_END}")
} else {
format!(
"{BLOCK_BEGIN}\n{BLOCK_HEADER}\n{}\n{BLOCK_END}",
lines.join("\n")
)
}
}
pub(crate) fn replace_block(crontab: &str, block: &str) -> String {
replace_block_with(crontab, block, BLOCK_BEGIN, BLOCK_END)
}
pub(crate) fn replace_block_with(
crontab: &str,
block: &str,
begin_marker: &str,
end_marker: &str,
) -> String {
let begin_pos = crontab.find(begin_marker);
let end_pos = crontab.find(end_marker);
match (begin_pos, end_pos) {
(Some(begin), Some(end)) if begin < end => {
let after = end + end_marker.len();
let mut result = crontab[..begin].to_string();
result.push_str(block);
result.push('\n');
let rest = crontab[after..].trim_start_matches('\n');
if !rest.is_empty() {
result.push('\n');
result.push_str(rest);
if !result.ends_with('\n') {
result.push('\n');
}
}
result
}
(Some(begin), _) => {
let mut result = crontab[..begin].to_string();
result.push_str(block);
result.push('\n');
result
}
_ => {
let mut result = crontab.trim_end_matches('\n').to_string();
if !result.is_empty() {
result.push('\n');
}
result.push_str(block);
result.push('\n');
result
}
}
}
#[allow(dead_code)]
pub(crate) fn parse_moadim_line(line: &str) -> Option<(String, String, String)> {
let tag = "# moadim:";
let comment_pos = line.rfind(tag)?;
let uuid = line[comment_pos + tag.len()..].trim().to_string();
if uuid.is_empty() {
return None;
}
let body = line[..comment_pos].trim();
if body.starts_with('@') {
let mut parts = body.splitn(2, |c: char| c.is_ascii_whitespace());
let schedule = parts.next()?.trim().to_string();
let command = parts.next()?.trim().to_string();
return Some((uuid, schedule, command));
}
let tokens: Vec<&str> = body.split_ascii_whitespace().collect();
if tokens.len() < 6 {
return None;
}
let schedule = tokens[..5].join(" ");
let command = tokens[5..].join(" ");
Some((uuid, schedule, command))
}
#[allow(dead_code)]
pub(crate) fn parse_block(crontab: &str) -> HashMap<String, (String, String)> {
let mut in_block = false;
let mut entries = HashMap::new();
for line in crontab.lines() {
let trimmed = line.trim();
if trimmed == BLOCK_BEGIN {
in_block = true;
continue;
}
if trimmed == BLOCK_END {
break;
}
if !in_block || trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
if let Some((id, sched, cmd)) = parse_moadim_line(line) {
entries.insert(id, (sched, cmd));
}
}
entries
}
pub fn sync_to_crontab(store: &CronStore) -> Result<(), SyncError> {
let current = read_crontab()?;
let block = build_block(store);
let new_crontab = replace_block(¤t, &block);
if new_crontab == current {
return Ok(());
}
write_crontab(&new_crontab)
}
#[allow(dead_code)]
pub fn sync_from_crontab(store: &CronStore) -> Result<bool, SyncError> {
let crontab = read_crontab()?;
let block_entries = parse_block(&crontab);
let dir = handlers_dir();
let now = now_secs();
let mut jobs_to_write: Vec<CronJob> = Vec::new();
let mut changed = false;
{
let mut lock = store.lock().unwrap();
for job in lock.values_mut().filter(|j| j.source == "managed") {
if let Some((os_sched, command)) = block_entries.get(&job.id) {
let new_schedule = to_moadim_schedule(os_sched);
let new_handler = handler_from_command(command, &dir);
let sched_changed = new_schedule != job.schedule;
let handler_changed = new_handler != job.handler;
if sched_changed || handler_changed {
if sched_changed {
job.schedule = new_schedule;
}
if handler_changed {
job.handler = new_handler;
}
job.updated_at = now;
jobs_to_write.push(job.clone());
changed = true;
}
}
}
let known: HashSet<String> = lock.keys().cloned().collect();
for (id, (os_sched, command)) in &block_entries {
if !known.contains(id) {
let job = CronJob {
id: id.clone(),
schedule: to_moadim_schedule(os_sched),
handler: handler_from_command(command, &dir),
metadata: serde_json::json!({}),
enabled: true,
source: "managed".to_string(),
created_at: now,
updated_at: now,
last_triggered_at: None,
};
lock.insert(id.clone(), job.clone());
jobs_to_write.push(job);
changed = true;
}
}
}
for job in &jobs_to_write {
if let Err(e) = write_job(job) {
log::warn!("cron_sync: failed to persist job {}: {e}", job.id);
}
}
Ok(changed)
}
#[cfg(test)]
#[path = "cron_sync_tests.rs"]
mod cron_sync_tests;