ccc 0.1.0

Call coding CLIs (opencode, claude, codex, kimi, etc.) from Rust programs
Documentation
use std::path::Path;
use std::path::PathBuf;

use crate::parser::CccConfig;

const EXAMPLE_CONFIG: &str = concat!(
    "# Generated by `ccc --print-config`.\n",
    "# Copy this file to ~/.config/ccc/config.toml and uncomment the settings you want.\n",
    "\n",
    "[defaults]\n",
    "# runner = \"oc\"\n",
    "# provider = \"anthropic\"\n",
    "# model = \"claude-4\"\n",
    "# thinking = 2\n",
    "# show_thinking = false\n",
    "# sanitize_osc = true\n",
    "# output_mode = \"text\"\n",
    "\n",
    "[abbreviations]\n",
    "# mycc = \"cc\"\n",
    "\n",
    "[aliases.reviewer]\n",
    "# runner = \"cc\"\n",
    "# provider = \"anthropic\"\n",
    "# model = \"claude-4\"\n",
    "# thinking = 3\n",
    "# show_thinking = true\n",
    "# sanitize_osc = true\n",
    "# output_mode = \"formatted\"\n",
    "# agent = \"reviewer\"\n",
    "# prompt = \"Review the current changes\"\n",
    "# prompt_mode = \"default\"\n",
);

pub fn render_example_config() -> String {
    EXAMPLE_CONFIG.to_string()
}

pub fn find_config_command_path() -> Option<PathBuf> {
    if let Ok(explicit) = std::env::var("CCC_CONFIG") {
        let trimmed = explicit.trim();
        if !trimmed.is_empty() {
            let candidate = PathBuf::from(trimmed);
            if candidate.is_file() {
                return Some(candidate);
            }
        }
    }

    let current_dir = std::env::current_dir().ok();
    let home_path = std::env::var("HOME")
        .ok()
        .map(|home| PathBuf::from(home).join(".config/ccc/config.toml"));
    let xdg_path = std::env::var("XDG_CONFIG_HOME").ok().and_then(|xdg| {
        if xdg.trim().is_empty() {
            None
        } else {
            Some(PathBuf::from(xdg).join("ccc/config.toml"))
        }
    });

    let project_path = current_dir
        .as_deref()
        .and_then(find_project_config_path_from);
    if project_path.is_some() {
        return project_path;
    }
    if let Some(xdg) = xdg_path.filter(|path| path.is_file()) {
        return Some(xdg);
    }
    home_path.filter(|path| path.is_file())
}

pub fn load_config(path: Option<&Path>) -> CccConfig {
    let mut config = CccConfig::default();

    let config_paths = match path {
        Some(p) => vec![p.to_path_buf()],
        None => {
            let current_dir = std::env::current_dir().ok();
            let home_path = std::env::var("HOME")
                .ok()
                .map(|home| PathBuf::from(home).join(".config/ccc/config.toml"));
            let xdg_path = std::env::var("XDG_CONFIG_HOME").ok().and_then(|xdg| {
                if xdg.is_empty() {
                    None
                } else {
                    Some(PathBuf::from(xdg).join("ccc/config.toml"))
                }
            });
            default_config_paths_from(
                current_dir.as_deref(),
                home_path.as_deref(),
                xdg_path.as_deref(),
            )
        }
    };

    for config_path in config_paths {
        if !config_path.exists() {
            continue;
        }
        let content = match std::fs::read_to_string(&config_path) {
            Ok(c) => c,
            Err(_) => continue,
        };
        parse_toml_config(&content, &mut config);
    }

    config
}

fn parse_bool(value: &str) -> Option<bool> {
    match value.trim().to_ascii_lowercase().as_str() {
        "true" | "1" | "yes" | "on" => Some(true),
        "false" | "0" | "no" | "off" => Some(false),
        _ => None,
    }
}

fn default_config_paths_from(
    current_dir: Option<&Path>,
    home_path: Option<&Path>,
    xdg_path: Option<&Path>,
) -> Vec<PathBuf> {
    let mut paths = Vec::new();

    if let Some(home) = home_path {
        paths.push(home.to_path_buf());
    }

    if let Some(xdg) = xdg_path {
        if Some(xdg) != home_path {
            paths.push(xdg.to_path_buf());
        }
    }

    if let Some(cwd) = current_dir {
        for directory in cwd.ancestors() {
            let candidate = directory.join(".ccc.toml");
            if candidate.exists() {
                paths.push(candidate);
                break;
            }
        }
    }

    paths
}

fn find_project_config_path_from(current_dir: &Path) -> Option<PathBuf> {
    for directory in current_dir.ancestors() {
        let candidate = directory.join(".ccc.toml");
        if candidate.is_file() {
            return Some(candidate);
        }
    }
    None
}

fn merge_alias(target: &mut crate::parser::AliasDef, overlay: &crate::parser::AliasDef) {
    if overlay.runner.is_some() {
        target.runner = overlay.runner.clone();
    }
    if overlay.thinking.is_some() {
        target.thinking = overlay.thinking;
    }
    if overlay.show_thinking.is_some() {
        target.show_thinking = overlay.show_thinking;
    }
    if overlay.sanitize_osc.is_some() {
        target.sanitize_osc = overlay.sanitize_osc;
    }
    if overlay.output_mode.is_some() {
        target.output_mode = overlay.output_mode.clone();
    }
    if overlay.provider.is_some() {
        target.provider = overlay.provider.clone();
    }
    if overlay.model.is_some() {
        target.model = overlay.model.clone();
    }
    if overlay.agent.is_some() {
        target.agent = overlay.agent.clone();
    }
    if overlay.prompt.is_some() {
        target.prompt = overlay.prompt.clone();
    }
    if overlay.prompt_mode.is_some() {
        target.prompt_mode = overlay.prompt_mode.clone();
    }
}

fn parse_toml_config(content: &str, config: &mut CccConfig) {
    let mut section: &str = "";
    let mut current_alias_name: Option<String> = None;
    let mut current_alias = crate::parser::AliasDef::default();

    let flush_alias = |config: &mut CccConfig,
                       current_alias_name: &mut Option<String>,
                       current_alias: &mut crate::parser::AliasDef| {
        if let Some(name) = current_alias_name.take() {
            let overlay = std::mem::take(current_alias);
            config
                .aliases
                .entry(name)
                .and_modify(|existing| merge_alias(existing, &overlay))
                .or_insert(overlay);
        }
    };

    for line in content.lines() {
        let trimmed = line.trim();

        if trimmed.starts_with('#') || trimmed.is_empty() {
            continue;
        }

        if trimmed.starts_with('[') {
            flush_alias(config, &mut current_alias_name, &mut current_alias);
            if trimmed == "[defaults]" {
                section = "defaults";
            } else if trimmed == "[abbreviations]" {
                section = "abbreviations";
            } else if let Some(name) = trimmed
                .strip_prefix("[aliases.")
                .and_then(|s| s.strip_suffix(']'))
            {
                section = "alias";
                current_alias_name = Some(name.to_string());
            } else {
                section = "";
            }
            continue;
        }

        if let Some((key, value)) = trimmed.split_once('=') {
            let key = key.trim();
            let value = value.trim().trim_matches('"');

            match (section, key) {
                ("defaults", "runner") => config.default_runner = value.to_string(),
                ("defaults", "provider") => config.default_provider = value.to_string(),
                ("defaults", "model") => config.default_model = value.to_string(),
                ("defaults", "output_mode") => config.default_output_mode = value.to_string(),
                ("defaults", "thinking") => {
                    if let Ok(n) = value.parse::<i32>() {
                        config.default_thinking = Some(n);
                    }
                }
                ("defaults", "show_thinking") => {
                    if let Some(flag) = parse_bool(value) {
                        config.default_show_thinking = flag;
                    }
                }
                ("defaults", "sanitize_osc") => {
                    config.default_sanitize_osc = parse_bool(value);
                }
                ("abbreviations", _) => {
                    config
                        .abbreviations
                        .insert(key.to_string(), value.to_string());
                }
                ("alias", "runner") => current_alias.runner = Some(value.to_string()),
                ("alias", "thinking") => {
                    if let Ok(n) = value.parse::<i32>() {
                        current_alias.thinking = Some(n);
                    }
                }
                ("alias", "show_thinking") => {
                    current_alias.show_thinking = parse_bool(value);
                }
                ("alias", "sanitize_osc") => {
                    current_alias.sanitize_osc = parse_bool(value);
                }
                ("alias", "output_mode") => current_alias.output_mode = Some(value.to_string()),
                ("alias", "provider") => current_alias.provider = Some(value.to_string()),
                ("alias", "model") => current_alias.model = Some(value.to_string()),
                ("alias", "agent") => current_alias.agent = Some(value.to_string()),
                ("alias", "prompt") => current_alias.prompt = Some(value.to_string()),
                ("alias", "prompt_mode") => current_alias.prompt_mode = Some(value.to_string()),
                _ => {}
            }
        }
    }

    flush_alias(config, &mut current_alias_name, &mut current_alias);
}

#[cfg(test)]
mod tests {
    use super::{default_config_paths_from, parse_toml_config};
    use crate::parser::CccConfig;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn test_load_config_prefers_nearest_project_local_file() {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let base_dir = std::env::temp_dir().join(format!("ccc-rust-project-config-{unique}"));
        let workspace_dir = base_dir.join("workspace");
        let repo_dir = workspace_dir.join("repo");
        let nested_dir = repo_dir.join("nested").join("deeper");
        let home_config_dir = base_dir.join("home").join(".config").join("ccc");
        let xdg_config_dir = base_dir.join("xdg").join("ccc");
        let workspace_config = workspace_dir.join(".ccc.toml");
        let repo_config = repo_dir.join(".ccc.toml");
        let home_config = home_config_dir.join("config.toml");
        let xdg_config = xdg_config_dir.join("config.toml");

        fs::create_dir_all(&nested_dir).unwrap();
        fs::create_dir_all(&home_config_dir).unwrap();
        fs::create_dir_all(&xdg_config_dir).unwrap();
        fs::write(
            &workspace_config,
            r#"
[defaults]
runner = "oc"

[aliases.review]
agent = "outer-agent"
"#,
        )
        .unwrap();
        fs::write(
            &repo_config,
            r#"
[aliases.review]
prompt = "Repo prompt"
"#,
        )
        .unwrap();
        fs::write(
            &home_config,
            r#"
[defaults]
runner = "k"

[aliases.review]
show_thinking = true
"#,
        )
        .unwrap();
        fs::write(
            &xdg_config,
            r#"
[defaults]
model = "xdg-model"

[aliases.review]
model = "xdg-model"
"#,
        )
        .unwrap();

        let paths = default_config_paths_from(
            Some(&nested_dir),
            Some(&home_config),
            Some(&xdg_config),
        );
        assert_eq!(paths, vec![home_config.clone(), xdg_config.clone(), repo_config.clone()]);
        assert!(!paths.contains(&workspace_config));

        let mut config = CccConfig::default();
        for path in &paths {
            let content = fs::read_to_string(path).unwrap();
            parse_toml_config(&content, &mut config);
        }

        assert_eq!(config.default_runner, "k");
        assert_eq!(config.default_model, "xdg-model");
        let review = config.aliases.get("review").unwrap();
        assert_eq!(review.prompt.as_deref(), Some("Repo prompt"));
        assert_eq!(review.model.as_deref(), Some("xdg-model"));
        assert_eq!(review.show_thinking, Some(true));
        assert_eq!(review.agent.as_deref(), None);
    }
}