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))
}
}
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 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 mut dict = String::from(" <dict>\n");
let mut any = false;
for (field, key) in fields.iter().zip(keys) {
if *field == "*" {
continue;
}
let n: i64 = field.parse().map_err(|_| {
OsScheduleError::UnsupportedSchedule(format!(
"launchd StartCalendarInterval supports only `*` or a plain integer per field; {field:?} is not (use Interval, or install on Linux/cron)"
))
})?;
dict.push_str(&format!(
" <key>{key}</key>\n <integer>{n}</integer>\n"
));
any = true;
}
if !any {
return Err(OsScheduleError::UnsupportedSchedule(
"an all-`*` cron has no calendar constraint; use Interval { seconds: 60 }".into(),
));
}
dict.push_str(" </dict>\n");
Ok(dict)
}
fn interval_to_cron(seconds: u64) -> Result<String, OsScheduleError> {
if seconds < 60 || seconds % 60 != 0 {
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 % 60 == 0 {
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"
)))
}
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
}
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(not(any(target_os = "macos", all(unix, not(target_os = "macos")))))]
impl OsScheduleSpec {
pub fn install(&self) -> Result<InstalledSchedule, OsScheduleError> {
Err(OsScheduleError::UnsupportedPlatform)
}
}
#[cfg(not(any(target_os = "macos", all(unix, not(target_os = "macos")))))]
pub fn uninstall(_label: &str) -> Result<bool, OsScheduleError> {
Err(OsScheduleError::UnsupportedPlatform)
}
#[cfg(not(any(target_os = "macos", all(unix, not(target_os = "macos")))))]
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_rejects_step_expressions() {
let err = spec(OsTrigger::Cron {
expr: "*/15 * * * *".into(),
})
.render_launchd_plist()
.unwrap_err();
assert!(matches!(err, OsScheduleError::UnsupportedSchedule(_)));
}
#[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());
}
}