use std::path::Path;
use std::path::PathBuf;
use crate::parser::{AliasDef, 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 = 1\n",
"# show_thinking = true\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 find_config_command_paths() -> Vec<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 vec![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"))
}
});
default_config_paths_from(
current_dir.as_deref(),
home_path.as_deref(),
xdg_path.as_deref(),
)
.into_iter()
.filter(|path| path.is_file())
.collect()
}
pub fn find_project_config_path() -> Option<PathBuf> {
let current_dir = std::env::current_dir().ok()?;
find_project_config_path_from(¤t_dir)
}
fn xdg_config_path() -> Option<PathBuf> {
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"))
}
})
}
fn home_config_path() -> Option<PathBuf> {
std::env::var("HOME")
.ok()
.map(|home| PathBuf::from(home).join(".config/ccc/config.toml"))
}
pub fn find_alias_write_path(global_only: bool) -> PathBuf {
if !global_only {
if let Some(resolved) = find_config_command_path() {
return resolved;
}
}
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 home_path = std::env::var("HOME")
.ok()
.map(|home| PathBuf::from(home).join(".config/ccc/config.toml"));
if global_only {
if let Some(path) = xdg_path.as_ref().filter(|path| path.is_file()) {
return path.clone();
}
if let Some(path) = home_path.as_ref().filter(|path| path.is_file()) {
return path.clone();
}
} else if let Some(path) = xdg_path.as_ref() {
return path.clone();
}
xdg_path
.unwrap_or_else(|| home_path.unwrap_or_else(|| PathBuf::from(".config/ccc/config.toml")))
}
pub fn find_user_config_write_path() -> PathBuf {
xdg_config_path()
.or_else(home_config_path)
.unwrap_or_else(|| PathBuf::from(".config/ccc/config.toml"))
}
pub fn find_local_config_write_path() -> PathBuf {
find_project_config_path().unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".ccc.toml")
})
}
pub fn find_config_edit_path(target: Option<&str>) -> PathBuf {
match target {
Some("user") => find_user_config_write_path(),
Some("local") => find_local_config_write_path(),
_ => find_config_command_path().unwrap_or_else(find_user_config_write_path),
}
}
pub fn normalize_alias_name(name: &str) -> Result<String, String> {
let normalized = name.strip_prefix('@').unwrap_or(name);
if normalized.is_empty()
|| !normalized
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
{
return Err("alias name must contain only letters, digits, '_' or '-'".to_string());
}
Ok(normalized.to_string())
}
fn toml_string(value: &str) -> String {
let escaped = value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
format!("\"{escaped}\"")
}
pub fn render_alias_block(name: &str, alias: &AliasDef) -> Result<String, String> {
let normalized = normalize_alias_name(name)?;
let mut lines = vec![format!("[aliases.{normalized}]")];
for (key, value) in [
(
"runner",
alias.runner.as_ref().map(|value| toml_string(value)),
),
(
"provider",
alias.provider.as_ref().map(|value| toml_string(value)),
),
(
"model",
alias.model.as_ref().map(|value| toml_string(value)),
),
("thinking", alias.thinking.map(|value| value.to_string())),
(
"show_thinking",
alias
.show_thinking
.map(|value| if value { "true" } else { "false" }.to_string()),
),
(
"sanitize_osc",
alias
.sanitize_osc
.map(|value| if value { "true" } else { "false" }.to_string()),
),
(
"output_mode",
alias.output_mode.as_ref().map(|value| toml_string(value)),
),
(
"agent",
alias.agent.as_ref().map(|value| toml_string(value)),
),
(
"prompt",
alias.prompt.as_ref().map(|value| toml_string(value)),
),
(
"prompt_mode",
alias.prompt_mode.as_ref().map(|value| toml_string(value)),
),
] {
if let Some(rendered) = value {
lines.push(format!("{key} = {rendered}"));
}
}
Ok(format!("{}\n", lines.join("\n")))
}
pub fn upsert_alias_block(content: &str, name: &str, alias: &AliasDef) -> Result<String, String> {
let normalized = normalize_alias_name(name)?;
let block = render_alias_block(&normalized, alias)?;
let section = format!("[aliases.{normalized}]");
let lines: Vec<&str> = content.split_inclusive('\n').collect();
let start = lines.iter().position(|line| line.trim() == section);
let Some(start) = start else {
let mut prefix = content.to_string();
if !prefix.is_empty() && !prefix.ends_with('\n') {
prefix.push('\n');
}
if !prefix.is_empty() && !prefix.ends_with("\n\n") {
prefix.push('\n');
}
return Ok(prefix + &block);
};
let mut end = lines.len();
for (index, line) in lines.iter().enumerate().skip(start + 1) {
let stripped = line.trim();
if stripped.starts_with('[') && stripped.ends_with(']') {
end = index;
break;
}
}
let mut replacement = block;
if end < lines.len() && !replacement.ends_with("\n\n") {
replacement.push('\n');
}
Ok(format!(
"{}{}{}",
lines[..start].concat(),
replacement,
lines[end..].concat()
))
}
pub fn write_alias_block(path: &Path, name: &str, alias: &AliasDef) -> Result<(), String> {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(format!("failed to read {}: {error}", path.display())),
};
let updated = upsert_alias_block(&content, name, alias)?;
let parent = path
.parent()
.ok_or_else(|| format!("config path {} has no parent directory", path.display()))?;
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
let tmp_name = format!(
".{}.{}.tmp",
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("config.toml"),
std::process::id()
);
let tmp_path = parent.join(tmp_name);
std::fs::write(&tmp_path, updated)
.map_err(|error| format!("failed to write {}: {error}", tmp_path.display()))?;
std::fs::rename(&tmp_path, path).map_err(|error| {
let _ = std::fs::remove_file(&tmp_path);
format!(
"failed to move temporary config into place at {}: {error}",
path.display()
)
})?;
Ok(())
}
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);
}
}