nils-codex-cli 0.7.3

CLI crate for nils-codex-cli in the nils-cli workspace.
Documentation
use nils_test_support::bin;
use nils_test_support::cmd::{self, CmdOptions, CmdOutput};
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};

fn codex_cli_bin() -> PathBuf {
    bin::resolve("codex-cli")
}

fn run_with(args: &[&str], envs: &[(&str, &Path)], vars: &[(&str, &str)]) -> CmdOutput {
    let mut options = CmdOptions::default();
    for (key, path) in envs {
        options = options.with_env(key, path.to_string_lossy().as_ref());
    }
    for (key, value) in vars {
        options = options.with_env(key, value);
    }
    let bin = codex_cli_bin();
    cmd::run_with(&bin, args, &options)
}

fn stdout(output: &CmdOutput) -> String {
    output.stdout_text()
}

fn stderr(output: &CmdOutput) -> String {
    output.stderr_text()
}

#[test]
fn auth_save_requires_file_name() {
    let output = run_with(&["auth", "save"], &[], &[]);
    assert_eq!(output.code, 64);
    assert!(stderr(&output).contains("usage"));
}

#[test]
fn auth_save_rejects_path_traversal() {
    let output = run_with(&["auth", "save", "../bad.json"], &[], &[]);
    assert_eq!(output.code, 64);
    assert!(stderr(&output).contains("invalid secret file name"));
}

#[test]
fn auth_save_errors_when_secret_dir_missing() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let auth_file = dir.path().join("auth.json");
    fs::write(&auth_file, r#"{"tokens":{"access_token":"tok"}}"#).expect("write auth");

    let output = run_with(
        &["auth", "save", "alpha.json"],
        &[("CODEX_AUTH_FILE", &auth_file)],
        &[("CODEX_SECRET_DIR", "")],
    );
    assert_eq!(output.code, 1);
    assert!(stderr(&output).contains("CODEX_SECRET_DIR is not configured"));
}

#[test]
fn auth_save_keeps_env_only_contract_even_if_home_secret_dir_exists() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let home = dir.path().join("home");
    let fallback_secret_dir = home.join(".config").join("codex_secrets");
    fs::create_dir_all(&fallback_secret_dir).expect("fallback secret dir");

    let auth_file = dir.path().join("auth.json");
    fs::write(&auth_file, r#"{"tokens":{"access_token":"tok"}}"#).expect("write auth");

    let output = run_with(
        &["auth", "save", "alpha.json"],
        &[("CODEX_AUTH_FILE", &auth_file), ("HOME", &home)],
        &[("CODEX_SECRET_DIR", "")],
    );

    assert_eq!(output.code, 1);
    assert!(stderr(&output).contains("CODEX_SECRET_DIR is not configured"));
    assert!(
        !fallback_secret_dir.join("alpha.json").exists(),
        "save must not use HOME fallback when CODEX_SECRET_DIR is empty"
    );
}

#[test]
fn auth_save_errors_when_auth_file_missing() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let secrets = dir.path().join("secrets");
    fs::create_dir_all(&secrets).expect("secrets");
    let missing_auth = dir.path().join("missing-auth.json");

    let output = run_with(
        &["auth", "save", "alpha.json"],
        &[
            ("CODEX_AUTH_FILE", &missing_auth),
            ("CODEX_SECRET_DIR", &secrets),
        ],
        &[],
    );
    assert_eq!(output.code, 1);
    assert!(stderr(&output).contains("auth file not found"));
}

#[test]
fn auth_save_writes_target_file() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let secrets = dir.path().join("secrets");
    fs::create_dir_all(&secrets).expect("secrets");
    let auth_file = dir.path().join("auth.json");
    fs::write(
        &auth_file,
        r#"{"tokens":{"access_token":"tok","refresh_token":"refresh"},"last_refresh":"2025-01-20T00:00:00Z"}"#,
    )
    .expect("write auth");

    let output = run_with(
        &["auth", "save", "alpha.json"],
        &[
            ("CODEX_AUTH_FILE", &auth_file),
            ("CODEX_SECRET_DIR", &secrets),
        ],
        &[],
    );
    assert_eq!(output.code, 0);
    assert!(stdout(&output).contains("codex: saved"));
    assert_eq!(
        fs::read_to_string(secrets.join("alpha.json")).expect("read saved"),
        fs::read_to_string(&auth_file).expect("read auth")
    );
}

#[test]
fn auth_save_appends_json_suffix_when_missing() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let secrets = dir.path().join("secrets");
    fs::create_dir_all(&secrets).expect("secrets");
    let auth_file = dir.path().join("auth.json");
    fs::write(
        &auth_file,
        r#"{"tokens":{"access_token":"tok","refresh_token":"refresh"}}"#,
    )
    .expect("write auth");

    let output = run_with(
        &["auth", "save", "alpha"],
        &[
            ("CODEX_AUTH_FILE", &auth_file),
            ("CODEX_SECRET_DIR", &secrets),
        ],
        &[],
    );
    assert_eq!(output.code, 0);
    assert!(secrets.join("alpha.json").is_file());
    assert!(!secrets.join("alpha").is_file());
}

#[test]
fn auth_save_overwrite_prompt_default_no_in_non_tty_mode() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let secrets = dir.path().join("secrets");
    fs::create_dir_all(&secrets).expect("secrets");
    let auth_file = dir.path().join("auth.json");
    let target = secrets.join("alpha.json");
    fs::write(&auth_file, r#"{"tokens":{"access_token":"new"}}"#).expect("write auth");
    fs::write(&target, r#"{"tokens":{"access_token":"old"}}"#).expect("write target");

    let output = run_with(
        &["auth", "save", "alpha.json"],
        &[
            ("CODEX_AUTH_FILE", &auth_file),
            ("CODEX_SECRET_DIR", &secrets),
        ],
        &[],
    );
    assert_eq!(output.code, 1);
    assert!(stderr(&output).contains("rerun with --yes"));
    assert_eq!(
        fs::read_to_string(&target).expect("read target"),
        r#"{"tokens":{"access_token":"old"}}"#
    );
}

#[test]
fn auth_save_overwrite_yes_bypasses_prompt() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let secrets = dir.path().join("secrets");
    fs::create_dir_all(&secrets).expect("secrets");
    let auth_file = dir.path().join("auth.json");
    let target = secrets.join("alpha.json");
    fs::write(&auth_file, r#"{"tokens":{"access_token":"new"}}"#).expect("write auth");
    fs::write(&target, r#"{"tokens":{"access_token":"old"}}"#).expect("write target");

    let output = run_with(
        &["auth", "save", "--yes", "alpha.json"],
        &[
            ("CODEX_AUTH_FILE", &auth_file),
            ("CODEX_SECRET_DIR", &secrets),
        ],
        &[],
    );
    assert_eq!(output.code, 0);
    assert!(stdout(&output).contains("(overwritten)"));
    assert_eq!(
        fs::read_to_string(&target).expect("read target"),
        r#"{"tokens":{"access_token":"new"}}"#
    );
}

#[test]
fn auth_save_json_overwrite_requires_confirmation() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let secrets = dir.path().join("secrets");
    fs::create_dir_all(&secrets).expect("secrets");
    let auth_file = dir.path().join("auth.json");
    let target = secrets.join("alpha.json");
    fs::write(&auth_file, r#"{"tokens":{"access_token":"new"}}"#).expect("write auth");
    fs::write(&target, r#"{"tokens":{"access_token":"old"}}"#).expect("write target");

    let output = run_with(
        &["auth", "save", "--json", "alpha.json"],
        &[
            ("CODEX_AUTH_FILE", &auth_file),
            ("CODEX_SECRET_DIR", &secrets),
        ],
        &[],
    );
    assert_eq!(output.code, 1);
    let payload: Value = serde_json::from_str(&stdout(&output)).expect("json");
    assert_eq!(payload["ok"], false);
    assert_eq!(payload["error"]["code"], "overwrite-confirmation-required");
}