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}
89
90#[derive(Debug, Clone, Deserialize, Default)]
94#[serde(default)]
95pub struct GeneralExecutionFlags {
96 pub force_universal_prompt: bool,
98 pub isolation_mode: bool,
100}
101
102#[derive(Debug, Clone, Deserialize)]
104#[serde(default)]
105pub struct GeneralConfig {
108 pub verbosity: u8,
110 #[serde(default)]
112 pub behavior: GeneralBehaviorFlags,
113 #[serde(default, flatten)]
115 pub workflow: GeneralWorkflowFlags,
116 #[serde(default, flatten)]
118 pub execution: GeneralExecutionFlags,
119 pub developer_iters: u32,
121 pub reviewer_reviews: u32,
123 pub developer_context: u8,
125 pub reviewer_context: u8,
127 #[serde(default)]
129 pub review_depth: String,
130 #[serde(default)]
132 pub prompt_path: Option<String>,
133 #[serde(default)]
136 pub templates_dir: Option<String>,
137 #[serde(default)]
139 pub git_user_name: Option<String>,
140 #[serde(default)]
142 pub git_user_email: Option<String>,
143}
144
145impl Default for GeneralConfig {
146 fn default() -> Self {
147 Self {
148 verbosity: 2, behavior: GeneralBehaviorFlags {
150 interactive: true,
151 auto_detect_stack: true,
152 strict_validation: false,
153 },
154 workflow: GeneralWorkflowFlags {
155 checkpoint_enabled: true,
156 },
157 execution: GeneralExecutionFlags {
158 force_universal_prompt: false,
159 isolation_mode: true,
160 },
161 developer_iters: 5,
162 reviewer_reviews: 2,
163 developer_context: 1,
164 reviewer_context: 0,
165 review_depth: "standard".to_string(),
166 prompt_path: None,
167 templates_dir: None,
168 git_user_name: None,
169 git_user_email: None,
170 }
171 }
172}
173
174pub type CcsAliases = HashMap<String, CcsAliasToml>;
179
180#[derive(Debug, Clone, Deserialize)]
182#[serde(default)]
183pub struct CcsConfig {
184 pub output_flag: String,
186 pub yolo_flag: String,
190 pub verbose_flag: String,
192 pub print_flag: String,
195 pub streaming_flag: String,
198 pub json_parser: String,
200 pub can_commit: bool,
202}
203
204impl Default for CcsConfig {
205 fn default() -> Self {
206 Self {
207 output_flag: "--output-format=stream-json".to_string(),
208 yolo_flag: "--dangerously-skip-permissions".to_string(),
210 verbose_flag: "--verbose".to_string(),
211 print_flag: "-p".to_string(),
212 streaming_flag: "--include-partial-messages".to_string(),
213 json_parser: "claude".to_string(),
214 can_commit: true,
215 }
216 }
217}
218
219#[derive(Debug, Clone, Deserialize, Default)]
221#[serde(default)]
222pub struct CcsAliasConfig {
223 pub cmd: String,
225 pub output_flag: Option<String>,
227 pub yolo_flag: Option<String>,
229 pub verbose_flag: Option<String>,
231 pub print_flag: Option<String>,
233 pub streaming_flag: Option<String>,
235 pub json_parser: Option<String>,
237 pub can_commit: Option<bool>,
239 pub model_flag: Option<String>,
241}
242
243#[derive(Debug, Clone, Deserialize)]
245#[serde(untagged)]
246pub enum CcsAliasToml {
247 Command(String),
248 Config(CcsAliasConfig),
249}
250
251impl CcsAliasToml {
252 pub fn as_config(&self) -> CcsAliasConfig {
253 match self {
254 Self::Command(cmd) => CcsAliasConfig {
255 cmd: cmd.clone(),
256 ..CcsAliasConfig::default()
257 },
258 Self::Config(cfg) => cfg.clone(),
259 }
260 }
261}
262
263#[derive(Debug, Clone, Deserialize, Default)]
267#[serde(default)]
268pub struct AgentConfigToml {
269 pub cmd: Option<String>,
273 pub output_flag: Option<String>,
277 pub yolo_flag: Option<String>,
281 pub verbose_flag: Option<String>,
285 pub print_flag: Option<String>,
289 pub streaming_flag: Option<String>,
293 pub can_commit: Option<bool>,
297 pub json_parser: Option<String>,
301 pub model_flag: Option<String>,
303 pub display_name: Option<String>,
307}
308
309#[derive(Debug, Clone, Deserialize, Default)]
314#[serde(default)]
315pub struct UnifiedConfig {
316 pub general: GeneralConfig,
318 pub ccs: CcsConfig,
320 #[serde(default)]
322 pub agents: HashMap<String, AgentConfigToml>,
323 #[serde(default)]
325 pub ccs_aliases: CcsAliases,
326 #[serde(default, rename = "agent_chain")]
330 pub agent_chain: Option<FallbackConfig>,
331}
332
333impl UnifiedConfig {
334 pub fn load_default() -> Option<Self> {
338 unified_config_path().and_then(|path| {
339 if path.exists() {
340 Self::load_from_path(&path).ok()
341 } else {
342 None
343 }
344 })
345 }
346
347 pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
349 let contents = std::fs::read_to_string(path)?;
350 let config: Self = toml::from_str(&contents)?;
351 Ok(config)
352 }
353
354 pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
359 let Some(path) = unified_config_path() else {
360 return Err(io::Error::new(
361 io::ErrorKind::NotFound,
362 "Cannot determine config directory (no home directory)",
363 ));
364 };
365
366 Self::ensure_config_exists_at(&path)
367 }
368
369 pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
371 if path.exists() {
372 return Ok(ConfigInitResult::AlreadyExists);
373 }
374
375 if let Some(parent) = path.parent() {
377 fs::create_dir_all(parent)?;
378 }
379
380 fs::write(path, DEFAULT_UNIFIED_CONFIG)?;
382
383 Ok(ConfigInitResult::Created)
384 }
385}
386
387#[derive(Debug, thiserror::Error)]
389pub enum ConfigLoadError {
390 #[error("Failed to read config file: {0}")]
391 Io(#[from] std::io::Error),
392 #[error("Failed to parse TOML: {0}")]
393 Toml(#[from] toml::de::Error),
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::config::types::Verbosity;
400
401 fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
402 config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
403 }
404
405 #[test]
406 fn test_general_config_defaults() {
407 let config = GeneralConfig::default();
408 assert_eq!(config.verbosity, 2);
409 assert!(config.behavior.interactive);
410 assert!(config.execution.isolation_mode);
411 assert!(config.behavior.auto_detect_stack);
412 assert!(config.workflow.checkpoint_enabled);
413 assert_eq!(config.developer_iters, 5);
414 assert_eq!(config.reviewer_reviews, 2);
415 }
416
417 #[test]
418 fn test_unified_config_defaults() {
419 let config = UnifiedConfig::default();
420 assert!(config.agents.is_empty());
421 assert!(config.ccs_aliases.is_empty());
422 assert!(config.agent_chain.is_none());
423 }
424
425 #[test]
426 fn test_parse_unified_config() {
427 let toml_str = r#"
428[general]
429verbosity = 3
430interactive = false
431developer_iters = 10
432
433[agents.claude]
434cmd = "claude -p"
435output_flag = "--output-format=stream-json"
436can_commit = true
437json_parser = "claude"
438
439[ccs_aliases]
440work = "ccs work"
441personal = "ccs personal"
442gemini = "ccs gemini"
443
444[agent_chain]
445developer = ["ccs/work", "claude"]
446reviewer = ["claude"]
447"#;
448 let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
449 assert_eq!(config.general.verbosity, 3);
450 assert!(!config.general.behavior.interactive);
451 assert_eq!(config.general.developer_iters, 10);
452 assert!(config.agents.contains_key("claude"));
453 assert_eq!(
454 config.ccs_aliases.get("work").unwrap().as_config().cmd,
455 "ccs work"
456 );
457 assert_eq!(
458 config.ccs_aliases.get("personal").unwrap().as_config().cmd,
459 "ccs personal"
460 );
461 assert!(config.ccs_aliases.contains_key("work"));
462 assert!(!config.ccs_aliases.contains_key("nonexistent"));
463 let chain = config.agent_chain.expect("agent_chain should parse");
464 assert_eq!(
465 chain.developer,
466 vec!["ccs/work".to_string(), "claude".to_string()]
467 );
468 assert_eq!(chain.reviewer, vec!["claude".to_string()]);
469 }
470
471 #[test]
472 fn test_ccs_alias_lookup() {
473 let mut config = UnifiedConfig::default();
474 config.ccs_aliases.insert(
475 "work".to_string(),
476 CcsAliasToml::Command("ccs work".to_string()),
477 );
478 config.ccs_aliases.insert(
479 "gemini".to_string(),
480 CcsAliasToml::Command("ccs gemini".to_string()),
481 );
482
483 assert_eq!(
484 get_ccs_alias_cmd(&config, "work"),
485 Some("ccs work".to_string())
486 );
487 assert_eq!(
488 get_ccs_alias_cmd(&config, "gemini"),
489 Some("ccs gemini".to_string())
490 );
491 assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
492 }
493
494 #[test]
495 fn test_verbosity_conversion() {
496 let mut config = UnifiedConfig::default();
497 config.general.verbosity = 0;
498 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
499 config.general.verbosity = 4;
500 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
501 }
502
503 #[test]
504 fn test_unified_config_path() {
505 let path = unified_config_path();
507 if let Some(p) = path {
508 assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
509 }
510 }
511}