use std::io::Write;
use std::process::{Command, Stdio};
pub mod routines;
#[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 {
Self::CrontabCommand(msg) => write!(f, "crontab: {msg}"),
Self::Io(err) => write!(f, "io: {err}"),
}
}
}
impl From<std::io::Error> for SyncError {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}
pub(crate) fn to_os_schedule(schedule: &str) -> String {
let trimmed = schedule.trim();
if trimmed.starts_with('@') {
return trimmed.to_string();
}
let fields: Vec<&str> = trimmed.split_ascii_whitespace().collect();
match fields.len() {
6 | 7 => fields[1..6].join(" "),
_ => trimmed.to_string(),
}
}
fn crontab_bin() -> String {
if let Ok(bin) = std::env::var("MOADIM_CRONTAB_BIN") {
return bin;
}
#[cfg(test)]
let fallback = "/nonexistent/moadim-test-crontab-guard".to_string();
#[cfg(not(test))]
let fallback = "crontab".to_string();
fallback
}
pub(crate) fn read_crontab() -> Result<String, SyncError> {
let out = Command::new(crontab_bin())
.arg("-l")
.output()
.map_err(|err| SyncError::CrontabCommand(format!("failed to run crontab -l: {err}")))?;
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_bin())
.arg("-")
.stdin(Stdio::piped())
.spawn()
.map_err(|err| SyncError::CrontabCommand(format!("failed to spawn crontab: {err}")))?;
child
.stdin
.take()
.expect("stdin is piped")
.write_all(content.as_bytes())
.expect("writing crontab content to crontab stdin must not fail");
let status = child
.wait()
.expect("waiting for crontab child process failed");
if !status.success() {
return Err(SyncError::CrontabCommand(format!(
"crontab - exited with {status}"
)));
}
Ok(())
}
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
}
}
}
pub fn clear_managed_crontab_blocks() -> Result<usize, SyncError> {
let current = read_crontab()?;
let removed = current
.lines()
.filter(|line| line.contains(routines::ROUTINE_LINE_MARKER))
.count();
if !current.contains(routines::BLOCK_BEGIN) {
return Ok(0);
}
let updated = replace_block_with(¤t, "", routines::BLOCK_BEGIN, routines::BLOCK_END);
write_crontab(&updated)?;
Ok(removed)
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod sync_tests;