malvin 0.2.4

Non-interactive research and coding agent
#[cfg(unix)]
mod common;

use std::process::Command;

#[cfg(unix)]
use common::{MALVIN_TEST_CMD_TIMEOUT, command_output_with_timeout};

#[cfg(unix)]
fn clear_agent_api_env(cmd: &mut Command) {
    cmd.env_remove("CURSOR_AGENT_API_KEY")
        .env_remove("CURSOR_API_KEY")
        .env_remove("AGENT_API_KEY")
        .env_remove("MALVIN_AGENT_ACP_BIN");
}

#[cfg(unix)]
fn run_malvin_path_timed(
    path_bin: &std::path::Path,
    configure: impl FnOnce(&mut Command),
) -> std::process::Output {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_malvin"));
    cmd.env("PATH", path_bin);
    configure(&mut cmd);
    command_output_with_timeout(&mut cmd, MALVIN_TEST_CMD_TIMEOUT).expect("spawn malvin")
}

fn isolated_path_and_home() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
    let path_root = tempfile::tempdir().unwrap();
    let isolated_bin = path_root.path().join("bin");
    std::fs::create_dir_all(&isolated_bin).unwrap();
    let isolated_home = path_root.path().join("home");
    std::fs::create_dir_all(&isolated_home).unwrap();
    (path_root, isolated_bin, isolated_home)
}

fn assert_auth_failure_not_kiss_precheck(out: &std::process::Output) {
    assert!(
        !out.status.success(),
        "expected non-zero exit; stdout/stderr: {out:?}"
    );
    let msg = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        msg.contains("not authenticated") && msg.contains("CURSOR_AGENT_API_KEY"),
        "expected auth failure path (not kiss precheck); got: {msg:?}"
    );
    assert!(
        !msg.contains("cargo install kiss-ai")
            && !msg.contains("`kiss` is not installed or not on PATH"),
        "expected auth failure path for no-kiss-gate subcommand; got: {msg:?}"
    );
}

fn assert_malvin_subcommand_not_kiss_gated_without_auth(
    args: &[&str],
    work_dir: Option<&std::path::Path>,
) {
    let (_root, isolated_bin, isolated_home) = isolated_path_and_home();
    #[cfg(unix)]
    let out = run_malvin_path_timed(&isolated_bin, |c| {
        clear_agent_api_env(c);
        c.env("HOME", &isolated_home);
        if let Some(work_dir) = work_dir {
            c.current_dir(work_dir);
        }
        c.args(args);
    });
    #[cfg(not(unix))]
    let out = Command::new(env!("CARGO_BIN_EXE_malvin"))
        .env("PATH", &isolated_bin)
        .env("HOME", &isolated_home)
        .env_remove("CURSOR_AGENT_API_KEY")
        .env_remove("CURSOR_API_KEY")
        .env_remove("AGENT_API_KEY")
        .env_remove("MALVIN_AGENT_ACP_BIN")
        .args(args)
        .output()
        .expect("spawn malvin");
    assert_auth_failure_not_kiss_precheck(&out);
}

#[test]
fn malvin_gates_only_is_not_kiss_gated_when_kiss_missing_from_path() {
    let work = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(work.path().join(".git")).unwrap();
    assert_malvin_subcommand_not_kiss_gated_without_auth(&["-g"], Some(work.path()));
}

#[test]
fn write_skips_external_linter_preflight() {
    let work = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(work.path().join(".git")).unwrap();
    assert_malvin_subcommand_not_kiss_gated_without_auth(&["write", "topic"], Some(work.path()));
}

#[test]
fn malvin_do_is_not_kiss_gated_when_kiss_missing_from_path() {
    assert_malvin_subcommand_not_kiss_gated_without_auth(&["--do", "hello"], None);
}