1use crate::agents::fallback::FallbackConfig;
28use serde::Deserialize;
29use std::collections::HashMap;
30use std::env;
31use std::io;
32use std::path::PathBuf;
33
34pub const DEFAULT_UNIFIED_CONFIG: &str = include_str!("../../examples/ralph-workflow.toml");
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ConfigInitResult {
40 Created,
42 AlreadyExists,
44}
45
46pub const DEFAULT_UNIFIED_CONFIG_NAME: &str = "ralph-workflow.toml";
48
49pub fn unified_config_path() -> Option<PathBuf> {
55 if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
56 let xdg = xdg.trim();
57 if !xdg.is_empty() {
58 return Some(PathBuf::from(xdg).join(DEFAULT_UNIFIED_CONFIG_NAME));
59 }
60 }
61
62 dirs::home_dir().map(|d| d.join(".config").join(DEFAULT_UNIFIED_CONFIG_NAME))
63}
64
65#[derive(Debug, Clone, Deserialize, Default)]
69#[serde(default)]
70pub struct GeneralBehaviorFlags {
71 pub interactive: bool,
73 pub auto_detect_stack: bool,
75 pub strict_validation: bool,
77}
78
79#[derive(Debug, Clone, Deserialize, Default)]
83#[serde(default)]
84pub struct GeneralWorkflowFlags {
85 pub checkpoint_enabled: bool,
87}
88
89#[derive(Debug, Clone, Deserialize, Default)]
93#[serde(default)]
94pub struct GeneralExecutionFlags {
95 pub force_universal_prompt: bool,
97 pub isolation_mode: bool,
99}
100
101#[derive(Debug, Clone, Deserialize)]
103#[serde(default)]
104pub struct GeneralConfig {
107 pub verbosity: u8,
109 #[serde(default)]
111 pub behavior: GeneralBehaviorFlags,
112 #[serde(default, flatten)]
114 pub workflow: GeneralWorkflowFlags,
115 #[serde(default, flatten)]
117 pub execution: GeneralExecutionFlags,
118 pub developer_iters: u32,
120 pub reviewer_reviews: u32,
122 pub developer_context: u8,
124 pub reviewer_context: u8,
126 #[serde(default)]
128 pub review_depth: String,
129 #[serde(default)]
131 pub prompt_path: Option<String>,
132 #[serde(default)]
135 pub templates_dir: Option<String>,
136 #[serde(default)]
138 pub git_user_name: Option<String>,
139 #[serde(default)]
141 pub git_user_email: Option<String>,
142 #[serde(default = "default_max_dev_continuations")]
151 pub max_dev_continuations: u32,
152}
153
154fn default_max_dev_continuations() -> u32 {
159 2
160}
161
162impl Default for GeneralConfig {
163 fn default() -> Self {
164 Self {
165 verbosity: 2, behavior: GeneralBehaviorFlags {
167 interactive: true,
168 auto_detect_stack: true,
169 strict_validation: false,
170 },
171 workflow: GeneralWorkflowFlags {
172 checkpoint_enabled: true,
173 },
174 execution: GeneralExecutionFlags {
175 force_universal_prompt: false,
176 isolation_mode: true,
177 },
178 developer_iters: 5,
179 reviewer_reviews: 2,
180 developer_context: 1,
181 reviewer_context: 0,
182 review_depth: "standard".to_string(),
183 prompt_path: None,
184 templates_dir: None,
185 git_user_name: None,
186 git_user_email: None,
187 max_dev_continuations: default_max_dev_continuations(),
188 }
189 }
190}
191
192pub type CcsAliases = HashMap<String, CcsAliasToml>;
197
198#[derive(Debug, Clone, Deserialize)]
200#[serde(default)]
201pub struct CcsConfig {
202 pub output_flag: String,
204 pub yolo_flag: String,
208 pub verbose_flag: String,
210 pub print_flag: String,
213 pub streaming_flag: String,
216 pub json_parser: String,
218 pub can_commit: bool,
220}
221
222impl Default for CcsConfig {
223 fn default() -> Self {
224 Self {
225 output_flag: "--output-format=stream-json".to_string(),
226 yolo_flag: "--dangerously-skip-permissions".to_string(),
228 verbose_flag: "--verbose".to_string(),
229 print_flag: "-p".to_string(),
230 streaming_flag: "--include-partial-messages".to_string(),
231 json_parser: "claude".to_string(),
232 can_commit: true,
233 }
234 }
235}
236
237#[derive(Debug, Clone, Deserialize, Default)]
239#[serde(default)]
240pub struct CcsAliasConfig {
241 pub cmd: String,
243 pub output_flag: Option<String>,
245 pub yolo_flag: Option<String>,
247 pub verbose_flag: Option<String>,
249 pub print_flag: Option<String>,
251 pub streaming_flag: Option<String>,
253 pub json_parser: Option<String>,
255 pub can_commit: Option<bool>,
257 pub model_flag: Option<String>,
259 pub session_flag: Option<String>,
262}
263
264#[derive(Debug, Clone, Deserialize)]
266#[serde(untagged)]
267pub enum CcsAliasToml {
268 Command(String),
269 Config(CcsAliasConfig),
270}
271
272impl CcsAliasToml {
273 pub fn as_config(&self) -> CcsAliasConfig {
274 match self {
275 Self::Command(cmd) => CcsAliasConfig {
276 cmd: cmd.clone(),
277 ..CcsAliasConfig::default()
278 },
279 Self::Config(cfg) => cfg.clone(),
280 }
281 }
282}
283
284#[derive(Debug, Clone, Deserialize, Default)]
288#[serde(default)]
289pub struct AgentConfigToml {
290 pub cmd: Option<String>,
294 pub output_flag: Option<String>,
298 pub yolo_flag: Option<String>,
302 pub verbose_flag: Option<String>,
306 pub print_flag: Option<String>,
310 pub streaming_flag: Option<String>,
314 pub session_flag: Option<String>,
322 pub can_commit: Option<bool>,
326 pub json_parser: Option<String>,
330 pub model_flag: Option<String>,
332 pub display_name: Option<String>,
336}
337
338#[derive(Debug, Clone, Deserialize, Default)]
343#[serde(default)]
344pub struct UnifiedConfig {
345 pub general: GeneralConfig,
347 pub ccs: CcsConfig,
349 #[serde(default)]
351 pub agents: HashMap<String, AgentConfigToml>,
352 #[serde(default)]
354 pub ccs_aliases: CcsAliases,
355 #[serde(default, rename = "agent_chain")]
359 pub agent_chain: Option<FallbackConfig>,
360}
361
362impl UnifiedConfig {
363 pub fn load_default() -> Option<Self> {
368 Self::load_with_env(&super::path_resolver::RealConfigEnvironment)
369 }
370
371 pub fn load_with_env(env: &dyn super::path_resolver::ConfigEnvironment) -> Option<Self> {
378 env.unified_config_path().and_then(|path| {
379 if env.file_exists(&path) {
380 Self::load_from_path_with_env(&path, env).ok()
381 } else {
382 None
383 }
384 })
385 }
386
387 pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
392 let contents = std::fs::read_to_string(path)?;
393 let config: Self = toml::from_str(&contents)?;
394 Ok(config)
395 }
396
397 pub fn load_from_path_with_env(
401 path: &std::path::Path,
402 env: &dyn super::path_resolver::ConfigEnvironment,
403 ) -> Result<Self, ConfigLoadError> {
404 let contents = env.read_file(path)?;
405 let config: Self = toml::from_str(&contents)?;
406 Ok(config)
407 }
408
409 pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
415 Self::ensure_config_exists_with_env(&super::path_resolver::RealConfigEnvironment)
416 }
417
418 pub fn ensure_config_exists_with_env(
422 env: &dyn super::path_resolver::ConfigEnvironment,
423 ) -> io::Result<ConfigInitResult> {
424 let Some(path) = env.unified_config_path() else {
425 return Err(io::Error::new(
426 io::ErrorKind::NotFound,
427 "Cannot determine config directory (no home directory)",
428 ));
429 };
430
431 Self::ensure_config_exists_at_with_env(&path, env)
432 }
433
434 pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
437 Self::ensure_config_exists_at_with_env(path, &super::path_resolver::RealConfigEnvironment)
438 }
439
440 pub fn ensure_config_exists_at_with_env(
444 path: &std::path::Path,
445 env: &dyn super::path_resolver::ConfigEnvironment,
446 ) -> io::Result<ConfigInitResult> {
447 if env.file_exists(path) {
448 return Ok(ConfigInitResult::AlreadyExists);
449 }
450
451 env.write_file(path, DEFAULT_UNIFIED_CONFIG)?;
453
454 Ok(ConfigInitResult::Created)
455 }
456}
457
458#[derive(Debug, thiserror::Error)]
460pub enum ConfigLoadError {
461 #[error("Failed to read config file: {0}")]
462 Io(#[from] std::io::Error),
463 #[error("Failed to parse TOML: {0}")]
464 Toml(#[from] toml::de::Error),
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::config::path_resolver::MemoryConfigEnvironment;
471 use crate::config::types::Verbosity;
472 use std::path::Path;
473
474 fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
475 config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
476 }
477
478 #[test]
479 fn test_load_with_env_reads_from_config_environment() {
480 let toml_str = r#"
481[general]
482verbosity = 3
483interactive = false
484developer_iters = 10
485"#;
486 let env = MemoryConfigEnvironment::new()
487 .with_unified_config_path("/test/config/ralph-workflow.toml")
488 .with_file("/test/config/ralph-workflow.toml", toml_str);
489
490 let config = UnifiedConfig::load_with_env(&env).unwrap();
491
492 assert_eq!(config.general.verbosity, 3);
493 assert!(!config.general.behavior.interactive);
494 assert_eq!(config.general.developer_iters, 10);
495 }
496
497 #[test]
498 fn test_load_with_env_returns_none_when_no_config_path() {
499 let env = MemoryConfigEnvironment::new();
500 let result = UnifiedConfig::load_with_env(&env);
503
504 assert!(result.is_none());
505 }
506
507 #[test]
508 fn test_load_with_env_returns_none_when_file_missing() {
509 let env = MemoryConfigEnvironment::new()
510 .with_unified_config_path("/test/config/ralph-workflow.toml");
511 let result = UnifiedConfig::load_with_env(&env);
514
515 assert!(result.is_none());
516 }
517
518 #[test]
519 fn test_load_from_path_with_env() {
520 let toml_str = r#"
521[general]
522verbosity = 4
523"#;
524 let env = MemoryConfigEnvironment::new().with_file("/custom/path.toml", toml_str);
525
526 let config =
527 UnifiedConfig::load_from_path_with_env(Path::new("/custom/path.toml"), &env).unwrap();
528
529 assert_eq!(config.general.verbosity, 4);
530 }
531
532 #[test]
533 fn test_ensure_config_exists_with_env_creates_file() {
534 let env = MemoryConfigEnvironment::new()
535 .with_unified_config_path("/test/config/ralph-workflow.toml");
536
537 let result = UnifiedConfig::ensure_config_exists_with_env(&env).unwrap();
538
539 assert_eq!(result, ConfigInitResult::Created);
540 assert!(env.was_written(Path::new("/test/config/ralph-workflow.toml")));
541 }
542
543 #[test]
544 fn test_ensure_config_exists_with_env_skips_existing() {
545 let env = MemoryConfigEnvironment::new()
546 .with_unified_config_path("/test/config/ralph-workflow.toml")
547 .with_file("/test/config/ralph-workflow.toml", "existing content");
548
549 let result = UnifiedConfig::ensure_config_exists_with_env(&env).unwrap();
550
551 assert_eq!(result, ConfigInitResult::AlreadyExists);
552 assert_eq!(
554 env.get_file(Path::new("/test/config/ralph-workflow.toml")),
555 Some("existing content".to_string())
556 );
557 }
558
559 #[test]
560 fn test_general_config_defaults() {
561 let config = GeneralConfig::default();
562 assert_eq!(config.verbosity, 2);
563 assert!(config.behavior.interactive);
564 assert!(config.execution.isolation_mode);
565 assert!(config.behavior.auto_detect_stack);
566 assert!(config.workflow.checkpoint_enabled);
567 assert_eq!(config.developer_iters, 5);
568 assert_eq!(config.reviewer_reviews, 2);
569 }
570
571 #[test]
572 fn test_unified_config_defaults() {
573 let config = UnifiedConfig::default();
574 assert!(config.agents.is_empty());
575 assert!(config.ccs_aliases.is_empty());
576 assert!(config.agent_chain.is_none());
577 }
578
579 #[test]
580 fn test_parse_unified_config() {
581 let toml_str = r#"
582[general]
583verbosity = 3
584interactive = false
585developer_iters = 10
586
587[agents.claude]
588cmd = "claude -p"
589output_flag = "--output-format=stream-json"
590can_commit = true
591json_parser = "claude"
592
593[ccs_aliases]
594work = "ccs work"
595personal = "ccs personal"
596gemini = "ccs gemini"
597
598[agent_chain]
599developer = ["ccs/work", "claude"]
600reviewer = ["claude"]
601"#;
602 let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
603 assert_eq!(config.general.verbosity, 3);
604 assert!(!config.general.behavior.interactive);
605 assert_eq!(config.general.developer_iters, 10);
606 assert!(config.agents.contains_key("claude"));
607 assert_eq!(
608 config.ccs_aliases.get("work").unwrap().as_config().cmd,
609 "ccs work"
610 );
611 assert_eq!(
612 config.ccs_aliases.get("personal").unwrap().as_config().cmd,
613 "ccs personal"
614 );
615 assert!(config.ccs_aliases.contains_key("work"));
616 assert!(!config.ccs_aliases.contains_key("nonexistent"));
617 let chain = config.agent_chain.expect("agent_chain should parse");
618 assert_eq!(
619 chain.developer,
620 vec!["ccs/work".to_string(), "claude".to_string()]
621 );
622 assert_eq!(chain.reviewer, vec!["claude".to_string()]);
623 }
624
625 #[test]
626 fn test_ccs_alias_lookup() {
627 let mut config = UnifiedConfig::default();
628 config.ccs_aliases.insert(
629 "work".to_string(),
630 CcsAliasToml::Command("ccs work".to_string()),
631 );
632 config.ccs_aliases.insert(
633 "gemini".to_string(),
634 CcsAliasToml::Command("ccs gemini".to_string()),
635 );
636
637 assert_eq!(
638 get_ccs_alias_cmd(&config, "work"),
639 Some("ccs work".to_string())
640 );
641 assert_eq!(
642 get_ccs_alias_cmd(&config, "gemini"),
643 Some("ccs gemini".to_string())
644 );
645 assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
646 }
647
648 #[test]
649 fn test_verbosity_conversion() {
650 let mut config = UnifiedConfig::default();
651 config.general.verbosity = 0;
652 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
653 config.general.verbosity = 4;
654 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
655 }
656
657 #[test]
658 fn test_unified_config_path() {
659 let path = unified_config_path();
661 if let Some(p) = path {
662 assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
663 }
664 }
665}