//! Unit coverage split from `toolchain.rs` for the soldr#2493 1,000-line
//! production-source ceiling.

use super::*;
use std::ffi::{OsStr, OsString};
use std::sync::Mutex;

static ENV_LOCK: Mutex<()> = Mutex::new(());

struct EnvVarGuard {
    key: &'static str,
    previous: Option<OsString>,
}

impl EnvVarGuard {
    fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
        let previous = std::env::var_os(key);
        unsafe {
            std::env::set_var(key, value);
        }
        Self { key, previous }
    }

    fn remove(key: &'static str) -> Self {
        let previous = std::env::var_os(key);
        unsafe {
            std::env::remove_var(key);
        }
        Self { key, previous }
    }
}

impl Drop for EnvVarGuard {
    fn drop(&mut self) {
        unsafe {
            match &self.previous {
                Some(value) => std::env::set_var(self.key, value),
                None => std::env::remove_var(self.key),
            }
        }
    }
}

#[test]
fn toolchain_command_timeout_is_an_explicit_safety_ceiling() {
    let _lock = ENV_LOCK.lock().expect("env lock");
    {
        let _guard = EnvVarGuard::set(TOOLCHAIN_COMMAND_TIMEOUT_ENV_VAR, "23");
        assert_eq!(
            InstallerWatchdogConfig::from_env(TOOLCHAIN_COMMAND_TIMEOUT_ENV_VAR).safety_timeout,
            Duration::from_secs(23)
        );
    }
    for value in ["", "0", "-1", "abc"] {
        let _guard = EnvVarGuard::set(TOOLCHAIN_COMMAND_TIMEOUT_ENV_VAR, value);
        assert_eq!(
            InstallerWatchdogConfig::from_env(TOOLCHAIN_COMMAND_TIMEOUT_ENV_VAR).safety_timeout,
            Duration::from_secs(crate::core::DEFAULT_INSTALLER_SAFETY_TIMEOUT_SECS),
            "invalid override {value:?} should use default"
        );
    }
    let _guard = EnvVarGuard::remove(TOOLCHAIN_COMMAND_TIMEOUT_ENV_VAR);
    assert_eq!(
        InstallerWatchdogConfig::from_env(TOOLCHAIN_COMMAND_TIMEOUT_ENV_VAR).safety_timeout,
        Duration::from_secs(crate::core::DEFAULT_INSTALLER_SAFETY_TIMEOUT_SECS)
    );
}

#[test]
fn dylint_rustup_scope_replaces_every_toolchain_selector() {
    let args = vec![
        "+stable".to_string(),
        "run".to_string(),
        "nightly-old".to_string(),
        "cargo".to_string(),
        "--toolchain=stable".to_string(),
    ];
    assert_eq!(
        scope_rustup_args_to_dylint(&args, "nightly-2026-01-18"),
        vec!["run", "nightly-2026-01-18", "cargo"]
    );
    let component = vec![
        "component".to_string(),
        "add".to_string(),
        "rustc-dev".to_string(),
        "--toolchain".to_string(),
        "stable".to_string(),
    ];
    assert_eq!(
        scope_rustup_args_to_dylint(&component, "nightly-2026-01-18"),
        vec!["component", "add", "rustc-dev"]
    );
}

fn test_memo_key(root: &Path) -> CargoPrepareMemoKey {
    CargoPrepareMemoKey {
        schema_version: CARGO_PREPARE_MEMO_SCHEMA_VERSION,
        channel: "1.94.1".to_string(),
        explicit_channel: None,
        profile: Some("minimal".to_string()),
        components: vec!["clippy".to_string(), "rustfmt".to_string()],
        targets: vec!["wasm32-unknown-unknown".to_string()],
        rustup_home: root.join("rustup"),
        rustup_binary: root.join("rustup-bin"),
    }
}

#[test]
fn cargo_prepare_memo_key_covers_every_requirement() {
    let root = tempfile::tempdir().expect("temp dir");
    let base = test_memo_key(root.path());
    let variants = [
        {
            let mut key = base.clone();
            key.channel = "1.95.0".to_string();
            key
        },
        {
            let mut key = base.clone();
            key.explicit_channel = Some("1.94.1".to_string());
            key
        },
        {
            let mut key = base.clone();
            key.profile = Some("default".to_string());
            key
        },
        {
            let mut key = base.clone();
            key.components.push("miri".to_string());
            key
        },
        {
            let mut key = base.clone();
            key.targets.push("x86_64-unknown-linux-musl".to_string());
            key
        },
        {
            let mut key = base.clone();
            key.rustup_home = root.path().join("other-rustup");
            key
        },
        {
            let mut key = base.clone();
            key.rustup_binary = root.path().join("other-rustup-bin");
            key
        },
    ];
    for variant in variants {
        assert_ne!(base, variant);
    }
}

#[test]
fn cargo_prepare_memo_rejects_changed_or_missing_toolchain() {
    let root = tempfile::tempdir().expect("temp dir");
    let key = test_memo_key(root.path());
    let toolchain = key.rustup_home.join("toolchains").join("1.94.1-test");
    std::fs::create_dir_all(toolchain.join("bin")).expect("create bin");
    std::fs::create_dir_all(toolchain.join("lib").join("rustlib")).expect("create rustlib");
    std::fs::write(&key.rustup_binary, b"rustup").expect("write rustup");
    std::fs::write(toolchain.join("bin").join("rustc"), b"rustc").expect("write rustc");
    let components = toolchain.join("lib").join("rustlib").join("components");
    std::fs::write(&components, b"rustc-test\n").expect("write components");

    let original = toolchain_identity(&key, &toolchain).expect("initial identity");
    filetime::set_file_mtime(
        &components,
        filetime::FileTime::from_unix_time(1_800_000_000, 0),
    )
    .expect("touch components without changing contents");
    let touched = toolchain_identity(&key, &toolchain).expect("touched identity");
    assert_eq!(
        original, touched,
        "content-hashed manifests must ignore mtime-only changes"
    );
    std::fs::write(&components, b"rustc-test\nrustfmt-preview-test\n").expect("change components");
    let changed = toolchain_identity(&key, &toolchain).expect("changed identity");
    assert_ne!(original, changed);

    std::fs::remove_dir_all(&toolchain).expect("remove fake toolchain");
    assert!(toolchain_identity(&key, &toolchain).is_none());
}

#[test]
fn cargo_prepare_memo_rejects_ambiguous_alias_toolchains() {
    let root = tempfile::tempdir().expect("temp dir");
    let key = test_memo_key(root.path());
    let paths = SoldrPaths::with_root(root.path().join("soldr"));
    std::fs::write(&key.rustup_binary, b"rustup").expect("write rustup");

    for host in ["x86_64-pc-windows-msvc", "x86_64-pc-windows-gnu"] {
        let toolchain = key
            .rustup_home
            .join("toolchains")
            .join(format!("1.94.1-{host}"));
        std::fs::create_dir_all(toolchain.join("bin")).expect("create bin");
        std::fs::create_dir_all(toolchain.join("lib").join("rustlib")).expect("create rustlib");
        std::fs::write(toolchain.join("bin").join("rustc"), host).expect("write rustc");
        std::fs::write(
            toolchain.join("lib").join("rustlib").join("components"),
            format!("rustc-{host}\n"),
        )
        .expect("write components");
    }

    let first = key
        .rustup_home
        .join("toolchains")
        .join("1.94.1-x86_64-pc-windows-msvc");
    write_cargo_prepare_memo(&paths, key.clone(), &first);
    assert!(
        memoized_toolchain_dir(&paths, &key).is_none(),
        "an alias with multiple installed hosts must prepare conservatively"
    );
}

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

    // soldr#1766. The gate refuses a build when no rust-toolchain.toml exists
    // at or above the working directory, instead of silently resolving rustc
    // from PATH.

    /// Clear *both* ways a build can be considered pinned or excused.
    ///
    /// `RUSTUP_TOOLCHAIN` has to be cleared too now that it counts as a pin:
    /// otherwise `unpinned_workspace_is_refused` and friends pass or fail
    /// depending on the ambient environment, and they would silently stop
    /// testing anything on a runner that exports it — which is precisely the
    /// thin-v2 lane that motivated this change.
    fn without_opt_out<T>(body: impl FnOnce() -> T) -> T {
        let _lock = crate::TEST_PROCESS_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let previous = std::env::var_os(ALLOW_UNPINNED_ENV_VAR);
        let previous_toolchain = std::env::var_os(RUSTUP_TOOLCHAIN_ENV_VAR);
        std::env::remove_var(ALLOW_UNPINNED_ENV_VAR);
        std::env::remove_var(RUSTUP_TOOLCHAIN_ENV_VAR);
        let out = body();
        match previous_toolchain {
            Some(value) => std::env::set_var(RUSTUP_TOOLCHAIN_ENV_VAR, value),
            None => std::env::remove_var(RUSTUP_TOOLCHAIN_ENV_VAR),
        }
        match previous {
            Some(value) => std::env::set_var(ALLOW_UNPINNED_ENV_VAR, value),
            None => std::env::remove_var(ALLOW_UNPINNED_ENV_VAR),
        }
        out
    }

    #[test]
    fn unpinned_workspace_is_refused() {
        let temp = tempfile::tempdir().expect("tempdir");
        let err = without_opt_out(|| require_toolchain_pin(temp.path()).unwrap_err());
        let rendered = err.to_string();
        assert!(
            rendered.contains("no rust-toolchain.toml found"),
            "error must name the missing pin: {rendered}"
        );
        assert!(
            rendered.contains("SOLDR_ALLOW_UNPINNED"),
            "error must tell the user how to opt out: {rendered}"
        );
    }

    #[test]
    fn pin_in_an_ancestor_satisfies_a_subdirectory_build() {
        // The trap this test exists for: reading only the cwd would reject
        // every build launched from a subdirectory of a pinned repo --
        // including this workspace's own tests, whose cwd is the package dir
        // while the pin lives at the workspace root.
        let temp = tempfile::tempdir().expect("tempdir");
        std::fs::write(
            temp.path().join("rust-toolchain.toml"),
            b"[toolchain]\nchannel = \"stable\"\n",
        )
        .expect("write pin");
        let nested = temp.path().join("crates").join("inner").join("src");
        std::fs::create_dir_all(&nested).expect("nested dirs");

        without_opt_out(|| {
            require_toolchain_pin(&nested).expect("an ancestor pin must satisfy the requirement")
        });
    }

    #[test]
    fn opt_out_env_var_permits_an_unpinned_build() {
        let temp = tempfile::tempdir().expect("tempdir");
        let _lock = crate::TEST_PROCESS_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let previous = std::env::var_os(ALLOW_UNPINNED_ENV_VAR);
        std::env::set_var(ALLOW_UNPINNED_ENV_VAR, "1");
        let result = require_toolchain_pin(temp.path());
        match previous {
            Some(value) => std::env::set_var(ALLOW_UNPINNED_ENV_VAR, value),
            None => std::env::remove_var(ALLOW_UNPINNED_ENV_VAR),
        }
        result.expect("SOLDR_ALLOW_UNPINNED must permit an unpinned build");
    }

    // soldr#1917 follow-up: RUSTUP_TOOLCHAIN is a pin, not an absence of one.

    #[test]
    fn an_explicit_rustup_toolchain_satisfies_the_pin_requirement() {
        // The thin-v2 verifier builds a synthesized crate under $RUNNER_TEMP,
        // outside any manifest, and selects the toolchain with
        // RUSTUP_TOOLCHAIN=1.94.1. That is a pinned build, and refusing it
        // sent the lane to the one workaround that is actively wrong:
        // SOLDR_ALLOW_UNPINNED=1, which disables the check for a caller who
        // *is* pinned.
        let temp = tempfile::tempdir().expect("tempdir");
        let _lock = crate::TEST_PROCESS_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let previous_opt_out = std::env::var_os(ALLOW_UNPINNED_ENV_VAR);
        let previous_toolchain = std::env::var_os(RUSTUP_TOOLCHAIN_ENV_VAR);
        std::env::remove_var(ALLOW_UNPINNED_ENV_VAR);
        std::env::set_var(RUSTUP_TOOLCHAIN_ENV_VAR, "1.94.1");

        let result = require_toolchain_pin(temp.path());

        match previous_opt_out {
            Some(value) => std::env::set_var(ALLOW_UNPINNED_ENV_VAR, value),
            None => std::env::remove_var(ALLOW_UNPINNED_ENV_VAR),
        }
        match previous_toolchain {
            Some(value) => std::env::set_var(RUSTUP_TOOLCHAIN_ENV_VAR, value),
            None => std::env::remove_var(RUSTUP_TOOLCHAIN_ENV_VAR),
        }
        result.expect("an explicitly selected rustup toolchain is a pin");
    }

    #[test]
    fn a_blank_rustup_toolchain_is_not_a_pin() {
        // Exported-but-empty is how a shell leaves a variable it meant to
        // unset. It selects nothing, so it must not read as a pin -- same
        // reasoning as `explicit_falsey_opt_out_still_requires_a_pin`.
        let temp = tempfile::tempdir().expect("tempdir");
        let _lock = crate::TEST_PROCESS_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let previous_opt_out = std::env::var_os(ALLOW_UNPINNED_ENV_VAR);
        let previous_toolchain = std::env::var_os(RUSTUP_TOOLCHAIN_ENV_VAR);
        std::env::remove_var(ALLOW_UNPINNED_ENV_VAR);

        let mut outcomes = Vec::new();
        for blank in ["", "   "] {
            std::env::set_var(RUSTUP_TOOLCHAIN_ENV_VAR, blank);
            outcomes.push((blank, require_toolchain_pin(temp.path()).is_err()));
        }

        match previous_opt_out {
            Some(value) => std::env::set_var(ALLOW_UNPINNED_ENV_VAR, value),
            None => std::env::remove_var(ALLOW_UNPINNED_ENV_VAR),
        }
        match previous_toolchain {
            Some(value) => std::env::set_var(RUSTUP_TOOLCHAIN_ENV_VAR, value),
            None => std::env::remove_var(RUSTUP_TOOLCHAIN_ENV_VAR),
        }
        for (blank, refused) in outcomes {
            assert!(
                refused,
                "RUSTUP_TOOLCHAIN={blank:?} must not count as a pin"
            );
        }
    }

    #[test]
    fn explicit_falsey_opt_out_still_requires_a_pin() {
        // An exported-but-disabled switch must not read as consent.
        let temp = tempfile::tempdir().expect("tempdir");
        let _lock = crate::TEST_PROCESS_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let previous = std::env::var_os(ALLOW_UNPINNED_ENV_VAR);
        // Also clear RUSTUP_TOOLCHAIN: it now satisfies the pin on its own, so
        // an ambient one would make these calls succeed and this test would
        // fail for a reason unrelated to the opt-out switch it is checking.
        let previous_toolchain = std::env::var_os(RUSTUP_TOOLCHAIN_ENV_VAR);
        std::env::remove_var(RUSTUP_TOOLCHAIN_ENV_VAR);
        let mut outcomes = Vec::new();
        for disabled in ["0", "false", "no", "off", ""] {
            std::env::set_var(ALLOW_UNPINNED_ENV_VAR, disabled);
            outcomes.push((disabled, require_toolchain_pin(temp.path()).is_err()));
        }
        match previous {
            Some(value) => std::env::set_var(ALLOW_UNPINNED_ENV_VAR, value),
            None => std::env::remove_var(ALLOW_UNPINNED_ENV_VAR),
        }
        match previous_toolchain {
            Some(value) => std::env::set_var(RUSTUP_TOOLCHAIN_ENV_VAR, value),
            None => std::env::remove_var(RUSTUP_TOOLCHAIN_ENV_VAR),
        }
        for (disabled, refused) in outcomes {
            assert!(
                refused,
                "SOLDR_ALLOW_UNPINNED={disabled:?} must not count as opting out"
            );
        }
    }
}
