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> {
352 unified_config_path().and_then(|path| {
353 if path.exists() {
354 Self::load_from_path(&path).ok()
355 } else {
356 None
357 }
358 })
359 }
360
361 pub fn load_with_env(env: &dyn super::path_resolver::ConfigEnvironment) -> Option<Self> {
368 env.unified_config_path().and_then(|path| {
369 if env.file_exists(&path) {
370 Self::load_from_path_with_env(&path, env).ok()
371 } else {
372 None
373 }
374 })
375 }
376
377 pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
382 let contents = std::fs::read_to_string(path)?;
383 let config: Self = toml::from_str(&contents)?;
384 Ok(config)
385 }
386
387 pub fn load_from_path_with_env(
391 path: &std::path::Path,
392 env: &dyn super::path_resolver::ConfigEnvironment,
393 ) -> Result<Self, ConfigLoadError> {
394 let contents = env.read_file(path)?;
395 let config: Self = toml::from_str(&contents)?;
396 Ok(config)
397 }
398
399 pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
407 let Some(path) = unified_config_path() else {
408 return Err(io::Error::new(
409 io::ErrorKind::NotFound,
410 "Cannot determine config directory (no home directory)",
411 ));
412 };
413
414 Self::ensure_config_exists_at(&path)
415 }
416
417 pub fn ensure_config_exists_with_env(
421 env: &dyn super::path_resolver::ConfigEnvironment,
422 ) -> io::Result<ConfigInitResult> {
423 let Some(path) = env.unified_config_path() else {
424 return Err(io::Error::new(
425 io::ErrorKind::NotFound,
426 "Cannot determine config directory (no home directory)",
427 ));
428 };
429
430 Self::ensure_config_exists_at_with_env(&path, env)
431 }
432
433 pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
438 if path.exists() {
439 return Ok(ConfigInitResult::AlreadyExists);
440 }
441
442 if let Some(parent) = path.parent() {
444 fs::create_dir_all(parent)?;
445 }
446
447 fs::write(path, DEFAULT_UNIFIED_CONFIG)?;
449
450 Ok(ConfigInitResult::Created)
451 }
452
453 pub fn ensure_config_exists_at_with_env(
457 path: &std::path::Path,
458 env: &dyn super::path_resolver::ConfigEnvironment,
459 ) -> io::Result<ConfigInitResult> {
460 if env.file_exists(path) {
461 return Ok(ConfigInitResult::AlreadyExists);
462 }
463
464 env.write_file(path, DEFAULT_UNIFIED_CONFIG)?;
466
467 Ok(ConfigInitResult::Created)
468 }
469}
470
471#[derive(Debug, thiserror::Error)]
473pub enum ConfigLoadError {
474 #[error("Failed to read config file: {0}")]
475 Io(#[from] std::io::Error),
476 #[error("Failed to parse TOML: {0}")]
477 Toml(#[from] toml::de::Error),
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::config::path_resolver::MemoryConfigEnvironment;
484 use crate::config::types::Verbosity;
485 use std::path::Path;
486
487 fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
488 config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
489 }
490
491 #[test]
492 fn test_load_with_env_reads_from_config_environment() {
493 let toml_str = r#"
494[general]
495verbosity = 3
496interactive = false
497developer_iters = 10
498"#;
499 let env = MemoryConfigEnvironment::new()
500 .with_unified_config_path("/test/config/ralph-workflow.toml")
501 .with_file("/test/config/ralph-workflow.toml", toml_str);
502
503 let config = UnifiedConfig::load_with_env(&env).unwrap();
504
505 assert_eq!(config.general.verbosity, 3);
506 assert!(!config.general.behavior.interactive);
507 assert_eq!(config.general.developer_iters, 10);
508 }
509
510 #[test]
511 fn test_load_with_env_returns_none_when_no_config_path() {
512 let env = MemoryConfigEnvironment::new();
513 let result = UnifiedConfig::load_with_env(&env);
516
517 assert!(result.is_none());
518 }
519
520 #[test]
521 fn test_load_with_env_returns_none_when_file_missing() {
522 let env = MemoryConfigEnvironment::new()
523 .with_unified_config_path("/test/config/ralph-workflow.toml");
524 let result = UnifiedConfig::load_with_env(&env);
527
528 assert!(result.is_none());
529 }
530
531 #[test]
532 fn test_load_from_path_with_env() {
533 let toml_str = r#"
534[general]
535verbosity = 4
536"#;
537 let env = MemoryConfigEnvironment::new().with_file("/custom/path.toml", toml_str);
538
539 let config =
540 UnifiedConfig::load_from_path_with_env(Path::new("/custom/path.toml"), &env).unwrap();
541
542 assert_eq!(config.general.verbosity, 4);
543 }
544
545 #[test]
546 fn test_ensure_config_exists_with_env_creates_file() {
547 let env = MemoryConfigEnvironment::new()
548 .with_unified_config_path("/test/config/ralph-workflow.toml");
549
550 let result = UnifiedConfig::ensure_config_exists_with_env(&env).unwrap();
551
552 assert_eq!(result, ConfigInitResult::Created);
553 assert!(env.was_written(Path::new("/test/config/ralph-workflow.toml")));
554 }
555
556 #[test]
557 fn test_ensure_config_exists_with_env_skips_existing() {
558 let env = MemoryConfigEnvironment::new()
559 .with_unified_config_path("/test/config/ralph-workflow.toml")
560 .with_file("/test/config/ralph-workflow.toml", "existing content");
561
562 let result = UnifiedConfig::ensure_config_exists_with_env(&env).unwrap();
563
564 assert_eq!(result, ConfigInitResult::AlreadyExists);
565 assert_eq!(
567 env.get_file(Path::new("/test/config/ralph-workflow.toml")),
568 Some("existing content".to_string())
569 );
570 }
571
572 #[test]
573 fn test_general_config_defaults() {
574 let config = GeneralConfig::default();
575 assert_eq!(config.verbosity, 2);
576 assert!(config.behavior.interactive);
577 assert!(config.execution.isolation_mode);
578 assert!(config.behavior.auto_detect_stack);
579 assert!(config.workflow.checkpoint_enabled);
580 assert_eq!(config.developer_iters, 5);
581 assert_eq!(config.reviewer_reviews, 2);
582 }
583
584 #[test]
585 fn test_unified_config_defaults() {
586 let config = UnifiedConfig::default();
587 assert!(config.agents.is_empty());
588 assert!(config.ccs_aliases.is_empty());
589 assert!(config.agent_chain.is_none());
590 }
591
592 #[test]
593 fn test_parse_unified_config() {
594 let toml_str = r#"
595[general]
596verbosity = 3
597interactive = false
598developer_iters = 10
599
600[agents.claude]
601cmd = "claude -p"
602output_flag = "--output-format=stream-json"
603can_commit = true
604json_parser = "claude"
605
606[ccs_aliases]
607work = "ccs work"
608personal = "ccs personal"
609gemini = "ccs gemini"
610
611[agent_chain]
612developer = ["ccs/work", "claude"]
613reviewer = ["claude"]
614"#;
615 let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
616 assert_eq!(config.general.verbosity, 3);
617 assert!(!config.general.behavior.interactive);
618 assert_eq!(config.general.developer_iters, 10);
619 assert!(config.agents.contains_key("claude"));
620 assert_eq!(
621 config.ccs_aliases.get("work").unwrap().as_config().cmd,
622 "ccs work"
623 );
624 assert_eq!(
625 config.ccs_aliases.get("personal").unwrap().as_config().cmd,
626 "ccs personal"
627 );
628 assert!(config.ccs_aliases.contains_key("work"));
629 assert!(!config.ccs_aliases.contains_key("nonexistent"));
630 let chain = config.agent_chain.expect("agent_chain should parse");
631 assert_eq!(
632 chain.developer,
633 vec!["ccs/work".to_string(), "claude".to_string()]
634 );
635 assert_eq!(chain.reviewer, vec!["claude".to_string()]);
636 }
637
638 #[test]
639 fn test_ccs_alias_lookup() {
640 let mut config = UnifiedConfig::default();
641 config.ccs_aliases.insert(
642 "work".to_string(),
643 CcsAliasToml::Command("ccs work".to_string()),
644 );
645 config.ccs_aliases.insert(
646 "gemini".to_string(),
647 CcsAliasToml::Command("ccs gemini".to_string()),
648 );
649
650 assert_eq!(
651 get_ccs_alias_cmd(&config, "work"),
652 Some("ccs work".to_string())
653 );
654 assert_eq!(
655 get_ccs_alias_cmd(&config, "gemini"),
656 Some("ccs gemini".to_string())
657 );
658 assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
659 }
660
661 #[test]
662 fn test_verbosity_conversion() {
663 let mut config = UnifiedConfig::default();
664 config.general.verbosity = 0;
665 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
666 config.general.verbosity = 4;
667 assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
668 }
669
670 #[test]
671 fn test_unified_config_path() {
672 let path = unified_config_path();
674 if let Some(p) = path {
675 assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
676 }
677 }
678}