Skip to main content

ralph_workflow/config/
unified.rs

1//! Unified Configuration Types
2//!
3//! This module defines the unified configuration format for Ralph,
4//! consolidating all settings into a single `~/.config/ralph-workflow.toml` file.
5//!
6//! # Configuration Structure
7//!
8//! ```toml
9//! [general]
10//! verbosity = 2
11//! interactive = true
12//! isolation_mode = true
13//!
14//! [agents.claude]
15//! cmd = "claude -p"
16//! # ...
17//!
18//! [ccs_aliases]
19//! work = "ccs work"
20//! personal = "ccs personal"
21//!
22//! [agent_chain]
23//! developer = ["ccs/work", "claude"]
24//! reviewer = ["claude"]
25//! ```
26
27use 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
35/// Default unified config template embedded at compile time.
36pub const DEFAULT_UNIFIED_CONFIG: &str = include_str!("../../examples/ralph-workflow.toml");
37
38/// Result of config initialization.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ConfigInitResult {
41    /// Config was created successfully.
42    Created,
43    /// Config already exists.
44    AlreadyExists,
45}
46
47/// Default path for the unified configuration file.
48pub const DEFAULT_UNIFIED_CONFIG_NAME: &str = "ralph-workflow.toml";
49
50/// Get the path to the unified config file.
51///
52/// Returns `~/.config/ralph-workflow.toml` by default.
53///
54/// If `XDG_CONFIG_HOME` is set, uses `{XDG_CONFIG_HOME}/ralph-workflow.toml`.
55pub 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/// General configuration behavioral flags.
67///
68/// Groups user interaction and validation-related boolean settings for `GeneralConfig`.
69#[derive(Debug, Clone, Deserialize, Default)]
70#[serde(default)]
71pub struct GeneralBehaviorFlags {
72    /// Interactive mode (keep agent in foreground).
73    pub interactive: bool,
74    /// Auto-detect project stack for review guidelines.
75    pub auto_detect_stack: bool,
76    /// Strict PROMPT.md validation.
77    pub strict_validation: bool,
78}
79
80/// General configuration workflow automation flags.
81///
82/// Groups workflow automation features for `GeneralConfig`.
83#[derive(Debug, Clone, Deserialize, Default)]
84#[serde(default)]
85pub struct GeneralWorkflowFlags {
86    /// Enable checkpoint/resume functionality.
87    pub checkpoint_enabled: bool,
88}
89
90/// General configuration execution behavior flags.
91///
92/// Groups execution behavior settings for `GeneralConfig`.
93#[derive(Debug, Clone, Deserialize, Default)]
94#[serde(default)]
95pub struct GeneralExecutionFlags {
96    /// Force universal review prompt for all agents.
97    pub force_universal_prompt: bool,
98    /// Isolation mode (prevent context contamination).
99    pub isolation_mode: bool,
100}
101
102/// General configuration section.
103#[derive(Debug, Clone, Deserialize)]
104#[serde(default)]
105// Configuration options naturally use many boolean flags. These represent
106// independent feature toggles, not a state machine, so bools are appropriate.
107pub struct GeneralConfig {
108    /// Verbosity level (0-4).
109    pub verbosity: u8,
110    /// Behavioral flags (interactive, auto-detect, strict validation)
111    #[serde(default)]
112    pub behavior: GeneralBehaviorFlags,
113    /// Workflow automation flags (checkpoint, auto-rebase)
114    #[serde(default, flatten)]
115    pub workflow: GeneralWorkflowFlags,
116    /// Execution behavior flags (universal prompt, isolation mode)
117    #[serde(default, flatten)]
118    pub execution: GeneralExecutionFlags,
119    /// Number of developer iterations.
120    pub developer_iters: u32,
121    /// Number of reviewer re-review passes.
122    pub reviewer_reviews: u32,
123    /// Developer context level.
124    pub developer_context: u8,
125    /// Reviewer context level.
126    pub reviewer_context: u8,
127    /// Review depth level.
128    #[serde(default)]
129    pub review_depth: String,
130    /// Path to save last prompt.
131    #[serde(default)]
132    pub prompt_path: Option<String>,
133    /// User templates directory for custom template overrides.
134    /// When set, templates in this directory take priority over embedded templates.
135    #[serde(default)]
136    pub templates_dir: Option<String>,
137    /// Git user name for commits (optional, falls back to git config).
138    #[serde(default)]
139    pub git_user_name: Option<String>,
140    /// Git user email for commits (optional, falls back to git config).
141    #[serde(default)]
142    pub git_user_email: Option<String>,
143}
144
145impl Default for GeneralConfig {
146    fn default() -> Self {
147        Self {
148            verbosity: 2, // Verbose
149            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
174/// CCS (Claude Code Switch) alias configuration.
175///
176/// Maps alias names to CCS profile commands.
177/// For example: `work = "ccs work"` allows using `ccs/work` as an agent.
178pub type CcsAliases = HashMap<String, CcsAliasToml>;
179
180/// CCS defaults applied to all CCS aliases unless overridden per-alias.
181#[derive(Debug, Clone, Deserialize)]
182#[serde(default)]
183pub struct CcsConfig {
184    /// Output-format flag for CCS (often Claude-compatible stream JSON).
185    pub output_flag: String,
186    /// Flag for autonomous mode (skip permission/confirmation prompts).
187    /// Ralph is designed for unattended automation, so this is enabled by default.
188    /// Set to empty string ("") to disable and require confirmations.
189    pub yolo_flag: String,
190    /// Flag for verbose output.
191    pub verbose_flag: String,
192    /// Print flag for non-interactive mode (required by Claude CLI).
193    /// Default: "-p"
194    pub print_flag: String,
195    /// Streaming flag for JSON output with -p (required for Claude/CCS to stream).
196    /// Default: "--include-partial-messages"
197    pub streaming_flag: String,
198    /// Which JSON parser to use for CCS output.
199    pub json_parser: String,
200    /// Whether CCS can run workflow tools (git commit, etc.).
201    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            // Default to unattended automation (config can override to disable).
209            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/// Per-alias CCS configuration (table form).
220#[derive(Debug, Clone, Deserialize, Default)]
221#[serde(default)]
222pub struct CcsAliasConfig {
223    /// Base CCS command to run (e.g., "ccs work", "ccs gemini").
224    pub cmd: String,
225    /// Optional output flag override for this alias. Use "" to disable.
226    pub output_flag: Option<String>,
227    /// Optional yolo flag override for this alias. Use "" to enable/disable explicitly.
228    pub yolo_flag: Option<String>,
229    /// Optional verbose flag override for this alias. Use "" to disable.
230    pub verbose_flag: Option<String>,
231    /// Optional print flag override for this alias (e.g., "-p" for Claude/CCS).
232    pub print_flag: Option<String>,
233    /// Optional streaming flag override for this alias (e.g., "--include-partial-messages").
234    pub streaming_flag: Option<String>,
235    /// Optional JSON parser override (e.g., "claude", "generic").
236    pub json_parser: Option<String>,
237    /// Optional `can_commit` override for this alias.
238    pub can_commit: Option<bool>,
239    /// Optional model flag appended to the command.
240    pub model_flag: Option<String>,
241    /// Optional session continuation flag (e.g., "--resume {}" for Claude CLI).
242    /// The "{}" placeholder is replaced with the session ID.
243    pub session_flag: Option<String>,
244}
245
246/// CCS alias entry supports both shorthand string and table form.
247#[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/// Agent TOML configuration (compatible with `examples/agents.toml`).
267///
268/// Fields are used via serde deserialization.
269#[derive(Debug, Clone, Deserialize, Default)]
270#[serde(default)]
271pub struct AgentConfigToml {
272    /// Base command to run the agent.
273    ///
274    /// When overriding a built-in agent, this may be omitted to keep the built-in command.
275    pub cmd: Option<String>,
276    /// Output-format flag.
277    ///
278    /// Omitted means "keep built-in default". Empty string explicitly disables output flag.
279    pub output_flag: Option<String>,
280    /// Flag for autonomous mode.
281    ///
282    /// Omitted means "keep built-in default". Empty string explicitly disables yolo mode.
283    pub yolo_flag: Option<String>,
284    /// Flag for verbose output.
285    ///
286    /// Omitted means "keep built-in default". Empty string explicitly disables verbose flag.
287    pub verbose_flag: Option<String>,
288    /// Print/non-interactive mode flag (e.g., "-p" for Claude/CCS).
289    ///
290    /// Omitted means "keep built-in default". Empty string explicitly disables print mode.
291    pub print_flag: Option<String>,
292    /// Include partial messages flag for streaming with -p (e.g., "--include-partial-messages").
293    ///
294    /// Omitted means "keep built-in default". Empty string explicitly disables streaming flag.
295    pub streaming_flag: Option<String>,
296    /// Session continuation flag template (e.g., "-s {}" for OpenCode, "--resume {}" for Claude).
297    /// The `{}` placeholder is replaced with the session ID at runtime.
298    ///
299    /// Omitted means "keep built-in default". Empty string explicitly disables session continuation.
300    /// See agent documentation for correct flag format:
301    /// - Claude: --resume <session_id> (from `claude --help`)
302    /// - OpenCode: -s <session_id> (from `opencode run --help`)
303    pub session_flag: Option<String>,
304    /// Whether the agent can run git commit.
305    ///
306    /// Omitted means "keep built-in default". For new agents, this defaults to true when omitted.
307    pub can_commit: Option<bool>,
308    /// Which JSON parser to use.
309    ///
310    /// Omitted means "keep built-in default". For new agents, defaults to "generic" when omitted.
311    pub json_parser: Option<String>,
312    /// Model/provider flag.
313    pub model_flag: Option<String>,
314    /// Human-readable display name for UI/UX.
315    ///
316    /// Omitted means "keep built-in default". Empty string explicitly clears the display name.
317    pub display_name: Option<String>,
318}
319
320/// Unified configuration file structure.
321///
322/// This is the sole source of truth for Ralph configuration,
323/// located at `~/.config/ralph-workflow.toml`.
324#[derive(Debug, Clone, Deserialize, Default)]
325#[serde(default)]
326pub struct UnifiedConfig {
327    /// General settings.
328    pub general: GeneralConfig,
329    /// CCS defaults for aliases.
330    pub ccs: CcsConfig,
331    /// Agent definitions (used via serde deserialization for future expansion).
332    #[serde(default)]
333    pub agents: HashMap<String, AgentConfigToml>,
334    /// CCS alias mappings.
335    #[serde(default)]
336    pub ccs_aliases: CcsAliases,
337    /// Agent chain configuration.
338    ///
339    /// When omitted, Ralph uses built-in defaults.
340    #[serde(default, rename = "agent_chain")]
341    pub agent_chain: Option<FallbackConfig>,
342}
343
344impl UnifiedConfig {
345    /// Load unified configuration from the default path.
346    ///
347    /// Returns None if the file doesn't exist.
348    ///
349    /// **Note:** This method uses `std::fs` directly. For testable code,
350    /// use [`load_with_env`] with a [`ConfigEnvironment`] instead.
351    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    /// Load unified configuration using a [`ConfigEnvironment`].
362    ///
363    /// This is the testable version of [`load_default`]. It reads from the
364    /// unified config path as determined by the environment.
365    ///
366    /// Returns None if no config path is available or the file doesn't exist.
367    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    /// Load unified configuration from a specific path.
378    ///
379    /// **Note:** This method uses `std::fs` directly. For testable code,
380    /// use [`load_from_path_with_env`] with a [`ConfigEnvironment`] instead.
381    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    /// Load unified configuration from a specific path using a [`ConfigEnvironment`].
388    ///
389    /// This is the testable version of [`load_from_path`].
390    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    /// Ensure unified config file exists, creating it from template if needed.
400    ///
401    /// This creates `~/.config/ralph-workflow.toml` with the default template
402    /// if it doesn't already exist.
403    ///
404    /// **Note:** This method uses `std::fs` directly. For testable code,
405    /// use [`ensure_config_exists_with_env`] with a [`ConfigEnvironment`] instead.
406    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    /// Ensure unified config file exists using a [`ConfigEnvironment`].
418    ///
419    /// This is the testable version of [`ensure_config_exists`].
420    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    /// Ensure a config file exists at the specified path.
434    ///
435    /// **Note:** This method uses `std::fs` directly. For testable code,
436    /// use [`ensure_config_exists_at_with_env`] with a [`ConfigEnvironment`] instead.
437    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        // Create parent directories if they don't exist
443        if let Some(parent) = path.parent() {
444            fs::create_dir_all(parent)?;
445        }
446
447        // Write the default template
448        fs::write(path, DEFAULT_UNIFIED_CONFIG)?;
449
450        Ok(ConfigInitResult::Created)
451    }
452
453    /// Ensure a config file exists at the specified path using a [`ConfigEnvironment`].
454    ///
455    /// This is the testable version of [`ensure_config_exists_at`].
456    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        // Write the default template (write_file creates parent directories)
465        env.write_file(path, DEFAULT_UNIFIED_CONFIG)?;
466
467        Ok(ConfigInitResult::Created)
468    }
469}
470
471/// Error type for unified config loading.
472#[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        // No unified_config_path set
514
515        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        // Path set but file doesn't exist
525
526        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        // Content should be unchanged
566        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        // Just verify it returns something (path depends on system)
673        let path = unified_config_path();
674        if let Some(p) = path {
675            assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
676        }
677    }
678}