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(¤t_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}