nils-codex-cli 0.7.3

CLI crate for nils-codex-cli in the nils-cli workspace.
Documentation
use nils_common::process as shared_process;
use nils_test_support::bin;
use nils_test_support::cmd::{self, CmdOptions, CmdOutput};
use nils_test_support::fs as test_fs;
use nils_test_support::git as test_git;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::{Path, PathBuf};

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

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

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

fn assert_exit(output: &CmdOutput, code: i32) {
    assert_eq!(
        output.code,
        code,
        "unexpected exit code.\nstdout:\n{}\nstderr:\n{}",
        stdout(output),
        stderr(output)
    );
}

fn real_git_path() -> String {
    shared_process::find_in_path("git")
        .map(|path| path.to_string_lossy().to_string())
        .unwrap_or_else(|| panic!("git not found in PATH for tests"))
}

fn write_stub_git(dir: &Path) {
    let git = real_git_path();
    let script = format!(
        r#"#!/bin/sh
exec "{git}" "$@"
"#
    );
    let path = dir.join("git");
    test_fs::write_executable(&path, &script);
}

fn write_stub_semantic_commit(dir: &Path) {
    let script = r#"#!/bin/sh
exit 0
"#;
    let path = dir.join("semantic-commit");
    test_fs::write_executable(&path, script);
}

fn write_stub_codex(dir: &Path) {
    let script = r#"#!/bin/bash
set -euo pipefail
out_dir="${CODEX_STUB_OUT_DIR:?missing CODEX_STUB_OUT_DIR}"
i=0
for arg in "$@"; do
  printf '%s' "$arg" > "$out_dir/arg-$i"
  i=$((i+1))
done
"#;
    let path = dir.join("codex");
    test_fs::write_executable(&path, script);
}

fn init_repo(dir: &Path) {
    test_git::git(dir, &["init"]);
    test_git::git(dir, &["config", "user.name", "Test User"]);
    test_git::git(dir, &["config", "user.email", "test@example.com"]);
    test_git::git(dir, &["config", "commit.gpgsign", "false"]);
}

#[test]
fn agent_commit_fallback_creates_commit() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let repo = dir.path().join("repo");
    fs::create_dir_all(&repo).expect("repo dir");
    init_repo(&repo);

    fs::write(repo.join("a.txt"), "hello").expect("write file");
    test_git::git(&repo, &["add", "a.txt"]);

    let stub_dir = dir.path().join("bin");
    fs::create_dir_all(&stub_dir).expect("stub dir");
    write_stub_git(&stub_dir);

    let stub_path = stub_dir.to_string_lossy().to_string();
    let options = CmdOptions::default()
        .with_cwd(&repo)
        .with_env("PATH", &stub_path)
        .with_stdin_bytes(b"\n\nmy subject\ny\n");
    let bin = codex_cli_bin();
    let output = cmd::run_with(&bin, &["agent", "commit"], &options);
    assert_exit(&output, 0);
    assert!(stderr(&output).contains("fallback mode"));

    assert_eq!(
        test_git::git(&repo, &["log", "-1", "--pretty=%s"]).trim(),
        "chore: my subject"
    );
}

#[test]
fn agent_commit_semantic_mode_executes_codex_with_template_and_push_note() {
    let dir = tempfile::TempDir::new().expect("tempdir");
    let repo = dir.path().join("repo");
    fs::create_dir_all(&repo).expect("repo dir");
    init_repo(&repo);

    fs::write(repo.join("a.txt"), "hello").expect("write file");

    let zdotdir = dir.path().join("zdotdir");
    let prompts_dir = zdotdir.join("prompts");
    fs::create_dir_all(&prompts_dir).expect("prompts dir");
    fs::write(
        prompts_dir.join("semantic-commit-autostage.md"),
        "SEMANTIC_AUTOSTAGE\n",
    )
    .expect("write template");

    let stub_dir = dir.path().join("bin");
    fs::create_dir_all(&stub_dir).expect("stub dir");
    write_stub_git(&stub_dir);
    write_stub_semantic_commit(&stub_dir);
    write_stub_codex(&stub_dir);

    let out_dir = dir.path().join("out");
    fs::create_dir_all(&out_dir).expect("out dir");
    let out_dir_str = out_dir.to_string_lossy().to_string();
    let stub_path = stub_dir.to_string_lossy().to_string();
    let zdotdir_str = zdotdir.to_string_lossy().to_string();
    let options = CmdOptions::default()
        .with_cwd(&repo)
        .with_env("PATH", &stub_path)
        .with_env("CODEX_ALLOW_DANGEROUS_ENABLED", "true")
        .with_env("CODEX_CLI_MODEL", "m-test")
        .with_env("CODEX_CLI_REASONING", "low")
        .with_env("ZDOTDIR", &zdotdir_str)
        .with_env("CODEX_STUB_OUT_DIR", &out_dir_str);
    let bin = codex_cli_bin();
    let output = cmd::run_with(
        &bin,
        &[
            "agent",
            "commit",
            "--ephemeral",
            "--push",
            "--auto-stage",
            "extra",
            "words",
        ],
        &options,
    );
    assert_exit(&output, 0);

    let ephemeral = fs::read_to_string(out_dir.join("arg-8")).expect("ephemeral");
    assert_eq!(ephemeral, "--ephemeral");
    let prompt = fs::read_to_string(out_dir.join("arg-10")).expect("prompt");
    assert!(prompt.contains("SEMANTIC_AUTOSTAGE"));
    assert!(prompt.contains("Furthermore, please push the committed changes"));
    assert!(prompt.contains("Additional instructions from user:"));
    assert!(prompt.contains("extra words"));

    let arg5 = fs::read_to_string(out_dir.join("arg-5")).expect("model");
    assert_eq!(arg5, "m-test");
    let arg7 = fs::read_to_string(out_dir.join("arg-7")).expect("reasoning");
    assert_eq!(arg7, "model_reasoning_effort=\"low\"");
}