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::io;
32use std::path::PathBuf;
33
34/// Default unified config template embedded at compile time.
35pub const DEFAULT_UNIFIED_CONFIG: &str = include_str!("../../examples/ralph-workflow.toml");
36
37/// Result of config initialization.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ConfigInitResult {
40    /// Config was created successfully.
41    Created,
42    /// Config already exists.
43    AlreadyExists,
44}
45
46/// Default path for the unified configuration file.
47pub const DEFAULT_UNIFIED_CONFIG_NAME: &str = "ralph-workflow.toml";
48
49/// Get the path to the unified config file.
50///
51/// Returns `~/.config/ralph-workflow.toml` by default.
52///
53/// If `XDG_CONFIG_HOME` is set, uses `{XDG_CONFIG_HOME}/ralph-workflow.toml`.
54pub 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/// General configuration behavioral flags.
66///
67/// Groups user interaction and validation-related boolean settings for `GeneralConfig`.
68#[derive(Debug, Clone, Deserialize, Default)]
69#[serde(default)]
70pub struct GeneralBehaviorFlags {
71    /// Interactive mode (keep agent in foreground).
72    pub interactive: bool,
73    /// Auto-detect project stack for review guidelines.
74    pub auto_detect_stack: bool,
75    /// Strict PROMPT.md validation.
76    pub strict_validation: bool,
77}
78
79/// General configuration workflow automation flags.
80///
81/// Groups workflow automation features for `GeneralConfig`.
82#[derive(Debug, Clone, Deserialize, Default)]
83#[serde(default)]
84pub struct GeneralWorkflowFlags {
85    /// Enable checkpoint/resume functionality.
86    pub checkpoint_enabled: bool,
87}
88
89/// General configuration execution behavior flags.
90///
91/// Groups execution behavior settings for `GeneralConfig`.
92#[derive(Debug, Clone, Deserialize, Default)]
93#[serde(default)]
94pub struct GeneralExecutionFlags {
95    /// Force universal review prompt for all agents.
96    pub force_universal_prompt: bool,
97    /// Isolation mode (prevent context contamination).
98    pub isolation_mode: bool,
99}
100
101/// General configuration section.
102#[derive(Debug, Clone, Deserialize)]
103#[serde(default)]
104// Configuration options naturally use many boolean flags. These represent
105// independent feature toggles, not a state machine, so bools are appropriate.
106pub struct GeneralConfig {
107    /// Verbosity level (0-4).
108    pub verbosity: u8,
109    /// Behavioral flags (interactive, auto-detect, strict validation)
110    #[serde(default)]
111    pub behavior: GeneralBehaviorFlags,
112    /// Workflow automation flags (checkpoint, auto-rebase)
113    #[serde(default, flatten)]
114    pub workflow: GeneralWorkflowFlags,
115    /// Execution behavior flags (universal prompt, isolation mode)
116    #[serde(default, flatten)]
117    pub execution: GeneralExecutionFlags,
118    /// Number of developer iterations.
119    pub developer_iters: u32,
120    /// Number of reviewer re-review passes.
121    pub reviewer_reviews: u32,
122    /// Developer context level.
123    pub developer_context: u8,
124    /// Reviewer context level.
125    pub reviewer_context: u8,
126    /// Review depth level.
127    #[serde(default)]
128    pub review_depth: String,
129    /// Path to save last prompt.
130    #[serde(default)]
131    pub prompt_path: Option<String>,
132    /// User templates directory for custom template overrides.
133    /// When set, templates in this directory take priority over embedded templates.
134    #[serde(default)]
135    pub templates_dir: Option<String>,
136    /// Git user name for commits (optional, falls back to git config).
137    #[serde(default)]
138    pub git_user_name: Option<String>,
139    /// Git user email for commits (optional, falls back to git config).
140    #[serde(default)]
141    pub git_user_email: Option<String>,
142    /// Maximum continuation attempts when developer returns "partial" or "failed".
143    ///
144    /// Higher values allow more attempts to complete complex tasks within a single plan.
145    ///
146    /// Semantics: this value counts *continuation attempts* (fresh sessions) beyond the initial
147    /// attempt. Total valid attempts per iteration is `1 + max_dev_continuations`.
148    ///
149    /// Default: 2 continuations (initial attempt + 2 continuations = 3 total attempts per iteration).
150    #[serde(default = "default_max_dev_continuations")]
151    pub max_dev_continuations: u32,
152    /// Maximum XSD retry attempts when agent output fails XML validation.
153    ///
154    /// Higher values allow more attempts to fix XML formatting issues before
155    /// switching to the next agent in the fallback chain.
156    ///
157    /// Default: 10 retries before falling back to the next agent.
158    #[serde(default = "default_max_xsd_retries")]
159    pub max_xsd_retries: u32,
160    /// Maximum same-agent retry attempts for transient invocation failures (timeout/internal).
161    ///
162    /// Semantics: this is a *failure budget* for the current agent. With a value of `2`:
163    /// 1st failure → retry the same agent; 2nd failure → fall back to the next agent.
164    ///
165    /// Default: 2 (one retry before falling back).
166    #[serde(default = "default_max_same_agent_retries")]
167    pub max_same_agent_retries: u32,
168}
169
170/// Default maximum continuation attempts per development iteration.
171///
172/// This allows 2 continuations per iteration (3 total valid attempts including the initial)
173/// for fast iteration cycles.
174fn default_max_dev_continuations() -> u32 {
175    2
176}
177
178/// Default maximum XSD retry attempts before agent fallback.
179///
180/// This allows 10 retries to fix XML formatting issues before switching agents.
181fn default_max_xsd_retries() -> u32 {
182    10
183}
184
185fn default_max_same_agent_retries() -> u32 {
186    2
187}
188
189impl Default for GeneralConfig {
190    fn default() -> Self {
191        Self {
192            verbosity: 2, // Verbose
193            behavior: GeneralBehaviorFlags {
194                interactive: true,
195                auto_detect_stack: true,
196                strict_validation: false,
197            },
198            workflow: GeneralWorkflowFlags {
199                checkpoint_enabled: true,
200            },
201            execution: GeneralExecutionFlags {
202                force_universal_prompt: false,
203                isolation_mode: true,
204            },
205            developer_iters: 5,
206            reviewer_reviews: 2,
207            developer_context: 1,
208            reviewer_context: 0,
209            review_depth: "standard".to_string(),
210            prompt_path: None,
211            templates_dir: None,
212            git_user_name: None,
213            git_user_email: None,
214            max_dev_continuations: default_max_dev_continuations(),
215            max_xsd_retries: default_max_xsd_retries(),
216            max_same_agent_retries: default_max_same_agent_retries(),
217        }
218    }
219}
220
221/// CCS (Claude Code Switch) alias configuration.
222///
223/// Maps alias names to CCS profile commands.
224/// For example: `work = "ccs work"` allows using `ccs/work` as an agent.
225pub type CcsAliases = HashMap<String, CcsAliasToml>;
226
227/// CCS defaults applied to all CCS aliases unless overridden per-alias.
228#[derive(Debug, Clone, Deserialize)]
229#[serde(default)]
230pub struct CcsConfig {
231    /// Output-format flag for CCS (often Claude-compatible stream JSON).
232    pub output_flag: String,
233    /// Flag for autonomous mode (skip permission/confirmation prompts).
234    /// Ralph is designed for unattended automation, so this is enabled by default.
235    /// Set to empty string ("") to disable and require confirmations.
236    pub yolo_flag: String,
237    /// Flag for verbose output.
238    pub verbose_flag: String,
239    /// Print flag for non-interactive mode.
240    ///
241    /// IMPORTANT: CCS treats `-p` / `--prompt` as *its own* headless delegation mode.
242    /// When we execute via the `ccs` wrapper (e.g. `ccs codex`), we must use
243    /// Claude's long-form `--print` flag to avoid triggering CCS delegation.
244    ///
245    /// Default: "--print"
246    pub print_flag: String,
247    /// Streaming flag for JSON output with -p (required for Claude/CCS to stream).
248    /// Default: "--include-partial-messages"
249    pub streaming_flag: String,
250    /// Which JSON parser to use for CCS output.
251    pub json_parser: String,
252    /// Session continuation flag template for CCS aliases (Claude CLI).
253    /// The `{}` placeholder is replaced with the session ID at runtime.
254    ///
255    /// Default: "--resume {}"
256    pub session_flag: String,
257    /// Whether CCS can run workflow tools (git commit, etc.).
258    pub can_commit: bool,
259}
260
261impl Default for CcsConfig {
262    fn default() -> Self {
263        Self {
264            output_flag: "--output-format=stream-json".to_string(),
265            // Default to unattended automation (config can override to disable).
266            yolo_flag: "--dangerously-skip-permissions".to_string(),
267            verbose_flag: "--verbose".to_string(),
268            print_flag: "--print".to_string(),
269            streaming_flag: "--include-partial-messages".to_string(),
270            json_parser: "claude".to_string(),
271            session_flag: "--resume {}".to_string(),
272            can_commit: true,
273        }
274    }
275}
276
277/// Per-alias CCS configuration (table form).
278#[derive(Debug, Clone, Deserialize, Default)]
279#[serde(default)]
280pub struct CcsAliasConfig {
281    /// Base CCS command to run (e.g., "ccs work", "ccs gemini").
282    pub cmd: String,
283    /// Optional output flag override for this alias. Use "" to disable.
284    pub output_flag: Option<String>,
285    /// Optional yolo flag override for this alias. Use "" to enable/disable explicitly.
286    pub yolo_flag: Option<String>,
287    /// Optional verbose flag override for this alias. Use "" to disable.
288    pub verbose_flag: Option<String>,
289    /// Optional print flag override for this alias (e.g., "-p" for Claude/CCS).
290    pub print_flag: Option<String>,
291    /// Optional streaming flag override for this alias (e.g., "--include-partial-messages").
292    pub streaming_flag: Option<String>,
293    /// Optional JSON parser override (e.g., "claude", "generic").
294    pub json_parser: Option<String>,
295    /// Optional `can_commit` override for this alias.
296    pub can_commit: Option<bool>,
297    /// Optional model flag appended to the command.
298    pub model_flag: Option<String>,
299    /// Optional session continuation flag (e.g., "--resume {}" for Claude CLI).
300    /// The "{}" placeholder is replaced with the session ID.
301    pub session_flag: Option<String>,
302}
303
304/// CCS alias entry supports both shorthand string and table form.
305#[derive(Debug, Clone, Deserialize)]
306#[serde(untagged)]
307pub enum CcsAliasToml {
308    Command(String),
309    Config(CcsAliasConfig),
310}
311
312impl CcsAliasToml {
313    pub fn as_config(&self) -> CcsAliasConfig {
314        match self {
315            Self::Command(cmd) => CcsAliasConfig {
316                cmd: cmd.clone(),
317                ..CcsAliasConfig::default()
318            },
319            Self::Config(cfg) => cfg.clone(),
320        }
321    }
322}
323
324/// Agent TOML configuration (compatible with `examples/agents.toml`).
325///
326/// Fields are used via serde deserialization.
327#[derive(Debug, Clone, Deserialize, Default)]
328#[serde(default)]
329pub struct AgentConfigToml {
330    /// Base command to run the agent.
331    ///
332    /// When overriding a built-in agent, this may be omitted to keep the built-in command.
333    pub cmd: Option<String>,
334    /// Output-format flag.
335    ///
336    /// Omitted means "keep built-in default". Empty string explicitly disables output flag.
337    pub output_flag: Option<String>,
338    /// Flag for autonomous mode.
339    ///
340    /// Omitted means "keep built-in default". Empty string explicitly disables yolo mode.
341    pub yolo_flag: Option<String>,
342    /// Flag for verbose output.
343    ///
344    /// Omitted means "keep built-in default". Empty string explicitly disables verbose flag.
345    pub verbose_flag: Option<String>,
346    /// Print/non-interactive mode flag (e.g., "-p" for Claude/CCS).
347    ///
348    /// Omitted means "keep built-in default". Empty string explicitly disables print mode.
349    pub print_flag: Option<String>,
350    /// Include partial messages flag for streaming with -p (e.g., "--include-partial-messages").
351    ///
352    /// Omitted means "keep built-in default". Empty string explicitly disables streaming flag.
353    pub streaming_flag: Option<String>,
354    /// Session continuation flag template (e.g., "-s {}" for OpenCode, "--resume {}" for Claude).
355    /// The `{}` placeholder is replaced with the session ID at runtime.
356    ///
357    /// Omitted means "keep built-in default". Empty string explicitly disables session continuation.
358    /// See agent documentation for correct flag format:
359    /// - Claude: --resume <session_id> (from `claude --help`)
360    /// - OpenCode: -s <session_id> (from `opencode run --help`)
361    pub session_flag: Option<String>,
362    /// Whether the agent can run git commit.
363    ///
364    /// Omitted means "keep built-in default". For new agents, this defaults to true when omitted.
365    pub can_commit: Option<bool>,
366    /// Which JSON parser to use.
367    ///
368    /// Omitted means "keep built-in default". For new agents, defaults to "generic" when omitted.
369    pub json_parser: Option<String>,
370    /// Model/provider flag.
371    pub model_flag: Option<String>,
372    /// Human-readable display name for UI/UX.
373    ///
374    /// Omitted means "keep built-in default". Empty string explicitly clears the display name.
375    pub display_name: Option<String>,
376}
377
378/// Unified configuration file structure.
379///
380/// This is the sole source of truth for Ralph configuration,
381/// located at `~/.config/ralph-workflow.toml`.
382#[derive(Debug, Clone, Deserialize, Default)]
383#[serde(default)]
384pub struct UnifiedConfig {
385    /// General settings.
386    pub general: GeneralConfig,
387    /// CCS defaults for aliases.
388    pub ccs: CcsConfig,
389    /// Agent definitions (used via serde deserialization for future expansion).
390    #[serde(default)]
391    pub agents: HashMap<String, AgentConfigToml>,
392    /// CCS alias mappings.
393    #[serde(default)]
394    pub ccs_aliases: CcsAliases,
395    /// Agent chain configuration.
396    ///
397    /// When omitted, Ralph uses built-in defaults.
398    #[serde(default, rename = "agent_chain")]
399    pub agent_chain: Option<FallbackConfig>,
400}
401
402impl UnifiedConfig {
403    /// Load unified configuration from the default path.
404    ///
405    /// Returns None if the file doesn't exist.
406    ///
407    pub fn load_default() -> Option<Self> {
408        Self::load_with_env(&super::path_resolver::RealConfigEnvironment)
409    }
410
411    /// Load unified configuration using a `ConfigEnvironment`.
412    ///
413    /// This is the testable version of `load_default`. It reads from the
414    /// unified config path as determined by the environment.
415    ///
416    /// Returns None if no config path is available or the file doesn't exist.
417    pub fn load_with_env(env: &dyn super::path_resolver::ConfigEnvironment) -> Option<Self> {
418        env.unified_config_path().and_then(|path| {
419            if env.file_exists(&path) {
420                Self::load_from_path_with_env(&path, env).ok()
421            } else {
422                None
423            }
424        })
425    }
426
427    /// Load unified configuration from a specific path.
428    ///
429    /// **Note:** This method uses `std::fs` directly. For testable code,
430    /// use `load_from_path_with_env` with a `ConfigEnvironment` instead.
431    pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
432        let contents = std::fs::read_to_string(path)?;
433        let config: Self = toml::from_str(&contents)?;
434        Ok(config)
435    }
436
437    /// Load unified configuration from a specific path using a `ConfigEnvironment`.
438    ///
439    /// This is the testable version of `load_from_path`.
440    pub fn load_from_path_with_env(
441        path: &std::path::Path,
442        env: &dyn super::path_resolver::ConfigEnvironment,
443    ) -> Result<Self, ConfigLoadError> {
444        let contents = env.read_file(path)?;
445        let config: Self = toml::from_str(&contents)?;
446        Ok(config)
447    }
448
449    /// Ensure unified config file exists, creating it from template if needed.
450    ///
451    /// This creates `~/.config/ralph-workflow.toml` with the default template
452    /// if it doesn't already exist.
453    ///
454    pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
455        Self::ensure_config_exists_with_env(&super::path_resolver::RealConfigEnvironment)
456    }
457
458    /// Ensure unified config file exists using a `ConfigEnvironment`.
459    ///
460    /// This is the testable version of `ensure_config_exists`.
461    pub fn ensure_config_exists_with_env(
462        env: &dyn super::path_resolver::ConfigEnvironment,
463    ) -> io::Result<ConfigInitResult> {
464        let Some(path) = env.unified_config_path() else {
465            return Err(io::Error::new(
466                io::ErrorKind::NotFound,
467                "Cannot determine config directory (no home directory)",
468            ));
469        };
470
471        Self::ensure_config_exists_at_with_env(&path, env)
472    }
473
474    /// Ensure a config file exists at the specified path.
475    ///
476    pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
477        Self::ensure_config_exists_at_with_env(path, &super::path_resolver::RealConfigEnvironment)
478    }
479
480    /// Ensure a config file exists at the specified path using a `ConfigEnvironment`.
481    ///
482    /// This is the testable version of `ensure_config_exists_at`.
483    pub fn ensure_config_exists_at_with_env(
484        path: &std::path::Path,
485        env: &dyn super::path_resolver::ConfigEnvironment,
486    ) -> io::Result<ConfigInitResult> {
487        if env.file_exists(path) {
488            return Ok(ConfigInitResult::AlreadyExists);
489        }
490
491        // Write the default template (write_file creates parent directories)
492        env.write_file(path, DEFAULT_UNIFIED_CONFIG)?;
493
494        Ok(ConfigInitResult::Created)
495    }
496}
497
498/// Error type for unified config loading.
499#[derive(Debug, thiserror::Error)]
500pub enum ConfigLoadError {
501    #[error("Failed to read config file: {0}")]
502    Io(#[from] std::io::Error),
503    #[error("Failed to parse TOML: {0}")]
504    Toml(#[from] toml::de::Error),
505}
506
507#[cfg(test)]
508mod tests;