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;