amphetamine 0.1.2

Reclaim memory and win scheduler contention on Apple Silicon, safely.
//! User configuration.
//!
//! The `close` and `demote` lists ship **empty**. Amphetamine will not quit or
//! reprioritise a single process until you name it here — that opt-in is the
//! consent gate the whole tool is built around, so there is deliberately no
//! "just close everything" default.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub apps: Apps,
    pub caches: Caches,
    pub focus: Focus,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Apps {
    /// Bundle IDs or app names to quit. Empty means nothing is ever closed.
    pub close: Vec<String>,
    /// Extra protections layered on top of the built-in denylist.
    pub protect: Vec<String>,
    /// How long to let an app save and exit before giving up on it.
    pub quit_timeout_secs: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Caches {
    pub enabled: bool,
    /// Only files untouched for at least this long are eligible.
    pub min_age_days: u64,
    /// Never clear a cache belonging to an app that is currently running —
    /// pulling a cache out from under a live app is how you corrupt one.
    pub skip_running_apps: bool,
    /// Cache buckets to leave alone, on top of the built-in list.
    pub skip: Vec<String>,
    /// Buckets to clear despite being skipped by default. Cannot override the
    /// built-in live-state protections.
    pub allow: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Focus {
    /// Processes to deprioritise for the duration of the session.
    pub demote: Vec<String>,
    /// Nice value applied to them. Clamped to 1–20; higher yields more CPU.
    pub nice_level: i32,
    pub prevent_sleep: bool,
}

impl Default for Apps {
    fn default() -> Self {
        Self {
            close: Vec::new(),
            protect: Vec::new(),
            quit_timeout_secs: 10,
        }
    }
}

impl Default for Caches {
    fn default() -> Self {
        Self {
            enabled: true,
            min_age_days: 7,
            skip_running_apps: true,
            skip: Vec::new(),
            allow: Vec::new(),
        }
    }
}

impl Default for Focus {
    fn default() -> Self {
        Self {
            demote: Vec::new(),
            nice_level: 10,
            prevent_sleep: true,
        }
    }
}

impl Caches {
    /// Buckets skipped for this run: the default list minus anything the user
    /// explicitly allowed, plus anything they explicitly skipped.
    pub fn effective_skips(&self) -> Vec<String> {
        crate::guard::CACHE_SKIP_DEFAULT
            .iter()
            .map(|s| (*s).to_owned())
            .filter(|s| !self.allow.iter().any(|a| a.eq_ignore_ascii_case(s)))
            .chain(self.skip.iter().cloned())
            .collect()
    }
}

pub fn path() -> PathBuf {
    std::env::var_os("AMPHETAMINE_CONFIG")
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var_os("XDG_CONFIG_HOME")
                .map(PathBuf::from)
                .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
                .map(|c| c.join("amphetamine").join("config.toml"))
        })
        .unwrap_or_else(|| PathBuf::from("amphetamine.toml"))
}

/// Loads config, or `None` when the file does not exist yet.
pub fn load() -> Result<Option<Config>> {
    let p = path();
    if !p.exists() {
        return Ok(None);
    }
    let text = std::fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?;
    // `deny_unknown_fields` turns a typo'd key into an error instead of a
    // setting that silently does nothing.
    toml::from_str(&text)
        .map(Some)
        .with_context(|| format!("parsing {}", p.display()))
}

/// Writes the commented starter config. Refuses to clobber an existing file.
pub fn init(at: &Path) -> Result<()> {
    if at.exists() {
        anyhow::bail!("{} already exists", at.display());
    }
    if let Some(parent) = at.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("creating {}", parent.display()))?;
    }
    std::fs::write(at, TEMPLATE).with_context(|| format!("writing {}", at.display()))?;
    Ok(())
}

pub const TEMPLATE: &str = r#"# Amphetamine
#
# Nothing is closed or reprioritised until you list it below. Both lists start
# empty on purpose. Entries match an app's bundle ID, its name, or the last
# component of its bundle ID, case-insensitively.
#
# Run `amph status` to see what is actually eating memory before filling these in.

[apps]
# Apps to quit on `amph boost`. They are asked to quit the normal way, so
# unsaved-work prompts still appear and nothing is killed out from under you.
close = [
  # "Slack",
  # "Spotify",
  # "Discord",
  # "com.tinyspeck.slackmacgap",
]

# Extra protections on top of the built-in denylist. The built-ins already
# cover the OS, your editor and terminal, and all VMs and container runtimes.
protect = []

# Seconds to let an app save and exit before Amphetamine gives up and leaves
# it running. It is never escalated to a kill unless you pass --force.
quit_timeout_secs = 10

[caches]
enabled = true

# Only files untouched for at least this many days are eligible. Raising this
# is the single easiest way to make cache clearing more conservative.
min_age_days = 7

# Never clear a cache belonging to a running app. Leave this on.
skip_running_apps = true

# Extra cache buckets to leave alone, on top of the built-in list.
skip = []

# Buckets to clear even though they are skipped by default — developer
# toolchains and browsers. These are safe to delete but cost you a rebuild or
# sign you out of websites, which is why they are opt-in.
#   allow = ["go-build", "Homebrew", "pip"]
allow = []

[focus]
# Processes deprioritised during `amph focus`, leaving more CPU for your
# editor. They keep running, just with a weaker claim on the scheduler.
#
# This requires `amph setup` first. Raising a nice value needs no privileges,
# but lowering it back needs root, and Amphetamine refuses to demote anything
# it cannot prove it can undo.
demote = [
  # "Spotify",
  # "Dropbox",
]

# How far to deprioritise, 1-20. 10 is a firm nudge; 20 means "only run when
# nothing else wants the CPU".
nice_level = 10

# Hold off idle sleep for the duration of a focus session. Your display still
# sleeps normally.
prevent_sleep = true
"#;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn template_parses_and_is_empty_by_default() {
        let c: Config = toml::from_str(TEMPLATE).expect("shipped template must parse");
        // The safety promise: a fresh install touches nothing.
        assert!(c.apps.close.is_empty());
        assert!(c.focus.demote.is_empty());
        assert!(c.caches.skip_running_apps);
        assert!(c.caches.min_age_days >= 1);
    }

    #[test]
    fn unknown_keys_are_rejected_rather_than_ignored() {
        let bad = "[apps]\nclose = []\nclosee = [\"Slack\"]\n";
        assert!(toml::from_str::<Config>(bad).is_err());
    }

    #[test]
    fn empty_file_yields_safe_defaults() {
        let c: Config = toml::from_str("").unwrap();
        assert!(c.apps.close.is_empty());
        assert_eq!(c.apps.quit_timeout_secs, 10);
    }

    #[test]
    fn allow_reopens_a_default_skip_but_skip_wins_when_both() {
        let c = Caches {
            allow: vec!["Arc".into()],
            ..Caches::default()
        };
        let skips = c.effective_skips();
        assert!(!skips.iter().any(|s| s == "Arc"));
        assert!(skips.iter().any(|s| s == "com.apple.Safari"));

        let both = Caches {
            allow: vec!["Arc".into()],
            skip: vec!["Arc".into()],
            ..Caches::default()
        };
        assert!(both.effective_skips().iter().any(|s| s == "Arc"));
    }
}