claudecode 0.1.20

A Rust SDK for programmatically interacting with Claude Code
Documentation
#![expect(clippy::unwrap_used, reason = "Tests should panic on failure")]

use claudecode::Client;
use claudecode::OutputFormat;
use claudecode::SessionConfig;
use nix::unistd::Pid;
use serial_test::serial;
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use std::time::Instant;
use tempfile::TempDir;
use tokio::process::Child;
use tokio::process::Command;

#[derive(Debug, serde::Deserialize)]
struct PidInfo {
    parent_pid: u32,
    child_pid: u32,
}

fn fake_claude_path() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_fake_claude"))
}

fn pid_file(temp_dir: &TempDir) -> PathBuf {
    temp_dir.path().join("fake-clause-pids.json")
}

fn config(output_format: OutputFormat, query: &str, pid_file: &Path) -> SessionConfig {
    SessionConfig::builder(query)
        .output_format(output_format)
        .env_var("FAKE_CLAUDE_PID_FILE", pid_file.display().to_string())
        .build()
        .unwrap()
}

fn spawn_fake_claude(query: &str, pid_file: &Path, force_error_after_spawn: bool) -> Child {
    let mut cmd = Command::new(fake_claude_path());
    cmd.arg("--output-format")
        .arg("text")
        .arg("--")
        .arg(query)
        .env("FAKE_CLAUDE_PID_FILE", pid_file.display().to_string())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    if force_error_after_spawn {
        cmd.env("FAKE_CLAUDE_FORCE_ERROR_AFTER_SPAWN", "1");
    }

    cmd.spawn().unwrap()
}

async fn wait_for_pid_info(pid_file: &PathBuf) -> PidInfo {
    for _ in 0..100 {
        if let Ok(bytes) = tokio::fs::read(pid_file).await {
            return serde_json::from_slice(&bytes).unwrap();
        }
        tokio::time::sleep(Duration::from_millis(20)).await;
    }
    panic!("timed out waiting for fake claude pid file");
}

async fn wait_for_process_exit(pid: u32) {
    for _ in 0..200 {
        let exists = match nix::sys::signal::kill(Pid::from_raw(pid.cast_signed()), None) {
            Err(nix::errno::Errno::ESRCH) => false,
            Ok(()) | Err(_) => true,
        };
        if !exists {
            return;
        }
        tokio::time::sleep(Duration::from_millis(20)).await;
    }
    panic!("process {pid} did not exit in time");
}

#[tokio::test]
#[serial]
async fn dropping_launch_and_wait_kills_fake_claude_and_child() {
    let temp_dir = TempDir::new().unwrap();
    let pid_path = pid_file(&temp_dir);
    let client = Client::with_path(fake_claude_path()).await.unwrap();
    let cfg = config(OutputFormat::Text, "drop test", &pid_path);

    let task = tokio::spawn(async move { client.launch_and_wait(cfg).await });
    let pids = wait_for_pid_info(&pid_path).await;
    task.abort();
    let _ = task.await;

    wait_for_process_exit(pids.parent_pid).await;
    wait_for_process_exit(pids.child_pid).await;
}

#[tokio::test]
#[serial]
async fn session_kill_works_after_worker_startup() {
    let temp_dir = TempDir::new().unwrap();
    let pid_path = pid_file(&temp_dir);
    let client = Client::with_path(fake_claude_path()).await.unwrap();
    let cfg = config(OutputFormat::Text, "kill test", &pid_path);

    let mut session = client.launch(cfg).await.unwrap();
    let pids = wait_for_pid_info(&pid_path).await;
    tokio::time::sleep(Duration::from_millis(100)).await;

    session.kill().await.unwrap();

    wait_for_process_exit(pids.parent_pid).await;
    wait_for_process_exit(pids.child_pid).await;
}

#[tokio::test]
#[serial]
async fn fake_claude_reaps_helper_child_after_normal_exit() {
    let temp_dir = TempDir::new().unwrap();
    let pid_path = pid_file(&temp_dir);
    let child = spawn_fake_claude("normal exit test", &pid_path, false);
    let pids = wait_for_pid_info(&pid_path).await;

    nix::sys::signal::kill(
        Pid::from_raw(pids.parent_pid.cast_signed()),
        Some(nix::sys::signal::Signal::SIGTERM),
    )
    .unwrap();

    let output = child.wait_with_output().await.unwrap();
    assert!(output.status.success());
    assert_eq!(
        String::from_utf8(output.stdout).unwrap(),
        "fake text output"
    );

    wait_for_process_exit(pids.parent_pid).await;
    wait_for_process_exit(pids.child_pid).await;
}

#[tokio::test]
#[serial]
async fn fake_claude_reaps_helper_child_after_forced_error_exit() {
    let temp_dir = TempDir::new().unwrap();
    let pid_path = pid_file(&temp_dir);
    let child = spawn_fake_claude("forced error test", &pid_path, true);
    let pids = wait_for_pid_info(&pid_path).await;

    let output = child.wait_with_output().await.unwrap();
    assert!(!output.status.success());
    assert!(
        String::from_utf8(output.stderr)
            .unwrap()
            .contains("forced fake_claude error after helper spawn")
    );

    wait_for_process_exit(pids.parent_pid).await;
    wait_for_process_exit(pids.child_pid).await;
}

#[tokio::test]
#[serial]
async fn fake_claude_runs_in_separate_process_group() {
    let temp_dir = TempDir::new().unwrap();
    let pid_path = pid_file(&temp_dir);
    let client = Client::with_path(fake_claude_path()).await.unwrap();
    let cfg = config(OutputFormat::Text, "pgid test", &pid_path);

    let session = client.launch(cfg).await.unwrap();
    let pids = wait_for_pid_info(&pid_path).await;

    let test_pgid = nix::unistd::getpgid(None).unwrap();
    let fake_pgid =
        nix::unistd::getpgid(Some(Pid::from_raw(pids.parent_pid.cast_signed()))).unwrap();
    assert_ne!(fake_pgid, test_pgid);

    session.cancel().await.unwrap();
    wait_for_process_exit(pids.parent_pid).await;
    wait_for_process_exit(pids.child_pid).await;
}

#[tokio::test]
#[serial]
async fn fake_claude_child_shares_parent_process_group() {
    let temp_dir = TempDir::new().unwrap();
    let pid_path = pid_file(&temp_dir);
    let client = Client::with_path(fake_claude_path()).await.unwrap();
    let cfg = config(OutputFormat::Text, "child pgid test", &pid_path);

    let session = client.launch(cfg).await.unwrap();
    let pids = wait_for_pid_info(&pid_path).await;

    let parent_pgid =
        nix::unistd::getpgid(Some(Pid::from_raw(pids.parent_pid.cast_signed()))).unwrap();
    let child_pgid =
        nix::unistd::getpgid(Some(Pid::from_raw(pids.child_pid.cast_signed()))).unwrap();
    assert_eq!(parent_pgid, child_pgid);

    session.cancel().await.unwrap();
    wait_for_process_exit(pids.parent_pid).await;
    wait_for_process_exit(pids.child_pid).await;
}

#[tokio::test]
#[serial]
async fn session_cancel_completes_within_bound_for_hung_process() {
    let temp_dir = TempDir::new().unwrap();
    let pid_path = pid_file(&temp_dir);
    let client = Client::with_path(fake_claude_path()).await.unwrap();
    let cfg = config(OutputFormat::Text, "[hang] cancel test", &pid_path);

    let session = client.launch(cfg).await.unwrap();
    let pids = wait_for_pid_info(&pid_path).await;

    let started = Instant::now();
    session.cancel().await.unwrap();
    assert!(started.elapsed() < Duration::from_secs(2));

    wait_for_process_exit(pids.parent_pid).await;
    wait_for_process_exit(pids.child_pid).await;
}