Skip to main content

ralph_core/
config.rs

1//! Configuration types for the Ralph Orchestrator.
2//!
3//! This module supports both v1.x flat configuration format and v2.0 nested format.
4//! Users can switch from Python v1.x to Rust v2.0 with zero config changes.
5
6use ralph_proto::Topic;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use tracing::debug;
11
12/// Top-level configuration for Ralph Orchestrator.
13///
14/// Supports both v1.x flat format and v2.0 nested format:
15/// - v1: `agent: claude`, `max_iterations: 100`
16/// - v2: `cli: { backend: claude }`, `event_loop: { max_iterations: 100 }`
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[allow(clippy::struct_excessive_bools)] // Configuration struct with multiple feature flags
19pub struct RalphConfig {
20    /// Event loop configuration (v2 nested style).
21    #[serde(default)]
22    pub event_loop: EventLoopConfig,
23
24    /// CLI backend configuration (v2 nested style).
25    #[serde(default)]
26    pub cli: CliConfig,
27
28    /// Core paths and settings shared across all hats.
29    #[serde(default)]
30    pub core: CoreConfig,
31
32    /// Custom hat definitions (optional).
33    /// If empty, default planner and builder hats are used.
34    #[serde(default)]
35    pub hats: HashMap<String, HatConfig>,
36
37    /// Event metadata definitions (optional).
38    /// Defines what each event topic means, enabling auto-derived instructions.
39    /// If a hat uses custom events, define them here for proper behavior injection.
40    #[serde(default)]
41    pub events: HashMap<String, EventMetadata>,
42
43    // ─────────────────────────────────────────────────────────────────────────
44    // V1 COMPATIBILITY FIELDS (flat format)
45    // These map to nested v2 fields for backwards compatibility.
46    // ─────────────────────────────────────────────────────────────────────────
47    /// V1 field: Backend CLI (maps to cli.backend).
48    /// Values: "claude", "kiro", "gemini", "codex", "amp", "auto", or "custom".
49    #[serde(default)]
50    pub agent: Option<String>,
51
52    /// V1 field: Fallback order for auto-detection.
53    #[serde(default)]
54    pub agent_priority: Vec<String>,
55
56    /// V1 field: Path to prompt file (maps to `event_loop.prompt_file`).
57    #[serde(default)]
58    pub prompt_file: Option<String>,
59
60    /// V1 field: Completion detection string (maps to event_loop.completion_promise).
61    #[serde(default)]
62    pub completion_promise: Option<String>,
63
64    /// V1 field: Maximum loop iterations (maps to event_loop.max_iterations).
65    #[serde(default)]
66    pub max_iterations: Option<u32>,
67
68    /// V1 field: Maximum runtime in seconds (maps to event_loop.max_runtime_seconds).
69    #[serde(default)]
70    pub max_runtime: Option<u64>,
71
72    /// V1 field: Maximum cost in USD (maps to event_loop.max_cost_usd).
73    #[serde(default)]
74    pub max_cost: Option<f64>,
75
76    // ─────────────────────────────────────────────────────────────────────────
77    // FEATURE FLAGS
78    // ─────────────────────────────────────────────────────────────────────────
79    /// Enable verbose output.
80    #[serde(default)]
81    pub verbose: bool,
82
83    /// Archive prompts after completion (DEFERRED: warn if enabled).
84    #[serde(default)]
85    pub archive_prompts: bool,
86
87    /// Enable metrics collection (DEFERRED: warn if enabled).
88    #[serde(default)]
89    pub enable_metrics: bool,
90
91    // ─────────────────────────────────────────────────────────────────────────
92    // DROPPED FIELDS (accepted but ignored with warning)
93    // ─────────────────────────────────────────────────────────────────────────
94    /// V1 field: Token limits (DROPPED: controlled by CLI tool).
95    #[serde(default)]
96    pub max_tokens: Option<u32>,
97
98    /// V1 field: Retry delay (DROPPED: handled differently in v2).
99    #[serde(default)]
100    pub retry_delay: Option<u32>,
101
102    /// V1 adapter settings (partially supported).
103    #[serde(default)]
104    pub adapters: AdaptersConfig,
105
106    // ─────────────────────────────────────────────────────────────────────────
107    // WARNING CONTROL
108    // ─────────────────────────────────────────────────────────────────────────
109    /// Suppress all warnings (for CI environments).
110    #[serde(default, rename = "_suppress_warnings")]
111    pub suppress_warnings: bool,
112
113    /// TUI configuration.
114    #[serde(default)]
115    pub tui: TuiConfig,
116
117    /// Memories configuration for persistent learning across sessions.
118    #[serde(default)]
119    pub memories: MemoriesConfig,
120
121    /// Tasks configuration for runtime work tracking.
122    #[serde(default)]
123    pub tasks: TasksConfig,
124
125    /// Feature flags for optional capabilities.
126    #[serde(default)]
127    pub features: FeaturesConfig,
128}
129
130fn default_true() -> bool {
131    true
132}
133
134#[allow(clippy::derivable_impls)] // Cannot derive due to serde default functions
135impl Default for RalphConfig {
136    fn default() -> Self {
137        Self {
138            event_loop: EventLoopConfig::default(),
139            cli: CliConfig::default(),
140            core: CoreConfig::default(),
141            hats: HashMap::new(),
142            events: HashMap::new(),
143            // V1 compatibility fields
144            agent: None,
145            agent_priority: vec![],
146            prompt_file: None,
147            completion_promise: None,
148            max_iterations: None,
149            max_runtime: None,
150            max_cost: None,
151            // Feature flags
152            verbose: false,
153            archive_prompts: false,
154            enable_metrics: false,
155            // Dropped fields
156            max_tokens: None,
157            retry_delay: None,
158            adapters: AdaptersConfig::default(),
159            // Warning control
160            suppress_warnings: false,
161            // TUI
162            tui: TuiConfig::default(),
163            // Memories
164            memories: MemoriesConfig::default(),
165            // Tasks
166            tasks: TasksConfig::default(),
167            // Features
168            features: FeaturesConfig::default(),
169        }
170    }
171}
172
173/// V1 adapter settings per backend.
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct AdaptersConfig {
176    /// Claude adapter settings.
177    #[serde(default)]
178    pub claude: AdapterSettings,
179
180    /// Gemini adapter settings.
181    #[serde(default)]
182    pub gemini: AdapterSettings,
183
184    /// Kiro adapter settings.
185    #[serde(default)]
186    pub kiro: AdapterSettings,
187
188    /// Codex adapter settings.
189    #[serde(default)]
190    pub codex: AdapterSettings,
191
192    /// Amp adapter settings.
193    #[serde(default)]
194    pub amp: AdapterSettings,
195}
196
197/// Per-adapter settings.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct AdapterSettings {
200    /// CLI execution timeout in seconds.
201    #[serde(default = "default_timeout")]
202    pub timeout: u64,
203
204    /// Include in auto-detection.
205    #[serde(default = "default_true")]
206    pub enabled: bool,
207
208    /// Tool permissions (DROPPED: CLI tool manages its own permissions).
209    #[serde(default)]
210    pub tool_permissions: Option<Vec<String>>,
211}
212
213fn default_timeout() -> u64 {
214    300 // 5 minutes
215}
216
217impl Default for AdapterSettings {
218    fn default() -> Self {
219        Self {
220            timeout: default_timeout(),
221            enabled: true,
222            tool_permissions: None,
223        }
224    }
225}
226
227impl RalphConfig {
228    /// Loads configuration from a YAML file.
229    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
230        let path_ref = path.as_ref();
231        debug!(path = %path_ref.display(), "Loading configuration from file");
232        let content = std::fs::read_to_string(path_ref)?;
233        Self::parse_yaml(&content)
234    }
235
236    /// Parses configuration from a YAML string.
237    pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
238        let config: Self = serde_yaml::from_str(content)?;
239        debug!(
240            backend = %config.cli.backend,
241            has_v1_fields = config.agent.is_some(),
242            custom_hats = config.hats.len(),
243            "Configuration loaded"
244        );
245        Ok(config)
246    }
247
248    /// Normalizes v1 flat fields into v2 nested structure.
249    ///
250    /// V1 flat fields take precedence over v2 nested fields when both are present.
251    /// This allows users to use either format or mix them.
252    pub fn normalize(&mut self) {
253        let mut normalized_count = 0;
254
255        // Map v1 `agent` to v2 `cli.backend`
256        if let Some(ref agent) = self.agent {
257            debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
258            self.cli.backend = agent.clone();
259            normalized_count += 1;
260        }
261
262        // Map v1 `prompt_file` to v2 `event_loop.prompt_file`
263        if let Some(ref pf) = self.prompt_file {
264            debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
265            self.event_loop.prompt_file = pf.clone();
266            normalized_count += 1;
267        }
268
269        // Map v1 `completion_promise` to v2 `event_loop.completion_promise`
270        if let Some(ref cp) = self.completion_promise {
271            debug!(
272                from = "completion_promise",
273                to = "event_loop.completion_promise",
274                "Normalizing v1 field"
275            );
276            self.event_loop.completion_promise = cp.clone();
277            normalized_count += 1;
278        }
279
280        // Map v1 `max_iterations` to v2 `event_loop.max_iterations`
281        if let Some(mi) = self.max_iterations {
282            debug!(
283                from = "max_iterations",
284                to = "event_loop.max_iterations",
285                value = mi,
286                "Normalizing v1 field"
287            );
288            self.event_loop.max_iterations = mi;
289            normalized_count += 1;
290        }
291
292        // Map v1 `max_runtime` to v2 `event_loop.max_runtime_seconds`
293        if let Some(mr) = self.max_runtime {
294            debug!(
295                from = "max_runtime",
296                to = "event_loop.max_runtime_seconds",
297                value = mr,
298                "Normalizing v1 field"
299            );
300            self.event_loop.max_runtime_seconds = mr;
301            normalized_count += 1;
302        }
303
304        // Map v1 `max_cost` to v2 `event_loop.max_cost_usd`
305        if self.max_cost.is_some() {
306            debug!(
307                from = "max_cost",
308                to = "event_loop.max_cost_usd",
309                "Normalizing v1 field"
310            );
311            self.event_loop.max_cost_usd = self.max_cost;
312            normalized_count += 1;
313        }
314
315        if normalized_count > 0 {
316            debug!(
317                fields_normalized = normalized_count,
318                "V1 to V2 config normalization complete"
319            );
320        }
321    }
322
323    /// Validates the configuration and returns warnings.
324    ///
325    /// This method checks for:
326    /// - Deferred features that are enabled (archive_prompts, enable_metrics)
327    /// - Dropped fields that are present (max_tokens, retry_delay, tool_permissions)
328    /// - Ambiguous trigger routing across custom hats
329    /// - Mutual exclusivity of prompt and prompt_file
330    ///
331    /// Returns a list of warnings that should be displayed to the user.
332    pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
333        let mut warnings = Vec::new();
334
335        // Skip all warnings if suppressed
336        if self.suppress_warnings {
337            return Ok(warnings);
338        }
339
340        // Check for mutual exclusivity of prompt and prompt_file in config
341        // Only error if both are explicitly set (not defaults)
342        if self.event_loop.prompt.is_some()
343            && !self.event_loop.prompt_file.is_empty()
344            && self.event_loop.prompt_file != default_prompt_file()
345        {
346            return Err(ConfigError::MutuallyExclusive {
347                field1: "event_loop.prompt".to_string(),
348                field2: "event_loop.prompt_file".to_string(),
349            });
350        }
351
352        // Check custom backend has a command
353        if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
354            return Err(ConfigError::CustomBackendRequiresCommand);
355        }
356
357        // Check for deferred features
358        if self.archive_prompts {
359            warnings.push(ConfigWarning::DeferredFeature {
360                field: "archive_prompts".to_string(),
361                message: "Feature not yet available in v2".to_string(),
362            });
363        }
364
365        if self.enable_metrics {
366            warnings.push(ConfigWarning::DeferredFeature {
367                field: "enable_metrics".to_string(),
368                message: "Feature not yet available in v2".to_string(),
369            });
370        }
371
372        // Check for dropped fields
373        if self.max_tokens.is_some() {
374            warnings.push(ConfigWarning::DroppedField {
375                field: "max_tokens".to_string(),
376                reason: "Token limits are controlled by the CLI tool".to_string(),
377            });
378        }
379
380        if self.retry_delay.is_some() {
381            warnings.push(ConfigWarning::DroppedField {
382                field: "retry_delay".to_string(),
383                reason: "Retry logic handled differently in v2".to_string(),
384            });
385        }
386
387        // Check adapter tool_permissions (dropped field)
388        if self.adapters.claude.tool_permissions.is_some()
389            || self.adapters.gemini.tool_permissions.is_some()
390            || self.adapters.codex.tool_permissions.is_some()
391            || self.adapters.amp.tool_permissions.is_some()
392        {
393            warnings.push(ConfigWarning::DroppedField {
394                field: "adapters.*.tool_permissions".to_string(),
395                reason: "CLI tool manages its own permissions".to_string(),
396            });
397        }
398
399        // Check for required description field on all hats
400        for (hat_id, hat_config) in &self.hats {
401            if hat_config
402                .description
403                .as_ref()
404                .is_none_or(|d| d.trim().is_empty())
405            {
406                return Err(ConfigError::MissingDescription {
407                    hat: hat_id.clone(),
408                });
409            }
410        }
411
412        // Check for reserved triggers: task.start and task.resume are reserved for Ralph
413        // Per design: Ralph coordinates first, then delegates to custom hats via events
414        const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
415        for (hat_id, hat_config) in &self.hats {
416            for trigger in &hat_config.triggers {
417                if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
418                    return Err(ConfigError::ReservedTrigger {
419                        trigger: trigger.clone(),
420                        hat: hat_id.clone(),
421                    });
422                }
423            }
424        }
425
426        // Check for ambiguous routing: each trigger topic must map to exactly one hat
427        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
428        if !self.hats.is_empty() {
429            let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
430            for (hat_id, hat_config) in &self.hats {
431                for trigger in &hat_config.triggers {
432                    if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
433                        return Err(ConfigError::AmbiguousRouting {
434                            trigger: trigger.clone(),
435                            hat1: (*existing_hat).to_string(),
436                            hat2: hat_id.clone(),
437                        });
438                    }
439                    trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
440                }
441            }
442        }
443
444        Ok(warnings)
445    }
446
447    /// Gets the effective backend name, resolving "auto" using the priority list.
448    pub fn effective_backend(&self) -> &str {
449        &self.cli.backend
450    }
451
452    /// Returns the agent priority list for auto-detection.
453    /// If empty, returns the default priority order.
454    pub fn get_agent_priority(&self) -> Vec<&str> {
455        if self.agent_priority.is_empty() {
456            vec!["claude", "kiro", "gemini", "codex", "amp"]
457        } else {
458            self.agent_priority.iter().map(String::as_str).collect()
459        }
460    }
461
462    /// Gets the adapter settings for a specific backend.
463    #[allow(clippy::match_same_arms)] // Explicit match arms for each backend improves readability
464    pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
465        match backend {
466            "claude" => &self.adapters.claude,
467            "gemini" => &self.adapters.gemini,
468            "kiro" => &self.adapters.kiro,
469            "codex" => &self.adapters.codex,
470            "amp" => &self.adapters.amp,
471            _ => &self.adapters.claude, // Default fallback
472        }
473    }
474}
475
476/// Configuration warnings emitted during validation.
477#[derive(Debug, Clone)]
478pub enum ConfigWarning {
479    /// Feature is enabled but not yet available in v2.
480    DeferredFeature { field: String, message: String },
481    /// Field is present but ignored in v2.
482    DroppedField { field: String, reason: String },
483    /// Field has an invalid value.
484    InvalidValue { field: String, message: String },
485}
486
487impl std::fmt::Display for ConfigWarning {
488    #[allow(clippy::match_same_arms)] // Different arms have different messages despite similar structure
489    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490        match self {
491            ConfigWarning::DeferredFeature { field, message }
492            | ConfigWarning::InvalidValue { field, message } => {
493                write!(f, "Warning [{field}]: {message}")
494            }
495            ConfigWarning::DroppedField { field, reason } => {
496                write!(f, "Warning [{field}]: Field ignored - {reason}")
497            }
498        }
499    }
500}
501
502/// Event loop configuration.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct EventLoopConfig {
505    /// Inline prompt text (mutually exclusive with prompt_file).
506    pub prompt: Option<String>,
507
508    /// Path to the prompt file.
509    #[serde(default = "default_prompt_file")]
510    pub prompt_file: String,
511
512    /// String that signals loop completion.
513    #[serde(default = "default_completion_promise")]
514    pub completion_promise: String,
515
516    /// Maximum number of iterations before timeout.
517    #[serde(default = "default_max_iterations")]
518    pub max_iterations: u32,
519
520    /// Maximum runtime in seconds.
521    #[serde(default = "default_max_runtime")]
522    pub max_runtime_seconds: u64,
523
524    /// Maximum cost in USD before stopping.
525    pub max_cost_usd: Option<f64>,
526
527    /// Stop after this many consecutive failures.
528    #[serde(default = "default_max_failures")]
529    pub max_consecutive_failures: u32,
530
531    /// Starting hat for multi-hat mode (deprecated, use starting_event instead).
532    pub starting_hat: Option<String>,
533
534    /// Event to publish after Ralph completes initial coordination.
535    ///
536    /// When custom hats are defined, Ralph handles `task.start` to do gap analysis
537    /// and planning, then publishes this event to delegate to the first hat.
538    ///
539    /// Example: `starting_event: "tdd.start"` for TDD workflow.
540    ///
541    /// If not specified and hats are defined, Ralph will determine the appropriate
542    /// event from the hat topology.
543    pub starting_event: Option<String>,
544}
545
546fn default_prompt_file() -> String {
547    "PROMPT.md".to_string()
548}
549
550fn default_completion_promise() -> String {
551    "LOOP_COMPLETE".to_string()
552}
553
554fn default_max_iterations() -> u32 {
555    100
556}
557
558fn default_max_runtime() -> u64 {
559    14400 // 4 hours
560}
561
562fn default_max_failures() -> u32 {
563    5
564}
565
566impl Default for EventLoopConfig {
567    fn default() -> Self {
568        Self {
569            prompt: None,
570            prompt_file: default_prompt_file(),
571            completion_promise: default_completion_promise(),
572            max_iterations: default_max_iterations(),
573            max_runtime_seconds: default_max_runtime(),
574            max_cost_usd: None,
575            max_consecutive_failures: default_max_failures(),
576            starting_hat: None,
577            starting_event: None,
578        }
579    }
580}
581
582/// Core paths and settings shared across all hats.
583///
584/// Per spec: "Core behaviors (always injected, can customize paths)"
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct CoreConfig {
587    /// Path to the scratchpad file (shared state between hats).
588    #[serde(default = "default_scratchpad")]
589    pub scratchpad: String,
590
591    /// Path to the specs directory (source of truth for requirements).
592    #[serde(default = "default_specs_dir")]
593    pub specs_dir: String,
594
595    /// Guardrails injected into every prompt (core behaviors).
596    ///
597    /// Per spec: These are always present regardless of hat.
598    #[serde(default = "default_guardrails")]
599    pub guardrails: Vec<String>,
600
601    /// Root directory for workspace-relative paths (.ralph/, specs, etc.).
602    ///
603    /// All relative paths (scratchpad, specs_dir, memories) are resolved relative
604    /// to this directory. Defaults to the current working directory.
605    ///
606    /// This is especially important for E2E tests that run in isolated workspaces.
607    #[serde(skip)]
608    pub workspace_root: std::path::PathBuf,
609}
610
611fn default_scratchpad() -> String {
612    ".ralph/agent/scratchpad.md".to_string()
613}
614
615fn default_specs_dir() -> String {
616    ".ralph/specs/".to_string()
617}
618
619fn default_guardrails() -> Vec<String> {
620    vec![
621        "Fresh context each iteration - scratchpad is memory".to_string(),
622        "Don't assume 'not implemented' - search first".to_string(),
623        "Backpressure is law - tests/typecheck/lint must pass".to_string(),
624        "Commit atomically - one logical change per commit, capture the why".to_string(),
625    ]
626}
627
628impl Default for CoreConfig {
629    fn default() -> Self {
630        Self {
631            scratchpad: default_scratchpad(),
632            specs_dir: default_specs_dir(),
633            guardrails: default_guardrails(),
634            workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
635                .map(std::path::PathBuf::from)
636                .unwrap_or_else(|_| {
637                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
638                }),
639        }
640    }
641}
642
643impl CoreConfig {
644    /// Sets the workspace root for resolving relative paths.
645    ///
646    /// This is used by E2E tests to point to their isolated test workspace.
647    pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
648        self.workspace_root = root.into();
649        self
650    }
651
652    /// Resolves a relative path against the workspace root.
653    ///
654    /// If the path is already absolute, it is returned as-is.
655    /// Otherwise, it is joined with the workspace root.
656    pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
657        let path = std::path::Path::new(relative);
658        if path.is_absolute() {
659            path.to_path_buf()
660        } else {
661            self.workspace_root.join(path)
662        }
663    }
664}
665
666/// CLI backend configuration.
667#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct CliConfig {
669    /// Backend to use: "claude", "kiro", "gemini", "codex", "amp", or "custom".
670    #[serde(default = "default_backend")]
671    pub backend: String,
672
673    /// Command override. Required for "custom" backend.
674    /// For named backends, overrides the default binary path.
675    pub command: Option<String>,
676
677    /// How to pass prompts: "arg" or "stdin".
678    #[serde(default = "default_prompt_mode")]
679    pub prompt_mode: String,
680
681    /// Execution mode when --interactive not specified.
682    /// Values: "autonomous" (default), "interactive"
683    #[serde(default = "default_mode")]
684    pub default_mode: String,
685
686    /// Idle timeout in seconds for interactive mode.
687    /// Process is terminated after this many seconds of inactivity (no output AND no user input).
688    /// Set to 0 to disable idle timeout.
689    #[serde(default = "default_idle_timeout")]
690    pub idle_timeout_secs: u32,
691
692    /// Custom arguments to pass to the CLI command (for backend: "custom").
693    /// These are inserted before the prompt argument.
694    #[serde(default)]
695    pub args: Vec<String>,
696
697    /// Custom prompt flag for arg mode (for backend: "custom").
698    /// If None, defaults to "-p" for arg mode.
699    #[serde(default)]
700    pub prompt_flag: Option<String>,
701}
702
703fn default_backend() -> String {
704    "claude".to_string()
705}
706
707fn default_prompt_mode() -> String {
708    "arg".to_string()
709}
710
711fn default_mode() -> String {
712    "autonomous".to_string()
713}
714
715fn default_idle_timeout() -> u32 {
716    30 // 30 seconds per spec
717}
718
719impl Default for CliConfig {
720    fn default() -> Self {
721        Self {
722            backend: default_backend(),
723            command: None,
724            prompt_mode: default_prompt_mode(),
725            default_mode: default_mode(),
726            idle_timeout_secs: default_idle_timeout(),
727            args: Vec::new(),
728            prompt_flag: None,
729        }
730    }
731}
732
733/// TUI configuration.
734#[derive(Debug, Clone, Serialize, Deserialize)]
735pub struct TuiConfig {
736    /// Prefix key combination (e.g., "ctrl-a", "ctrl-b").
737    #[serde(default = "default_prefix_key")]
738    pub prefix_key: String,
739}
740
741/// Memory injection mode.
742///
743/// Controls how memories are injected into agent context.
744#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
745#[serde(rename_all = "lowercase")]
746pub enum InjectMode {
747    /// Ralph automatically injects memories at the start of each iteration.
748    #[default]
749    Auto,
750    /// Agent must explicitly run `ralph memory search` to access memories.
751    Manual,
752    /// Memories feature is disabled.
753    None,
754}
755
756impl std::fmt::Display for InjectMode {
757    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758        match self {
759            Self::Auto => write!(f, "auto"),
760            Self::Manual => write!(f, "manual"),
761            Self::None => write!(f, "none"),
762        }
763    }
764}
765
766/// Memories configuration.
767///
768/// Controls the persistent learning system that allows Ralph to accumulate
769/// wisdom across sessions. Memories are stored in `.ralph/agent/memories.md`.
770///
771/// When enabled, the memories skill is automatically injected to teach
772/// agents how to create and search memories (skill injection is implicit).
773///
774/// Example configuration:
775/// ```yaml
776/// memories:
777///   enabled: true
778///   inject: auto
779///   budget: 2000
780/// ```
781#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct MemoriesConfig {
783    /// Whether the memories feature is enabled.
784    ///
785    /// When true, memories are injected and the skill is taught to the agent.
786    #[serde(default)]
787    pub enabled: bool,
788
789    /// How memories are injected into agent context.
790    #[serde(default)]
791    pub inject: InjectMode,
792
793    /// Maximum tokens to inject (0 = unlimited).
794    ///
795    /// When set, memories are truncated to fit within this budget.
796    #[serde(default)]
797    pub budget: usize,
798
799    /// Filter configuration for memory injection.
800    #[serde(default)]
801    pub filter: MemoriesFilter,
802}
803
804impl Default for MemoriesConfig {
805    fn default() -> Self {
806        Self {
807            enabled: true, // Memories enabled by default
808            inject: InjectMode::Auto,
809            budget: 0,
810            filter: MemoriesFilter::default(),
811        }
812    }
813}
814
815/// Filter configuration for memory injection.
816///
817/// Controls which memories are included when priming context.
818#[derive(Debug, Clone, Default, Serialize, Deserialize)]
819pub struct MemoriesFilter {
820    /// Filter by memory types (empty = all types).
821    #[serde(default)]
822    pub types: Vec<String>,
823
824    /// Filter by tags (empty = all tags).
825    #[serde(default)]
826    pub tags: Vec<String>,
827
828    /// Only include memories from the last N days (0 = no time limit).
829    #[serde(default)]
830    pub recent: u32,
831}
832
833/// Tasks configuration.
834///
835/// Controls the runtime task tracking system that allows Ralph to manage
836/// work items across iterations. Tasks are stored in `.ralph/agent/tasks.jsonl`.
837///
838/// When enabled, tasks replace scratchpad for loop completion verification.
839///
840/// Example configuration:
841/// ```yaml
842/// tasks:
843///   enabled: true
844/// ```
845#[derive(Debug, Clone, Serialize, Deserialize)]
846pub struct TasksConfig {
847    /// Whether the tasks feature is enabled.
848    ///
849    /// When true, tasks are used for loop completion verification.
850    #[serde(default = "default_true")]
851    pub enabled: bool,
852}
853
854impl Default for TasksConfig {
855    fn default() -> Self {
856        Self {
857            enabled: true, // Tasks enabled by default
858        }
859    }
860}
861
862/// Chaos mode configuration.
863///
864/// Chaos mode activates after LOOP_COMPLETE to grow the original objective
865/// into related improvements and learnings.
866///
867/// Example configuration:
868/// ```yaml
869/// features:
870///   chaos_mode:
871///     enabled: false              # Disabled by default (opt-in via --chaos)
872///     max_iterations: 5           # Max chaos iterations (default: 5)
873///     cooldown_seconds: 30        # Cooldown between chaos iterations (default: 30)
874///     completion_promise: "CHAOS_COMPLETE"  # Exit token
875///     research_focus:             # Configurable focus areas
876///       - domain_best_practices   # Web search for domain patterns
877///       - codebase_patterns       # Internal code analysis
878///       - self_improvement        # Meta-prompt and event loop study
879///     outputs:                    # What chaos mode can create
880///       - memories                # Always enabled
881/// ```
882#[derive(Debug, Clone, Serialize, Deserialize)]
883pub struct ChaosModeConfig {
884    /// Whether chaos mode is enabled.
885    #[serde(default)]
886    pub enabled: bool,
887
888    /// Maximum iterations in chaos mode.
889    #[serde(default = "default_chaos_max_iterations")]
890    pub max_iterations: u32,
891
892    /// Cooldown period between chaos iterations (seconds).
893    #[serde(default = "default_chaos_cooldown")]
894    pub cooldown_seconds: u64,
895
896    /// Completion promise for chaos mode exit.
897    #[serde(default = "default_chaos_completion")]
898    pub completion_promise: String,
899
900    /// Configurable research focus areas.
901    #[serde(default = "default_research_focus")]
902    pub research_focus: Vec<ResearchFocus>,
903
904    /// What outputs chaos mode can create.
905    #[serde(default = "default_chaos_outputs")]
906    pub outputs: Vec<ChaosOutput>,
907}
908
909fn default_chaos_max_iterations() -> u32 {
910    5
911}
912
913fn default_chaos_cooldown() -> u64 {
914    30 // 30 seconds between iterations
915}
916
917fn default_chaos_completion() -> String {
918    "CHAOS_COMPLETE".to_string()
919}
920
921fn default_research_focus() -> Vec<ResearchFocus> {
922    vec![
923        ResearchFocus::DomainBestPractices,
924        ResearchFocus::CodebasePatterns,
925        ResearchFocus::SelfImprovement,
926    ]
927}
928
929fn default_chaos_outputs() -> Vec<ChaosOutput> {
930    vec![ChaosOutput::Memories] // Only memories by default
931}
932
933impl Default for ChaosModeConfig {
934    fn default() -> Self {
935        Self {
936            enabled: false,
937            max_iterations: default_chaos_max_iterations(),
938            cooldown_seconds: default_chaos_cooldown(),
939            completion_promise: default_chaos_completion(),
940            research_focus: default_research_focus(),
941            outputs: default_chaos_outputs(),
942        }
943    }
944}
945
946/// Research focus area for chaos mode.
947#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
948#[serde(rename_all = "snake_case")]
949pub enum ResearchFocus {
950    /// Web search for domain patterns and best practices.
951    DomainBestPractices,
952    /// Internal codebase analysis for patterns and antipatterns.
953    CodebasePatterns,
954    /// Meta-prompt and event loop study for self-improvement.
955    SelfImprovement,
956}
957
958/// Output type that chaos mode can create.
959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
960#[serde(rename_all = "snake_case")]
961pub enum ChaosOutput {
962    /// Persistent learning memories.
963    Memories,
964    /// Create tasks for concrete work.
965    Tasks,
966    /// Create specs for larger improvements.
967    Specs,
968}
969
970/// Feature flags for optional Ralph capabilities.
971///
972/// Example configuration:
973/// ```yaml
974/// features:
975///   parallel: true  # Enable parallel loops via git worktrees
976///   auto_merge: false  # Auto-merge worktree branches on completion
977///   loop_naming:
978///     format: human-readable  # or "timestamp" for legacy format
979///     max_length: 50
980///   chaos_mode:
981///     enabled: false
982///     max_iterations: 5
983/// ```
984#[derive(Debug, Clone, Serialize, Deserialize)]
985pub struct FeaturesConfig {
986    /// Whether parallel loops are enabled.
987    ///
988    /// When true (default), if another loop holds the lock, Ralph spawns
989    /// a parallel loop in a git worktree. When false, Ralph errors instead.
990    #[serde(default = "default_true")]
991    pub parallel: bool,
992
993    /// Whether to automatically merge worktree branches on completion.
994    ///
995    /// When false (default), completed worktree loops queue for manual merge.
996    /// When true, Ralph automatically merges the worktree branch into the
997    /// main branch after a parallel loop completes.
998    #[serde(default)]
999    pub auto_merge: bool,
1000
1001    /// Loop naming configuration for worktree branches.
1002    ///
1003    /// Controls how loop IDs are generated for parallel loops.
1004    /// Default uses human-readable format: `fix-header-swift-peacock`
1005    /// Legacy timestamp format: `ralph-YYYYMMDD-HHMMSS-XXXX`
1006    #[serde(default)]
1007    pub loop_naming: crate::loop_name::LoopNamingConfig,
1008
1009    /// Chaos mode configuration.
1010    ///
1011    /// Chaos mode activates after LOOP_COMPLETE to explore related
1012    /// improvements and learnings based on the original objective.
1013    #[serde(default)]
1014    pub chaos_mode: ChaosModeConfig,
1015}
1016
1017impl Default for FeaturesConfig {
1018    fn default() -> Self {
1019        Self {
1020            parallel: true,    // Parallel loops enabled by default
1021            auto_merge: false, // Auto-merge disabled by default for safety
1022            loop_naming: crate::loop_name::LoopNamingConfig::default(),
1023            chaos_mode: ChaosModeConfig::default(),
1024        }
1025    }
1026}
1027
1028fn default_prefix_key() -> String {
1029    "ctrl-a".to_string()
1030}
1031
1032impl Default for TuiConfig {
1033    fn default() -> Self {
1034        Self {
1035            prefix_key: default_prefix_key(),
1036        }
1037    }
1038}
1039
1040impl TuiConfig {
1041    /// Parses the prefix_key string into KeyCode and KeyModifiers.
1042    /// Returns an error if the format is invalid.
1043    pub fn parse_prefix(
1044        &self,
1045    ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1046        use crossterm::event::{KeyCode, KeyModifiers};
1047
1048        let parts: Vec<&str> = self.prefix_key.split('-').collect();
1049        if parts.len() != 2 {
1050            return Err(format!(
1051                "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1052                self.prefix_key
1053            ));
1054        }
1055
1056        let modifier = match parts[0].to_lowercase().as_str() {
1057            "ctrl" => KeyModifiers::CONTROL,
1058            _ => {
1059                return Err(format!(
1060                    "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1061                    parts[0]
1062                ));
1063            }
1064        };
1065
1066        let key_str = parts[1];
1067        if key_str.len() != 1 {
1068            return Err(format!(
1069                "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1070                key_str
1071            ));
1072        }
1073
1074        let key_char = key_str.chars().next().unwrap();
1075        let key_code = KeyCode::Char(key_char);
1076
1077        Ok((key_code, modifier))
1078    }
1079}
1080
1081/// Metadata for an event topic.
1082///
1083/// Defines what an event means, enabling auto-derived instructions for hats.
1084/// When a hat triggers on or publishes an event, this metadata is used to
1085/// generate appropriate behavior instructions.
1086///
1087/// Example:
1088/// ```yaml
1089/// events:
1090///   deploy.start:
1091///     description: "Deployment has been requested"
1092///     on_trigger: "Prepare artifacts, validate config, check dependencies"
1093///     on_publish: "Signal that deployment should begin"
1094/// ```
1095#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1096pub struct EventMetadata {
1097    /// Brief description of what this event represents.
1098    #[serde(default)]
1099    pub description: String,
1100
1101    /// Instructions for a hat that triggers on (receives) this event.
1102    /// Describes what the hat should do when it receives this event.
1103    #[serde(default)]
1104    pub on_trigger: String,
1105
1106    /// Instructions for a hat that publishes (emits) this event.
1107    /// Describes when/how the hat should emit this event.
1108    #[serde(default)]
1109    pub on_publish: String,
1110}
1111
1112/// Backend configuration for a hat.
1113#[derive(Debug, Clone, Serialize, Deserialize)]
1114#[serde(untagged)]
1115pub enum HatBackend {
1116    // Order matters for serde untagged - most specific first
1117    /// Kiro agent with custom agent name and optional args.
1118    KiroAgent {
1119        #[serde(rename = "type")]
1120        backend_type: String,
1121        agent: String,
1122        #[serde(default)]
1123        args: Vec<String>,
1124    },
1125    /// Named backend with args (has `type` but no `agent`).
1126    NamedWithArgs {
1127        #[serde(rename = "type")]
1128        backend_type: String,
1129        #[serde(default)]
1130        args: Vec<String>,
1131    },
1132    /// Simple named backend (string form).
1133    Named(String),
1134    /// Custom backend with command and args.
1135    Custom {
1136        command: String,
1137        #[serde(default)]
1138        args: Vec<String>,
1139    },
1140}
1141
1142impl HatBackend {
1143    /// Converts to CLI backend string for execution.
1144    pub fn to_cli_backend(&self) -> String {
1145        match self {
1146            HatBackend::Named(name) => name.clone(),
1147            HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1148            HatBackend::KiroAgent { .. } => "kiro".to_string(),
1149            HatBackend::Custom { .. } => "custom".to_string(),
1150        }
1151    }
1152}
1153
1154/// Configuration for a single hat.
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1156pub struct HatConfig {
1157    /// Human-readable name for the hat.
1158    pub name: String,
1159
1160    /// Short description of the hat's purpose (required).
1161    /// Used in the HATS table to help Ralph understand when to delegate to this hat.
1162    pub description: Option<String>,
1163
1164    /// Events that trigger this hat to be worn.
1165    /// Per spec: "Hats define triggers — which events cause Ralph to wear this hat."
1166    #[serde(default)]
1167    pub triggers: Vec<String>,
1168
1169    /// Topics this hat publishes.
1170    #[serde(default)]
1171    pub publishes: Vec<String>,
1172
1173    /// Instructions prepended to prompts.
1174    #[serde(default)]
1175    pub instructions: String,
1176
1177    /// Backend to use for this hat (inherits from cli.backend if not specified).
1178    #[serde(default)]
1179    pub backend: Option<HatBackend>,
1180
1181    /// Default event to publish if hat forgets to write an event.
1182    #[serde(default)]
1183    pub default_publishes: Option<String>,
1184
1185    /// Maximum number of times this hat may be activated in a single loop run.
1186    ///
1187    /// When the limit is exceeded, the orchestrator publishes `<hat_id>.exhausted`
1188    /// instead of activating the hat again.
1189    pub max_activations: Option<u32>,
1190}
1191
1192impl HatConfig {
1193    /// Converts trigger strings to Topic objects.
1194    pub fn trigger_topics(&self) -> Vec<Topic> {
1195        self.triggers.iter().map(|s| Topic::new(s)).collect()
1196    }
1197
1198    /// Converts publish strings to Topic objects.
1199    pub fn publish_topics(&self) -> Vec<Topic> {
1200        self.publishes.iter().map(|s| Topic::new(s)).collect()
1201    }
1202}
1203
1204/// Configuration errors.
1205#[derive(Debug, thiserror::Error)]
1206pub enum ConfigError {
1207    #[error("IO error: {0}")]
1208    Io(#[from] std::io::Error),
1209
1210    #[error("YAML parse error: {0}")]
1211    Yaml(#[from] serde_yaml::Error),
1212
1213    #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
1214    AmbiguousRouting {
1215        trigger: String,
1216        hat1: String,
1217        hat2: String,
1218    },
1219
1220    #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
1221    MutuallyExclusive { field1: String, field2: String },
1222
1223    #[error("Custom backend requires a command - set 'cli.command' in config")]
1224    CustomBackendRequiresCommand,
1225
1226    #[error(
1227        "Reserved trigger '{trigger}' used by hat '{hat}' - task.start and task.resume are reserved for Ralph (the coordinator). Use a delegated event like 'work.start' instead."
1228    )]
1229    ReservedTrigger { trigger: String, hat: String },
1230
1231    #[error(
1232        "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose"
1233    )]
1234    MissingDescription { hat: String },
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240
1241    #[test]
1242    fn test_default_config() {
1243        let config = RalphConfig::default();
1244        // Default config has no custom hats (uses default planner+builder)
1245        assert!(config.hats.is_empty());
1246        assert_eq!(config.event_loop.max_iterations, 100);
1247        assert!(!config.verbose);
1248    }
1249
1250    #[test]
1251    fn test_parse_yaml_with_custom_hats() {
1252        let yaml = r#"
1253event_loop:
1254  prompt_file: "TASK.md"
1255  completion_promise: "DONE"
1256  max_iterations: 50
1257cli:
1258  backend: "claude"
1259hats:
1260  implementer:
1261    name: "Implementer"
1262    triggers: ["task.*", "review.done"]
1263    publishes: ["impl.done"]
1264    instructions: "You are the implementation agent."
1265"#;
1266        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1267        // Custom hats are defined
1268        assert_eq!(config.hats.len(), 1);
1269        assert_eq!(config.event_loop.prompt_file, "TASK.md");
1270
1271        let hat = config.hats.get("implementer").unwrap();
1272        assert_eq!(hat.triggers.len(), 2);
1273    }
1274
1275    #[test]
1276    fn test_parse_yaml_v1_format() {
1277        // V1 flat format - identical to Python v1.x config
1278        let yaml = r#"
1279agent: gemini
1280prompt_file: "TASK.md"
1281completion_promise: "RALPH_DONE"
1282max_iterations: 75
1283max_runtime: 7200
1284max_cost: 10.0
1285verbose: true
1286"#;
1287        let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1288
1289        // Before normalization, v2 fields have defaults
1290        assert_eq!(config.cli.backend, "claude"); // default
1291        assert_eq!(config.event_loop.max_iterations, 100); // default
1292
1293        // Normalize v1 -> v2
1294        config.normalize();
1295
1296        // After normalization, v2 fields have v1 values
1297        assert_eq!(config.cli.backend, "gemini");
1298        assert_eq!(config.event_loop.prompt_file, "TASK.md");
1299        assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
1300        assert_eq!(config.event_loop.max_iterations, 75);
1301        assert_eq!(config.event_loop.max_runtime_seconds, 7200);
1302        assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
1303        assert!(config.verbose);
1304    }
1305
1306    #[test]
1307    fn test_agent_priority() {
1308        let yaml = r"
1309agent: auto
1310agent_priority: [gemini, claude, codex]
1311";
1312        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1313        let priority = config.get_agent_priority();
1314        assert_eq!(priority, vec!["gemini", "claude", "codex"]);
1315    }
1316
1317    #[test]
1318    fn test_default_agent_priority() {
1319        let config = RalphConfig::default();
1320        let priority = config.get_agent_priority();
1321        assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
1322    }
1323
1324    #[test]
1325    fn test_validate_deferred_features() {
1326        let yaml = r"
1327archive_prompts: true
1328enable_metrics: true
1329";
1330        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1331        let warnings = config.validate().unwrap();
1332
1333        assert_eq!(warnings.len(), 2);
1334        assert!(warnings
1335            .iter()
1336            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
1337        assert!(warnings
1338            .iter()
1339            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
1340    }
1341
1342    #[test]
1343    fn test_validate_dropped_fields() {
1344        let yaml = r#"
1345max_tokens: 4096
1346retry_delay: 5
1347adapters:
1348  claude:
1349    tool_permissions: ["read", "write"]
1350"#;
1351        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1352        let warnings = config.validate().unwrap();
1353
1354        assert_eq!(warnings.len(), 3);
1355        assert!(warnings.iter().any(
1356            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
1357        ));
1358        assert!(warnings.iter().any(
1359            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
1360        ));
1361        assert!(warnings
1362            .iter()
1363            .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
1364    }
1365
1366    #[test]
1367    fn test_suppress_warnings() {
1368        let yaml = r"
1369_suppress_warnings: true
1370archive_prompts: true
1371max_tokens: 4096
1372";
1373        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1374        let warnings = config.validate().unwrap();
1375
1376        // All warnings should be suppressed
1377        assert!(warnings.is_empty());
1378    }
1379
1380    #[test]
1381    fn test_adapter_settings() {
1382        let yaml = r"
1383adapters:
1384  claude:
1385    timeout: 600
1386    enabled: true
1387  gemini:
1388    timeout: 300
1389    enabled: false
1390";
1391        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1392
1393        let claude = config.adapter_settings("claude");
1394        assert_eq!(claude.timeout, 600);
1395        assert!(claude.enabled);
1396
1397        let gemini = config.adapter_settings("gemini");
1398        assert_eq!(gemini.timeout, 300);
1399        assert!(!gemini.enabled);
1400    }
1401
1402    #[test]
1403    fn test_unknown_fields_ignored() {
1404        // Unknown fields should be silently ignored (forward compatibility)
1405        let yaml = r#"
1406agent: claude
1407unknown_field: "some value"
1408future_feature: true
1409"#;
1410        let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1411        // Should parse successfully, ignoring unknown fields
1412        assert!(result.is_ok());
1413    }
1414
1415    #[test]
1416    fn test_ambiguous_routing_rejected() {
1417        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
1418        // Note: using semantic events since task.start is reserved
1419        let yaml = r#"
1420hats:
1421  planner:
1422    name: "Planner"
1423    description: "Plans tasks"
1424    triggers: ["planning.start", "build.done"]
1425  builder:
1426    name: "Builder"
1427    description: "Builds code"
1428    triggers: ["build.task", "build.done"]
1429"#;
1430        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1431        let result = config.validate();
1432
1433        assert!(result.is_err());
1434        let err = result.unwrap_err();
1435        assert!(
1436            matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1437            "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1438            err
1439        );
1440    }
1441
1442    #[test]
1443    fn test_unique_triggers_accepted() {
1444        // Valid config: each trigger maps to exactly one hat
1445        // Note: task.start is reserved for Ralph, so use semantic events
1446        let yaml = r#"
1447hats:
1448  planner:
1449    name: "Planner"
1450    description: "Plans tasks"
1451    triggers: ["planning.start", "build.done", "build.blocked"]
1452  builder:
1453    name: "Builder"
1454    description: "Builds code"
1455    triggers: ["build.task"]
1456"#;
1457        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1458        let result = config.validate();
1459
1460        assert!(
1461            result.is_ok(),
1462            "Expected valid config, got: {:?}",
1463            result.unwrap_err()
1464        );
1465    }
1466
1467    #[test]
1468    fn test_reserved_trigger_task_start_rejected() {
1469        // Per design: task.start is reserved for Ralph (the coordinator)
1470        let yaml = r#"
1471hats:
1472  my_hat:
1473    name: "My Hat"
1474    description: "Test hat"
1475    triggers: ["task.start"]
1476"#;
1477        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1478        let result = config.validate();
1479
1480        assert!(result.is_err());
1481        let err = result.unwrap_err();
1482        assert!(
1483            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1484                if trigger == "task.start" && hat == "my_hat"),
1485            "Expected ReservedTrigger error for 'task.start', got: {:?}",
1486            err
1487        );
1488    }
1489
1490    #[test]
1491    fn test_reserved_trigger_task_resume_rejected() {
1492        // Per design: task.resume is reserved for Ralph (the coordinator)
1493        let yaml = r#"
1494hats:
1495  my_hat:
1496    name: "My Hat"
1497    description: "Test hat"
1498    triggers: ["task.resume", "other.event"]
1499"#;
1500        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1501        let result = config.validate();
1502
1503        assert!(result.is_err());
1504        let err = result.unwrap_err();
1505        assert!(
1506            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1507                if trigger == "task.resume" && hat == "my_hat"),
1508            "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1509            err
1510        );
1511    }
1512
1513    #[test]
1514    fn test_missing_description_rejected() {
1515        // Description is required for all hats
1516        let yaml = r#"
1517hats:
1518  my_hat:
1519    name: "My Hat"
1520    triggers: ["build.task"]
1521"#;
1522        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1523        let result = config.validate();
1524
1525        assert!(result.is_err());
1526        let err = result.unwrap_err();
1527        assert!(
1528            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1529            "Expected MissingDescription error, got: {:?}",
1530            err
1531        );
1532    }
1533
1534    #[test]
1535    fn test_empty_description_rejected() {
1536        // Empty description should also be rejected
1537        let yaml = r#"
1538hats:
1539  my_hat:
1540    name: "My Hat"
1541    description: "   "
1542    triggers: ["build.task"]
1543"#;
1544        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1545        let result = config.validate();
1546
1547        assert!(result.is_err());
1548        let err = result.unwrap_err();
1549        assert!(
1550            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1551            "Expected MissingDescription error for empty description, got: {:?}",
1552            err
1553        );
1554    }
1555
1556    #[test]
1557    fn test_core_config_defaults() {
1558        let config = RalphConfig::default();
1559        assert_eq!(config.core.scratchpad, ".ralph/agent/scratchpad.md");
1560        assert_eq!(config.core.specs_dir, ".ralph/specs/");
1561        // Default guardrails per spec
1562        assert_eq!(config.core.guardrails.len(), 4);
1563        assert!(config.core.guardrails[0].contains("Fresh context"));
1564        assert!(config.core.guardrails[1].contains("search first"));
1565        assert!(config.core.guardrails[2].contains("Backpressure"));
1566        assert!(config.core.guardrails[3].contains("Commit atomically"));
1567    }
1568
1569    #[test]
1570    fn test_core_config_customizable() {
1571        let yaml = r#"
1572core:
1573  scratchpad: ".workspace/plan.md"
1574  specs_dir: "./specifications/"
1575"#;
1576        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1577        assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1578        assert_eq!(config.core.specs_dir, "./specifications/");
1579        // Guardrails should use defaults when not specified
1580        assert_eq!(config.core.guardrails.len(), 4);
1581    }
1582
1583    #[test]
1584    fn test_core_config_custom_guardrails() {
1585        let yaml = r#"
1586core:
1587  scratchpad: ".ralph/agent/scratchpad.md"
1588  specs_dir: "./specs/"
1589  guardrails:
1590    - "Custom rule one"
1591    - "Custom rule two"
1592"#;
1593        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1594        assert_eq!(config.core.guardrails.len(), 2);
1595        assert_eq!(config.core.guardrails[0], "Custom rule one");
1596        assert_eq!(config.core.guardrails[1], "Custom rule two");
1597    }
1598
1599    #[test]
1600    fn test_prompt_and_prompt_file_mutually_exclusive() {
1601        // Both prompt and prompt_file specified in config should error
1602        let yaml = r#"
1603event_loop:
1604  prompt: "inline text"
1605  prompt_file: "custom.md"
1606"#;
1607        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1608        let result = config.validate();
1609
1610        assert!(result.is_err());
1611        let err = result.unwrap_err();
1612        assert!(
1613            matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1614                if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1615            "Expected MutuallyExclusive error, got: {:?}",
1616            err
1617        );
1618    }
1619
1620    #[test]
1621    fn test_prompt_with_default_prompt_file_allowed() {
1622        // Having inline prompt with default prompt_file value should be OK
1623        let yaml = r#"
1624event_loop:
1625  prompt: "inline text"
1626"#;
1627        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1628        let result = config.validate();
1629
1630        assert!(
1631            result.is_ok(),
1632            "Should allow inline prompt with default prompt_file"
1633        );
1634        assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1635        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1636    }
1637
1638    #[test]
1639    fn test_custom_backend_requires_command() {
1640        // Custom backend without command should error
1641        let yaml = r#"
1642cli:
1643  backend: "custom"
1644"#;
1645        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1646        let result = config.validate();
1647
1648        assert!(result.is_err());
1649        let err = result.unwrap_err();
1650        assert!(
1651            matches!(&err, ConfigError::CustomBackendRequiresCommand),
1652            "Expected CustomBackendRequiresCommand error, got: {:?}",
1653            err
1654        );
1655    }
1656
1657    #[test]
1658    fn test_custom_backend_with_empty_command_errors() {
1659        // Custom backend with empty command should error
1660        let yaml = r#"
1661cli:
1662  backend: "custom"
1663  command: ""
1664"#;
1665        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1666        let result = config.validate();
1667
1668        assert!(result.is_err());
1669        let err = result.unwrap_err();
1670        assert!(
1671            matches!(&err, ConfigError::CustomBackendRequiresCommand),
1672            "Expected CustomBackendRequiresCommand error, got: {:?}",
1673            err
1674        );
1675    }
1676
1677    #[test]
1678    fn test_custom_backend_with_command_succeeds() {
1679        // Custom backend with valid command should pass validation
1680        let yaml = r#"
1681cli:
1682  backend: "custom"
1683  command: "my-agent"
1684"#;
1685        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1686        let result = config.validate();
1687
1688        assert!(
1689            result.is_ok(),
1690            "Should allow custom backend with command: {:?}",
1691            result.unwrap_err()
1692        );
1693    }
1694
1695    #[test]
1696    fn test_prompt_file_with_no_inline_allowed() {
1697        // Having only prompt_file specified should be OK
1698        let yaml = r#"
1699event_loop:
1700  prompt_file: "custom.md"
1701"#;
1702        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1703        let result = config.validate();
1704
1705        assert!(
1706            result.is_ok(),
1707            "Should allow prompt_file without inline prompt"
1708        );
1709        assert_eq!(config.event_loop.prompt, None);
1710        assert_eq!(config.event_loop.prompt_file, "custom.md");
1711    }
1712
1713    #[test]
1714    fn test_default_prompt_file_value() {
1715        let config = RalphConfig::default();
1716        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1717        assert_eq!(config.event_loop.prompt, None);
1718    }
1719
1720    #[test]
1721    fn test_tui_config_default() {
1722        let config = RalphConfig::default();
1723        assert_eq!(config.tui.prefix_key, "ctrl-a");
1724    }
1725
1726    #[test]
1727    fn test_tui_config_parse_ctrl_b() {
1728        let yaml = r#"
1729tui:
1730  prefix_key: "ctrl-b"
1731"#;
1732        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1733        let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1734
1735        use crossterm::event::{KeyCode, KeyModifiers};
1736        assert_eq!(key_code, KeyCode::Char('b'));
1737        assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1738    }
1739
1740    #[test]
1741    fn test_tui_config_parse_invalid_format() {
1742        let tui_config = TuiConfig {
1743            prefix_key: "invalid".to_string(),
1744        };
1745        let result = tui_config.parse_prefix();
1746        assert!(result.is_err());
1747        assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1748    }
1749
1750    #[test]
1751    fn test_tui_config_parse_invalid_modifier() {
1752        let tui_config = TuiConfig {
1753            prefix_key: "alt-a".to_string(),
1754        };
1755        let result = tui_config.parse_prefix();
1756        assert!(result.is_err());
1757        assert!(result.unwrap_err().contains("Invalid modifier"));
1758    }
1759
1760    #[test]
1761    fn test_tui_config_parse_invalid_key() {
1762        let tui_config = TuiConfig {
1763            prefix_key: "ctrl-abc".to_string(),
1764        };
1765        let result = tui_config.parse_prefix();
1766        assert!(result.is_err());
1767        assert!(result.unwrap_err().contains("Invalid key"));
1768    }
1769
1770    #[test]
1771    fn test_hat_backend_named() {
1772        let yaml = r#""claude""#;
1773        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1774        assert_eq!(backend.to_cli_backend(), "claude");
1775        match backend {
1776            HatBackend::Named(name) => assert_eq!(name, "claude"),
1777            _ => panic!("Expected Named variant"),
1778        }
1779    }
1780
1781    #[test]
1782    fn test_hat_backend_kiro_agent() {
1783        let yaml = r#"
1784type: "kiro"
1785agent: "builder"
1786"#;
1787        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1788        assert_eq!(backend.to_cli_backend(), "kiro");
1789        match backend {
1790            HatBackend::KiroAgent {
1791                backend_type,
1792                agent,
1793                args,
1794            } => {
1795                assert_eq!(backend_type, "kiro");
1796                assert_eq!(agent, "builder");
1797                assert!(args.is_empty());
1798            }
1799            _ => panic!("Expected KiroAgent variant"),
1800        }
1801    }
1802
1803    #[test]
1804    fn test_hat_backend_kiro_agent_with_args() {
1805        let yaml = r#"
1806type: "kiro"
1807agent: "builder"
1808args: ["--verbose", "--debug"]
1809"#;
1810        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1811        assert_eq!(backend.to_cli_backend(), "kiro");
1812        match backend {
1813            HatBackend::KiroAgent {
1814                backend_type,
1815                agent,
1816                args,
1817            } => {
1818                assert_eq!(backend_type, "kiro");
1819                assert_eq!(agent, "builder");
1820                assert_eq!(args, vec!["--verbose", "--debug"]);
1821            }
1822            _ => panic!("Expected KiroAgent variant"),
1823        }
1824    }
1825
1826    #[test]
1827    fn test_hat_backend_named_with_args() {
1828        let yaml = r#"
1829type: "claude"
1830args: ["--model", "claude-sonnet-4"]
1831"#;
1832        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1833        assert_eq!(backend.to_cli_backend(), "claude");
1834        match backend {
1835            HatBackend::NamedWithArgs { backend_type, args } => {
1836                assert_eq!(backend_type, "claude");
1837                assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
1838            }
1839            _ => panic!("Expected NamedWithArgs variant"),
1840        }
1841    }
1842
1843    #[test]
1844    fn test_hat_backend_named_with_args_empty() {
1845        // type: claude without args should still work (NamedWithArgs with empty args)
1846        let yaml = r#"
1847type: "gemini"
1848"#;
1849        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1850        assert_eq!(backend.to_cli_backend(), "gemini");
1851        match backend {
1852            HatBackend::NamedWithArgs { backend_type, args } => {
1853                assert_eq!(backend_type, "gemini");
1854                assert!(args.is_empty());
1855            }
1856            _ => panic!("Expected NamedWithArgs variant"),
1857        }
1858    }
1859
1860    #[test]
1861    fn test_hat_backend_custom() {
1862        let yaml = r#"
1863command: "/usr/bin/my-agent"
1864args: ["--flag", "value"]
1865"#;
1866        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1867        assert_eq!(backend.to_cli_backend(), "custom");
1868        match backend {
1869            HatBackend::Custom { command, args } => {
1870                assert_eq!(command, "/usr/bin/my-agent");
1871                assert_eq!(args, vec!["--flag", "value"]);
1872            }
1873            _ => panic!("Expected Custom variant"),
1874        }
1875    }
1876
1877    #[test]
1878    fn test_hat_config_with_backend() {
1879        let yaml = r#"
1880name: "Custom Builder"
1881triggers: ["build.task"]
1882publishes: ["build.done"]
1883instructions: "Build stuff"
1884backend: "gemini"
1885default_publishes: "task.done"
1886"#;
1887        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1888        assert_eq!(hat.name, "Custom Builder");
1889        assert!(hat.backend.is_some());
1890        match hat.backend.unwrap() {
1891            HatBackend::Named(name) => assert_eq!(name, "gemini"),
1892            _ => panic!("Expected Named backend"),
1893        }
1894        assert_eq!(hat.default_publishes, Some("task.done".to_string()));
1895    }
1896
1897    #[test]
1898    fn test_hat_config_without_backend() {
1899        let yaml = r#"
1900name: "Default Hat"
1901triggers: ["task.start"]
1902publishes: ["task.done"]
1903instructions: "Do work"
1904"#;
1905        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1906        assert_eq!(hat.name, "Default Hat");
1907        assert!(hat.backend.is_none());
1908        assert!(hat.default_publishes.is_none());
1909    }
1910
1911    #[test]
1912    fn test_mixed_backends_config() {
1913        let yaml = r#"
1914event_loop:
1915  prompt_file: "TASK.md"
1916  max_iterations: 50
1917
1918cli:
1919  backend: "claude"
1920
1921hats:
1922  planner:
1923    name: "Planner"
1924    triggers: ["task.start"]
1925    publishes: ["build.task"]
1926    instructions: "Plan the work"
1927    backend: "claude"
1928    
1929  builder:
1930    name: "Builder"
1931    triggers: ["build.task"]
1932    publishes: ["build.done"]
1933    instructions: "Build the thing"
1934    backend:
1935      type: "kiro"
1936      agent: "builder"
1937      
1938  reviewer:
1939    name: "Reviewer"
1940    triggers: ["build.done"]
1941    publishes: ["review.complete"]
1942    instructions: "Review the work"
1943    backend:
1944      command: "/usr/local/bin/custom-agent"
1945      args: ["--mode", "review"]
1946    default_publishes: "review.complete"
1947"#;
1948        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1949        assert_eq!(config.hats.len(), 3);
1950
1951        // Check planner (Named backend)
1952        let planner = config.hats.get("planner").unwrap();
1953        assert!(planner.backend.is_some());
1954        match planner.backend.as_ref().unwrap() {
1955            HatBackend::Named(name) => assert_eq!(name, "claude"),
1956            _ => panic!("Expected Named backend for planner"),
1957        }
1958
1959        // Check builder (KiroAgent backend)
1960        let builder = config.hats.get("builder").unwrap();
1961        assert!(builder.backend.is_some());
1962        match builder.backend.as_ref().unwrap() {
1963            HatBackend::KiroAgent {
1964                backend_type,
1965                agent,
1966                args,
1967            } => {
1968                assert_eq!(backend_type, "kiro");
1969                assert_eq!(agent, "builder");
1970                assert!(args.is_empty());
1971            }
1972            _ => panic!("Expected KiroAgent backend for builder"),
1973        }
1974
1975        // Check reviewer (Custom backend)
1976        let reviewer = config.hats.get("reviewer").unwrap();
1977        assert!(reviewer.backend.is_some());
1978        match reviewer.backend.as_ref().unwrap() {
1979            HatBackend::Custom { command, args } => {
1980                assert_eq!(command, "/usr/local/bin/custom-agent");
1981                assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
1982            }
1983            _ => panic!("Expected Custom backend for reviewer"),
1984        }
1985        assert_eq!(
1986            reviewer.default_publishes,
1987            Some("review.complete".to_string())
1988        );
1989    }
1990
1991    #[test]
1992    fn test_features_config_auto_merge_defaults_to_false() {
1993        // Per spec: auto_merge should default to false for safety
1994        // This prevents automatic merging of parallel loop branches
1995        let config = RalphConfig::default();
1996        assert!(
1997            !config.features.auto_merge,
1998            "auto_merge should default to false"
1999        );
2000    }
2001
2002    #[test]
2003    fn test_features_config_auto_merge_from_yaml() {
2004        // Users can opt into auto_merge via config
2005        let yaml = r"
2006features:
2007  auto_merge: true
2008";
2009        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2010        assert!(
2011            config.features.auto_merge,
2012            "auto_merge should be true when configured"
2013        );
2014    }
2015
2016    #[test]
2017    fn test_features_config_auto_merge_false_from_yaml() {
2018        // Explicit false should work too
2019        let yaml = r"
2020features:
2021  auto_merge: false
2022";
2023        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2024        assert!(
2025            !config.features.auto_merge,
2026            "auto_merge should be false when explicitly configured"
2027        );
2028    }
2029
2030    #[test]
2031    fn test_features_config_preserves_parallel_when_adding_auto_merge() {
2032        // Ensure adding auto_merge doesn't break existing parallel feature
2033        let yaml = r"
2034features:
2035  parallel: false
2036  auto_merge: true
2037";
2038        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2039        assert!(!config.features.parallel, "parallel should be false");
2040        assert!(config.features.auto_merge, "auto_merge should be true");
2041    }
2042}