mod history;
mod launchd;
mod period;
use anyhow::{Result, bail};
use serde::Serialize;
pub use history::{JobStats, due as job_due, record as record_run, stats as job_stats};
pub use period::{Every, parse_every};
pub const JOB_SCHEMA: i64 = 3;
pub const LABEL_PREFIX: &str = "com.maclean.job.";
pub(crate) const MANAGED_KEY: &str = "MacleanManaged";
pub(crate) const SCHEMA_KEY: &str = "MacleanSchema";
pub(crate) const ITEM_KEY: &str = "MacleanItemId";
pub(crate) const JOB_ID_KEY: &str = "MacleanJobId";
pub(crate) const SELECTORS_KEY: &str = "MacleanSelectors";
pub(crate) const EVERY_KEY: &str = "MacleanEvery";
pub(crate) const COMMENT: &str =
"Managed by maclean. Do not edit this file. Use the maclean TUI or: maclean schedule";
#[derive(Debug, Clone, Serialize)]
pub struct ScheduledJob {
pub id: String,
pub selectors: Vec<String>,
pub every: Every,
pub command: Vec<String>,
pub schema: i64,
}
impl ScheduledJob {
pub fn label(&self) -> String {
self.selectors.join(" ")
}
}
pub trait Scheduler {
fn list(&self) -> Result<Vec<ScheduledJob>>;
fn add(&self, job: &ScheduledJob) -> Result<()>;
fn remove(&self, job_id: &str) -> Result<()>;
fn purge(&self) -> Result<Vec<ScheduledJob>>;
}
pub fn current() -> Box<dyn Scheduler> {
Box::new(launchd::LaunchdScheduler)
}
pub fn job_id(selectors: &[String]) -> String {
let mut ids = selectors.to_vec();
ids.sort();
ids.dedup();
ids.join("+")
}
pub fn maclean_command(job_id: &str, selectors: &[String], every: u64) -> Result<Vec<String>> {
if selectors.is_empty() {
bail!("pick at least one thing to run");
}
for id in selectors {
if !valid_selector(id) {
bail!("invalid selector '{id}'");
}
}
let exe = std::env::current_exe()?;
Ok(vec![
exe.display().to_string(),
"reclaim".into(),
"--yes".into(),
"--job".into(),
job_id.into(),
"--every".into(),
every.to_string(),
]
.into_iter()
.chain(selectors.iter().cloned())
.collect())
}
pub fn poll_seconds(every: u64) -> u64 {
every.min(3_600)
}
pub fn valid_selector(id: &str) -> bool {
!id.is_empty()
&& !id.starts_with('-')
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '.' | '-' | '_' | '/' | '+'))
}
pub fn is_maclean_job(
filename: &str,
label: Option<&str>,
comment: Option<&str>,
schema: Option<i64>,
managed: Option<bool>,
) -> bool {
if !filename.starts_with(LABEL_PREFIX) || !filename.ends_with(".plist") {
return false;
}
let Some(label) = label else {
return false;
};
if !label.starts_with(LABEL_PREFIX) {
return false;
}
if managed == Some(true) || schema.is_some_and(|s| s >= 1) {
return true;
}
comment.is_some_and(|c| c.starts_with("Managed by maclean"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selectors_reject_flags_and_junk() {
assert!(valid_selector("spotify:cache"));
assert!(valid_selector("cargo:projects"));
assert!(!valid_selector(""));
assert!(!valid_selector("--yes"));
assert!(!valid_selector("foo;rm"));
}
#[test]
fn job_id_is_stable() {
assert_eq!(
job_id(&["node:caches".into(), "cargo:projects".into()]),
job_id(&["cargo:projects".into(), "node:caches".into()])
);
}
#[test]
fn weekly_jobs_are_nudged_hourly() {
assert_eq!(poll_seconds(7 * 24 * 3600), 3600);
assert_eq!(poll_seconds(1800), 1800);
}
#[test]
fn foreign_plists_are_not_ours_even_with_our_filename() {
assert!(!is_maclean_job(
"com.maclean.job.evil.plist",
Some("com.apple.something"),
Some("Managed by maclean. Do not edit this file."),
None,
None,
));
assert!(is_maclean_job(
"com.maclean.job.spotify-cache.plist",
Some("com.maclean.job.spotify-cache"),
None,
Some(2),
Some(true),
));
}
}