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    /// Enable automatic rebasing before development.
89    pub auto_rebase: bool,
90    /// Maximum number of recovery attempts during rebase.
91    pub max_recovery_attempts: u32,
92}
93
94/// General configuration execution behavior flags.
95///
96/// Groups execution behavior settings for `GeneralConfig`.
97#[derive(Debug, Clone, Deserialize, Default)]
98#[serde(default)]
99pub struct GeneralExecutionFlags {
100    /// Force universal review prompt for all agents.
101    pub force_universal_prompt: bool,
102    /// Isolation mode (prevent context contamination).
103    pub isolation_mode: bool,
104}
105
106/// General configuration section.
107#[derive(Debug, Clone, Deserialize)]
108#[serde(default)]
109// Configuration options naturally use many boolean flags. These represent
110// independent feature toggles, not a state machine, so bools are appropriate.
111pub struct GeneralConfig {
112    /// Verbosity level (0-4).
113    pub verbosity: u8,
114    /// Behavioral flags (interactive, auto-detect, strict validation)
115    #[serde(default)]
116    pub behavior: GeneralBehaviorFlags,
117    /// Workflow automation flags (checkpoint, auto-rebase)
118    #[serde(default, flatten)]
119    pub workflow: GeneralWorkflowFlags,
120    /// Execution behavior flags (universal prompt, isolation mode)
121    #[serde(default, flatten)]
122    pub execution: GeneralExecutionFlags,
123    /// Number of developer iterations.
124    pub developer_iters: u32,
125    /// Number of reviewer re-review passes.
126    pub reviewer_reviews: u32,
127    /// Developer context level.
128    pub developer_context: u8,
129    /// Reviewer context level.
130    pub reviewer_context: u8,
131    /// Review depth level.
132    #[serde(default)]
133    pub review_depth: String,
134    /// Path to save last prompt.
135    #[serde(default)]
136    pub prompt_path: Option<String>,
137    /// User templates directory for custom template overrides.
138    /// When set, templates in this directory take priority over embedded templates.
139    #[serde(default)]
140    pub templates_dir: Option<String>,
141    /// Git user name for commits (optional, falls back to git config).
142    #[serde(default)]
143    pub git_user_name: Option<String>,
144    /// Git user email for commits (optional, falls back to git config).
145    #[serde(default)]
146    pub git_user_email: Option<String>,
147}
148
149impl Default for GeneralConfig {
150    fn default() -> Self {
151        Self {
152            verbosity: 2, // Verbose
153            behavior: GeneralBehaviorFlags {
154                interactive: true,
155                auto_detect_stack: true,
156                strict_validation: false,
157            },
158            workflow: GeneralWorkflowFlags {
159                checkpoint_enabled: true,
160                auto_rebase: true,
161                max_recovery_attempts: 3,
162            },
163            execution: GeneralExecutionFlags {
164                force_universal_prompt: false,
165                isolation_mode: true,
166            },
167            developer_iters: 5,
168            reviewer_reviews: 2,
169            developer_context: 1,
170            reviewer_context: 0,
171            review_depth: "standard".to_string(),
172            prompt_path: None,
173            templates_dir: None,
174            git_user_name: None,
175            git_user_email: None,
176        }
177    }
178}
179
180/// CCS (Claude Code Switch) alias configuration.
181///
182/// Maps alias names to CCS profile commands.
183/// For example: `work = "ccs work"` allows using `ccs/work` as an agent.
184pub type CcsAliases = HashMap<String, CcsAliasToml>;
185
186/// CCS defaults applied to all CCS aliases unless overridden per-alias.
187#[derive(Debug, Clone, Deserialize)]
188#[serde(default)]
189pub struct CcsConfig {
190    /// Output-format flag for CCS (often Claude-compatible stream JSON).
191    pub output_flag: String,
192    /// Flag for autonomous mode (skip permission/confirmation prompts).
193    /// Ralph is designed for unattended automation, so this is enabled by default.
194    /// Set to empty string ("") to disable and require confirmations.
195    pub yolo_flag: String,
196    /// Flag for verbose output.
197    pub verbose_flag: String,
198    /// Print flag for non-interactive mode (required by Claude CLI).
199    /// Default: "-p"
200    pub print_flag: String,
201    /// Streaming flag for JSON output with -p (required for Claude/CCS to stream).
202    /// Default: "--include-partial-messages"
203    pub streaming_flag: String,
204    /// Which JSON parser to use for CCS output.
205    pub json_parser: String,
206    /// Whether CCS can run workflow tools (git commit, etc.).
207    pub can_commit: bool,
208}
209
210impl Default for CcsConfig {
211    fn default() -> Self {
212        Self {
213            output_flag: "--output-format=stream-json".to_string(),
214            // Default to unattended automation (config can override to disable).
215            yolo_flag: "--dangerously-skip-permissions".to_string(),
216            verbose_flag: "--verbose".to_string(),
217            print_flag: "-p".to_string(),
218            streaming_flag: "--include-partial-messages".to_string(),
219            json_parser: "claude".to_string(),
220            can_commit: true,
221        }
222    }
223}
224
225/// Per-alias CCS configuration (table form).
226#[derive(Debug, Clone, Deserialize, Default)]
227#[serde(default)]
228pub struct CcsAliasConfig {
229    /// Base CCS command to run (e.g., "ccs work", "ccs gemini").
230    pub cmd: String,
231    /// Optional output flag override for this alias. Use "" to disable.
232    pub output_flag: Option<String>,
233    /// Optional yolo flag override for this alias. Use "" to enable/disable explicitly.
234    pub yolo_flag: Option<String>,
235    /// Optional verbose flag override for this alias. Use "" to disable.
236    pub verbose_flag: Option<String>,
237    /// Optional print flag override for this alias (e.g., "-p" for Claude/CCS).
238    pub print_flag: Option<String>,
239    /// Optional streaming flag override for this alias (e.g., "--include-partial-messages").
240    pub streaming_flag: Option<String>,
241    /// Optional JSON parser override (e.g., "claude", "generic").
242    pub json_parser: Option<String>,
243    /// Optional `can_commit` override for this alias.
244    pub can_commit: Option<bool>,
245    /// Optional model flag appended to the command.
246    pub model_flag: Option<String>,
247}
248
249/// CCS alias entry supports both shorthand string and table form.
250#[derive(Debug, Clone, Deserialize)]
251#[serde(untagged)]
252pub enum CcsAliasToml {
253    Command(String),
254    Config(CcsAliasConfig),
255}
256
257impl CcsAliasToml {
258    pub fn as_config(&self) -> CcsAliasConfig {
259        match self {
260            Self::Command(cmd) => CcsAliasConfig {
261                cmd: cmd.clone(),
262                ..CcsAliasConfig::default()
263            },
264            Self::Config(cfg) => cfg.clone(),
265        }
266    }
267}
268
269/// Agent TOML configuration (compatible with `examples/agents.toml`).
270///
271/// Fields are used via serde deserialization.
272#[derive(Debug, Clone, Deserialize, Default)]
273#[serde(default)]
274pub struct AgentConfigToml {
275    /// Base command to run the agent.
276    ///
277    /// When overriding a built-in agent, this may be omitted to keep the built-in command.
278    pub cmd: Option<String>,
279    /// Output-format flag.
280    ///
281    /// Omitted means "keep built-in default". Empty string explicitly disables output flag.
282    pub output_flag: Option<String>,
283    /// Flag for autonomous mode.
284    ///
285    /// Omitted means "keep built-in default". Empty string explicitly disables yolo mode.
286    pub yolo_flag: Option<String>,
287    /// Flag for verbose output.
288    ///
289    /// Omitted means "keep built-in default". Empty string explicitly disables verbose flag.
290    pub verbose_flag: Option<String>,
291    /// Print/non-interactive mode flag (e.g., "-p" for Claude/CCS).
292    ///
293    /// Omitted means "keep built-in default". Empty string explicitly disables print mode.
294    pub print_flag: Option<String>,
295    /// Include partial messages flag for streaming with -p (e.g., "--include-partial-messages").
296    ///
297    /// Omitted means "keep built-in default". Empty string explicitly disables streaming flag.
298    pub streaming_flag: Option<String>,
299    /// Whether the agent can run git commit.
300    ///
301    /// Omitted means "keep built-in default". For new agents, this defaults to true when omitted.
302    pub can_commit: Option<bool>,
303    /// Which JSON parser to use.
304    ///
305    /// Omitted means "keep built-in default". For new agents, defaults to "generic" when omitted.
306    pub json_parser: Option<String>,
307    /// Model/provider flag.
308    pub model_flag: Option<String>,
309    /// Human-readable display name for UI/UX.
310    ///
311    /// Omitted means "keep built-in default". Empty string explicitly clears the display name.
312    pub display_name: Option<String>,
313}
314
315/// Unified configuration file structure.
316///
317/// This is the sole source of truth for Ralph configuration,
318/// located at `~/.config/ralph-workflow.toml`.
319#[derive(Debug, Clone, Deserialize, Default)]
320#[serde(default)]
321pub struct UnifiedConfig {
322    /// General settings.
323    pub general: GeneralConfig,
324    /// CCS defaults for aliases.
325    pub ccs: CcsConfig,
326    /// Agent definitions (used via serde deserialization for future expansion).
327    #[serde(default)]
328    pub agents: HashMap<String, AgentConfigToml>,
329    /// CCS alias mappings.
330    #[serde(default)]
331    pub ccs_aliases: CcsAliases,
332    /// Agent chain configuration.
333    ///
334    /// When omitted, Ralph uses built-in defaults.
335    #[serde(default, rename = "agent_chain")]
336    pub agent_chain: Option<FallbackConfig>,
337}
338
339impl UnifiedConfig {
340    /// Load unified configuration from the default path.
341    ///
342    /// Returns None if the file doesn't exist.
343    pub fn load_default() -> Option<Self> {
344        unified_config_path().and_then(|path| {
345            if path.exists() {
346                Self::load_from_path(&path).ok()
347            } else {
348                None
349            }
350        })
351    }
352
353    /// Load unified configuration from a specific path.
354    pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
355        let contents = std::fs::read_to_string(path)?;
356        let config: Self = toml::from_str(&contents)?;
357        Ok(config)
358    }
359
360    /// Ensure unified config file exists, creating it from template if needed.
361    ///
362    /// This creates `~/.config/ralph-workflow.toml` with the default template
363    /// if it doesn't already exist.
364    pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
365        let Some(path) = unified_config_path() else {
366            return Err(io::Error::new(
367                io::ErrorKind::NotFound,
368                "Cannot determine config directory (no home directory)",
369            ));
370        };
371
372        Self::ensure_config_exists_at(&path)
373    }
374
375    /// Ensure a config file exists at the specified path.
376    pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
377        if path.exists() {
378            return Ok(ConfigInitResult::AlreadyExists);
379        }
380
381        // Create parent directories if they don't exist
382        if let Some(parent) = path.parent() {
383            fs::create_dir_all(parent)?;
384        }
385
386        // Write the default template
387        fs::write(path, DEFAULT_UNIFIED_CONFIG)?;
388
389        Ok(ConfigInitResult::Created)
390    }
391}
392
393/// Error type for unified config loading.
394#[derive(Debug, thiserror::Error)]
395pub enum ConfigLoadError {
396    #[error("Failed to read config file: {0}")]
397    Io(#[from] std::io::Error),
398    #[error("Failed to parse TOML: {0}")]
399    Toml(#[from] toml::de::Error),
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use crate::config::types::Verbosity;
406
407    fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
408        config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
409    }
410
411    #[test]
412    fn test_general_config_defaults() {
413        let config = GeneralConfig::default();
414        assert_eq!(config.verbosity, 2);
415        assert!(config.behavior.interactive);
416        assert!(config.execution.isolation_mode);
417        assert!(config.behavior.auto_detect_stack);
418        assert!(config.workflow.checkpoint_enabled);
419        assert_eq!(config.developer_iters, 5);
420        assert_eq!(config.reviewer_reviews, 2);
421    }
422
423    #[test]
424    fn test_unified_config_defaults() {
425        let config = UnifiedConfig::default();
426        assert!(config.agents.is_empty());
427        assert!(config.ccs_aliases.is_empty());
428        assert!(config.agent_chain.is_none());
429    }
430
431    #[test]
432    fn test_parse_unified_config() {
433        let toml_str = r#"
434[general]
435verbosity = 3
436interactive = false
437developer_iters = 10
438
439[agents.claude]
440cmd = "claude -p"
441output_flag = "--output-format=stream-json"
442can_commit = true
443json_parser = "claude"
444
445[ccs_aliases]
446work = "ccs work"
447personal = "ccs personal"
448gemini = "ccs gemini"
449
450[agent_chain]
451developer = ["ccs/work", "claude"]
452reviewer = ["claude"]
453"#;
454        let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
455        assert_eq!(config.general.verbosity, 3);
456        assert!(!config.general.behavior.interactive);
457        assert_eq!(config.general.developer_iters, 10);
458        assert!(config.agents.contains_key("claude"));
459        assert_eq!(
460            config.ccs_aliases.get("work").unwrap().as_config().cmd,
461            "ccs work"
462        );
463        assert_eq!(
464            config.ccs_aliases.get("personal").unwrap().as_config().cmd,
465            "ccs personal"
466        );
467        assert!(config.ccs_aliases.contains_key("work"));
468        assert!(!config.ccs_aliases.contains_key("nonexistent"));
469        let chain = config.agent_chain.expect("agent_chain should parse");
470        assert_eq!(
471            chain.developer,
472            vec!["ccs/work".to_string(), "claude".to_string()]
473        );
474        assert_eq!(chain.reviewer, vec!["claude".to_string()]);
475    }
476
477    #[test]
478    fn test_ccs_alias_lookup() {
479        let mut config = UnifiedConfig::default();
480        config.ccs_aliases.insert(
481            "work".to_string(),
482            CcsAliasToml::Command("ccs work".to_string()),
483        );
484        config.ccs_aliases.insert(
485            "gemini".to_string(),
486            CcsAliasToml::Command("ccs gemini".to_string()),
487        );
488
489        assert_eq!(
490            get_ccs_alias_cmd(&config, "work"),
491            Some("ccs work".to_string())
492        );
493        assert_eq!(
494            get_ccs_alias_cmd(&config, "gemini"),
495            Some("ccs gemini".to_string())
496        );
497        assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
498    }
499
500    #[test]
501    fn test_verbosity_conversion() {
502        let mut config = UnifiedConfig::default();
503        config.general.verbosity = 0;
504        assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
505        config.general.verbosity = 4;
506        assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
507    }
508
509    #[test]
510    fn test_unified_config_path() {
511        // Just verify it returns something (path depends on system)
512        let path = unified_config_path();
513        if let Some(p) = path {
514            assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
515        }
516    }
517}