Skip to main content

call_coding_clis/
config.rs

1use std::path::Path;
2use std::path::PathBuf;
3
4use crate::parser::{AliasDef, CccConfig};
5
6const EXAMPLE_CONFIG: &str = concat!(
7    "# Generated by `ccc --print-config`.\n",
8    "# Copy this file to ~/.config/ccc/config.toml and uncomment the settings you want.\n",
9    "\n",
10    "[defaults]\n",
11    "# runner = \"oc\"\n",
12    "# provider = \"anthropic\"\n",
13    "# model = \"claude-4\"\n",
14    "# thinking = 1\n",
15    "# show_thinking = true\n",
16    "# sanitize_osc = true\n",
17    "# output_mode = \"text\"\n",
18    "\n",
19    "[abbreviations]\n",
20    "# mycc = \"cc\"\n",
21    "\n",
22    "[aliases.reviewer]\n",
23    "# runner = \"cc\"\n",
24    "# provider = \"anthropic\"\n",
25    "# model = \"claude-4\"\n",
26    "# thinking = 3\n",
27    "# show_thinking = true\n",
28    "# sanitize_osc = true\n",
29    "# output_mode = \"formatted\"\n",
30    "# agent = \"reviewer\"\n",
31    "# prompt = \"Review the current changes\"\n",
32    "# prompt_mode = \"default\"\n",
33);
34
35pub fn render_example_config() -> String {
36    EXAMPLE_CONFIG.to_string()
37}
38
39pub fn find_config_command_path() -> Option<PathBuf> {
40    if let Ok(explicit) = std::env::var("CCC_CONFIG") {
41        let trimmed = explicit.trim();
42        if !trimmed.is_empty() {
43            let candidate = PathBuf::from(trimmed);
44            if candidate.is_file() {
45                return Some(candidate);
46            }
47        }
48    }
49
50    let current_dir = std::env::current_dir().ok();
51    let home_path = std::env::var("HOME")
52        .ok()
53        .map(|home| PathBuf::from(home).join(".config/ccc/config.toml"));
54    let xdg_path = std::env::var("XDG_CONFIG_HOME").ok().and_then(|xdg| {
55        if xdg.trim().is_empty() {
56            None
57        } else {
58            Some(PathBuf::from(xdg).join("ccc/config.toml"))
59        }
60    });
61
62    let project_path = current_dir
63        .as_deref()
64        .and_then(find_project_config_path_from);
65    if project_path.is_some() {
66        return project_path;
67    }
68    if let Some(xdg) = xdg_path.filter(|path| path.is_file()) {
69        return Some(xdg);
70    }
71    home_path.filter(|path| path.is_file())
72}
73
74pub fn find_config_command_paths() -> Vec<PathBuf> {
75    if let Ok(explicit) = std::env::var("CCC_CONFIG") {
76        let trimmed = explicit.trim();
77        if !trimmed.is_empty() {
78            let candidate = PathBuf::from(trimmed);
79            if candidate.is_file() {
80                return vec![candidate];
81            }
82        }
83    }
84
85    let current_dir = std::env::current_dir().ok();
86    let home_path = std::env::var("HOME")
87        .ok()
88        .map(|home| PathBuf::from(home).join(".config/ccc/config.toml"));
89    let xdg_path = std::env::var("XDG_CONFIG_HOME").ok().and_then(|xdg| {
90        if xdg.trim().is_empty() {
91            None
92        } else {
93            Some(PathBuf::from(xdg).join("ccc/config.toml"))
94        }
95    });
96
97    default_config_paths_from(
98        current_dir.as_deref(),
99        home_path.as_deref(),
100        xdg_path.as_deref(),
101    )
102    .into_iter()
103    .filter(|path| path.is_file())
104    .collect()
105}
106
107pub fn find_project_config_path() -> Option<PathBuf> {
108    let current_dir = std::env::current_dir().ok()?;
109    find_project_config_path_from(&current_dir)
110}
111
112fn xdg_config_path() -> Option<PathBuf> {
113    std::env::var("XDG_CONFIG_HOME").ok().and_then(|xdg| {
114        if xdg.trim().is_empty() {
115            None
116        } else {
117            Some(PathBuf::from(xdg).join("ccc/config.toml"))
118        }
119    })
120}
121
122fn home_config_path() -> Option<PathBuf> {
123    std::env::var("HOME")
124        .ok()
125        .map(|home| PathBuf::from(home).join(".config/ccc/config.toml"))
126}
127
128pub fn find_alias_write_path(global_only: bool) -> PathBuf {
129    if !global_only {
130        if let Some(resolved) = find_config_command_path() {
131            return resolved;
132        }
133    }
134
135    let xdg_path = std::env::var("XDG_CONFIG_HOME").ok().and_then(|xdg| {
136        if xdg.trim().is_empty() {
137            None
138        } else {
139            Some(PathBuf::from(xdg).join("ccc/config.toml"))
140        }
141    });
142    let home_path = std::env::var("HOME")
143        .ok()
144        .map(|home| PathBuf::from(home).join(".config/ccc/config.toml"));
145
146    if global_only {
147        if let Some(path) = xdg_path.as_ref().filter(|path| path.is_file()) {
148            return path.clone();
149        }
150        if let Some(path) = home_path.as_ref().filter(|path| path.is_file()) {
151            return path.clone();
152        }
153    } else if let Some(path) = xdg_path.as_ref() {
154        return path.clone();
155    }
156
157    xdg_path
158        .unwrap_or_else(|| home_path.unwrap_or_else(|| PathBuf::from(".config/ccc/config.toml")))
159}
160
161pub fn find_user_config_write_path() -> PathBuf {
162    xdg_config_path()
163        .or_else(home_config_path)
164        .unwrap_or_else(|| PathBuf::from(".config/ccc/config.toml"))
165}
166
167pub fn find_local_config_write_path() -> PathBuf {
168    find_project_config_path().unwrap_or_else(|| {
169        std::env::current_dir()
170            .unwrap_or_else(|_| PathBuf::from("."))
171            .join(".ccc.toml")
172    })
173}
174
175pub fn find_config_edit_path(target: Option<&str>) -> PathBuf {
176    match target {
177        Some("user") => find_user_config_write_path(),
178        Some("local") => find_local_config_write_path(),
179        _ => find_config_command_path().unwrap_or_else(find_user_config_write_path),
180    }
181}
182
183pub fn normalize_alias_name(name: &str) -> Result<String, String> {
184    let normalized = name.strip_prefix('@').unwrap_or(name);
185    if normalized.is_empty()
186        || !normalized
187            .chars()
188            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
189    {
190        return Err("alias name must contain only letters, digits, '_' or '-'".to_string());
191    }
192    Ok(normalized.to_string())
193}
194
195fn toml_string(value: &str) -> String {
196    let escaped = value
197        .replace('\\', "\\\\")
198        .replace('"', "\\\"")
199        .replace('\n', "\\n")
200        .replace('\r', "\\r")
201        .replace('\t', "\\t");
202    format!("\"{escaped}\"")
203}
204
205pub fn render_alias_block(name: &str, alias: &AliasDef) -> Result<String, String> {
206    let normalized = normalize_alias_name(name)?;
207    let mut lines = vec![format!("[aliases.{normalized}]")];
208    for (key, value) in [
209        (
210            "runner",
211            alias.runner.as_ref().map(|value| toml_string(value)),
212        ),
213        (
214            "provider",
215            alias.provider.as_ref().map(|value| toml_string(value)),
216        ),
217        (
218            "model",
219            alias.model.as_ref().map(|value| toml_string(value)),
220        ),
221        ("thinking", alias.thinking.map(|value| value.to_string())),
222        (
223            "show_thinking",
224            alias
225                .show_thinking
226                .map(|value| if value { "true" } else { "false" }.to_string()),
227        ),
228        (
229            "sanitize_osc",
230            alias
231                .sanitize_osc
232                .map(|value| if value { "true" } else { "false" }.to_string()),
233        ),
234        (
235            "output_mode",
236            alias.output_mode.as_ref().map(|value| toml_string(value)),
237        ),
238        (
239            "agent",
240            alias.agent.as_ref().map(|value| toml_string(value)),
241        ),
242        (
243            "prompt",
244            alias.prompt.as_ref().map(|value| toml_string(value)),
245        ),
246        (
247            "prompt_mode",
248            alias.prompt_mode.as_ref().map(|value| toml_string(value)),
249        ),
250    ] {
251        if let Some(rendered) = value {
252            lines.push(format!("{key} = {rendered}"));
253        }
254    }
255    Ok(format!("{}\n", lines.join("\n")))
256}
257
258pub fn upsert_alias_block(content: &str, name: &str, alias: &AliasDef) -> Result<String, String> {
259    let normalized = normalize_alias_name(name)?;
260    let block = render_alias_block(&normalized, alias)?;
261    let section = format!("[aliases.{normalized}]");
262    let lines: Vec<&str> = content.split_inclusive('\n').collect();
263    let start = lines.iter().position(|line| line.trim() == section);
264
265    let Some(start) = start else {
266        let mut prefix = content.to_string();
267        if !prefix.is_empty() && !prefix.ends_with('\n') {
268            prefix.push('\n');
269        }
270        if !prefix.is_empty() && !prefix.ends_with("\n\n") {
271            prefix.push('\n');
272        }
273        return Ok(prefix + &block);
274    };
275
276    let mut end = lines.len();
277    for (index, line) in lines.iter().enumerate().skip(start + 1) {
278        let stripped = line.trim();
279        if stripped.starts_with('[') && stripped.ends_with(']') {
280            end = index;
281            break;
282        }
283    }
284
285    let mut replacement = block;
286    if end < lines.len() && !replacement.ends_with("\n\n") {
287        replacement.push('\n');
288    }
289
290    Ok(format!(
291        "{}{}{}",
292        lines[..start].concat(),
293        replacement,
294        lines[end..].concat()
295    ))
296}
297
298pub fn write_alias_block(path: &Path, name: &str, alias: &AliasDef) -> Result<(), String> {
299    let content = match std::fs::read_to_string(path) {
300        Ok(content) => content,
301        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
302        Err(error) => return Err(format!("failed to read {}: {error}", path.display())),
303    };
304    let updated = upsert_alias_block(&content, name, alias)?;
305    let parent = path
306        .parent()
307        .ok_or_else(|| format!("config path {} has no parent directory", path.display()))?;
308    std::fs::create_dir_all(parent)
309        .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
310    let tmp_name = format!(
311        ".{}.{}.tmp",
312        path.file_name()
313            .and_then(|value| value.to_str())
314            .unwrap_or("config.toml"),
315        std::process::id()
316    );
317    let tmp_path = parent.join(tmp_name);
318    std::fs::write(&tmp_path, updated)
319        .map_err(|error| format!("failed to write {}: {error}", tmp_path.display()))?;
320    std::fs::rename(&tmp_path, path).map_err(|error| {
321        let _ = std::fs::remove_file(&tmp_path);
322        format!(
323            "failed to move temporary config into place at {}: {error}",
324            path.display()
325        )
326    })?;
327    Ok(())
328}
329
330pub fn load_config(path: Option<&Path>) -> CccConfig {
331    let mut config = CccConfig::default();
332
333    let config_paths = match path {
334        Some(p) => vec![p.to_path_buf()],
335        None => {
336            let current_dir = std::env::current_dir().ok();
337            let home_path = std::env::var("HOME")
338                .ok()
339                .map(|home| PathBuf::from(home).join(".config/ccc/config.toml"));
340            let xdg_path = std::env::var("XDG_CONFIG_HOME").ok().and_then(|xdg| {
341                if xdg.is_empty() {
342                    None
343                } else {
344                    Some(PathBuf::from(xdg).join("ccc/config.toml"))
345                }
346            });
347            default_config_paths_from(
348                current_dir.as_deref(),
349                home_path.as_deref(),
350                xdg_path.as_deref(),
351            )
352        }
353    };
354
355    for config_path in config_paths {
356        if !config_path.exists() {
357            continue;
358        }
359        let content = match std::fs::read_to_string(&config_path) {
360            Ok(c) => c,
361            Err(_) => continue,
362        };
363        parse_toml_config(&content, &mut config);
364    }
365
366    config
367}
368
369fn parse_bool(value: &str) -> Option<bool> {
370    match value.trim().to_ascii_lowercase().as_str() {
371        "true" | "1" | "yes" | "on" => Some(true),
372        "false" | "0" | "no" | "off" => Some(false),
373        _ => None,
374    }
375}
376
377fn default_config_paths_from(
378    current_dir: Option<&Path>,
379    home_path: Option<&Path>,
380    xdg_path: Option<&Path>,
381) -> Vec<PathBuf> {
382    let mut paths = Vec::new();
383
384    if let Some(home) = home_path {
385        paths.push(home.to_path_buf());
386    }
387
388    if let Some(xdg) = xdg_path {
389        if Some(xdg) != home_path {
390            paths.push(xdg.to_path_buf());
391        }
392    }
393
394    if let Some(cwd) = current_dir {
395        for directory in cwd.ancestors() {
396            let candidate = directory.join(".ccc.toml");
397            if candidate.exists() {
398                paths.push(candidate);
399                break;
400            }
401        }
402    }
403
404    paths
405}
406
407fn find_project_config_path_from(current_dir: &Path) -> Option<PathBuf> {
408    for directory in current_dir.ancestors() {
409        let candidate = directory.join(".ccc.toml");
410        if candidate.is_file() {
411            return Some(candidate);
412        }
413    }
414    None
415}
416
417fn merge_alias(target: &mut crate::parser::AliasDef, overlay: &crate::parser::AliasDef) {
418    if overlay.runner.is_some() {
419        target.runner = overlay.runner.clone();
420    }
421    if overlay.thinking.is_some() {
422        target.thinking = overlay.thinking;
423    }
424    if overlay.show_thinking.is_some() {
425        target.show_thinking = overlay.show_thinking;
426    }
427    if overlay.sanitize_osc.is_some() {
428        target.sanitize_osc = overlay.sanitize_osc;
429    }
430    if overlay.output_mode.is_some() {
431        target.output_mode = overlay.output_mode.clone();
432    }
433    if overlay.provider.is_some() {
434        target.provider = overlay.provider.clone();
435    }
436    if overlay.model.is_some() {
437        target.model = overlay.model.clone();
438    }
439    if overlay.agent.is_some() {
440        target.agent = overlay.agent.clone();
441    }
442    if overlay.prompt.is_some() {
443        target.prompt = overlay.prompt.clone();
444    }
445    if overlay.prompt_mode.is_some() {
446        target.prompt_mode = overlay.prompt_mode.clone();
447    }
448}
449
450fn parse_toml_config(content: &str, config: &mut CccConfig) {
451    let mut section: &str = "";
452    let mut current_alias_name: Option<String> = None;
453    let mut current_alias = crate::parser::AliasDef::default();
454
455    let flush_alias = |config: &mut CccConfig,
456                       current_alias_name: &mut Option<String>,
457                       current_alias: &mut crate::parser::AliasDef| {
458        if let Some(name) = current_alias_name.take() {
459            let overlay = std::mem::take(current_alias);
460            config
461                .aliases
462                .entry(name)
463                .and_modify(|existing| merge_alias(existing, &overlay))
464                .or_insert(overlay);
465        }
466    };
467
468    for line in content.lines() {
469        let trimmed = line.trim();
470
471        if trimmed.starts_with('#') || trimmed.is_empty() {
472            continue;
473        }
474
475        if trimmed.starts_with('[') {
476            flush_alias(config, &mut current_alias_name, &mut current_alias);
477            if trimmed == "[defaults]" {
478                section = "defaults";
479            } else if trimmed == "[abbreviations]" {
480                section = "abbreviations";
481            } else if let Some(name) = trimmed
482                .strip_prefix("[aliases.")
483                .and_then(|s| s.strip_suffix(']'))
484            {
485                section = "alias";
486                current_alias_name = Some(name.to_string());
487            } else {
488                section = "";
489            }
490            continue;
491        }
492
493        if let Some((key, value)) = trimmed.split_once('=') {
494            let key = key.trim();
495            let value = value.trim().trim_matches('"');
496
497            match (section, key) {
498                ("defaults", "runner") => config.default_runner = value.to_string(),
499                ("defaults", "provider") => config.default_provider = value.to_string(),
500                ("defaults", "model") => config.default_model = value.to_string(),
501                ("defaults", "output_mode") => config.default_output_mode = value.to_string(),
502                ("defaults", "thinking") => {
503                    if let Ok(n) = value.parse::<i32>() {
504                        config.default_thinking = Some(n);
505                    }
506                }
507                ("defaults", "show_thinking") => {
508                    if let Some(flag) = parse_bool(value) {
509                        config.default_show_thinking = flag;
510                    }
511                }
512                ("defaults", "sanitize_osc") => {
513                    config.default_sanitize_osc = parse_bool(value);
514                }
515                ("abbreviations", _) => {
516                    config
517                        .abbreviations
518                        .insert(key.to_string(), value.to_string());
519                }
520                ("alias", "runner") => current_alias.runner = Some(value.to_string()),
521                ("alias", "thinking") => {
522                    if let Ok(n) = value.parse::<i32>() {
523                        current_alias.thinking = Some(n);
524                    }
525                }
526                ("alias", "show_thinking") => {
527                    current_alias.show_thinking = parse_bool(value);
528                }
529                ("alias", "sanitize_osc") => {
530                    current_alias.sanitize_osc = parse_bool(value);
531                }
532                ("alias", "output_mode") => current_alias.output_mode = Some(value.to_string()),
533                ("alias", "provider") => current_alias.provider = Some(value.to_string()),
534                ("alias", "model") => current_alias.model = Some(value.to_string()),
535                ("alias", "agent") => current_alias.agent = Some(value.to_string()),
536                ("alias", "prompt") => current_alias.prompt = Some(value.to_string()),
537                ("alias", "prompt_mode") => current_alias.prompt_mode = Some(value.to_string()),
538                _ => {}
539            }
540        }
541    }
542
543    flush_alias(config, &mut current_alias_name, &mut current_alias);
544}
545
546#[cfg(test)]
547mod tests {
548    use super::{default_config_paths_from, parse_toml_config};
549    use crate::parser::CccConfig;
550    use std::fs;
551    use std::time::{SystemTime, UNIX_EPOCH};
552
553    #[test]
554    fn test_load_config_prefers_nearest_project_local_file() {
555        let unique = SystemTime::now()
556            .duration_since(UNIX_EPOCH)
557            .unwrap()
558            .as_nanos();
559        let base_dir = std::env::temp_dir().join(format!("ccc-rust-project-config-{unique}"));
560        let workspace_dir = base_dir.join("workspace");
561        let repo_dir = workspace_dir.join("repo");
562        let nested_dir = repo_dir.join("nested").join("deeper");
563        let home_config_dir = base_dir.join("home").join(".config").join("ccc");
564        let xdg_config_dir = base_dir.join("xdg").join("ccc");
565        let workspace_config = workspace_dir.join(".ccc.toml");
566        let repo_config = repo_dir.join(".ccc.toml");
567        let home_config = home_config_dir.join("config.toml");
568        let xdg_config = xdg_config_dir.join("config.toml");
569
570        fs::create_dir_all(&nested_dir).unwrap();
571        fs::create_dir_all(&home_config_dir).unwrap();
572        fs::create_dir_all(&xdg_config_dir).unwrap();
573        fs::write(
574            &workspace_config,
575            r#"
576[defaults]
577runner = "oc"
578
579[aliases.review]
580agent = "outer-agent"
581"#,
582        )
583        .unwrap();
584        fs::write(
585            &repo_config,
586            r#"
587[aliases.review]
588prompt = "Repo prompt"
589"#,
590        )
591        .unwrap();
592        fs::write(
593            &home_config,
594            r#"
595[defaults]
596runner = "k"
597
598[aliases.review]
599show_thinking = true
600"#,
601        )
602        .unwrap();
603        fs::write(
604            &xdg_config,
605            r#"
606[defaults]
607model = "xdg-model"
608
609[aliases.review]
610model = "xdg-model"
611"#,
612        )
613        .unwrap();
614
615        let paths =
616            default_config_paths_from(Some(&nested_dir), Some(&home_config), Some(&xdg_config));
617        assert_eq!(
618            paths,
619            vec![home_config.clone(), xdg_config.clone(), repo_config.clone()]
620        );
621        assert!(!paths.contains(&workspace_config));
622
623        let mut config = CccConfig::default();
624        for path in &paths {
625            let content = fs::read_to_string(path).unwrap();
626            parse_toml_config(&content, &mut config);
627        }
628
629        assert_eq!(config.default_runner, "k");
630        assert_eq!(config.default_model, "xdg-model");
631        let review = config.aliases.get("review").unwrap();
632        assert_eq!(review.prompt.as_deref(), Some("Repo prompt"));
633        assert_eq!(review.model.as_deref(), Some("xdg-model"));
634        assert_eq!(review.show_thinking, Some(true));
635        assert_eq!(review.agent.as_deref(), None);
636    }
637}