use std::collections::BTreeSet;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::task::{Task, TaskTrigger};
pub const LABEL_PREFIX: &str = "ai.parslee.car.task.";
#[derive(Debug, thiserror::Error)]
pub enum OsScheduleError {
#[error("trigger {0:?} is not OS-schedulable (use Interval or Cron)")]
NotSchedulable(TaskTrigger),
#[error("schedule not expressible: {0}")]
UnsupportedSchedule(String),
#[error("invalid command value: {0}")]
InvalidValue(String),
#[error("no OS scheduling backend for this platform")]
UnsupportedPlatform,
#[error("{0} failed: {1}")]
Command(String, String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OsTrigger {
Interval { seconds: u64 },
Cron { expr: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsScheduleSpec {
pub label: String,
pub program: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_path: Option<String>,
pub trigger: OsTrigger,
}
impl OsScheduleSpec {
pub fn from_task(
task: &Task,
program: impl Into<String>,
args: Vec<String>,
) -> Result<Self, OsScheduleError> {
let trigger = match task.trigger {
TaskTrigger::Interval => OsTrigger::Interval {
seconds: crate::task::parse_interval(&task.schedule).round().max(1.0) as u64,
},
TaskTrigger::Cron => OsTrigger::Cron {
expr: task.schedule.trim().to_string(),
},
t @ (TaskTrigger::Once | TaskTrigger::FileWatch | TaskTrigger::Manual) => {
return Err(OsScheduleError::NotSchedulable(t))
}
};
let program = program.into();
validate_command_value("program", &program)?;
for arg in &args {
validate_command_value("arg", arg)?;
}
if let OsTrigger::Cron { expr } = &trigger {
validate_cron(expr)?;
}
Ok(Self {
label: format!("{LABEL_PREFIX}{}", task.id),
program,
args,
working_dir: None,
log_path: None,
trigger,
})
}
fn validate_values(&self) -> Result<(), OsScheduleError> {
validate_command_value("program", &self.program)?;
for arg in &self.args {
validate_command_value("arg", arg)?;
}
if let Some(dir) = &self.working_dir {
validate_command_value("working_dir", dir)?;
}
if let Some(log) = &self.log_path {
validate_command_value("log_path", log)?;
}
Ok(())
}
pub fn render_launchd_plist(&self) -> Result<String, OsScheduleError> {
self.validate_values()?;
let mut body = String::new();
body.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
body.push_str("<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n");
body.push_str("<plist version=\"1.0\">\n<dict>\n");
body.push_str(" <key>Label</key>\n");
body.push_str(&format!(" <string>{}</string>\n", xml_escape(&self.label)));
body.push_str(" <key>ProgramArguments</key>\n <array>\n");
body.push_str(&format!(
" <string>{}</string>\n",
xml_escape(&self.program)
));
for arg in &self.args {
body.push_str(&format!(" <string>{}</string>\n", xml_escape(arg)));
}
body.push_str(" </array>\n");
match &self.trigger {
OsTrigger::Interval { seconds } => {
body.push_str(" <key>StartInterval</key>\n");
body.push_str(&format!(" <integer>{seconds}</integer>\n"));
}
OsTrigger::Cron { expr } => {
body.push_str(" <key>StartCalendarInterval</key>\n");
body.push_str(&render_calendar_interval(expr)?);
}
}
if let Some(dir) = &self.working_dir {
body.push_str(" <key>WorkingDirectory</key>\n");
body.push_str(&format!(" <string>{}</string>\n", xml_escape(dir)));
}
if let Some(log) = &self.log_path {
body.push_str(" <key>StandardOutPath</key>\n");
body.push_str(&format!(" <string>{}</string>\n", xml_escape(log)));
body.push_str(" <key>StandardErrorPath</key>\n");
body.push_str(&format!(" <string>{}</string>\n", xml_escape(log)));
}
body.push_str(" <key>RunAtLoad</key>\n <false/>\n");
body.push_str("</dict>\n</plist>\n");
Ok(body)
}
pub fn render_crontab_line(&self) -> Result<String, OsScheduleError> {
self.validate_values()?;
let schedule = match &self.trigger {
OsTrigger::Cron { expr } => {
validate_cron(expr)?;
expr.clone()
}
OsTrigger::Interval { seconds } => interval_to_cron(*seconds)?,
};
let mut cmd = shell_quote(&self.program);
for arg in &self.args {
cmd.push(' ');
cmd.push_str(&shell_quote(arg));
}
if let Some(dir) = &self.working_dir {
cmd = format!("cd {} && {cmd}", shell_quote(dir));
}
if let Some(log) = &self.log_path {
cmd = format!("{cmd} >> {} 2>&1", shell_quote(log));
}
Ok(format!("{schedule} {cmd} # {}", self.label))
}
pub fn launchd_plist_path(&self) -> PathBuf {
launch_agents_dir().join(format!("{}.plist", self.label))
}
pub fn render_schtasks_xml(&self) -> Result<String, OsScheduleError> {
self.validate_values()?;
let trigger = render_schtasks_trigger(&self.trigger)?;
let mut args = String::new();
for (i, a) in self.args.iter().enumerate() {
if i > 0 {
args.push(' ');
}
args.push_str(&windows_arg_quote(a));
}
let mut body = String::new();
body.push_str("<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n");
body.push_str("<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n");
body.push_str(" <RegistrationInfo>\n");
body.push_str(&format!(
" <Description>CAR task {}</Description>\n",
xml_escape(&self.label)
));
body.push_str(" </RegistrationInfo>\n");
body.push_str(" <Triggers>\n");
body.push_str(&trigger);
body.push_str(" </Triggers>\n");
body.push_str(" <Principals>\n <Principal id=\"Author\">\n");
body.push_str(" <LogonType>InteractiveToken</LogonType>\n");
body.push_str(" <RunLevel>LeastPrivilege</RunLevel>\n");
body.push_str(" </Principal>\n </Principals>\n");
body.push_str(" <Settings>\n");
body.push_str(" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n");
body.push_str(" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n");
body.push_str(" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n");
body.push_str(" <StartWhenAvailable>true</StartWhenAvailable>\n");
body.push_str(" <Enabled>true</Enabled>\n");
body.push_str(" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n");
body.push_str(" </Settings>\n");
body.push_str(" <Actions Context=\"Author\">\n <Exec>\n");
body.push_str(&format!(
" <Command>{}</Command>\n",
xml_escape(&self.program)
));
if !self.args.is_empty() {
body.push_str(&format!(
" <Arguments>{}</Arguments>\n",
xml_escape(&args)
));
}
if let Some(dir) = &self.working_dir {
body.push_str(&format!(
" <WorkingDirectory>{}</WorkingDirectory>\n",
xml_escape(dir)
));
}
body.push_str(" </Exec>\n </Actions>\n</Task>\n");
Ok(body)
}
}
fn validate_cron(expr: &str) -> Result<(), OsScheduleError> {
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields.len() != 5 {
return Err(OsScheduleError::UnsupportedSchedule(format!(
"expected a 5-field cron expression, got {} field(s): {expr:?}",
fields.len()
)));
}
for f in fields {
if !f
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '*' | '/' | ',' | '-'))
{
return Err(OsScheduleError::UnsupportedSchedule(format!(
"cron field {f:?} has unsupported characters"
)));
}
}
Ok(())
}
fn expand_cron_field(field: &str, min: i64, max: i64) -> Result<Option<Vec<i64>>, OsScheduleError> {
if field == "*" {
return Ok(None);
}
let bad = |m: &str| OsScheduleError::UnsupportedSchedule(m.to_string());
let mut out = std::collections::BTreeSet::new();
for part in field.split(',') {
let (base, step, has_step) = match part.split_once('/') {
Some((b, s)) => {
let n: i64 = s
.parse()
.map_err(|_| bad(&format!("bad step in cron field {part:?}")))?;
if n <= 0 {
return Err(bad(&format!("cron step must be positive: {part:?}")));
}
(b, n, true)
}
None => (part, 1, false),
};
let (lo, hi) = if base == "*" {
(min, max)
} else if let Some((a, b)) = base.split_once('-') {
(
a.parse()
.map_err(|_| bad(&format!("bad range in cron field {part:?}")))?,
b.parse()
.map_err(|_| bad(&format!("bad range in cron field {part:?}")))?,
)
} else {
let v: i64 = base
.parse()
.map_err(|_| bad(&format!("bad value in cron field {part:?}")))?;
if has_step {
(v, max)
} else {
(v, v)
}
};
if lo < min || hi > max || lo > hi {
return Err(bad(&format!(
"cron field {part:?} out of range [{min},{max}]"
)));
}
let mut v = lo;
while v <= hi {
out.insert(v);
v += step;
}
}
Ok(Some(out.into_iter().collect()))
}
fn render_calendar_interval(expr: &str) -> Result<String, OsScheduleError> {
validate_cron(expr)?;
let fields: Vec<&str> = expr.split_whitespace().collect();
if fields[2] != "*" && fields[4] != "*" {
return Err(OsScheduleError::UnsupportedSchedule(
"launchd can't express cron's OR of day-of-month and day-of-week; install on Linux/cron or split into two schedules".into(),
));
}
let keys = ["Minute", "Hour", "Day", "Month", "Weekday"];
let ranges = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)];
let mut constrained: Vec<(&str, Vec<i64>)> = Vec::new();
for ((field, key), (min, max)) in fields.iter().zip(keys).zip(ranges) {
if let Some(vals) = expand_cron_field(field, min, max)? {
constrained.push((key, vals));
}
}
if constrained.is_empty() {
return Err(OsScheduleError::UnsupportedSchedule(
"an all-`*` cron has no calendar constraint; use Interval { seconds: 60 }".into(),
));
}
let total: usize = constrained.iter().map(|(_, v)| v.len()).product();
if total > 366 {
return Err(OsScheduleError::UnsupportedSchedule(format!(
"schedule expands to {total} launchd calendar entries (>366); use Interval, or a coarser cadence"
)));
}
let mut combos: Vec<Vec<(&str, i64)>> = vec![vec![]];
for (key, vals) in &constrained {
let mut next = Vec::with_capacity(combos.len() * vals.len());
for combo in &combos {
for v in vals {
let mut c = combo.clone();
c.push((key, *v));
next.push(c);
}
}
combos = next;
}
let render_dict = |combo: &[(&str, i64)]| {
let mut d = String::from(" <dict>\n");
for (key, v) in combo {
d.push_str(&format!(
" <key>{key}</key>\n <integer>{v}</integer>\n"
));
}
d.push_str(" </dict>\n");
d
};
if combos.len() == 1 {
Ok(render_dict(&combos[0]))
} else {
let mut arr = String::from(" <array>\n");
for combo in &combos {
arr.push_str(&render_dict(combo));
}
arr.push_str(" </array>\n");
Ok(arr)
}
}
fn interval_to_cron(seconds: u64) -> Result<String, OsScheduleError> {
if seconds < 60 || !seconds.is_multiple_of(60) {
return Err(OsScheduleError::UnsupportedSchedule(format!(
"cron granularity is whole minutes; {seconds}s is not (use launchd StartInterval on macOS)"
)));
}
let mins = seconds / 60;
if mins < 60 {
if 60 % mins == 0 {
return Ok(format!("*/{mins} * * * *"));
}
return Err(OsScheduleError::UnsupportedSchedule(format!(
"{mins}-minute interval doesn't divide 60 evenly (1,2,3,4,5,6,10,12,15,20,30 do); spacing would be irregular"
)));
}
if mins == 60 {
return Ok("0 * * * *".to_string());
}
if mins.is_multiple_of(60) {
let hours = mins / 60;
if hours < 24 && 24 % hours == 0 {
return Ok(format!("0 */{hours} * * *"));
}
if hours == 24 {
return Ok("0 0 * * *".to_string());
}
return Err(OsScheduleError::UnsupportedSchedule(format!(
"{hours}-hour interval doesn't divide 24 evenly"
)));
}
Err(OsScheduleError::UnsupportedSchedule(format!(
"{seconds}s isn't expressible as an evenly-spaced cron schedule; use launchd StartInterval"
)))
}
fn windows_arg_quote(arg: &str) -> String {
if arg.chars().any(|c| c == ' ' || c == '\t' || c == '"') {
format!("\"{}\"", arg.replace('"', "\"\""))
} else {
arg.to_string()
}
}
fn interval_to_minutes(seconds: u64) -> Result<u64, OsScheduleError> {
if seconds < 60 || !seconds.is_multiple_of(60) {
return Err(OsScheduleError::UnsupportedSchedule(format!(
"Task Scheduler repetition granularity is whole minutes; {seconds}s is not (use the in-daemon timer for sub-minute)"
)));
}
Ok(seconds / 60)
}
fn render_schtasks_trigger(trigger: &OsTrigger) -> Result<String, OsScheduleError> {
const BASE_DATE: &str = "2000-01-01";
match trigger {
OsTrigger::Interval { seconds } => {
let mins = interval_to_minutes(*seconds)?;
let mut t = String::new();
t.push_str(" <TimeTrigger>\n");
t.push_str(&format!(
" <StartBoundary>{BASE_DATE}T00:00:00</StartBoundary>\n"
));
t.push_str(" <Enabled>true</Enabled>\n");
t.push_str(" <Repetition>\n");
t.push_str(&format!(" <Interval>PT{mins}M</Interval>\n"));
t.push_str(" <StopAtDurationEnd>false</StopAtDurationEnd>\n");
t.push_str(" </Repetition>\n");
t.push_str(" </TimeTrigger>\n");
Ok(t)
}
OsTrigger::Cron { expr } => render_schtasks_cron(expr, BASE_DATE),
}
}
fn render_schtasks_cron(expr: &str, base_date: &str) -> Result<String, OsScheduleError> {
validate_cron(expr)?;
let f: Vec<&str> = expr.split_whitespace().collect();
let single = |field: &str,
min: i64,
max: i64,
name: &str|
-> Result<Option<i64>, OsScheduleError> {
match expand_cron_field(field, min, max)? {
None => Ok(None),
Some(v) if v.len() == 1 => Ok(Some(v[0])),
Some(_) => Err(OsScheduleError::UnsupportedSchedule(format!(
"Task Scheduler can't express a multi-valued {name} field ({field:?}) in one trigger; use the in-daemon timer or split the schedule"
))),
}
};
let minute = single(f[0], 0, 59, "minute")?;
let hour = single(f[1], 0, 23, "hour")?;
let dom = single(f[2], 1, 31, "day-of-month")?;
let month = single(f[3], 1, 12, "month")?;
let dow = single(f[4], 0, 6, "day-of-week")?;
let minute = minute.ok_or_else(|| {
OsScheduleError::UnsupportedSchedule(
"a `*` minute means every minute; use Interval { seconds: 60 } instead".into(),
)
})?;
if dom.is_some() && dow.is_some() {
return Err(OsScheduleError::UnsupportedSchedule(
"Task Scheduler can't express cron's OR of day-of-month and day-of-week; split into two schedules".into(),
));
}
let mut t = String::new();
let Some(hour) = hour else {
if dom.is_some() || dow.is_some() || month.is_some() {
return Err(OsScheduleError::UnsupportedSchedule(
"a `*` hour with a day/month constraint isn't expressible here; pin the hour"
.into(),
));
}
t.push_str(" <TimeTrigger>\n");
t.push_str(&format!(
" <StartBoundary>{base_date}T00:{minute:02}:00</StartBoundary>\n"
));
t.push_str(" <Enabled>true</Enabled>\n");
t.push_str(" <Repetition>\n");
t.push_str(" <Interval>PT1H</Interval>\n");
t.push_str(" <StopAtDurationEnd>false</StopAtDurationEnd>\n");
t.push_str(" </Repetition>\n");
t.push_str(" </TimeTrigger>\n");
return Ok(t);
};
t.push_str(" <CalendarTrigger>\n");
t.push_str(&format!(
" <StartBoundary>{base_date}T{hour:02}:{minute:02}:00</StartBoundary>\n"
));
t.push_str(" <Enabled>true</Enabled>\n");
if let Some(d) = dow {
if month.is_some() {
return Err(OsScheduleError::UnsupportedSchedule(
"a month filter on a weekly (day-of-week) schedule isn't expressible here".into(),
));
}
t.push_str(" <ScheduleByWeek>\n <DaysOfWeek>\n");
t.push_str(&format!(" <{}/>\n", weekday_name(d)));
t.push_str(" </DaysOfWeek>\n <WeeksInterval>1</WeeksInterval>\n");
t.push_str(" </ScheduleByWeek>\n");
} else if let Some(d) = dom {
t.push_str(" <ScheduleByMonth>\n <DaysOfMonth>\n");
t.push_str(&format!(" <Day>{d}</Day>\n"));
t.push_str(" </DaysOfMonth>\n <Months>\n");
match month {
Some(m) => t.push_str(&format!(" <{}/>\n", month_name(m))),
None => {
for m in 1..=12 {
t.push_str(&format!(" <{}/>\n", month_name(m)));
}
}
}
t.push_str(" </Months>\n </ScheduleByMonth>\n");
} else {
if month.is_some() {
return Err(OsScheduleError::UnsupportedSchedule(
"a month filter on a daily schedule isn't expressible here; add a day-of-month"
.into(),
));
}
t.push_str(" <ScheduleByDay>\n <DaysInterval>1</DaysInterval>\n </ScheduleByDay>\n");
}
t.push_str(" </CalendarTrigger>\n");
Ok(t)
}
fn weekday_name(dow: i64) -> &'static str {
match dow {
0 => "Sunday",
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
4 => "Thursday",
5 => "Friday",
_ => "Saturday",
}
}
fn month_name(m: i64) -> &'static str {
match m {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
_ => "December",
}
}
pub fn apply_crontab_edit(existing: &str, label: &str, line: Option<&str>) -> String {
let tag = format!("# {label}");
let mut out: Vec<String> = existing
.lines()
.filter(|l| !l.trim_end().ends_with(&tag))
.map(|l| l.to_string())
.collect();
if let Some(line) = line {
out.push(line.to_string());
}
let mut body = out.join("\n");
if !body.is_empty() && !body.ends_with('\n') {
body.push('\n');
}
body
}
pub(crate) fn validate_command_value(what: &str, value: &str) -> Result<(), OsScheduleError> {
if value.is_empty() {
return Err(OsScheduleError::InvalidValue(format!("{what} is empty")));
}
if let Some(c) = value.chars().find(|c| c.is_control()) {
return Err(OsScheduleError::InvalidValue(format!(
"{what} contains a control character (U+{:04X})",
c as u32
)));
}
Ok(())
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn shell_quote(s: &str) -> String {
if !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | '.' | '=' | ':'))
{
return s.to_string();
}
format!("'{}'", s.replace('\'', r"'\''"))
}
fn launch_agents_dir() -> PathBuf {
home_dir().join("Library").join("LaunchAgents")
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledSchedule {
pub label: String,
pub backend: String,
pub detail: String,
}
#[cfg(target_os = "macos")]
impl OsScheduleSpec {
pub fn install(&self) -> Result<InstalledSchedule, OsScheduleError> {
let plist = self.render_launchd_plist()?;
let dir = launch_agents_dir();
std::fs::create_dir_all(&dir)?;
let path = self.launchd_plist_path();
let _ = run_cmd("launchctl", &["unload".into(), path_str(&path)]);
std::fs::write(&path, plist)?;
run_cmd("launchctl", &["load".into(), "-w".into(), path_str(&path)])?;
Ok(InstalledSchedule {
label: self.label.clone(),
backend: "launchd".into(),
detail: path.display().to_string(),
})
}
}
#[cfg(target_os = "macos")]
pub fn uninstall(label: &str) -> Result<bool, OsScheduleError> {
let path = launch_agents_dir().join(format!("{label}.plist"));
if !path.exists() {
return Ok(false);
}
let _ = run_cmd("launchctl", &["unload".into(), path_str(&path)]);
std::fs::remove_file(&path)?;
Ok(true)
}
#[cfg(target_os = "macos")]
pub fn list_installed() -> Result<Vec<String>, OsScheduleError> {
let dir = launch_agents_dir();
let mut labels = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
if let Some(stem) = entry.path().file_stem().and_then(|s| s.to_str()) {
if stem.starts_with(LABEL_PREFIX) {
labels.push(stem.to_string());
}
}
}
}
labels.sort();
Ok(labels)
}
#[cfg(all(unix, not(target_os = "macos")))]
impl OsScheduleSpec {
pub fn install(&self) -> Result<InstalledSchedule, OsScheduleError> {
let line = self.render_crontab_line()?;
let existing = current_crontab();
let updated = apply_crontab_edit(&existing, &self.label, Some(&line));
write_crontab(&updated)?;
Ok(InstalledSchedule {
label: self.label.clone(),
backend: "cron".into(),
detail: line,
})
}
}
#[cfg(all(unix, not(target_os = "macos")))]
pub fn uninstall(label: &str) -> Result<bool, OsScheduleError> {
let existing = current_crontab();
let tag = format!("# {label}");
let had = existing.lines().any(|l| l.trim_end().ends_with(&tag));
if had {
let updated = apply_crontab_edit(&existing, label, None);
write_crontab(&updated)?;
}
Ok(had)
}
#[cfg(all(unix, not(target_os = "macos")))]
pub fn list_installed() -> Result<Vec<String>, OsScheduleError> {
let mut labels: Vec<String> = current_crontab()
.lines()
.filter_map(|l| l.rsplit_once("# ").map(|(_, tag)| tag.trim().to_string()))
.filter(|tag| tag.starts_with(LABEL_PREFIX))
.collect();
labels.sort();
labels.dedup();
Ok(labels)
}
#[cfg(all(unix, not(target_os = "macos")))]
fn current_crontab() -> String {
std::process::Command::new("crontab")
.arg("-l")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
}
#[cfg(all(unix, not(target_os = "macos")))]
fn write_crontab(body: &str) -> Result<(), OsScheduleError> {
use std::io::Write;
let mut child = std::process::Command::new("crontab")
.arg("-")
.stdin(std::process::Stdio::piped())
.spawn()?;
child
.stdin
.take()
.ok_or_else(|| OsScheduleError::Command("crontab".into(), "no stdin".into()))?
.write_all(body.as_bytes())?;
let status = child.wait()?;
if !status.success() {
return Err(OsScheduleError::Command(
"crontab".into(),
format!("exited with {status}"),
));
}
Ok(())
}
#[cfg(target_os = "windows")]
impl OsScheduleSpec {
pub fn install(&self) -> Result<InstalledSchedule, OsScheduleError> {
let xml = self.render_schtasks_xml()?;
let path = std::env::temp_dir().join(format!("{}.xml", self.label));
write_utf16le(&path, &xml)?;
let res = run_schtasks(&[
"/Create",
"/TN",
&self.label,
"/XML",
&path.to_string_lossy(),
"/F",
]);
let _ = std::fs::remove_file(&path);
res?;
Ok(InstalledSchedule {
label: self.label.clone(),
backend: "schtasks".into(),
detail: xml,
})
}
}
#[cfg(target_os = "windows")]
pub fn uninstall(label: &str) -> Result<bool, OsScheduleError> {
if !schtasks_task_exists(label)? {
return Ok(false);
}
run_schtasks(&["/Delete", "/TN", label, "/F"])?;
Ok(true)
}
#[cfg(target_os = "windows")]
pub fn list_installed() -> Result<Vec<String>, OsScheduleError> {
let out = std::process::Command::new("schtasks")
.args(["/Query", "/FO", "CSV", "/NH"])
.output()?;
if !out.status.success() {
return Err(OsScheduleError::Command(
"schtasks".into(),
String::from_utf8_lossy(&out.stderr).trim().to_string(),
));
}
let text = String::from_utf8_lossy(&out.stdout);
let mut labels: Vec<String> = text
.lines()
.filter_map(|l| {
let first = l.split(',').next()?.trim().trim_matches('"');
let name = first.strip_prefix('\\').unwrap_or(first);
name.starts_with(LABEL_PREFIX).then(|| name.to_string())
})
.collect();
labels.sort();
labels.dedup();
Ok(labels)
}
#[cfg(target_os = "windows")]
fn schtasks_task_exists(label: &str) -> Result<bool, OsScheduleError> {
let out = std::process::Command::new("schtasks")
.args(["/Query", "/TN", label])
.output()?;
Ok(out.status.success())
}
#[cfg(target_os = "windows")]
fn run_schtasks(args: &[&str]) -> Result<(), OsScheduleError> {
let out = std::process::Command::new("schtasks").args(args).output()?;
if !out.status.success() {
return Err(OsScheduleError::Command(
"schtasks".into(),
String::from_utf8_lossy(&out.stderr).trim().to_string(),
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn write_utf16le(path: &std::path::Path, s: &str) -> Result<(), OsScheduleError> {
use std::io::Write;
let mut bytes = Vec::with_capacity(s.len() * 2 + 2);
bytes.extend_from_slice(&[0xFF, 0xFE]); for u in s.encode_utf16() {
bytes.extend_from_slice(&u.to_le_bytes());
}
std::fs::File::create(path)?.write_all(&bytes)?;
Ok(())
}
#[cfg(not(any(
target_os = "macos",
all(unix, not(target_os = "macos")),
target_os = "windows"
)))]
impl OsScheduleSpec {
pub fn install(&self) -> Result<InstalledSchedule, OsScheduleError> {
Err(OsScheduleError::UnsupportedPlatform)
}
}
#[cfg(not(any(
target_os = "macos",
all(unix, not(target_os = "macos")),
target_os = "windows"
)))]
pub fn uninstall(_label: &str) -> Result<bool, OsScheduleError> {
Err(OsScheduleError::UnsupportedPlatform)
}
#[cfg(not(any(
target_os = "macos",
all(unix, not(target_os = "macos")),
target_os = "windows"
)))]
pub fn list_installed() -> Result<Vec<String>, OsScheduleError> {
Err(OsScheduleError::UnsupportedPlatform)
}
#[cfg(target_os = "macos")]
fn path_str(p: &std::path::Path) -> String {
p.display().to_string()
}
#[cfg(target_os = "macos")]
fn run_cmd(bin: &str, args: &[String]) -> Result<(), OsScheduleError> {
let output = std::process::Command::new(bin).args(args).output()?;
if !output.status.success() {
return Err(OsScheduleError::Command(
bin.to_string(),
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReconcileReport {
pub removed: Vec<String>,
pub kept: usize,
pub errors: Vec<String>,
}
pub fn schedulable_label(task: &Task) -> Option<String> {
matches!(task.trigger, TaskTrigger::Interval | TaskTrigger::Cron)
.then(|| format!("{LABEL_PREFIX}{}", task.id))
}
pub fn schedulable_labels(tasks: &[Task]) -> BTreeSet<String> {
tasks.iter().filter_map(schedulable_label).collect()
}
pub fn labels_to_remove(installed: &[String], keep: &BTreeSet<String>) -> Vec<String> {
installed
.iter()
.filter(|l| l.starts_with(LABEL_PREFIX) && !keep.contains(*l))
.cloned()
.collect()
}
pub fn reconcile(keep: &BTreeSet<String>) -> Result<ReconcileReport, OsScheduleError> {
let installed = match list_installed() {
Ok(v) => v,
Err(OsScheduleError::UnsupportedPlatform) => return Ok(ReconcileReport::default()),
Err(e) => return Err(e),
};
let mut report = ReconcileReport {
kept: installed.iter().filter(|l| keep.contains(*l)).count(),
..Default::default()
};
for label in labels_to_remove(&installed, keep) {
match uninstall(&label) {
Ok(_) => report.removed.push(label),
Err(e) => report.errors.push(format!("{label}: {e}")),
}
}
Ok(report)
}
pub fn reconcile_with_tasks(tasks: &[Task]) -> Result<ReconcileReport, OsScheduleError> {
reconcile(&schedulable_labels(tasks))
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(trigger: OsTrigger) -> OsScheduleSpec {
OsScheduleSpec {
label: "ai.parslee.car.task.abc123".into(),
program: "/usr/local/bin/car".into(),
args: vec!["task".into(), "run".into(), "abc123".into()],
working_dir: None,
log_path: Some("/tmp/car/abc123.log".into()),
trigger,
}
}
#[test]
fn from_task_rejects_non_recurring_triggers() {
let t = Task::new("x", "p").with_trigger(TaskTrigger::Manual, "");
assert!(matches!(
OsScheduleSpec::from_task(&t, "car", vec![]),
Err(OsScheduleError::NotSchedulable(TaskTrigger::Manual))
));
let once = Task::new("x", "p"); let mut once = once;
once.trigger = TaskTrigger::Once;
assert!(OsScheduleSpec::from_task(&once, "car", vec![]).is_err());
}
#[test]
fn from_task_maps_interval_and_cron() {
let iv = Task::new("x", "p").with_trigger(TaskTrigger::Interval, "5m");
let s = OsScheduleSpec::from_task(&iv, "car", vec!["run".into()]).unwrap();
assert_eq!(s.trigger, OsTrigger::Interval { seconds: 300 });
assert_eq!(s.label, format!("{LABEL_PREFIX}{}", iv.id));
let cr = Task::new("x", "p").with_trigger(TaskTrigger::Cron, "0 9 * * 1");
let s = OsScheduleSpec::from_task(&cr, "car", vec![]).unwrap();
assert_eq!(
s.trigger,
OsTrigger::Cron {
expr: "0 9 * * 1".into()
}
);
}
#[test]
fn launchd_interval_uses_start_interval() {
let plist = spec(OsTrigger::Interval { seconds: 300 })
.render_launchd_plist()
.unwrap();
assert!(plist.contains("<key>StartInterval</key>"));
assert!(plist.contains("<integer>300</integer>"));
assert!(plist.contains("<string>ai.parslee.car.task.abc123</string>"));
assert!(plist.contains("<string>/usr/local/bin/car</string>"));
assert!(plist.contains("<key>StandardErrorPath</key>"));
assert!(plist.contains("<key>RunAtLoad</key>\n <false/>"));
}
#[test]
fn launchd_cron_renders_calendar_interval() {
let plist = spec(OsTrigger::Cron {
expr: "30 9 * * *".into(),
})
.render_launchd_plist()
.unwrap();
assert!(plist.contains("<key>StartCalendarInterval</key>"));
assert!(plist.contains("<key>Minute</key>\n <integer>30</integer>"));
assert!(plist.contains("<key>Hour</key>\n <integer>9</integer>"));
assert!(!plist.contains("<key>Day</key>"));
}
#[test]
fn launchd_cron_expands_step_expressions_to_an_array() {
let plist = spec(OsTrigger::Cron {
expr: "*/15 * * * *".into(),
})
.render_launchd_plist()
.unwrap();
assert!(plist.contains("<key>StartCalendarInterval</key>"));
assert!(
plist.contains("<array>"),
"step expr should render an array"
);
for m in ["0", "15", "30", "45"] {
assert!(
plist.contains(&format!("<integer>{m}</integer>")),
"missing minute {m}"
);
}
assert_eq!(plist.matches("<key>Minute</key>").count(), 4);
assert!(!plist.contains("<key>Hour</key>"));
}
#[test]
fn launchd_n_step_matches_vixie_not_single_value() {
let plist = spec(OsTrigger::Cron {
expr: "5/10 * * * *".into(),
})
.render_launchd_plist()
.unwrap();
for m in ["5", "15", "25", "35", "45", "55"] {
assert!(
plist.contains(&format!("<integer>{m}</integer>")),
"missing minute {m}"
);
}
assert_eq!(plist.matches("<key>Minute</key>").count(), 6);
}
#[test]
fn launchd_cron_single_value_stays_a_dict() {
let plist = spec(OsTrigger::Cron {
expr: "30 9 * * *".into(),
})
.render_launchd_plist()
.unwrap();
assert!(plist.contains("<key>StartCalendarInterval</key>\n <dict>"));
assert!(plist.contains("<key>Minute</key>\n <integer>30</integer>"));
assert!(plist.contains("<key>Hour</key>\n <integer>9</integer>"));
}
#[test]
fn launchd_cron_rejects_all_star() {
let err = spec(OsTrigger::Cron {
expr: "* * * * *".into(),
})
.render_launchd_plist()
.unwrap_err();
assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
}
#[test]
fn crontab_line_for_cron_is_verbatim_and_tagged() {
let line = spec(OsTrigger::Cron {
expr: "0 9 * * 1".into(),
})
.render_crontab_line()
.unwrap();
assert!(line.starts_with("0 9 * * 1 "));
assert!(line.contains("/usr/local/bin/car task run abc123"));
assert!(line.ends_with("# ai.parslee.car.task.abc123"));
assert!(line.contains(">> /tmp/car/abc123.log 2>&1"));
}
#[test]
fn interval_to_cron_divisors() {
assert_eq!(interval_to_cron(300).unwrap(), "*/5 * * * *");
assert_eq!(interval_to_cron(60).unwrap(), "*/1 * * * *");
assert_eq!(interval_to_cron(1800).unwrap(), "*/30 * * * *");
assert_eq!(interval_to_cron(3600).unwrap(), "0 * * * *");
assert_eq!(interval_to_cron(7200).unwrap(), "0 */2 * * *");
assert_eq!(interval_to_cron(86400).unwrap(), "0 0 * * *");
}
#[test]
fn interval_to_cron_rejects_inexpressible() {
assert!(interval_to_cron(30).is_err()); assert!(interval_to_cron(90).is_err()); assert!(interval_to_cron(2520).is_err()); assert!(interval_to_cron(18000).is_err()); }
#[test]
fn crontab_edit_replaces_idempotently() {
let label = "ai.parslee.car.task.x";
let base = "MAILTO=me\n0 0 * * * /bin/true # ai.parslee.car.task.x\n@reboot /bin/other\n";
let line = "*/5 * * * * /usr/bin/car run x # ai.parslee.car.task.x";
let out = apply_crontab_edit(base, label, Some(line));
assert_eq!(out.matches("# ai.parslee.car.task.x").count(), 1);
assert!(out.contains("*/5 * * * * /usr/bin/car run x"));
assert!(out.contains("MAILTO=me"));
assert!(out.contains("@reboot /bin/other"));
let removed = apply_crontab_edit(&out, label, None);
assert!(!removed.contains("car run x"));
assert!(removed.contains("@reboot /bin/other"));
}
#[test]
fn shell_quote_escapes_specials() {
assert_eq!(shell_quote("car"), "car");
assert_eq!(shell_quote("/usr/bin/car"), "/usr/bin/car");
assert_eq!(shell_quote("a b"), "'a b'");
assert_eq!(shell_quote("it's"), r"'it'\''s'");
}
#[test]
fn xml_escape_handles_entities() {
assert_eq!(xml_escape("a & b < c"), "a & b < c");
}
#[test]
fn schedulable_labels_only_includes_interval_and_cron() {
let iv = Task::new("a", "p").with_trigger(TaskTrigger::Interval, "5m");
let cr = Task::new("b", "p").with_trigger(TaskTrigger::Cron, "0 9 * * *");
let manual = Task::new("c", "p"); let mut once = Task::new("d", "p");
once.trigger = TaskTrigger::Once;
let labels = schedulable_labels(&[iv.clone(), cr.clone(), manual, once]);
assert_eq!(labels.len(), 2);
assert!(labels.contains(&format!("{LABEL_PREFIX}{}", iv.id)));
assert!(labels.contains(&format!("{LABEL_PREFIX}{}", cr.id)));
}
#[test]
fn labels_to_remove_reaps_only_orphaned_car_labels() {
let keep: BTreeSet<String> = [format!("{LABEL_PREFIX}live")].into_iter().collect();
let installed = vec![
format!("{LABEL_PREFIX}live"), format!("{LABEL_PREFIX}gone"), "com.example.someone-else".to_string(), ];
let remove = labels_to_remove(&installed, &keep);
assert_eq!(remove, vec![format!("{LABEL_PREFIX}gone")]);
}
#[test]
fn labels_to_remove_empty_keep_reaps_all_car_labels_but_not_foreign() {
let installed = vec![
format!("{LABEL_PREFIX}x"),
format!("{LABEL_PREFIX}y"),
"other.tool.job".to_string(),
];
let remove = labels_to_remove(&installed, &BTreeSet::new());
assert_eq!(remove.len(), 2);
assert!(!remove.iter().any(|l| l == "other.tool.job"));
}
#[test]
fn crontab_rejects_newline_injection() {
let mut s = spec(OsTrigger::Cron {
expr: "0 9 * * *".into(),
});
s.args = vec!["x\n*/1 * * * * /bin/evil".into()];
let err = s.render_crontab_line().unwrap_err();
assert!(matches!(err, OsScheduleError::InvalidValue(_)));
assert!(matches!(
s.render_launchd_plist().unwrap_err(),
OsScheduleError::InvalidValue(_)
));
}
#[test]
fn empty_program_rejected_at_construction() {
let t = Task::new("x", "p").with_trigger(TaskTrigger::Interval, "5m");
assert!(matches!(
OsScheduleSpec::from_task(&t, "", vec![]),
Err(OsScheduleError::InvalidValue(_))
));
}
#[test]
fn launchd_rejects_dom_and_dow_both_constrained() {
let err = spec(OsTrigger::Cron {
expr: "0 9 5 * 1".into(),
})
.render_launchd_plist()
.unwrap_err();
assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
assert!(spec(OsTrigger::Cron {
expr: "0 9 5 * 1".into()
})
.render_crontab_line()
.is_ok());
assert!(spec(OsTrigger::Cron {
expr: "0 9 5 * *".into()
})
.render_launchd_plist()
.is_ok());
}
#[test]
fn schtasks_interval_renders_repetition() {
let xml = spec(OsTrigger::Interval { seconds: 300 })
.render_schtasks_xml()
.unwrap();
assert!(xml.contains("<TimeTrigger>"), "{xml}");
assert!(xml.contains("<Interval>PT5M</Interval>"), "{xml}");
assert!(xml.contains("schemas.microsoft.com/windows/2004/02/mit/task"));
assert!(xml.contains("<Command>"));
}
#[test]
fn schtasks_interval_rejects_sub_minute() {
let err = spec(OsTrigger::Interval { seconds: 30 })
.render_schtasks_xml()
.unwrap_err();
assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
}
#[test]
fn schtasks_daily_cron_renders_schedule_by_day() {
let xml = spec(OsTrigger::Cron {
expr: "30 9 * * *".into(),
})
.render_schtasks_xml()
.unwrap();
assert!(xml.contains("<CalendarTrigger>"), "{xml}");
assert!(
xml.contains("<StartBoundary>2000-01-01T09:30:00</StartBoundary>"),
"{xml}"
);
assert!(xml.contains("<ScheduleByDay>"), "{xml}");
}
#[test]
fn schtasks_weekly_cron_renders_day_of_week() {
let xml = spec(OsTrigger::Cron {
expr: "0 9 * * 1".into(),
})
.render_schtasks_xml()
.unwrap();
assert!(xml.contains("<ScheduleByWeek>"), "{xml}");
assert!(xml.contains("<Monday/>"), "{xml}");
}
#[test]
fn schtasks_monthly_cron_renders_day_of_month() {
let xml = spec(OsTrigger::Cron {
expr: "0 9 5 * *".into(),
})
.render_schtasks_xml()
.unwrap();
assert!(xml.contains("<ScheduleByMonth>"), "{xml}");
assert!(xml.contains("<Day>5</Day>"), "{xml}");
assert!(
xml.contains("<January/>") && xml.contains("<December/>"),
"all months: {xml}"
);
}
#[test]
fn schtasks_hourly_cron_renders_time_trigger_with_hourly_repetition() {
let xml = spec(OsTrigger::Cron {
expr: "5 * * * *".into(),
})
.render_schtasks_xml()
.unwrap();
assert!(xml.contains("<TimeTrigger>"), "{xml}");
assert!(
xml.contains("<StartBoundary>2000-01-01T00:05:00</StartBoundary>"),
"{xml}"
);
assert!(xml.contains("<Interval>PT1H</Interval>"), "{xml}");
}
#[test]
fn schtasks_rejects_dom_and_dow_both_constrained() {
let err = spec(OsTrigger::Cron {
expr: "0 9 5 * 1".into(),
})
.render_schtasks_xml()
.unwrap_err();
assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
}
#[test]
fn schtasks_rejects_multivalued_field() {
let err = spec(OsTrigger::Cron {
expr: "0,30 9 * * *".into(),
})
.render_schtasks_xml()
.unwrap_err();
assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
}
#[test]
fn schtasks_arguments_are_quoted_and_escaped() {
let mut s = spec(OsTrigger::Interval { seconds: 60 });
s.args = vec!["task".into(), "run".into(), "id with space".into()];
let xml = s.render_schtasks_xml().unwrap();
assert!(xml.contains(""id with space""), "{xml}");
}
#[cfg(target_os = "windows")]
#[test]
#[ignore = "registers a real Task Scheduler entry; run explicitly on Windows"]
fn schtasks_install_list_uninstall_roundtrip() {
let mut s = spec(OsTrigger::Interval { seconds: 3600 });
s.label = format!("{LABEL_PREFIX}itest_{}", std::process::id());
s.program = "cmd.exe".into();
s.args = vec!["/C".into(), "rem".into()];
s.working_dir = None;
s.log_path = None;
let installed = s.install().expect("install should succeed");
assert_eq!(installed.backend, "schtasks");
let listed = list_installed().expect("list should succeed");
assert!(
listed.iter().any(|l| l == &s.label),
"installed label {} not in {listed:?}",
s.label
);
assert!(uninstall(&s.label).expect("uninstall should succeed"));
assert!(!uninstall(&s.label).expect("second uninstall should succeed"));
}
}