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}
242
243/// CCS alias entry supports both shorthand string and table form.
244#[derive(Debug, Clone, Deserialize)]
245#[serde(untagged)]
246pub enum CcsAliasToml {
247    Command(String),
248    Config(CcsAliasConfig),
249}
250
251impl CcsAliasToml {
252    pub fn as_config(&self) -> CcsAliasConfig {
253        match self {
254            Self::Command(cmd) => CcsAliasConfig {
255                cmd: cmd.clone(),
256                ..CcsAliasConfig::default()
257            },
258            Self::Config(cfg) => cfg.clone(),
259        }
260    }
261}
262
263/// Agent TOML configuration (compatible with `examples/agents.toml`).
264///
265/// Fields are used via serde deserialization.
266#[derive(Debug, Clone, Deserialize, Default)]
267#[serde(default)]
268pub struct AgentConfigToml {
269    /// Base command to run the agent.
270    ///
271    /// When overriding a built-in agent, this may be omitted to keep the built-in command.
272    pub cmd: Option<String>,
273    /// Output-format flag.
274    ///
275    /// Omitted means "keep built-in default". Empty string explicitly disables output flag.
276    pub output_flag: Option<String>,
277    /// Flag for autonomous mode.
278    ///
279    /// Omitted means "keep built-in default". Empty string explicitly disables yolo mode.
280    pub yolo_flag: Option<String>,
281    /// Flag for verbose output.
282    ///
283    /// Omitted means "keep built-in default". Empty string explicitly disables verbose flag.
284    pub verbose_flag: Option<String>,
285    /// Print/non-interactive mode flag (e.g., "-p" for Claude/CCS).
286    ///
287    /// Omitted means "keep built-in default". Empty string explicitly disables print mode.
288    pub print_flag: Option<String>,
289    /// Include partial messages flag for streaming with -p (e.g., "--include-partial-messages").
290    ///
291    /// Omitted means "keep built-in default". Empty string explicitly disables streaming flag.
292    pub streaming_flag: Option<String>,
293    /// Whether the agent can run git commit.
294    ///
295    /// Omitted means "keep built-in default". For new agents, this defaults to true when omitted.
296    pub can_commit: Option<bool>,
297    /// Which JSON parser to use.
298    ///
299    /// Omitted means "keep built-in default". For new agents, defaults to "generic" when omitted.
300    pub json_parser: Option<String>,
301    /// Model/provider flag.
302    pub model_flag: Option<String>,
303    /// Human-readable display name for UI/UX.
304    ///
305    /// Omitted means "keep built-in default". Empty string explicitly clears the display name.
306    pub display_name: Option<String>,
307}
308
309/// Unified configuration file structure.
310///
311/// This is the sole source of truth for Ralph configuration,
312/// located at `~/.config/ralph-workflow.toml`.
313#[derive(Debug, Clone, Deserialize, Default)]
314#[serde(default)]
315pub struct UnifiedConfig {
316    /// General settings.
317    pub general: GeneralConfig,
318    /// CCS defaults for aliases.
319    pub ccs: CcsConfig,
320    /// Agent definitions (used via serde deserialization for future expansion).
321    #[serde(default)]
322    pub agents: HashMap<String, AgentConfigToml>,
323    /// CCS alias mappings.
324    #[serde(default)]
325    pub ccs_aliases: CcsAliases,
326    /// Agent chain configuration.
327    ///
328    /// When omitted, Ralph uses built-in defaults.
329    #[serde(default, rename = "agent_chain")]
330    pub agent_chain: Option<FallbackConfig>,
331}
332
333impl UnifiedConfig {
334    /// Load unified configuration from the default path.
335    ///
336    /// Returns None if the file doesn't exist.
337    pub fn load_default() -> Option<Self> {
338        unified_config_path().and_then(|path| {
339            if path.exists() {
340                Self::load_from_path(&path).ok()
341            } else {
342                None
343            }
344        })
345    }
346
347    /// Load unified configuration from a specific path.
348    pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
349        let contents = std::fs::read_to_string(path)?;
350        let config: Self = toml::from_str(&contents)?;
351        Ok(config)
352    }
353
354    /// Ensure unified config file exists, creating it from template if needed.
355    ///
356    /// This creates `~/.config/ralph-workflow.toml` with the default template
357    /// if it doesn't already exist.
358    pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
359        let Some(path) = unified_config_path() else {
360            return Err(io::Error::new(
361                io::ErrorKind::NotFound,
362                "Cannot determine config directory (no home directory)",
363            ));
364        };
365
366        Self::ensure_config_exists_at(&path)
367    }
368
369    /// Ensure a config file exists at the specified path.
370    pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
371        if path.exists() {
372            return Ok(ConfigInitResult::AlreadyExists);
373        }
374
375        // Create parent directories if they don't exist
376        if let Some(parent) = path.parent() {
377            fs::create_dir_all(parent)?;
378        }
379
380        // Write the default template
381        fs::write(path, DEFAULT_UNIFIED_CONFIG)?;
382
383        Ok(ConfigInitResult::Created)
384    }
385}
386
387/// Error type for unified config loading.
388#[derive(Debug, thiserror::Error)]
389pub enum ConfigLoadError {
390    #[error("Failed to read config file: {0}")]
391    Io(#[from] std::io::Error),
392    #[error("Failed to parse TOML: {0}")]
393    Toml(#[from] toml::de::Error),
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::config::types::Verbosity;
400
401    fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
402        config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
403    }
404
405    #[test]
406    fn test_general_config_defaults() {
407        let config = GeneralConfig::default();
408        assert_eq!(config.verbosity, 2);
409        assert!(config.behavior.interactive);
410        assert!(config.execution.isolation_mode);
411        assert!(config.behavior.auto_detect_stack);
412        assert!(config.workflow.checkpoint_enabled);
413        assert_eq!(config.developer_iters, 5);
414        assert_eq!(config.reviewer_reviews, 2);
415    }
416
417    #[test]
418    fn test_unified_config_defaults() {
419        let config = UnifiedConfig::default();
420        assert!(config.agents.is_empty());
421        assert!(config.ccs_aliases.is_empty());
422        assert!(config.agent_chain.is_none());
423    }
424
425    #[test]
426    fn test_parse_unified_config() {
427        let toml_str = r#"
428[general]
429verbosity = 3
430interactive = false
431developer_iters = 10
432
433[agents.claude]
434cmd = "claude -p"
435output_flag = "--output-format=stream-json"
436can_commit = true
437json_parser = "claude"
438
439[ccs_aliases]
440work = "ccs work"
441personal = "ccs personal"
442gemini = "ccs gemini"
443
444[agent_chain]
445developer = ["ccs/work", "claude"]
446reviewer = ["claude"]
447"#;
448        let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
449        assert_eq!(config.general.verbosity, 3);
450        assert!(!config.general.behavior.interactive);
451        assert_eq!(config.general.developer_iters, 10);
452        assert!(config.agents.contains_key("claude"));
453        assert_eq!(
454            config.ccs_aliases.get("work").unwrap().as_config().cmd,
455            "ccs work"
456        );
457        assert_eq!(
458            config.ccs_aliases.get("personal").unwrap().as_config().cmd,
459            "ccs personal"
460        );
461        assert!(config.ccs_aliases.contains_key("work"));
462        assert!(!config.ccs_aliases.contains_key("nonexistent"));
463        let chain = config.agent_chain.expect("agent_chain should parse");
464        assert_eq!(
465            chain.developer,
466            vec!["ccs/work".to_string(), "claude".to_string()]
467        );
468        assert_eq!(chain.reviewer, vec!["claude".to_string()]);
469    }
470
471    #[test]
472    fn test_ccs_alias_lookup() {
473        let mut config = UnifiedConfig::default();
474        config.ccs_aliases.insert(
475            "work".to_string(),
476            CcsAliasToml::Command("ccs work".to_string()),
477        );
478        config.ccs_aliases.insert(
479            "gemini".to_string(),
480            CcsAliasToml::Command("ccs gemini".to_string()),
481        );
482
483        assert_eq!(
484            get_ccs_alias_cmd(&config, "work"),
485            Some("ccs work".to_string())
486        );
487        assert_eq!(
488            get_ccs_alias_cmd(&config, "gemini"),
489            Some("ccs gemini".to_string())
490        );
491        assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
492    }
493
494    #[test]
495    fn test_verbosity_conversion() {
496        let mut config = UnifiedConfig::default();
497        config.general.verbosity = 0;
498        assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
499        config.general.verbosity = 4;
500        assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
501    }
502
503    #[test]
504    fn test_unified_config_path() {
505        // Just verify it returns something (path depends on system)
506        let path = unified_config_path();
507        if let Some(p) = path {
508            assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
509        }
510    }
511}