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}