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}
153
154/// Default maximum continuation attempts per development iteration.
155///
156/// This allows 2 continuations per iteration (3 total valid attempts including the initial)
157/// for fast iteration cycles.
158fn default_max_dev_continuations() -> u32 {
159    2
160}
161
162impl Default for GeneralConfig {
163    fn default() -> Self {
164        Self {
165            verbosity: 2, // Verbose
166            behavior: GeneralBehaviorFlags {
167                interactive: true,
168                auto_detect_stack: true,
169                strict_validation: false,
170            },
171            workflow: GeneralWorkflowFlags {
172                checkpoint_enabled: true,
173            },
174            execution: GeneralExecutionFlags {
175                force_universal_prompt: false,
176                isolation_mode: true,
177            },
178            developer_iters: 5,
179            reviewer_reviews: 2,
180            developer_context: 1,
181            reviewer_context: 0,
182            review_depth: "standard".to_string(),
183            prompt_path: None,
184            templates_dir: None,
185            git_user_name: None,
186            git_user_email: None,
187            max_dev_continuations: default_max_dev_continuations(),
188        }
189    }
190}
191
192/// CCS (Claude Code Switch) alias configuration.
193///
194/// Maps alias names to CCS profile commands.
195/// For example: `work = "ccs work"` allows using `ccs/work` as an agent.
196pub type CcsAliases = HashMap<String, CcsAliasToml>;
197
198/// CCS defaults applied to all CCS aliases unless overridden per-alias.
199#[derive(Debug, Clone, Deserialize)]
200#[serde(default)]
201pub struct CcsConfig {
202    /// Output-format flag for CCS (often Claude-compatible stream JSON).
203    pub output_flag: String,
204    /// Flag for autonomous mode (skip permission/confirmation prompts).
205    /// Ralph is designed for unattended automation, so this is enabled by default.
206    /// Set to empty string ("") to disable and require confirmations.
207    pub yolo_flag: String,
208    /// Flag for verbose output.
209    pub verbose_flag: String,
210    /// Print flag for non-interactive mode (required by Claude CLI).
211    /// Default: "-p"
212    pub print_flag: String,
213    /// Streaming flag for JSON output with -p (required for Claude/CCS to stream).
214    /// Default: "--include-partial-messages"
215    pub streaming_flag: String,
216    /// Which JSON parser to use for CCS output.
217    pub json_parser: String,
218    /// Whether CCS can run workflow tools (git commit, etc.).
219    pub can_commit: bool,
220}
221
222impl Default for CcsConfig {
223    fn default() -> Self {
224        Self {
225            output_flag: "--output-format=stream-json".to_string(),
226            // Default to unattended automation (config can override to disable).
227            yolo_flag: "--dangerously-skip-permissions".to_string(),
228            verbose_flag: "--verbose".to_string(),
229            print_flag: "-p".to_string(),
230            streaming_flag: "--include-partial-messages".to_string(),
231            json_parser: "claude".to_string(),
232            can_commit: true,
233        }
234    }
235}
236
237/// Per-alias CCS configuration (table form).
238#[derive(Debug, Clone, Deserialize, Default)]
239#[serde(default)]
240pub struct CcsAliasConfig {
241    /// Base CCS command to run (e.g., "ccs work", "ccs gemini").
242    pub cmd: String,
243    /// Optional output flag override for this alias. Use "" to disable.
244    pub output_flag: Option<String>,
245    /// Optional yolo flag override for this alias. Use "" to enable/disable explicitly.
246    pub yolo_flag: Option<String>,
247    /// Optional verbose flag override for this alias. Use "" to disable.
248    pub verbose_flag: Option<String>,
249    /// Optional print flag override for this alias (e.g., "-p" for Claude/CCS).
250    pub print_flag: Option<String>,
251    /// Optional streaming flag override for this alias (e.g., "--include-partial-messages").
252    pub streaming_flag: Option<String>,
253    /// Optional JSON parser override (e.g., "claude", "generic").
254    pub json_parser: Option<String>,
255    /// Optional `can_commit` override for this alias.
256    pub can_commit: Option<bool>,
257    /// Optional model flag appended to the command.
258    pub model_flag: Option<String>,
259    /// Optional session continuation flag (e.g., "--resume {}" for Claude CLI).
260    /// The "{}" placeholder is replaced with the session ID.
261    pub session_flag: Option<String>,
262}
263
264/// CCS alias entry supports both shorthand string and table form.
265#[derive(Debug, Clone, Deserialize)]
266#[serde(untagged)]
267pub enum CcsAliasToml {
268    Command(String),
269    Config(CcsAliasConfig),
270}
271
272impl CcsAliasToml {
273    pub fn as_config(&self) -> CcsAliasConfig {
274        match self {
275            Self::Command(cmd) => CcsAliasConfig {
276                cmd: cmd.clone(),
277                ..CcsAliasConfig::default()
278            },
279            Self::Config(cfg) => cfg.clone(),
280        }
281    }
282}
283
284/// Agent TOML configuration (compatible with `examples/agents.toml`).
285///
286/// Fields are used via serde deserialization.
287#[derive(Debug, Clone, Deserialize, Default)]
288#[serde(default)]
289pub struct AgentConfigToml {
290    /// Base command to run the agent.
291    ///
292    /// When overriding a built-in agent, this may be omitted to keep the built-in command.
293    pub cmd: Option<String>,
294    /// Output-format flag.
295    ///
296    /// Omitted means "keep built-in default". Empty string explicitly disables output flag.
297    pub output_flag: Option<String>,
298    /// Flag for autonomous mode.
299    ///
300    /// Omitted means "keep built-in default". Empty string explicitly disables yolo mode.
301    pub yolo_flag: Option<String>,
302    /// Flag for verbose output.
303    ///
304    /// Omitted means "keep built-in default". Empty string explicitly disables verbose flag.
305    pub verbose_flag: Option<String>,
306    /// Print/non-interactive mode flag (e.g., "-p" for Claude/CCS).
307    ///
308    /// Omitted means "keep built-in default". Empty string explicitly disables print mode.
309    pub print_flag: Option<String>,
310    /// Include partial messages flag for streaming with -p (e.g., "--include-partial-messages").
311    ///
312    /// Omitted means "keep built-in default". Empty string explicitly disables streaming flag.
313    pub streaming_flag: Option<String>,
314    /// Session continuation flag template (e.g., "-s {}" for OpenCode, "--resume {}" for Claude).
315    /// The `{}` placeholder is replaced with the session ID at runtime.
316    ///
317    /// Omitted means "keep built-in default". Empty string explicitly disables session continuation.
318    /// See agent documentation for correct flag format:
319    /// - Claude: --resume <session_id> (from `claude --help`)
320    /// - OpenCode: -s <session_id> (from `opencode run --help`)
321    pub session_flag: Option<String>,
322    /// Whether the agent can run git commit.
323    ///
324    /// Omitted means "keep built-in default". For new agents, this defaults to true when omitted.
325    pub can_commit: Option<bool>,
326    /// Which JSON parser to use.
327    ///
328    /// Omitted means "keep built-in default". For new agents, defaults to "generic" when omitted.
329    pub json_parser: Option<String>,
330    /// Model/provider flag.
331    pub model_flag: Option<String>,
332    /// Human-readable display name for UI/UX.
333    ///
334    /// Omitted means "keep built-in default". Empty string explicitly clears the display name.
335    pub display_name: Option<String>,
336}
337
338/// Unified configuration file structure.
339///
340/// This is the sole source of truth for Ralph configuration,
341/// located at `~/.config/ralph-workflow.toml`.
342#[derive(Debug, Clone, Deserialize, Default)]
343#[serde(default)]
344pub struct UnifiedConfig {
345    /// General settings.
346    pub general: GeneralConfig,
347    /// CCS defaults for aliases.
348    pub ccs: CcsConfig,
349    /// Agent definitions (used via serde deserialization for future expansion).
350    #[serde(default)]
351    pub agents: HashMap<String, AgentConfigToml>,
352    /// CCS alias mappings.
353    #[serde(default)]
354    pub ccs_aliases: CcsAliases,
355    /// Agent chain configuration.
356    ///
357    /// When omitted, Ralph uses built-in defaults.
358    #[serde(default, rename = "agent_chain")]
359    pub agent_chain: Option<FallbackConfig>,
360}
361
362impl UnifiedConfig {
363    /// Load unified configuration from the default path.
364    ///
365    /// Returns None if the file doesn't exist.
366    ///
367    pub fn load_default() -> Option<Self> {
368        Self::load_with_env(&super::path_resolver::RealConfigEnvironment)
369    }
370
371    /// Load unified configuration using a [`ConfigEnvironment`].
372    ///
373    /// This is the testable version of [`load_default`]. It reads from the
374    /// unified config path as determined by the environment.
375    ///
376    /// Returns None if no config path is available or the file doesn't exist.
377    pub fn load_with_env(env: &dyn super::path_resolver::ConfigEnvironment) -> Option<Self> {
378        env.unified_config_path().and_then(|path| {
379            if env.file_exists(&path) {
380                Self::load_from_path_with_env(&path, env).ok()
381            } else {
382                None
383            }
384        })
385    }
386
387    /// Load unified configuration from a specific path.
388    ///
389    /// **Note:** This method uses `std::fs` directly. For testable code,
390    /// use [`load_from_path_with_env`] with a [`ConfigEnvironment`] instead.
391    pub fn load_from_path(path: &std::path::Path) -> Result<Self, ConfigLoadError> {
392        let contents = std::fs::read_to_string(path)?;
393        let config: Self = toml::from_str(&contents)?;
394        Ok(config)
395    }
396
397    /// Load unified configuration from a specific path using a [`ConfigEnvironment`].
398    ///
399    /// This is the testable version of [`load_from_path`].
400    pub fn load_from_path_with_env(
401        path: &std::path::Path,
402        env: &dyn super::path_resolver::ConfigEnvironment,
403    ) -> Result<Self, ConfigLoadError> {
404        let contents = env.read_file(path)?;
405        let config: Self = toml::from_str(&contents)?;
406        Ok(config)
407    }
408
409    /// Ensure unified config file exists, creating it from template if needed.
410    ///
411    /// This creates `~/.config/ralph-workflow.toml` with the default template
412    /// if it doesn't already exist.
413    ///
414    pub fn ensure_config_exists() -> io::Result<ConfigInitResult> {
415        Self::ensure_config_exists_with_env(&super::path_resolver::RealConfigEnvironment)
416    }
417
418    /// Ensure unified config file exists using a [`ConfigEnvironment`].
419    ///
420    /// This is the testable version of [`ensure_config_exists`].
421    pub fn ensure_config_exists_with_env(
422        env: &dyn super::path_resolver::ConfigEnvironment,
423    ) -> io::Result<ConfigInitResult> {
424        let Some(path) = env.unified_config_path() else {
425            return Err(io::Error::new(
426                io::ErrorKind::NotFound,
427                "Cannot determine config directory (no home directory)",
428            ));
429        };
430
431        Self::ensure_config_exists_at_with_env(&path, env)
432    }
433
434    /// Ensure a config file exists at the specified path.
435    ///
436    pub fn ensure_config_exists_at(path: &std::path::Path) -> io::Result<ConfigInitResult> {
437        Self::ensure_config_exists_at_with_env(path, &super::path_resolver::RealConfigEnvironment)
438    }
439
440    /// Ensure a config file exists at the specified path using a [`ConfigEnvironment`].
441    ///
442    /// This is the testable version of [`ensure_config_exists_at`].
443    pub fn ensure_config_exists_at_with_env(
444        path: &std::path::Path,
445        env: &dyn super::path_resolver::ConfigEnvironment,
446    ) -> io::Result<ConfigInitResult> {
447        if env.file_exists(path) {
448            return Ok(ConfigInitResult::AlreadyExists);
449        }
450
451        // Write the default template (write_file creates parent directories)
452        env.write_file(path, DEFAULT_UNIFIED_CONFIG)?;
453
454        Ok(ConfigInitResult::Created)
455    }
456}
457
458/// Error type for unified config loading.
459#[derive(Debug, thiserror::Error)]
460pub enum ConfigLoadError {
461    #[error("Failed to read config file: {0}")]
462    Io(#[from] std::io::Error),
463    #[error("Failed to parse TOML: {0}")]
464    Toml(#[from] toml::de::Error),
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::config::path_resolver::MemoryConfigEnvironment;
471    use crate::config::types::Verbosity;
472    use std::path::Path;
473
474    fn get_ccs_alias_cmd(config: &UnifiedConfig, alias: &str) -> Option<String> {
475        config.ccs_aliases.get(alias).map(|v| v.as_config().cmd)
476    }
477
478    #[test]
479    fn test_load_with_env_reads_from_config_environment() {
480        let toml_str = r#"
481[general]
482verbosity = 3
483interactive = false
484developer_iters = 10
485"#;
486        let env = MemoryConfigEnvironment::new()
487            .with_unified_config_path("/test/config/ralph-workflow.toml")
488            .with_file("/test/config/ralph-workflow.toml", toml_str);
489
490        let config = UnifiedConfig::load_with_env(&env).unwrap();
491
492        assert_eq!(config.general.verbosity, 3);
493        assert!(!config.general.behavior.interactive);
494        assert_eq!(config.general.developer_iters, 10);
495    }
496
497    #[test]
498    fn test_load_with_env_returns_none_when_no_config_path() {
499        let env = MemoryConfigEnvironment::new();
500        // No unified_config_path set
501
502        let result = UnifiedConfig::load_with_env(&env);
503
504        assert!(result.is_none());
505    }
506
507    #[test]
508    fn test_load_with_env_returns_none_when_file_missing() {
509        let env = MemoryConfigEnvironment::new()
510            .with_unified_config_path("/test/config/ralph-workflow.toml");
511        // Path set but file doesn't exist
512
513        let result = UnifiedConfig::load_with_env(&env);
514
515        assert!(result.is_none());
516    }
517
518    #[test]
519    fn test_load_from_path_with_env() {
520        let toml_str = r#"
521[general]
522verbosity = 4
523"#;
524        let env = MemoryConfigEnvironment::new().with_file("/custom/path.toml", toml_str);
525
526        let config =
527            UnifiedConfig::load_from_path_with_env(Path::new("/custom/path.toml"), &env).unwrap();
528
529        assert_eq!(config.general.verbosity, 4);
530    }
531
532    #[test]
533    fn test_ensure_config_exists_with_env_creates_file() {
534        let env = MemoryConfigEnvironment::new()
535            .with_unified_config_path("/test/config/ralph-workflow.toml");
536
537        let result = UnifiedConfig::ensure_config_exists_with_env(&env).unwrap();
538
539        assert_eq!(result, ConfigInitResult::Created);
540        assert!(env.was_written(Path::new("/test/config/ralph-workflow.toml")));
541    }
542
543    #[test]
544    fn test_ensure_config_exists_with_env_skips_existing() {
545        let env = MemoryConfigEnvironment::new()
546            .with_unified_config_path("/test/config/ralph-workflow.toml")
547            .with_file("/test/config/ralph-workflow.toml", "existing content");
548
549        let result = UnifiedConfig::ensure_config_exists_with_env(&env).unwrap();
550
551        assert_eq!(result, ConfigInitResult::AlreadyExists);
552        // Content should be unchanged
553        assert_eq!(
554            env.get_file(Path::new("/test/config/ralph-workflow.toml")),
555            Some("existing content".to_string())
556        );
557    }
558
559    #[test]
560    fn test_general_config_defaults() {
561        let config = GeneralConfig::default();
562        assert_eq!(config.verbosity, 2);
563        assert!(config.behavior.interactive);
564        assert!(config.execution.isolation_mode);
565        assert!(config.behavior.auto_detect_stack);
566        assert!(config.workflow.checkpoint_enabled);
567        assert_eq!(config.developer_iters, 5);
568        assert_eq!(config.reviewer_reviews, 2);
569    }
570
571    #[test]
572    fn test_unified_config_defaults() {
573        let config = UnifiedConfig::default();
574        assert!(config.agents.is_empty());
575        assert!(config.ccs_aliases.is_empty());
576        assert!(config.agent_chain.is_none());
577    }
578
579    #[test]
580    fn test_parse_unified_config() {
581        let toml_str = r#"
582[general]
583verbosity = 3
584interactive = false
585developer_iters = 10
586
587[agents.claude]
588cmd = "claude -p"
589output_flag = "--output-format=stream-json"
590can_commit = true
591json_parser = "claude"
592
593[ccs_aliases]
594work = "ccs work"
595personal = "ccs personal"
596gemini = "ccs gemini"
597
598[agent_chain]
599developer = ["ccs/work", "claude"]
600reviewer = ["claude"]
601"#;
602        let config: UnifiedConfig = toml::from_str(toml_str).unwrap();
603        assert_eq!(config.general.verbosity, 3);
604        assert!(!config.general.behavior.interactive);
605        assert_eq!(config.general.developer_iters, 10);
606        assert!(config.agents.contains_key("claude"));
607        assert_eq!(
608            config.ccs_aliases.get("work").unwrap().as_config().cmd,
609            "ccs work"
610        );
611        assert_eq!(
612            config.ccs_aliases.get("personal").unwrap().as_config().cmd,
613            "ccs personal"
614        );
615        assert!(config.ccs_aliases.contains_key("work"));
616        assert!(!config.ccs_aliases.contains_key("nonexistent"));
617        let chain = config.agent_chain.expect("agent_chain should parse");
618        assert_eq!(
619            chain.developer,
620            vec!["ccs/work".to_string(), "claude".to_string()]
621        );
622        assert_eq!(chain.reviewer, vec!["claude".to_string()]);
623    }
624
625    #[test]
626    fn test_ccs_alias_lookup() {
627        let mut config = UnifiedConfig::default();
628        config.ccs_aliases.insert(
629            "work".to_string(),
630            CcsAliasToml::Command("ccs work".to_string()),
631        );
632        config.ccs_aliases.insert(
633            "gemini".to_string(),
634            CcsAliasToml::Command("ccs gemini".to_string()),
635        );
636
637        assert_eq!(
638            get_ccs_alias_cmd(&config, "work"),
639            Some("ccs work".to_string())
640        );
641        assert_eq!(
642            get_ccs_alias_cmd(&config, "gemini"),
643            Some("ccs gemini".to_string())
644        );
645        assert_eq!(get_ccs_alias_cmd(&config, "nonexistent"), None);
646    }
647
648    #[test]
649    fn test_verbosity_conversion() {
650        let mut config = UnifiedConfig::default();
651        config.general.verbosity = 0;
652        assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Quiet);
653        config.general.verbosity = 4;
654        assert_eq!(Verbosity::from(config.general.verbosity), Verbosity::Debug);
655    }
656
657    #[test]
658    fn test_unified_config_path() {
659        // Just verify it returns something (path depends on system)
660        let path = unified_config_path();
661        if let Some(p) = path {
662            assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
663        }
664    }
665}