moadim 3.2.2

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
//! Tests for machine identity resolution, persistence, CLI, and the targeting predicate.

use super::*;

/// Save an env var's prior value and restore it on drop, so a test's override never leaks. Tests in
/// this crate run single-threaded per binary (`RUST_TEST_THREADS=1`), so the global mutation is safe.
struct EnvGuard {
    name: &'static str,
    previous: Option<std::ffi::OsString>,
}

impl EnvGuard {
    /// Set `name` to `value`, remembering the prior value for restoration.
    fn set(name: &'static str, value: &str) -> Self {
        let previous = std::env::var_os(name);
        // SAFETY: single-threaded test execution.
        unsafe { std::env::set_var(name, value) }
        Self { name, previous }
    }

    /// Ensure `name` is unset for the duration of the guard.
    fn unset(name: &'static str) -> Self {
        let previous = std::env::var_os(name);
        // SAFETY: single-threaded test execution.
        unsafe { std::env::remove_var(name) }
        Self { name, previous }
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        // SAFETY: single-threaded test execution.
        unsafe {
            match self.previous.take() {
                Some(value) => std::env::set_var(self.name, value),
                None => std::env::remove_var(self.name),
            }
        }
    }
}

/// Create a unique tempdir to use as `MOADIM_HOME_OVERRIDE` for a test.
fn temp_home(tag: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!("moadim-machine-{tag}-{}", uuid::Uuid::new_v4()));
    std::fs::create_dir_all(&dir).expect("create temp home");
    dir
}

// ─── resolve_from precedence ───────────────────────────────────────────────

#[test]
fn resolve_from_prefers_env() {
    let (name, source) = resolve_from(
        Some("from-env".to_string()),
        Some("from-file".to_string()),
        "from-host".to_string(),
    );
    assert_eq!(name, "from-env");
    assert_eq!(source, MachineSource::Env);
}

#[test]
fn resolve_from_uses_file_when_no_env() {
    let (name, source) = resolve_from(None, Some("from-file".to_string()), "from-host".to_string());
    assert_eq!(name, "from-file");
    assert_eq!(source, MachineSource::File);
}

#[test]
fn resolve_from_falls_back_to_hostname() {
    let (name, source) = resolve_from(None, None, "from-host".to_string());
    assert_eq!(name, "from-host");
    assert_eq!(source, MachineSource::Hostname);
}

#[test]
fn resolve_from_treats_blank_env_and_file_as_absent() {
    // Whitespace-only env and file values must not win — they fall through to the hostname.
    let (name, source) = resolve_from(
        Some("   ".to_string()),
        Some("\t\n".to_string()),
        "from-host".to_string(),
    );
    assert_eq!(name, "from-host");
    assert_eq!(source, MachineSource::Hostname);
}

#[test]
fn resolve_from_trims_winning_value() {
    let (name, source) = resolve_from(Some("  padded  ".to_string()), None, "host".to_string());
    assert_eq!(name, "padded");
    assert_eq!(source, MachineSource::Env);
}

// ─── non_empty ─────────────────────────────────────────────────────────────

#[test]
fn non_empty_filters_blank_and_none() {
    assert_eq!(non_empty(None), None);
    assert_eq!(non_empty(Some("   ".to_string())), None);
    assert_eq!(non_empty(Some(" ok ".to_string())), Some("ok".to_string()));
}

// ─── hostname ──────────────────────────────────────────────────────────────

#[test]
fn hostname_is_non_empty() {
    assert!(!hostname().is_empty());
}

// ─── targets predicate ─────────────────────────────────────────────────────

#[test]
fn targets_matches_only_named_machine() {
    assert!(targets(&["a".to_string(), "b".to_string()], "b"));
    assert!(!targets(&["a".to_string()], "b"));
    // Empty list targets no machine.
    assert!(!targets(&[], "a"));
}

#[test]
fn targets_glob_entry_matches_any_or_a_family() {
    assert!(targets(&["*".to_string()], "anything"));
    let machines = vec!["box-*".to_string()];
    assert!(targets(&machines, "box-1"));
    assert!(!targets(&machines, "other-1"));
    // A glob is still a full match: no implicit substring/suffix.
    assert!(!targets(&machines, "prefix-box-1"));
}

// ─── glob_match ─────────────────────────────────────────────────────────────

#[test]
fn glob_match_without_star_is_exact() {
    assert!(glob_match("box", "box"));
    assert!(!glob_match("box", "boxes"));
    assert!(!glob_match("box", ""));
}

#[test]
fn glob_match_star_matches_prefix_middle_suffix_and_everything() {
    assert!(glob_match("*", "anything at all"));
    assert!(glob_match("box-*", "box-1"));
    // Name exhausted exactly at a trailing `*`: the post-loop "consume remaining stars" step runs.
    assert!(glob_match("box-*", "box-"));
    assert!(!glob_match("box-*", "other"));
    assert!(glob_match("*-work", "m4-work"));
    assert!(!glob_match("*-work", "m4-personal"));
    assert!(glob_match("a*z", "abcz"));
    assert!(!glob_match("a*z", "abcy"));
    assert!(glob_match("a**b", "axxxb")); // Consecutive stars collapse to one.
                                          // The first `*` in "*bc" greedily consumes too much and must backtrack for "bc" to land.
    assert!(glob_match("*bc", "abcbc"));
    assert!(!glob_match("*bc", "abcd"));
}

// ─── MachineSource labels ──────────────────────────────────────────────────

#[test]
fn source_labels_are_distinct() {
    assert_eq!(MachineSource::Env.label(), "MOADIM_MACHINE env");
    assert_eq!(MachineSource::File.label(), "machine.local.toml");
    assert_eq!(
        MachineSource::Generated.label(),
        "auto-generated (first run)"
    );
    assert_eq!(MachineSource::Hostname.label(), "system hostname");
}
include!("read_machine_file_absent_is_none_tests.rs");