amphetamine 0.1.2

Reclaim memory and win scheduler contention on Apple Silicon, safely.
//! End-to-end tests of the CLI surface, run against the real binary.
//!
//! Every test is hermetic: `AMPHETAMINE_CONFIG` points each invocation at its
//! own scratch path, so nothing here reads or writes the developer's real
//! config. Only read-only subcommands and refusal paths are exercised — the
//! job of this suite is proving the safe surfaces stay safe from outside the
//! process boundary, where unit tests cannot see.

use std::path::PathBuf;
use std::process::{Command, Output};

const BIN: &str = env!("CARGO_BIN_EXE_amph");

/// A per-test config home, deleted on drop. Each test owns its own directory
/// so the suite is safe to run in parallel.
struct Home(PathBuf);

impl Home {
    fn new(tag: &str) -> Self {
        let dir = std::env::temp_dir().join(format!("amph-cli-{tag}"));
        std::fs::remove_dir_all(&dir).ok();
        std::fs::create_dir_all(&dir).unwrap();
        Self(dir)
    }

    fn config(&self) -> PathBuf {
        self.0.join("config.toml")
    }
}

impl Drop for Home {
    fn drop(&mut self) {
        std::fs::remove_dir_all(&self.0).ok();
    }
}

fn amph(home: &Home, args: &[&str]) -> Output {
    Command::new(BIN)
        .args(args)
        .env("AMPHETAMINE_CONFIG", home.config())
        .output()
        .expect("binary should run")
}

fn stdout(out: &Output) -> String {
    String::from_utf8_lossy(&out.stdout).into_owned()
}

#[test]
fn version_matches_the_manifest() {
    let h = Home::new("version");
    let out = amph(&h, &["--version"]);
    assert!(out.status.success());
    assert!(stdout(&out).contains(env!("CARGO_PKG_VERSION")));
}

#[test]
fn help_names_every_subcommand() {
    let h = Home::new("help");
    let out = amph(&h, &["--help"]);
    assert!(out.status.success());
    let text = stdout(&out);
    for cmd in [
        "status", "boost", "focus", "restore", "setup", "add", "rm", "pick", "config",
    ] {
        assert!(text.contains(cmd), "help does not mention `{cmd}`");
    }
}

#[test]
fn an_unknown_flag_fails_with_the_usage_exit_code() {
    let h = Home::new("badflag");
    let out = amph(&h, &["--definitely-not-a-flag"]);
    assert_eq!(out.status.code(), Some(2), "clap's usage-error exit code");
}

#[test]
fn config_path_honors_the_env_override() {
    let h = Home::new("path");
    let out = amph(&h, &["config", "path"]);
    assert!(out.status.success());
    assert_eq!(stdout(&out).trim(), h.config().to_string_lossy());
}

#[test]
fn status_json_is_valid_and_shaped() {
    let h = Home::new("json");
    let out = amph(&h, &["status", "--json", "--top", "3"]);
    assert!(out.status.success());
    let v: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("valid JSON");
    for key in ["memory", "swap", "power", "reclaimable", "top_apps"] {
        assert!(v.get(key).is_some(), "missing key `{key}`");
    }
    assert!(
        v["top_apps"].as_array().is_some_and(|a| a.len() <= 3),
        "--top was not honored"
    );
    assert!(v["memory"]["total"].as_u64().is_some_and(|t| t > 0));
}

#[test]
fn config_init_writes_the_template_and_refuses_to_clobber() {
    let h = Home::new("init");
    assert!(amph(&h, &["config", "init"]).status.success());
    let text = std::fs::read_to_string(h.config()).unwrap();
    assert!(text.contains("[apps]") && text.contains("close = ["));

    let again = amph(&h, &["config", "init"]);
    assert!(!again.status.success(), "init must refuse to overwrite");
    assert_eq!(
        std::fs::read_to_string(h.config()).unwrap(),
        text,
        "the existing config was modified"
    );
}

#[test]
fn first_boost_onboards_and_changes_nothing() {
    let h = Home::new("onboard");
    let out = amph(&h, &["boost"]);
    assert!(out.status.success());
    assert!(stdout(&out).contains("First run"));

    // The starter config must carry the safety promise: empty lists, so a
    // brand-new install cannot close or deprioritise anything.
    let text = std::fs::read_to_string(h.config()).expect("onboarding writes the config");
    let cfg: toml::Value = toml::from_str(&text).unwrap();
    assert!(cfg["apps"]["close"].as_array().unwrap().is_empty());
    assert!(cfg["focus"]["demote"].as_array().unwrap().is_empty());
}

#[test]
fn boost_dry_run_announces_itself_and_explains_the_empty_list() {
    let h = Home::new("dry");
    // Caches disabled keeps this test off the real cache tree entirely.
    std::fs::write(h.config(), "[caches]\nenabled = false\n").unwrap();
    let out = amph(&h, &["boost", "--dry-run"]);
    assert!(out.status.success());
    let text = stdout(&out);
    assert!(text.contains("Dry run"));
    assert!(text.contains("close list is empty"));
}

#[test]
fn adding_a_protected_app_is_refused_end_to_end() {
    let h = Home::new("protected");
    let out = amph(&h, &["add", "Cursor", "Docker"]);
    assert!(out.status.success());
    let text = stdout(&out);
    assert!(text.contains("not added"), "refusal was not reported");

    // The refusal must also hold on disk: neither name may be persisted.
    let cfg: toml::Value = toml::from_str(&std::fs::read_to_string(h.config()).unwrap()).unwrap();
    assert!(cfg["apps"]["close"].as_array().unwrap().is_empty());
}

#[test]
fn setup_print_shows_only_the_restore_to_zero_rule() {
    let h = Home::new("setup");
    let out = amph(&h, &["setup", "--print"]);
    assert!(out.status.success());
    let rule = stdout(&out);
    assert!(rule.contains("NOPASSWD"));
    assert!(
        rule.contains("/usr/bin/renice 0 -p"),
        "the literal 0 is the whole security argument: {rule}"
    );
}