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