snipexpand 0.4.1

Fast, config-based text expansion for Linux and Wayland
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;

struct Harness {
    _root: TempDir,
    repo: PathBuf,
    config_home: PathBuf,
    data_home: PathBuf,
}

impl Harness {
    fn native() -> Self {
        let root = tempfile::tempdir().unwrap();
        let harness = Self {
            repo: root.path().join("repo"),
            config_home: root.path().join("config"),
            data_home: root.path().join("data"),
            _root: root,
        };
        harness.write_native("0.1.0", "old expansion");
        harness.init_git();
        harness
    }

    fn write_native(&self, version: &str, replacement: &str) {
        write(
            &self.repo.join("pack.yml"),
            &format!(
                "name: test-pack\ntitle: Test Pack\nversion: {version}\ndescription: Test snippets\nauthor: Test\n"
            ),
        );
        write(
            &self.repo.join("match/pack.yml"),
            &format!("matches:\n  - trigger: ';pack'\n    replace: '{replacement}'\n"),
        );
    }

    fn init_git(&self) {
        git(&self.repo, &["init", "-q"]);
        self.commit("initial");
    }

    fn commit(&self, message: &str) {
        git(&self.repo, &["add", "."]);
        git(
            &self.repo,
            &[
                "-c",
                "user.name=Test",
                "-c",
                "user.email=test@example.invalid",
                "commit",
                "-qm",
                message,
            ],
        );
    }

    fn run(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_snipexpand"))
            .args(args)
            .env("XDG_CONFIG_HOME", &self.config_home)
            .env("XDG_DATA_HOME", &self.data_home)
            .output()
            .unwrap()
    }

    fn success(&self, args: &[&str]) -> String {
        let output = self.run(args);
        assert!(
            output.status.success(),
            "command failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout).unwrap()
    }

    fn mirror(&self) -> PathBuf {
        self.config_home
            .join("snipexpand/match/packs/test-pack/pack.yml")
    }
}

#[test]
fn pack_lifecycle_is_isolated_and_reversible() {
    let harness = Harness::native();
    let source = harness.repo.to_str().unwrap();

    let inspected = harness.success(&["pack", "inspect", source]);
    assert!(inspected.contains("Test Pack 0.1.0"));
    assert!(inspected.contains("snippets: 1"));

    harness.success(&["pack", "install", source]);
    assert!(harness.mirror().is_file());
    assert!(harness
        .success(&["list", "--json"])
        .contains("old expansion"));

    harness.success(&["pack", "disable", "test-pack"]);
    assert!(!harness.mirror().exists());
    harness.success(&["pack", "enable", "test-pack"]);
    assert!(harness.mirror().is_file());

    harness.success(&["pack", "remove", "test-pack"]);
    assert!(!harness.mirror().exists());
    assert_eq!(harness.success(&["pack", "list"]), "No packs installed.\n");
}

#[test]
fn pack_update_replaces_content_and_records_the_new_commit() {
    let harness = Harness::native();
    let source = harness.repo.to_str().unwrap();
    harness.success(&["pack", "install", source]);
    let before = pack_json(&harness)["commit"].as_str().unwrap().to_string();

    harness.write_native("0.2.0", "new expansion");
    harness.commit("update");
    harness.success(&["pack", "update", "test-pack"]);

    let after = pack_json(&harness);
    assert_eq!(after["version"], "0.2.0");
    assert_ne!(after["commit"].as_str().unwrap(), before);
    assert!(std::fs::read_to_string(harness.mirror())
        .unwrap()
        .contains("new expansion"));
}

#[test]
fn invalid_update_rolls_back_repository_state_and_active_matches() {
    let harness = Harness::native();
    let source = harness.repo.to_str().unwrap();
    harness.success(&["pack", "install", source]);
    let before = pack_json(&harness);

    write(
        &harness.repo.join("match/pack.yml"),
        "matches:\n  - trigger: ';pack'\n    shell: 'echo unsafe'\n",
    );
    harness.commit("invalid update");
    let output = harness.run(&["pack", "update", "test-pack"]);

    assert!(!output.status.success());
    assert_eq!(pack_json(&harness), before);
    assert!(std::fs::read_to_string(harness.mirror())
        .unwrap()
        .contains("old expansion"));
    assert!(harness.success(&["check"]).starts_with("OK:"));
}

#[test]
fn disabled_install_stays_out_of_the_active_configuration() {
    let harness = Harness::native();
    let source = harness.repo.to_str().unwrap();
    harness.success(&["pack", "install", source, "--disabled"]);

    assert!(!harness.mirror().exists());
    assert_eq!(pack_json(&harness)["enabled"], false);
    assert!(!harness.success(&["list", "--json"]).contains(";pack"));
}

#[test]
fn install_reports_trigger_conflicts_without_hiding_either_match() {
    let harness = Harness::native();
    write(
        &harness.config_home.join("snipexpand/match/personal.yml"),
        "matches:\n  - trigger: ';pack'\n    replace: 'personal expansion'\n",
    );
    let output = harness.success(&["pack", "install", harness.repo.to_str().unwrap()]);

    assert!(output.contains("WARNING: trigger ';pack' conflicts across 2 files"));
    let listed: Vec<Value> = serde_json::from_str(&harness.success(&["list", "--json"])).unwrap();
    assert_eq!(
        listed
            .iter()
            .filter(|entry| entry["trigger"] == ";pack")
            .count(),
        2
    );
}

fn pack_json(harness: &Harness) -> Value {
    let packs: Vec<Value> =
        serde_json::from_str(&harness.success(&["pack", "list", "--json"])).unwrap();
    packs.into_iter().next().unwrap()
}

fn write(path: &Path, content: &str) {
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(path, content).unwrap();
}

fn git(repo: &Path, args: &[&str]) {
    let output = Command::new("git")
        .args(args)
        .current_dir(repo)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}