maclean 1.0.0

Find and reclaim disk space on macOS
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};

/// Written into every plist we create. Bump when the on-disk job format
/// changes; [`is_maclean_job`] still has to recognize every older schema
/// we have ever shipped, or uninstall will leave orphans.
pub const JOB_SCHEMA: i64 = 3;

/// launchd Label / filename prefix. Not enough on its own to decide a
/// file is ours — see [`is_maclean_job`].
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,
    /// What to run when the job fires. Looked up at run time — not a scan snapshot.
    pub selectors: Vec<String>,
    pub every: Every,
    /// Full argv that launchd will run.
    pub command: Vec<String>,
    /// 0 = written before schema keys existed; still ours if the comment matches.
    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<()>;
    /// Unload and delete every job we can prove we created. Leaves every
    /// other LaunchAgent alone.
    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())
}

/// How often launchd should wake us to ask "is this due?".
/// The user interval lives in [`EVERY_KEY`]; this is just a nudge.
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, ':' | '.' | '-' | '_' | '/' | '+'))
}

/// Decide whether a LaunchAgent is one we created.
///
/// Filename prefix is the first filter (so we never open every plist in
/// LaunchAgents). After that we require our Label prefix *and* one of:
/// - schema 1+: `MacleanManaged` / `MacleanSchema`
/// - schema 0: Comment starts with "Managed by maclean"
///
/// A file that only happens to be named `com.maclean.job.*` is not enough.
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),
        ));
    }
}