ccc 0.1.0

Call coding CLIs (opencode, claude, codex, kimi, etc.) from Rust programs
Documentation
use call_coding_clis::{find_config_command_path, load_config, render_example_config};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

fn example_config_fixture() -> String {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../tests/fixtures/config-example.toml");
    fs::read_to_string(path).unwrap()
}

#[test]
fn test_load_config_parses_alias_agent() {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let base_dir = std::env::temp_dir().join(format!("ccc-rust-config-{unique}"));
    let config_path = base_dir.join("config.toml");
    fs::create_dir_all(&base_dir).unwrap();
    fs::write(
        &config_path,
        r#"
[defaults]
runner = "cc"
provider = "anthropic"
model = "claude-4"
output_mode = "stream-formatted"
thinking = 2
show_thinking = true
sanitize_osc = false

[abbreviations]
mycc = "cc"

[aliases.work]
runner = "cc"
thinking = 3
show_thinking = true
sanitize_osc = false
output_mode = "formatted"
model = "claude-4"
agent = "reviewer"

[aliases.quick]
runner = "oc"

[aliases.commit]
prompt = "Commit all changes"
prompt_mode = "append"
"#,
    )
    .unwrap();

    let config = load_config(Some(&config_path));

    assert_eq!(config.default_runner, "cc");
    assert_eq!(config.default_provider, "anthropic");
    assert_eq!(config.default_model, "claude-4");
    assert_eq!(config.default_output_mode, "stream-formatted");
    assert_eq!(config.default_thinking, Some(2));
    assert!(config.default_show_thinking);
    assert_eq!(config.default_sanitize_osc, Some(false));
    assert_eq!(
        config.abbreviations.get("mycc").map(|s| s.as_str()),
        Some("cc")
    );
    let work = config.aliases.get("work").unwrap();
    assert_eq!(work.runner.as_deref(), Some("cc"));
    assert_eq!(work.thinking, Some(3));
    assert_eq!(work.show_thinking, Some(true));
    assert_eq!(work.sanitize_osc, Some(false));
    assert_eq!(work.output_mode.as_deref(), Some("formatted"));
    assert_eq!(work.model.as_deref(), Some("claude-4"));
    assert_eq!(work.agent.as_deref(), Some("reviewer"));
    let quick = config.aliases.get("quick").unwrap();
    assert_eq!(quick.runner.as_deref(), Some("oc"));
    let commit = config.aliases.get("commit").unwrap();
    assert_eq!(commit.prompt.as_deref(), Some("Commit all changes"));
    assert_eq!(commit.prompt_mode.as_deref(), Some("append"));
}

#[test]
fn test_render_example_config_matches_fixture() {
    assert_eq!(render_example_config(), example_config_fixture());
}

#[test]
fn test_legacy_default_keys_are_ignored() {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let base_dir = std::env::temp_dir().join(format!("ccc-rust-config-legacy-{unique}"));
    let config_path = base_dir.join("config.toml");
    fs::create_dir_all(&base_dir).unwrap();
    fs::write(
        &config_path,
        r#"
default_runner = "cc"
default_provider = "anthropic"
default_model = "claude-4"
default_output_mode = "json"
default_thinking = 4
default_show_thinking = true
default_sanitize_osc = false
"#,
    )
    .unwrap();

    let config = load_config(Some(&config_path));

    assert_eq!(config.default_runner, "oc");
    assert_eq!(config.default_provider, "");
    assert_eq!(config.default_model, "");
    assert_eq!(config.default_output_mode, "text");
    assert_eq!(config.default_thinking, None);
    assert!(!config.default_show_thinking);
    assert_eq!(config.default_sanitize_osc, None);
}

#[test]
fn test_singular_alias_section_is_ignored() {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let base_dir = std::env::temp_dir().join(format!("ccc-rust-config-singular-alias-{unique}"));
    let config_path = base_dir.join("config.toml");
    fs::create_dir_all(&base_dir).unwrap();
    fs::write(
        &config_path,
        r#"
[alias.work]
runner = "cc"
"#,
    )
    .unwrap();

    let config = load_config(Some(&config_path));

    assert!(config.aliases.is_empty());
}

#[test]
fn test_find_config_command_path_prefers_explicit_ccc_config() {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let base_dir = std::env::temp_dir().join(format!("ccc-rust-config-command-explicit-{unique}"));
    let explicit_path = base_dir.join("explicit.toml");
    fs::create_dir_all(&base_dir).unwrap();
    fs::write(&explicit_path, "[defaults]\nrunner = \"cc\"\n").unwrap();

    let old_env = std::env::var("CCC_CONFIG").ok();
    unsafe { std::env::set_var("CCC_CONFIG", &explicit_path) };
    let resolved = find_config_command_path();
    if let Some(value) = old_env {
        unsafe { std::env::set_var("CCC_CONFIG", value) };
    } else {
        unsafe { std::env::remove_var("CCC_CONFIG") };
    }

    assert_eq!(resolved, Some(explicit_path));
}

#[test]
fn test_find_config_command_path_prefers_project_local_then_xdg_then_home() {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let base_dir = std::env::temp_dir().join(format!("ccc-rust-config-command-order-{unique}"));
    let home_root = base_dir.join("home");
    let xdg_root = base_dir.join("xdg");
    let repo_root = base_dir.join("repo");
    let nested_cwd = repo_root.join("nested");
    fs::create_dir_all(&nested_cwd).unwrap();

    let project_path = repo_root.join(".ccc.toml");
    let xdg_path = xdg_root.join("ccc/config.toml");
    let home_path = home_root.join(".config/ccc/config.toml");
    fs::write(&project_path, "[defaults]\nrunner = \"cc\"\n").unwrap();
    fs::create_dir_all(xdg_path.parent().unwrap()).unwrap();
    fs::write(&xdg_path, "[defaults]\nrunner = \"k\"\n").unwrap();
    fs::create_dir_all(home_path.parent().unwrap()).unwrap();
    fs::write(&home_path, "[defaults]\nrunner = \"oc\"\n").unwrap();

    let old_cwd = std::env::current_dir().unwrap();
    let old_home = std::env::var("HOME").ok();
    let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
    let old_explicit = std::env::var("CCC_CONFIG").ok();

    std::env::set_current_dir(&nested_cwd).unwrap();
    unsafe { std::env::set_var("HOME", &home_root) };
    unsafe { std::env::set_var("XDG_CONFIG_HOME", &xdg_root) };
    unsafe { std::env::remove_var("CCC_CONFIG") };

    let resolved = find_config_command_path();

    std::env::set_current_dir(old_cwd).unwrap();
    if let Some(value) = old_home {
        unsafe { std::env::set_var("HOME", value) };
    } else {
        unsafe { std::env::remove_var("HOME") };
    }
    if let Some(value) = old_xdg {
        unsafe { std::env::set_var("XDG_CONFIG_HOME", value) };
    } else {
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
    }
    if let Some(value) = old_explicit {
        unsafe { std::env::set_var("CCC_CONFIG", value) };
    } else {
        unsafe { std::env::remove_var("CCC_CONFIG") };
    }

    assert_eq!(resolved, Some(project_path));
}

#[test]
fn test_find_config_command_path_falls_back_when_ccc_config_is_missing() {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let base_dir =
        std::env::temp_dir().join(format!("ccc-rust-config-command-fallback-{unique}"));
    let home_root = base_dir.join("home");
    let xdg_root = base_dir.join("xdg");
    let xdg_path = xdg_root.join("ccc/config.toml");
    let missing_path = base_dir.join("missing.toml");

    fs::create_dir_all(xdg_path.parent().unwrap()).unwrap();
    fs::write(&xdg_path, "[defaults]\nrunner = \"k\"\n").unwrap();

    let old_home = std::env::var("HOME").ok();
    let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
    let old_explicit = std::env::var("CCC_CONFIG").ok();

    unsafe { std::env::set_var("HOME", &home_root) };
    unsafe { std::env::set_var("XDG_CONFIG_HOME", &xdg_root) };
    unsafe { std::env::set_var("CCC_CONFIG", &missing_path) };

    let resolved = find_config_command_path();

    if let Some(value) = old_home {
        unsafe { std::env::set_var("HOME", value) };
    } else {
        unsafe { std::env::remove_var("HOME") };
    }
    if let Some(value) = old_xdg {
        unsafe { std::env::set_var("XDG_CONFIG_HOME", value) };
    } else {
        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
    }
    if let Some(value) = old_explicit {
        unsafe { std::env::set_var("CCC_CONFIG", value) };
    } else {
        unsafe { std::env::remove_var("CCC_CONFIG") };
    }

    assert_eq!(resolved, Some(xdg_path));
}