1use crate::agents::fallback::FallbackConfig;
28use serde::Deserialize;
29use std::collections::HashMap;
30use std::env;
31use std::fs;
32use std::io;
33use std::path::PathBuf;
34
35pub const DEFAULT_UNIFIED_CONFIG: &str = include_str!("../../examples/ralph-workflow.toml");
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ConfigInitResult {
41 Created,
43 AlreadyExists,
45}
46
47pub const DEFAULT_UNIFIED_CONFIG_NAME: &str = "ralph-workflow.toml";
49
50pub fn unified_config_path() -> Option<PathBuf> {
56 if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
57 let xdg = xdg.trim();
58 if !xdg.is_empty() {
59 return Some(PathBuf::from(xdg).join(DEFAULT_UNIFIED_CONFIG_NAME));
60 }
61 }
62
63 dirs::home_dir().map(|d| d.join(".config").join(DEFAULT_UNIFIED_CONFIG_NAME))
64}
65
66#[derive(Debug, Clone, Deserialize, Default)]
70#[serde(default)]
71pub struct GeneralBehaviorFlags {
72 pub interactive: bool,
74 pub auto_detect_stack: bool,
76 pub strict_validation: bool,
78}
79
80#[derive(Debug, Clone, Deserialize, Default)]
84#[serde(default)]
85pub struct GeneralWorkflowFlags {
86 pub checkpoint_enabled: bool,
88 pub auto_rebase: bool,
90 pub max_recovery_attempts: u32,
92}
93
94#[derive(Debug, Clone, Deserialize, Default)]
98#[serde(default)]
99pub struct GeneralExecutionFlags {
100 pub force_universal_prompt: bool,
102 pub isolation_mode: bool,
104}
105
106#[derive(Debug, Clone, Deserialize)]
108#[serde(default)]
109pub struct GeneralConfig {
112 pub verbosity: u8,
114 #[serde(default)]
116 pub behavior: GeneralBehaviorFlags,
117 #[serde(default, flatten)]
119 pub workflow: GeneralWorkflowFlags,
120 #[serde(default, flatten)]
122 pub execution: GeneralExecutionFlags,
123 pub developer_iters: u32,
125 pub reviewer_reviews: u32,
127 pub developer_context: u8,
129 pub reviewer_context: u8,
131 #[serde(default)]
133 pub review_depth: String,
134 #[serde(default)]
136 pub prompt_path: Option<String>,
137 #[serde(default)]
140 pub templates_dir: Option<String>,
141 #[serde(default)]
143 pub git_user_name: Option<String>,
144 #[serde(default)]
146 pub git_user_email: Option<String>,
147}
148
149impl Default for GeneralConfig {
150 fn default() -> Self {
151 Self {
152 verbosity: 2, behavior: GeneralBehaviorFlags {
154 interactive: true,
155 auto_detect_stack: true,
156 strict_validation: false,
157 },
158 workflow: GeneralWorkflowFlags {
159 checkpoint_enabled: true,
160 auto_rebase: true,
161 max_recovery_attempts: 3,
162 },
163 execution: GeneralExecutionFlags {
164 force_universal_prompt: false,
165 isolation_mode: true,
166 },
167 developer_iters: 5,
168 reviewer_reviews: 2,
169 developer_context: 1,
170 reviewer_context: 0,
171 review_depth: "standard".to_string(),
172 prompt_path: None,
173 templates_dir: None,
174 git_user_name: None,
175 git_user_email: None,
176 }
177 }
178}
179
180pub type CcsAliases = HashMap<String, CcsAliasToml>;
185
186#[derive(Debug, Clone, Deserialize)]
188#[serde(default)]
189pub struct CcsConfig {
190 pub output_flag: String,
192 pub yolo_flag: String,
196 pub verbose_flag: String,
198 pub print_flag: String,
201 pub streaming_flag: String,
204 pub json_parser: String,
206 pub can_commit: bool,
208}
209
210impl Default for CcsConfig {
211 fn default() -> Self {
212 Self {
213 output_flag: "--output-format=stream-json".to_string(),
214 yolo_flag: "--dangerously-skip-permissions".to_string(),
216 verbose_flag: "--verbose".to_string(),
217 print_flag: "-p".to_string(),
218 streaming_flag: "--include-partial-messages".to_string(),
219 json_parser: "claude".to_string(),
220 can_commit: true,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Deserialize, Default)]
227#[serde(default)]
228pub struct CcsAliasConfig {
229 pub cmd: String,
231 pub output_flag: Option<String>,
233 pub yolo_flag: Option<String>,
235 pub verbose_flag: Option<String>,
237 pub print_flag: Option<String>,
239 pub streaming_flag: Option<String>,
241 pub json_parser: Option<String>,
243 pub can_commit: Option<bool>,
245 pub model_flag: Option<String>,
247}
248
249#[derive(Debug, Clone, Deserialize)]
251#[serde(untagged)]
252pub enum CcsAliasToml {
253 Command(String),
254 Config(CcsAliasConfig),
255}
256
257impl CcsAliasToml {
258 pub fn as_config(&self) -> CcsAliasConfig {
259 match self {
260 Self::Command(cmd) => CcsAliasConfig {
261 cmd: cmd.clone(),
262 ..CcsAliasConfig::default()
263 },
264 Self::Config(cfg) => cfg.clone(),
265 }
266 }
267}
268
269#[derive(Debug, Clone, Deserialize, Default)]
273#[serde(default)]
274pub struct AgentConfigToml {
275 pub cmd: Option<String>,
279 pub output_flag: Option<String>,
283 pub yolo_flag: Option<String>,
287 pub verbose_flag: Option<String>,
291 pub print_flag: Option<String>,
295 pub streaming_flag: Option<String>,
299 pub can_commit: Option<bool>,
303 pub json_parser: Option<String>,
307 pub model_flag: Option<String>,
309 pub display_name: Option<String>,
313}
314
315#[derive(Debug, Clone, Deserialize, Default)]
320#[serde(default)]
321pub struct UnifiedConfig {
322 pub general: GeneralConfig,
324 pub ccs: CcsConfig,
326 #[serde(default)]
328 pub agents: HashMap<String, AgentConfigToml>,
329 #[serde(default)]
331 pub ccs_aliases: CcsAliases,
332 #[serde(default, rename = "agent_chain")]
336 pub agent_chain: Option<FallbackConfig>,
337}
338
339impl UnifiedConfig {
340 pub fn load_default() -> Option<Self> {
344 unified_config_path().and_then(|path| {
345 if path.exists() {
346 Self::load_from_path(&path).ok()
347 } else {
348 None
349 }
350 })
351 }
352
353 pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
355 let contents = std::fs::read_to_string(path)?;
356 let config: Self = toml::from_str(&contents)?;
357 Ok(config)
358 }
359
360 pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
365 let Some(path) = unified_config_path() else {
366 return Err(io::Error::new(
367 io::ErrorKind::NotFound,
368 "Cannot determine config directory (no home directory)",
369 ));
370 };
371
372 Self::ensure_config_exists_at(&path)
373 }
374
375 pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
377 if path.exists() {
378 return Ok(ConfigInitResult::AlreadyExists);
379 }
380
381 if let Some(parent) = path.parent() {
383 fs::create_dir_all(parent)?;
384 }
385
386 fs::write(path, DEFAULT_UNIFIED_CONFIG)?;
388
389 Ok(ConfigInitResult::Created)
390 }
391}
392
393#[derive(Debug, thiserror::Error)]
395pub enum ConfigLoadError {
396 #[error("Failed to read config file: {0}")]
397 Io(#[from] std::io::Error),
398 #[error("Failed to parse TOML: {0}")]
399 Toml(#[from] toml::de::Error),
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::config::types::Verbosity;
406
407 fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
408 config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
409 }
410
411 #[test]
412 fn test_general_config_defaults() {
413 let config = GeneralConfig::default();
414 assert_eq!(config.verbosity, 2);
415 assert!(config.behavior.interactive);
416 assert!(config.execution.isolation_mode);
417 assert!(config.behavior.auto_detect_stack);
418 assert!(config.workflow.checkpoint_enabled);
419 assert_eq!(config.developer_iters, 5);
420 assert_eq!(config.reviewer_reviews, 2);
421 }
422
423 #[test]
424 fn test_unified_config_defaults() {
425 let config = UnifiedConfig::default();
426 assert!(config.agents.is_empty());
427 assert!(config.ccs_aliases.is_empty());
428 assert!(config.agent_chain.is_none());
429 }
430
431 #[test]
432 fn test_parse_unified_config() {
433 let toml_str = r#"
434[general]
435verbosity = 3
436interactive = false
437developer_iters = 10
438
439[agents.claude]
440cmd = "claude -p"
441output_flag = "--output-format=stream-json"
442can_commit = true
443json_parser = "claude"
444
445[ccs_aliases]
446work = "ccs work"
447personal = "ccs personal"
448gemini = "ccs gemini"
449
450[agent_chain]
451developer = ["ccs/work", "claude"]
452reviewer = ["claude"]
453"#;
454 let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
455 assert_eq!(config.general.verbosity, 3);
456 assert!(!config.general.behavior.interactive);
457 assert_eq!(config.general.developer_iters, 10);
458 assert!(config.agents.contains_key("claude"));
459 assert_eq!(
460 config.ccs_aliases.get("work").unwrap().as_config().cmd,
461 "ccs work"
462 );
463 assert_eq!(
464 config.ccs_aliases.get("personal").unwrap().as_config().cmd,
465 "ccs personal"
466 );
467 assert!(config.ccs_aliases.contains_key("work"));
468 assert!(!config.ccs_aliases.contains_key("nonexistent"));
469 let chain = config.agent_chain.expect("agent_chain should parse");
470 assert_eq!(
471 chain.developer,
472 vec!["ccs/work".to_string(), "claude".to_string()]
473 );
474 assert_eq!(chain.reviewer, vec!["claude".to_string()]);
475 }
476
477 #[test]
478 fn test_ccs_alias_lookup() {
479 let mut config = UnifiedConfig::default();
480 config.ccs_aliases.insert(
481 "work".to_string(),
482 CcsAliasToml::Command("ccs work".to_string()),
483 );
484 config.ccs_aliases.insert(
485 "gemini".to_string(),
486 CcsAliasToml::Command("ccs gemini".to_string()),
487 );
488
489 assert_eq!(
490 get_ccs_alias_cmd(&config, "work"),
491 Some("ccs work".to_string())
492 );
493 assert_eq!(
494 get_ccs_alias_cmd(&config, "gemini"),
495 Some("ccs gemini".to_string())
496 );
497 assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
498 }
499
500 #[test]
501 fn test_verbosity_conversion() {
502 let mut config = UnifiedConfig::default();
503 config.general.verbosity = 0;
504 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
505 config.general.verbosity = 4;
506 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
507 }
508
509 #[test]
510 fn test_unified_config_path() {
511 let path = unified_config_path();
513 if let Some(p) = path {
514 assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
515 }
516 }
517}