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