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    pub fn load_default() -> Option<Self> {
349        unified_config_path().and_then(|path| {
350            if path.exists() {
351                Self::load_from_path(&path).ok()
352            } else {
353                None
354            }
355        })
356    }
357
358    /// Load unified configuration from a specific path.
359    pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
360        let contents = std::fs::read_to_string(path)?;
361        let config: Self = toml::from_str(&contents)?;
362        Ok(config)
363    }
364
365    /// Ensure unified config file exists, creating it from template if needed.
366    ///
367    /// This creates `~/.config/ralph-workflow.toml` with the default template
368    /// if it doesn't already exist.
369    pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
370        let Some(path) = unified_config_path() else {
371            return Err(io::Error::new(
372                io::ErrorKind::NotFound,
373                "Cannot determine config directory (no home directory)",
374            ));
375        };
376
377        Self::ensure_config_exists_at(&path)
378    }
379
380    /// Ensure a config file exists at the specified path.
381    pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
382        if path.exists() {
383            return Ok(ConfigInitResult::AlreadyExists);
384        }
385
386        // Create parent directories if they don't exist
387        if let Some(parent) = path.parent() {
388            fs::create_dir_all(parent)?;
389        }
390
391        // Write the default template
392        fs::write(path, DEFAULT_UNIFIED_CONFIG)?;
393
394        Ok(ConfigInitResult::Created)
395    }
396}
397
398/// Error type for unified config loading.
399#[derive(Debug, thiserror::Error)]
400pub enum ConfigLoadError {
401    #[error("Failed to read config file: {0}")]
402    Io(#[from] std::io::Error),
403    #[error("Failed to parse TOML: {0}")]
404    Toml(#[from] toml::de::Error),
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::config::types::Verbosity;
411
412    fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
413        config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
414    }
415
416    #[test]
417    fn test_general_config_defaults() {
418        let config = GeneralConfig::default();
419        assert_eq!(config.verbosity, 2);
420        assert!(config.behavior.interactive);
421        assert!(config.execution.isolation_mode);
422        assert!(config.behavior.auto_detect_stack);
423        assert!(config.workflow.checkpoint_enabled);
424        assert_eq!(config.developer_iters, 5);
425        assert_eq!(config.reviewer_reviews, 2);
426    }
427
428    #[test]
429    fn test_unified_config_defaults() {
430        let config = UnifiedConfig::default();
431        assert!(config.agents.is_empty());
432        assert!(config.ccs_aliases.is_empty());
433        assert!(config.agent_chain.is_none());
434    }
435
436    #[test]
437    fn test_parse_unified_config() {
438        let toml_str = r#"
439[general]
440verbosity = 3
441interactive = false
442developer_iters = 10
443
444[agents.claude]
445cmd = "claude -p"
446output_flag = "--output-format=stream-json"
447can_commit = true
448json_parser = "claude"
449
450[ccs_aliases]
451work = "ccs work"
452personal = "ccs personal"
453gemini = "ccs gemini"
454
455[agent_chain]
456developer = ["ccs/work", "claude"]
457reviewer = ["claude"]
458"#;
459        let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
460        assert_eq!(config.general.verbosity, 3);
461        assert!(!config.general.behavior.interactive);
462        assert_eq!(config.general.developer_iters, 10);
463        assert!(config.agents.contains_key("claude"));
464        assert_eq!(
465            config.ccs_aliases.get("work").unwrap().as_config().cmd,
466            "ccs work"
467        );
468        assert_eq!(
469            config.ccs_aliases.get("personal").unwrap().as_config().cmd,
470            "ccs personal"
471        );
472        assert!(config.ccs_aliases.contains_key("work"));
473        assert!(!config.ccs_aliases.contains_key("nonexistent"));
474        let chain = config.agent_chain.expect("agent_chain should parse");
475        assert_eq!(
476            chain.developer,
477            vec!["ccs/work".to_string(), "claude".to_string()]
478        );
479        assert_eq!(chain.reviewer, vec!["claude".to_string()]);
480    }
481
482    #[test]
483    fn test_ccs_alias_lookup() {
484        let mut config = UnifiedConfig::default();
485        config.ccs_aliases.insert(
486            "work".to_string(),
487            CcsAliasToml::Command("ccs work".to_string()),
488        );
489        config.ccs_aliases.insert(
490            "gemini".to_string(),
491            CcsAliasToml::Command("ccs gemini".to_string()),
492        );
493
494        assert_eq!(
495            get_ccs_alias_cmd(&config, "work"),
496            Some("ccs work".to_string())
497        );
498        assert_eq!(
499            get_ccs_alias_cmd(&config, "gemini"),
500            Some("ccs gemini".to_string())
501        );
502        assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
503    }
504
505    #[test]
506    fn test_verbosity_conversion() {
507        let mut config = UnifiedConfig::default();
508        config.general.verbosity = 0;
509        assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
510        config.general.verbosity = 4;
511        assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
512    }
513
514    #[test]
515    fn test_unified_config_path() {
516        // Just verify it returns something (path depends on system)
517        let path = unified_config_path();
518        if let Some(p) = path {
519            assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
520        }
521    }
522}