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 pub session_flag: Option<String>,
244}
245
246#[derive(Debug, Clone, Deserialize)]
248#[serde(untagged)]
249pub enum CcsAliasToml {
250 Command(String),
251 Config(CcsAliasConfig),
252}
253
254impl CcsAliasToml {
255 pub fn as_config(&self) -> CcsAliasConfig {
256 match self {
257 Self::Command(cmd) => CcsAliasConfig {
258 cmd: cmd.clone(),
259 ..CcsAliasConfig::default()
260 },
261 Self::Config(cfg) => cfg.clone(),
262 }
263 }
264}
265
266#[derive(Debug, Clone, Deserialize, Default)]
270#[serde(default)]
271pub struct AgentConfigToml {
272 pub cmd: Option<String>,
276 pub output_flag: Option<String>,
280 pub yolo_flag: Option<String>,
284 pub verbose_flag: Option<String>,
288 pub print_flag: Option<String>,
292 pub streaming_flag: Option<String>,
296 pub session_flag: Option<String>,
304 pub can_commit: Option<bool>,
308 pub json_parser: Option<String>,
312 pub model_flag: Option<String>,
314 pub display_name: Option<String>,
318}
319
320#[derive(Debug, Clone, Deserialize, Default)]
325#[serde(default)]
326pub struct UnifiedConfig {
327 pub general: GeneralConfig,
329 pub ccs: CcsConfig,
331 #[serde(default)]
333 pub agents: HashMap<String, AgentConfigToml>,
334 #[serde(default)]
336 pub ccs_aliases: CcsAliases,
337 #[serde(default, rename = "agent_chain")]
341 pub agent_chain: Option<FallbackConfig>,
342}
343
344impl UnifiedConfig {
345 pub fn load_default() -> Option<Self> {
349 unified_config_path().and_then(|path| {
350 if path.exists() {
351 Self::load_from_path(&path).ok()
352 } else {
353 None
354 }
355 })
356 }
357
358 pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
360 let contents = std::fs::read_to_string(path)?;
361 let config: Self = toml::from_str(&contents)?;
362 Ok(config)
363 }
364
365 pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
370 let Some(path) = unified_config_path() else {
371 return Err(io::Error::new(
372 io::ErrorKind::NotFound,
373 "Cannot determine config directory (no home directory)",
374 ));
375 };
376
377 Self::ensure_config_exists_at(&path)
378 }
379
380 pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
382 if path.exists() {
383 return Ok(ConfigInitResult::AlreadyExists);
384 }
385
386 if let Some(parent) = path.parent() {
388 fs::create_dir_all(parent)?;
389 }
390
391 fs::write(path, DEFAULT_UNIFIED_CONFIG)?;
393
394 Ok(ConfigInitResult::Created)
395 }
396}
397
398#[derive(Debug, thiserror::Error)]
400pub enum ConfigLoadError {
401 #[error("Failed to read config file: {0}")]
402 Io(#[from] std::io::Error),
403 #[error("Failed to parse TOML: {0}")]
404 Toml(#[from] toml::de::Error),
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::config::types::Verbosity;
411
412 fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
413 config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
414 }
415
416 #[test]
417 fn test_general_config_defaults() {
418 let config = GeneralConfig::default();
419 assert_eq!(config.verbosity, 2);
420 assert!(config.behavior.interactive);
421 assert!(config.execution.isolation_mode);
422 assert!(config.behavior.auto_detect_stack);
423 assert!(config.workflow.checkpoint_enabled);
424 assert_eq!(config.developer_iters, 5);
425 assert_eq!(config.reviewer_reviews, 2);
426 }
427
428 #[test]
429 fn test_unified_config_defaults() {
430 let config = UnifiedConfig::default();
431 assert!(config.agents.is_empty());
432 assert!(config.ccs_aliases.is_empty());
433 assert!(config.agent_chain.is_none());
434 }
435
436 #[test]
437 fn test_parse_unified_config() {
438 let toml_str = r#"
439[general]
440verbosity = 3
441interactive = false
442developer_iters = 10
443
444[agents.claude]
445cmd = "claude -p"
446output_flag = "--output-format=stream-json"
447can_commit = true
448json_parser = "claude"
449
450[ccs_aliases]
451work = "ccs work"
452personal = "ccs personal"
453gemini = "ccs gemini"
454
455[agent_chain]
456developer = ["ccs/work", "claude"]
457reviewer = ["claude"]
458"#;
459 let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
460 assert_eq!(config.general.verbosity, 3);
461 assert!(!config.general.behavior.interactive);
462 assert_eq!(config.general.developer_iters, 10);
463 assert!(config.agents.contains_key("claude"));
464 assert_eq!(
465 config.ccs_aliases.get("work").unwrap().as_config().cmd,
466 "ccs work"
467 );
468 assert_eq!(
469 config.ccs_aliases.get("personal").unwrap().as_config().cmd,
470 "ccs personal"
471 );
472 assert!(config.ccs_aliases.contains_key("work"));
473 assert!(!config.ccs_aliases.contains_key("nonexistent"));
474 let chain = config.agent_chain.expect("agent_chain should parse");
475 assert_eq!(
476 chain.developer,
477 vec!["ccs/work".to_string(), "claude".to_string()]
478 );
479 assert_eq!(chain.reviewer, vec!["claude".to_string()]);
480 }
481
482 #[test]
483 fn test_ccs_alias_lookup() {
484 let mut config = UnifiedConfig::default();
485 config.ccs_aliases.insert(
486 "work".to_string(),
487 CcsAliasToml::Command("ccs work".to_string()),
488 );
489 config.ccs_aliases.insert(
490 "gemini".to_string(),
491 CcsAliasToml::Command("ccs gemini".to_string()),
492 );
493
494 assert_eq!(
495 get_ccs_alias_cmd(&config, "work"),
496 Some("ccs work".to_string())
497 );
498 assert_eq!(
499 get_ccs_alias_cmd(&config, "gemini"),
500 Some("ccs gemini".to_string())
501 );
502 assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
503 }
504
505 #[test]
506 fn test_verbosity_conversion() {
507 let mut config = UnifiedConfig::default();
508 config.general.verbosity = 0;
509 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
510 config.general.verbosity = 4;
511 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
512 }
513
514 #[test]
515 fn test_unified_config_path() {
516 let path = unified_config_path();
518 if let Some(p) = path {
519 assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
520 }
521 }
522}