Skip to main content

call_coding_clis/
config.rs

1use std::path::Path;
2use std::path::PathBuf;
3
4use crate::parser::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 = 2\n",
15    "# show_thinking = false\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 load_config(path: Option<&Path>) -> CccConfig {
75    let mut config = CccConfig::default();
76
77    let config_paths = match path {
78        Some(p) => vec![p.to_path_buf()],
79        None => {
80            let current_dir = std::env::current_dir().ok();
81            let home_path = std::env::var("HOME")
82                .ok()
83                .map(|home| PathBuf::from(home).join(".config/ccc/config.toml"));
84            let xdg_path = std::env::var("XDG_CONFIG_HOME").ok().and_then(|xdg| {
85                if xdg.is_empty() {
86                    None
87                } else {
88                    Some(PathBuf::from(xdg).join("ccc/config.toml"))
89                }
90            });
91            default_config_paths_from(
92                current_dir.as_deref(),
93                home_path.as_deref(),
94                xdg_path.as_deref(),
95            )
96        }
97    };
98
99    for config_path in config_paths {
100        if !config_path.exists() {
101            continue;
102        }
103        let content = match std::fs::read_to_string(&config_path) {
104            Ok(c) => c,
105            Err(_) => continue,
106        };
107        parse_toml_config(&content, &mut config);
108    }
109
110    config
111}
112
113fn parse_bool(value: &str) -> Option<bool> {
114    match value.trim().to_ascii_lowercase().as_str() {
115        "true" | "1" | "yes" | "on" => Some(true),
116        "false" | "0" | "no" | "off" => Some(false),
117        _ => None,
118    }
119}
120
121fn default_config_paths_from(
122    current_dir: Option<&Path>,
123    home_path: Option<&Path>,
124    xdg_path: Option<&Path>,
125) -> Vec<PathBuf> {
126    let mut paths = Vec::new();
127
128    if let Some(home) = home_path {
129        paths.push(home.to_path_buf());
130    }
131
132    if let Some(xdg) = xdg_path {
133        if Some(xdg) != home_path {
134            paths.push(xdg.to_path_buf());
135        }
136    }
137
138    if let Some(cwd) = current_dir {
139        for directory in cwd.ancestors() {
140            let candidate = directory.join(".ccc.toml");
141            if candidate.exists() {
142                paths.push(candidate);
143                break;
144            }
145        }
146    }
147
148    paths
149}
150
151fn find_project_config_path_from(current_dir: &Path) -> Option<PathBuf> {
152    for directory in current_dir.ancestors() {
153        let candidate = directory.join(".ccc.toml");
154        if candidate.is_file() {
155            return Some(candidate);
156        }
157    }
158    None
159}
160
161fn merge_alias(target: &mut crate::parser::AliasDef, overlay: &crate::parser::AliasDef) {
162    if overlay.runner.is_some() {
163        target.runner = overlay.runner.clone();
164    }
165    if overlay.thinking.is_some() {
166        target.thinking = overlay.thinking;
167    }
168    if overlay.show_thinking.is_some() {
169        target.show_thinking = overlay.show_thinking;
170    }
171    if overlay.sanitize_osc.is_some() {
172        target.sanitize_osc = overlay.sanitize_osc;
173    }
174    if overlay.output_mode.is_some() {
175        target.output_mode = overlay.output_mode.clone();
176    }
177    if overlay.provider.is_some() {
178        target.provider = overlay.provider.clone();
179    }
180    if overlay.model.is_some() {
181        target.model = overlay.model.clone();
182    }
183    if overlay.agent.is_some() {
184        target.agent = overlay.agent.clone();
185    }
186    if overlay.prompt.is_some() {
187        target.prompt = overlay.prompt.clone();
188    }
189    if overlay.prompt_mode.is_some() {
190        target.prompt_mode = overlay.prompt_mode.clone();
191    }
192}
193
194fn parse_toml_config(content: &str, config: &mut CccConfig) {
195    let mut section: &str = "";
196    let mut current_alias_name: Option<String> = None;
197    let mut current_alias = crate::parser::AliasDef::default();
198
199    let flush_alias = |config: &mut CccConfig,
200                       current_alias_name: &mut Option<String>,
201                       current_alias: &mut crate::parser::AliasDef| {
202        if let Some(name) = current_alias_name.take() {
203            let overlay = std::mem::take(current_alias);
204            config
205                .aliases
206                .entry(name)
207                .and_modify(|existing| merge_alias(existing, &overlay))
208                .or_insert(overlay);
209        }
210    };
211
212    for line in content.lines() {
213        let trimmed = line.trim();
214
215        if trimmed.starts_with('#') || trimmed.is_empty() {
216            continue;
217        }
218
219        if trimmed.starts_with('[') {
220            flush_alias(config, &mut current_alias_name, &mut current_alias);
221            if trimmed == "[defaults]" {
222                section = "defaults";
223            } else if trimmed == "[abbreviations]" {
224                section = "abbreviations";
225            } else if let Some(name) = trimmed
226                .strip_prefix("[aliases.")
227                .and_then(|s| s.strip_suffix(']'))
228            {
229                section = "alias";
230                current_alias_name = Some(name.to_string());
231            } else {
232                section = "";
233            }
234            continue;
235        }
236
237        if let Some((key, value)) = trimmed.split_once('=') {
238            let key = key.trim();
239            let value = value.trim().trim_matches('"');
240
241            match (section, key) {
242                ("defaults", "runner") => config.default_runner = value.to_string(),
243                ("defaults", "provider") => config.default_provider = value.to_string(),
244                ("defaults", "model") => config.default_model = value.to_string(),
245                ("defaults", "output_mode") => config.default_output_mode = value.to_string(),
246                ("defaults", "thinking") => {
247                    if let Ok(n) = value.parse::<i32>() {
248                        config.default_thinking = Some(n);
249                    }
250                }
251                ("defaults", "show_thinking") => {
252                    if let Some(flag) = parse_bool(value) {
253                        config.default_show_thinking = flag;
254                    }
255                }
256                ("defaults", "sanitize_osc") => {
257                    config.default_sanitize_osc = parse_bool(value);
258                }
259                ("abbreviations", _) => {
260                    config
261                        .abbreviations
262                        .insert(key.to_string(), value.to_string());
263                }
264                ("alias", "runner") => current_alias.runner = Some(value.to_string()),
265                ("alias", "thinking") => {
266                    if let Ok(n) = value.parse::<i32>() {
267                        current_alias.thinking = Some(n);
268                    }
269                }
270                ("alias", "show_thinking") => {
271                    current_alias.show_thinking = parse_bool(value);
272                }
273                ("alias", "sanitize_osc") => {
274                    current_alias.sanitize_osc = parse_bool(value);
275                }
276                ("alias", "output_mode") => current_alias.output_mode = Some(value.to_string()),
277                ("alias", "provider") => current_alias.provider = Some(value.to_string()),
278                ("alias", "model") => current_alias.model = Some(value.to_string()),
279                ("alias", "agent") => current_alias.agent = Some(value.to_string()),
280                ("alias", "prompt") => current_alias.prompt = Some(value.to_string()),
281                ("alias", "prompt_mode") => current_alias.prompt_mode = Some(value.to_string()),
282                _ => {}
283            }
284        }
285    }
286
287    flush_alias(config, &mut current_alias_name, &mut current_alias);
288}
289
290#[cfg(test)]
291mod tests {
292    use super::{default_config_paths_from, parse_toml_config};
293    use crate::parser::CccConfig;
294    use std::fs;
295    use std::time::{SystemTime, UNIX_EPOCH};
296
297    #[test]
298    fn test_load_config_prefers_nearest_project_local_file() {
299        let unique = SystemTime::now()
300            .duration_since(UNIX_EPOCH)
301            .unwrap()
302            .as_nanos();
303        let base_dir = std::env::temp_dir().join(format!("ccc-rust-project-config-{unique}"));
304        let workspace_dir = base_dir.join("workspace");
305        let repo_dir = workspace_dir.join("repo");
306        let nested_dir = repo_dir.join("nested").join("deeper");
307        let home_config_dir = base_dir.join("home").join(".config").join("ccc");
308        let xdg_config_dir = base_dir.join("xdg").join("ccc");
309        let workspace_config = workspace_dir.join(".ccc.toml");
310        let repo_config = repo_dir.join(".ccc.toml");
311        let home_config = home_config_dir.join("config.toml");
312        let xdg_config = xdg_config_dir.join("config.toml");
313
314        fs::create_dir_all(&nested_dir).unwrap();
315        fs::create_dir_all(&home_config_dir).unwrap();
316        fs::create_dir_all(&xdg_config_dir).unwrap();
317        fs::write(
318            &workspace_config,
319            r#"
320[defaults]
321runner = "oc"
322
323[aliases.review]
324agent = "outer-agent"
325"#,
326        )
327        .unwrap();
328        fs::write(
329            &repo_config,
330            r#"
331[aliases.review]
332prompt = "Repo prompt"
333"#,
334        )
335        .unwrap();
336        fs::write(
337            &home_config,
338            r#"
339[defaults]
340runner = "k"
341
342[aliases.review]
343show_thinking = true
344"#,
345        )
346        .unwrap();
347        fs::write(
348            &xdg_config,
349            r#"
350[defaults]
351model = "xdg-model"
352
353[aliases.review]
354model = "xdg-model"
355"#,
356        )
357        .unwrap();
358
359        let paths = default_config_paths_from(
360            Some(&nested_dir),
361            Some(&home_config),
362            Some(&xdg_config),
363        );
364        assert_eq!(paths, vec![home_config.clone(), xdg_config.clone(), repo_config.clone()]);
365        assert!(!paths.contains(&workspace_config));
366
367        let mut config = CccConfig::default();
368        for path in &paths {
369            let content = fs::read_to_string(path).unwrap();
370            parse_toml_config(&content, &mut config);
371        }
372
373        assert_eq!(config.default_runner, "k");
374        assert_eq!(config.default_model, "xdg-model");
375        let review = config.aliases.get("review").unwrap();
376        assert_eq!(review.prompt.as_deref(), Some("Repo prompt"));
377        assert_eq!(review.model.as_deref(), Some("xdg-model"));
378        assert_eq!(review.show_thinking, Some(true));
379        assert_eq!(review.agent.as_deref(), None);
380    }
381}