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
118fn default_true() -> bool {
119    true
120}
121
122#[allow(clippy::derivable_impls)] // Cannot derive due to serde default functions
123impl Default for RalphConfig {
124    fn default() -> Self {
125        Self {
126            event_loop: EventLoopConfig::default(),
127            cli: CliConfig::default(),
128            core: CoreConfig::default(),
129            hats: HashMap::new(),
130            events: HashMap::new(),
131            // V1 compatibility fields
132            agent: None,
133            agent_priority: vec![],
134            prompt_file: None,
135            completion_promise: None,
136            max_iterations: None,
137            max_runtime: None,
138            max_cost: None,
139            // Feature flags
140            verbose: false,
141            archive_prompts: false,
142            enable_metrics: false,
143            // Dropped fields
144            max_tokens: None,
145            retry_delay: None,
146            adapters: AdaptersConfig::default(),
147            // Warning control
148            suppress_warnings: false,
149            // TUI
150            tui: TuiConfig::default(),
151        }
152    }
153}
154
155/// V1 adapter settings per backend.
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct AdaptersConfig {
158    /// Claude adapter settings.
159    #[serde(default)]
160    pub claude: AdapterSettings,
161
162    /// Gemini adapter settings.
163    #[serde(default)]
164    pub gemini: AdapterSettings,
165
166    /// Kiro adapter settings.
167    #[serde(default)]
168    pub kiro: AdapterSettings,
169
170    /// Codex adapter settings.
171    #[serde(default)]
172    pub codex: AdapterSettings,
173
174    /// Amp adapter settings.
175    #[serde(default)]
176    pub amp: AdapterSettings,
177}
178
179/// Per-adapter settings.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct AdapterSettings {
182    /// CLI execution timeout in seconds.
183    #[serde(default = "default_timeout")]
184    pub timeout: u64,
185
186    /// Include in auto-detection.
187    #[serde(default = "default_true")]
188    pub enabled: bool,
189
190    /// Tool permissions (DROPPED: CLI tool manages its own permissions).
191    #[serde(default)]
192    pub tool_permissions: Option<Vec<String>>,
193}
194
195fn default_timeout() -> u64 {
196    300 // 5 minutes
197}
198
199impl Default for AdapterSettings {
200    fn default() -> Self {
201        Self {
202            timeout: default_timeout(),
203            enabled: true,
204            tool_permissions: None,
205        }
206    }
207}
208
209impl RalphConfig {
210    /// Loads configuration from a YAML file.
211    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
212        let path_ref = path.as_ref();
213        debug!(path = %path_ref.display(), "Loading configuration from file");
214        let content = std::fs::read_to_string(path_ref)?;
215        let config: Self = serde_yaml::from_str(&content)?;
216        debug!(
217            backend = %config.cli.backend,
218            has_v1_fields = config.agent.is_some(),
219            custom_hats = config.hats.len(),
220            "Configuration loaded"
221        );
222        Ok(config)
223    }
224
225    /// Normalizes v1 flat fields into v2 nested structure.
226    ///
227    /// V1 flat fields take precedence over v2 nested fields when both are present.
228    /// This allows users to use either format or mix them.
229    pub fn normalize(&mut self) {
230        let mut normalized_count = 0;
231
232        // Map v1 `agent` to v2 `cli.backend`
233        if let Some(ref agent) = self.agent {
234            debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
235            self.cli.backend = agent.clone();
236            normalized_count += 1;
237        }
238
239        // Map v1 `prompt_file` to v2 `event_loop.prompt_file`
240        if let Some(ref pf) = self.prompt_file {
241            debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
242            self.event_loop.prompt_file = pf.clone();
243            normalized_count += 1;
244        }
245
246        // Map v1 `completion_promise` to v2 `event_loop.completion_promise`
247        if let Some(ref cp) = self.completion_promise {
248            debug!(
249                from = "completion_promise",
250                to = "event_loop.completion_promise",
251                "Normalizing v1 field"
252            );
253            self.event_loop.completion_promise = cp.clone();
254            normalized_count += 1;
255        }
256
257        // Map v1 `max_iterations` to v2 `event_loop.max_iterations`
258        if let Some(mi) = self.max_iterations {
259            debug!(
260                from = "max_iterations",
261                to = "event_loop.max_iterations",
262                value = mi,
263                "Normalizing v1 field"
264            );
265            self.event_loop.max_iterations = mi;
266            normalized_count += 1;
267        }
268
269        // Map v1 `max_runtime` to v2 `event_loop.max_runtime_seconds`
270        if let Some(mr) = self.max_runtime {
271            debug!(
272                from = "max_runtime",
273                to = "event_loop.max_runtime_seconds",
274                value = mr,
275                "Normalizing v1 field"
276            );
277            self.event_loop.max_runtime_seconds = mr;
278            normalized_count += 1;
279        }
280
281        // Map v1 `max_cost` to v2 `event_loop.max_cost_usd`
282        if self.max_cost.is_some() {
283            debug!(
284                from = "max_cost",
285                to = "event_loop.max_cost_usd",
286                "Normalizing v1 field"
287            );
288            self.event_loop.max_cost_usd = self.max_cost;
289            normalized_count += 1;
290        }
291
292        if normalized_count > 0 {
293            debug!(
294                fields_normalized = normalized_count,
295                "V1 to V2 config normalization complete"
296            );
297        }
298    }
299
300    /// Validates the configuration and returns warnings.
301    ///
302    /// This method checks for:
303    /// - Deferred features that are enabled (archive_prompts, enable_metrics)
304    /// - Dropped fields that are present (max_tokens, retry_delay, tool_permissions)
305    /// - Ambiguous trigger routing across custom hats
306    /// - Mutual exclusivity of prompt and prompt_file
307    ///
308    /// Returns a list of warnings that should be displayed to the user.
309    pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
310        let mut warnings = Vec::new();
311
312        // Skip all warnings if suppressed
313        if self.suppress_warnings {
314            return Ok(warnings);
315        }
316
317        // Check for mutual exclusivity of prompt and prompt_file in config
318        // Only error if both are explicitly set (not defaults)
319        if self.event_loop.prompt.is_some()
320            && !self.event_loop.prompt_file.is_empty()
321            && self.event_loop.prompt_file != default_prompt_file()
322        {
323            return Err(ConfigError::MutuallyExclusive {
324                field1: "event_loop.prompt".to_string(),
325                field2: "event_loop.prompt_file".to_string(),
326            });
327        }
328
329        // Check custom backend has a command
330        if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
331            return Err(ConfigError::CustomBackendRequiresCommand);
332        }
333
334        // Check for deferred features
335        if self.archive_prompts {
336            warnings.push(ConfigWarning::DeferredFeature {
337                field: "archive_prompts".to_string(),
338                message: "Feature not yet available in v2".to_string(),
339            });
340        }
341
342        if self.enable_metrics {
343            warnings.push(ConfigWarning::DeferredFeature {
344                field: "enable_metrics".to_string(),
345                message: "Feature not yet available in v2".to_string(),
346            });
347        }
348
349        // Check for dropped fields
350        if self.max_tokens.is_some() {
351            warnings.push(ConfigWarning::DroppedField {
352                field: "max_tokens".to_string(),
353                reason: "Token limits are controlled by the CLI tool".to_string(),
354            });
355        }
356
357        if self.retry_delay.is_some() {
358            warnings.push(ConfigWarning::DroppedField {
359                field: "retry_delay".to_string(),
360                reason: "Retry logic handled differently in v2".to_string(),
361            });
362        }
363
364        // Check adapter tool_permissions (dropped field)
365        if self.adapters.claude.tool_permissions.is_some()
366            || self.adapters.gemini.tool_permissions.is_some()
367            || self.adapters.codex.tool_permissions.is_some()
368            || self.adapters.amp.tool_permissions.is_some()
369        {
370            warnings.push(ConfigWarning::DroppedField {
371                field: "adapters.*.tool_permissions".to_string(),
372                reason: "CLI tool manages its own permissions".to_string(),
373            });
374        }
375
376        // Check for required description field on all hats
377        for (hat_id, hat_config) in &self.hats {
378            if hat_config
379                .description
380                .as_ref()
381                .is_none_or(|d| d.trim().is_empty())
382            {
383                return Err(ConfigError::MissingDescription {
384                    hat: hat_id.clone(),
385                });
386            }
387        }
388
389        // Check for reserved triggers: task.start and task.resume are reserved for Ralph
390        // Per design: Ralph coordinates first, then delegates to custom hats via events
391        const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
392        for (hat_id, hat_config) in &self.hats {
393            for trigger in &hat_config.triggers {
394                if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
395                    return Err(ConfigError::ReservedTrigger {
396                        trigger: trigger.clone(),
397                        hat: hat_id.clone(),
398                    });
399                }
400            }
401        }
402
403        // Check for ambiguous routing: each trigger topic must map to exactly one hat
404        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
405        if !self.hats.is_empty() {
406            let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
407            for (hat_id, hat_config) in &self.hats {
408                for trigger in &hat_config.triggers {
409                    if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
410                        return Err(ConfigError::AmbiguousRouting {
411                            trigger: trigger.clone(),
412                            hat1: (*existing_hat).to_string(),
413                            hat2: hat_id.clone(),
414                        });
415                    }
416                    trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
417                }
418            }
419        }
420
421        Ok(warnings)
422    }
423
424    /// Gets the effective backend name, resolving "auto" using the priority list.
425    pub fn effective_backend(&self) -> &str {
426        &self.cli.backend
427    }
428
429    /// Returns the agent priority list for auto-detection.
430    /// If empty, returns the default priority order.
431    pub fn get_agent_priority(&self) -> Vec<&str> {
432        if self.agent_priority.is_empty() {
433            vec!["claude", "kiro", "gemini", "codex", "amp"]
434        } else {
435            self.agent_priority.iter().map(String::as_str).collect()
436        }
437    }
438
439    /// Gets the adapter settings for a specific backend.
440    #[allow(clippy::match_same_arms)] // Explicit match arms for each backend improves readability
441    pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
442        match backend {
443            "claude" => &self.adapters.claude,
444            "gemini" => &self.adapters.gemini,
445            "kiro" => &self.adapters.kiro,
446            "codex" => &self.adapters.codex,
447            "amp" => &self.adapters.amp,
448            _ => &self.adapters.claude, // Default fallback
449        }
450    }
451}
452
453/// Configuration warnings emitted during validation.
454#[derive(Debug, Clone)]
455pub enum ConfigWarning {
456    /// Feature is enabled but not yet available in v2.
457    DeferredFeature { field: String, message: String },
458    /// Field is present but ignored in v2.
459    DroppedField { field: String, reason: String },
460    /// Field has an invalid value.
461    InvalidValue { field: String, message: String },
462}
463
464impl std::fmt::Display for ConfigWarning {
465    #[allow(clippy::match_same_arms)] // Different arms have different messages despite similar structure
466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        match self {
468            ConfigWarning::DeferredFeature { field, message }
469            | ConfigWarning::InvalidValue { field, message } => {
470                write!(f, "Warning [{field}]: {message}")
471            }
472            ConfigWarning::DroppedField { field, reason } => {
473                write!(f, "Warning [{field}]: Field ignored - {reason}")
474            }
475        }
476    }
477}
478
479/// Event loop configuration.
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct EventLoopConfig {
482    /// Inline prompt text (mutually exclusive with prompt_file).
483    pub prompt: Option<String>,
484
485    /// Path to the prompt file.
486    #[serde(default = "default_prompt_file")]
487    pub prompt_file: String,
488
489    /// String that signals loop completion.
490    #[serde(default = "default_completion_promise")]
491    pub completion_promise: String,
492
493    /// Maximum number of iterations before timeout.
494    #[serde(default = "default_max_iterations")]
495    pub max_iterations: u32,
496
497    /// Maximum runtime in seconds.
498    #[serde(default = "default_max_runtime")]
499    pub max_runtime_seconds: u64,
500
501    /// Maximum cost in USD before stopping.
502    pub max_cost_usd: Option<f64>,
503
504    /// Stop after this many consecutive failures.
505    #[serde(default = "default_max_failures")]
506    pub max_consecutive_failures: u32,
507
508    /// Starting hat for multi-hat mode (deprecated, use starting_event instead).
509    pub starting_hat: Option<String>,
510
511    /// Event to publish after Ralph completes initial coordination.
512    ///
513    /// When custom hats are defined, Ralph handles `task.start` to do gap analysis
514    /// and planning, then publishes this event to delegate to the first hat.
515    ///
516    /// Example: `starting_event: "tdd.start"` for TDD workflow.
517    ///
518    /// If not specified and hats are defined, Ralph will determine the appropriate
519    /// event from the hat topology.
520    pub starting_event: Option<String>,
521}
522
523fn default_prompt_file() -> String {
524    "PROMPT.md".to_string()
525}
526
527fn default_completion_promise() -> String {
528    "LOOP_COMPLETE".to_string()
529}
530
531fn default_max_iterations() -> u32 {
532    100
533}
534
535fn default_max_runtime() -> u64 {
536    14400 // 4 hours
537}
538
539fn default_max_failures() -> u32 {
540    5
541}
542
543impl Default for EventLoopConfig {
544    fn default() -> Self {
545        Self {
546            prompt: None,
547            prompt_file: default_prompt_file(),
548            completion_promise: default_completion_promise(),
549            max_iterations: default_max_iterations(),
550            max_runtime_seconds: default_max_runtime(),
551            max_cost_usd: None,
552            max_consecutive_failures: default_max_failures(),
553            starting_hat: None,
554            starting_event: None,
555        }
556    }
557}
558
559/// Core paths and settings shared across all hats.
560///
561/// Per spec: "Core behaviors (always injected, can customize paths)"
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct CoreConfig {
564    /// Path to the scratchpad file (shared state between hats).
565    #[serde(default = "default_scratchpad")]
566    pub scratchpad: String,
567
568    /// Path to the specs directory (source of truth for requirements).
569    #[serde(default = "default_specs_dir")]
570    pub specs_dir: String,
571
572    /// Guardrails injected into every prompt (core behaviors).
573    ///
574    /// Per spec: These are always present regardless of hat.
575    #[serde(default = "default_guardrails")]
576    pub guardrails: Vec<String>,
577}
578
579fn default_scratchpad() -> String {
580    ".agent/scratchpad.md".to_string()
581}
582
583fn default_specs_dir() -> String {
584    "./specs/".to_string()
585}
586
587fn default_guardrails() -> Vec<String> {
588    vec![
589        "Fresh context each iteration - scratchpad is memory".to_string(),
590        "Don't assume 'not implemented' - search first".to_string(),
591        "Backpressure is law - tests/typecheck/lint must pass".to_string(),
592    ]
593}
594
595impl Default for CoreConfig {
596    fn default() -> Self {
597        Self {
598            scratchpad: default_scratchpad(),
599            specs_dir: default_specs_dir(),
600            guardrails: default_guardrails(),
601        }
602    }
603}
604
605/// CLI backend configuration.
606#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct CliConfig {
608    /// Backend to use: "claude", "kiro", "gemini", "codex", "amp", or "custom".
609    #[serde(default = "default_backend")]
610    pub backend: String,
611
612    /// Custom command (for backend: "custom").
613    pub command: Option<String>,
614
615    /// How to pass prompts: "arg" or "stdin".
616    #[serde(default = "default_prompt_mode")]
617    pub prompt_mode: String,
618
619    /// Execution mode when --interactive not specified.
620    /// Values: "autonomous" (default), "interactive"
621    #[serde(default = "default_mode")]
622    pub default_mode: String,
623
624    /// Idle timeout in seconds for interactive mode.
625    /// Process is terminated after this many seconds of inactivity (no output AND no user input).
626    /// Set to 0 to disable idle timeout.
627    #[serde(default = "default_idle_timeout")]
628    pub idle_timeout_secs: u32,
629
630    /// Custom arguments to pass to the CLI command (for backend: "custom").
631    /// These are inserted before the prompt argument.
632    #[serde(default)]
633    pub args: Vec<String>,
634
635    /// Custom prompt flag for arg mode (for backend: "custom").
636    /// If None, defaults to "-p" for arg mode.
637    #[serde(default)]
638    pub prompt_flag: Option<String>,
639}
640
641fn default_backend() -> String {
642    "claude".to_string()
643}
644
645fn default_prompt_mode() -> String {
646    "arg".to_string()
647}
648
649fn default_mode() -> String {
650    "autonomous".to_string()
651}
652
653fn default_idle_timeout() -> u32 {
654    30 // 30 seconds per spec
655}
656
657impl Default for CliConfig {
658    fn default() -> Self {
659        Self {
660            backend: default_backend(),
661            command: None,
662            prompt_mode: default_prompt_mode(),
663            default_mode: default_mode(),
664            idle_timeout_secs: default_idle_timeout(),
665            args: Vec::new(),
666            prompt_flag: None,
667        }
668    }
669}
670
671/// TUI configuration.
672#[derive(Debug, Clone, Serialize, Deserialize)]
673pub struct TuiConfig {
674    /// Prefix key combination (e.g., "ctrl-a", "ctrl-b").
675    #[serde(default = "default_prefix_key")]
676    pub prefix_key: String,
677}
678
679fn default_prefix_key() -> String {
680    "ctrl-a".to_string()
681}
682
683impl Default for TuiConfig {
684    fn default() -> Self {
685        Self {
686            prefix_key: default_prefix_key(),
687        }
688    }
689}
690
691impl TuiConfig {
692    /// Parses the prefix_key string into KeyCode and KeyModifiers.
693    /// Returns an error if the format is invalid.
694    pub fn parse_prefix(
695        &self,
696    ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
697        use crossterm::event::{KeyCode, KeyModifiers};
698
699        let parts: Vec<&str> = self.prefix_key.split('-').collect();
700        if parts.len() != 2 {
701            return Err(format!(
702                "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
703                self.prefix_key
704            ));
705        }
706
707        let modifier = match parts[0].to_lowercase().as_str() {
708            "ctrl" => KeyModifiers::CONTROL,
709            _ => {
710                return Err(format!(
711                    "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
712                    parts[0]
713                ));
714            }
715        };
716
717        let key_str = parts[1];
718        if key_str.len() != 1 {
719            return Err(format!(
720                "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
721                key_str
722            ));
723        }
724
725        let key_char = key_str.chars().next().unwrap();
726        let key_code = KeyCode::Char(key_char);
727
728        Ok((key_code, modifier))
729    }
730}
731
732/// Metadata for an event topic.
733///
734/// Defines what an event means, enabling auto-derived instructions for hats.
735/// When a hat triggers on or publishes an event, this metadata is used to
736/// generate appropriate behavior instructions.
737///
738/// Example:
739/// ```yaml
740/// events:
741///   deploy.start:
742///     description: "Deployment has been requested"
743///     on_trigger: "Prepare artifacts, validate config, check dependencies"
744///     on_publish: "Signal that deployment should begin"
745/// ```
746#[derive(Debug, Clone, Default, Serialize, Deserialize)]
747pub struct EventMetadata {
748    /// Brief description of what this event represents.
749    #[serde(default)]
750    pub description: String,
751
752    /// Instructions for a hat that triggers on (receives) this event.
753    /// Describes what the hat should do when it receives this event.
754    #[serde(default)]
755    pub on_trigger: String,
756
757    /// Instructions for a hat that publishes (emits) this event.
758    /// Describes when/how the hat should emit this event.
759    #[serde(default)]
760    pub on_publish: String,
761}
762
763/// Backend configuration for a hat.
764#[derive(Debug, Clone, Serialize, Deserialize)]
765#[serde(untagged)]
766pub enum HatBackend {
767    /// Named backend (e.g., "claude", "gemini", "kiro").
768    Named(String),
769    /// Kiro agent with custom agent name.
770    KiroAgent {
771        #[serde(rename = "type")]
772        backend_type: String,
773        agent: String,
774    },
775    /// Custom backend with command and args.
776    Custom { command: String, args: Vec<String> },
777}
778
779impl HatBackend {
780    /// Converts to CLI backend string for execution.
781    pub fn to_cli_backend(&self) -> String {
782        match self {
783            HatBackend::Named(name) => name.clone(),
784            HatBackend::KiroAgent { .. } => "kiro".to_string(),
785            HatBackend::Custom { .. } => "custom".to_string(),
786        }
787    }
788}
789
790/// Configuration for a single hat.
791#[derive(Debug, Clone, Serialize, Deserialize)]
792pub struct HatConfig {
793    /// Human-readable name for the hat.
794    pub name: String,
795
796    /// Short description of the hat's purpose (required).
797    /// Used in the HATS table to help Ralph understand when to delegate to this hat.
798    pub description: Option<String>,
799
800    /// Events that trigger this hat to be worn.
801    /// Per spec: "Hats define triggers — which events cause Ralph to wear this hat."
802    #[serde(default)]
803    pub triggers: Vec<String>,
804
805    /// Topics this hat publishes.
806    #[serde(default)]
807    pub publishes: Vec<String>,
808
809    /// Instructions prepended to prompts.
810    #[serde(default)]
811    pub instructions: String,
812
813    /// Backend to use for this hat (inherits from cli.backend if not specified).
814    #[serde(default)]
815    pub backend: Option<HatBackend>,
816
817    /// Default event to publish if hat forgets to write an event.
818    #[serde(default)]
819    pub default_publishes: Option<String>,
820}
821
822impl HatConfig {
823    /// Converts trigger strings to Topic objects.
824    pub fn trigger_topics(&self) -> Vec<Topic> {
825        self.triggers.iter().map(|s| Topic::new(s)).collect()
826    }
827
828    /// Converts publish strings to Topic objects.
829    pub fn publish_topics(&self) -> Vec<Topic> {
830        self.publishes.iter().map(|s| Topic::new(s)).collect()
831    }
832}
833
834/// Configuration errors.
835#[derive(Debug, thiserror::Error)]
836pub enum ConfigError {
837    #[error("IO error: {0}")]
838    Io(#[from] std::io::Error),
839
840    #[error("YAML parse error: {0}")]
841    Yaml(#[from] serde_yaml::Error),
842
843    #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
844    AmbiguousRouting {
845        trigger: String,
846        hat1: String,
847        hat2: String,
848    },
849
850    #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
851    MutuallyExclusive { field1: String, field2: String },
852
853    #[error("Custom backend requires a command - set 'cli.command' in config")]
854    CustomBackendRequiresCommand,
855
856    #[error(
857        "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."
858    )]
859    ReservedTrigger { trigger: String, hat: String },
860
861    #[error(
862        "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose"
863    )]
864    MissingDescription { hat: String },
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    #[test]
872    fn test_default_config() {
873        let config = RalphConfig::default();
874        // Default config has no custom hats (uses default planner+builder)
875        assert!(config.hats.is_empty());
876        assert_eq!(config.event_loop.max_iterations, 100);
877        assert!(!config.verbose);
878    }
879
880    #[test]
881    fn test_parse_yaml_with_custom_hats() {
882        let yaml = r#"
883event_loop:
884  prompt_file: "TASK.md"
885  completion_promise: "DONE"
886  max_iterations: 50
887cli:
888  backend: "claude"
889hats:
890  implementer:
891    name: "Implementer"
892    triggers: ["task.*", "review.done"]
893    publishes: ["impl.done"]
894    instructions: "You are the implementation agent."
895"#;
896        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
897        // Custom hats are defined
898        assert_eq!(config.hats.len(), 1);
899        assert_eq!(config.event_loop.prompt_file, "TASK.md");
900
901        let hat = config.hats.get("implementer").unwrap();
902        assert_eq!(hat.triggers.len(), 2);
903    }
904
905    #[test]
906    fn test_parse_yaml_v1_format() {
907        // V1 flat format - identical to Python v1.x config
908        let yaml = r#"
909agent: gemini
910prompt_file: "TASK.md"
911completion_promise: "RALPH_DONE"
912max_iterations: 75
913max_runtime: 7200
914max_cost: 10.0
915verbose: true
916"#;
917        let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
918
919        // Before normalization, v2 fields have defaults
920        assert_eq!(config.cli.backend, "claude"); // default
921        assert_eq!(config.event_loop.max_iterations, 100); // default
922
923        // Normalize v1 -> v2
924        config.normalize();
925
926        // After normalization, v2 fields have v1 values
927        assert_eq!(config.cli.backend, "gemini");
928        assert_eq!(config.event_loop.prompt_file, "TASK.md");
929        assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
930        assert_eq!(config.event_loop.max_iterations, 75);
931        assert_eq!(config.event_loop.max_runtime_seconds, 7200);
932        assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
933        assert!(config.verbose);
934    }
935
936    #[test]
937    fn test_agent_priority() {
938        let yaml = r"
939agent: auto
940agent_priority: [gemini, claude, codex]
941";
942        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
943        let priority = config.get_agent_priority();
944        assert_eq!(priority, vec!["gemini", "claude", "codex"]);
945    }
946
947    #[test]
948    fn test_default_agent_priority() {
949        let config = RalphConfig::default();
950        let priority = config.get_agent_priority();
951        assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
952    }
953
954    #[test]
955    fn test_validate_deferred_features() {
956        let yaml = r"
957archive_prompts: true
958enable_metrics: true
959";
960        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
961        let warnings = config.validate().unwrap();
962
963        assert_eq!(warnings.len(), 2);
964        assert!(warnings
965            .iter()
966            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
967        assert!(warnings
968            .iter()
969            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
970    }
971
972    #[test]
973    fn test_validate_dropped_fields() {
974        let yaml = r#"
975max_tokens: 4096
976retry_delay: 5
977adapters:
978  claude:
979    tool_permissions: ["read", "write"]
980"#;
981        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
982        let warnings = config.validate().unwrap();
983
984        assert_eq!(warnings.len(), 3);
985        assert!(warnings.iter().any(
986            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
987        ));
988        assert!(warnings.iter().any(
989            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
990        ));
991        assert!(warnings
992            .iter()
993            .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
994    }
995
996    #[test]
997    fn test_suppress_warnings() {
998        let yaml = r"
999_suppress_warnings: true
1000archive_prompts: true
1001max_tokens: 4096
1002";
1003        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1004        let warnings = config.validate().unwrap();
1005
1006        // All warnings should be suppressed
1007        assert!(warnings.is_empty());
1008    }
1009
1010    #[test]
1011    fn test_adapter_settings() {
1012        let yaml = r"
1013adapters:
1014  claude:
1015    timeout: 600
1016    enabled: true
1017  gemini:
1018    timeout: 300
1019    enabled: false
1020";
1021        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1022
1023        let claude = config.adapter_settings("claude");
1024        assert_eq!(claude.timeout, 600);
1025        assert!(claude.enabled);
1026
1027        let gemini = config.adapter_settings("gemini");
1028        assert_eq!(gemini.timeout, 300);
1029        assert!(!gemini.enabled);
1030    }
1031
1032    #[test]
1033    fn test_unknown_fields_ignored() {
1034        // Unknown fields should be silently ignored (forward compatibility)
1035        let yaml = r#"
1036agent: claude
1037unknown_field: "some value"
1038future_feature: true
1039"#;
1040        let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1041        // Should parse successfully, ignoring unknown fields
1042        assert!(result.is_ok());
1043    }
1044
1045    #[test]
1046    fn test_ambiguous_routing_rejected() {
1047        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
1048        // Note: using semantic events since task.start is reserved
1049        let yaml = r#"
1050hats:
1051  planner:
1052    name: "Planner"
1053    description: "Plans tasks"
1054    triggers: ["planning.start", "build.done"]
1055  builder:
1056    name: "Builder"
1057    description: "Builds code"
1058    triggers: ["build.task", "build.done"]
1059"#;
1060        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1061        let result = config.validate();
1062
1063        assert!(result.is_err());
1064        let err = result.unwrap_err();
1065        assert!(
1066            matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1067            "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1068            err
1069        );
1070    }
1071
1072    #[test]
1073    fn test_unique_triggers_accepted() {
1074        // Valid config: each trigger maps to exactly one hat
1075        // Note: task.start is reserved for Ralph, so use semantic events
1076        let yaml = r#"
1077hats:
1078  planner:
1079    name: "Planner"
1080    description: "Plans tasks"
1081    triggers: ["planning.start", "build.done", "build.blocked"]
1082  builder:
1083    name: "Builder"
1084    description: "Builds code"
1085    triggers: ["build.task"]
1086"#;
1087        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1088        let result = config.validate();
1089
1090        assert!(
1091            result.is_ok(),
1092            "Expected valid config, got: {:?}",
1093            result.unwrap_err()
1094        );
1095    }
1096
1097    #[test]
1098    fn test_reserved_trigger_task_start_rejected() {
1099        // Per design: task.start is reserved for Ralph (the coordinator)
1100        let yaml = r#"
1101hats:
1102  my_hat:
1103    name: "My Hat"
1104    description: "Test hat"
1105    triggers: ["task.start"]
1106"#;
1107        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1108        let result = config.validate();
1109
1110        assert!(result.is_err());
1111        let err = result.unwrap_err();
1112        assert!(
1113            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1114                if trigger == "task.start" && hat == "my_hat"),
1115            "Expected ReservedTrigger error for 'task.start', got: {:?}",
1116            err
1117        );
1118    }
1119
1120    #[test]
1121    fn test_reserved_trigger_task_resume_rejected() {
1122        // Per design: task.resume is reserved for Ralph (the coordinator)
1123        let yaml = r#"
1124hats:
1125  my_hat:
1126    name: "My Hat"
1127    description: "Test hat"
1128    triggers: ["task.resume", "other.event"]
1129"#;
1130        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1131        let result = config.validate();
1132
1133        assert!(result.is_err());
1134        let err = result.unwrap_err();
1135        assert!(
1136            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1137                if trigger == "task.resume" && hat == "my_hat"),
1138            "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1139            err
1140        );
1141    }
1142
1143    #[test]
1144    fn test_missing_description_rejected() {
1145        // Description is required for all hats
1146        let yaml = r#"
1147hats:
1148  my_hat:
1149    name: "My Hat"
1150    triggers: ["build.task"]
1151"#;
1152        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1153        let result = config.validate();
1154
1155        assert!(result.is_err());
1156        let err = result.unwrap_err();
1157        assert!(
1158            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1159            "Expected MissingDescription error, got: {:?}",
1160            err
1161        );
1162    }
1163
1164    #[test]
1165    fn test_empty_description_rejected() {
1166        // Empty description should also be rejected
1167        let yaml = r#"
1168hats:
1169  my_hat:
1170    name: "My Hat"
1171    description: "   "
1172    triggers: ["build.task"]
1173"#;
1174        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1175        let result = config.validate();
1176
1177        assert!(result.is_err());
1178        let err = result.unwrap_err();
1179        assert!(
1180            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1181            "Expected MissingDescription error for empty description, got: {:?}",
1182            err
1183        );
1184    }
1185
1186    #[test]
1187    fn test_core_config_defaults() {
1188        let config = RalphConfig::default();
1189        assert_eq!(config.core.scratchpad, ".agent/scratchpad.md");
1190        assert_eq!(config.core.specs_dir, "./specs/");
1191        // Default guardrails per spec
1192        assert_eq!(config.core.guardrails.len(), 3);
1193        assert!(config.core.guardrails[0].contains("Fresh context"));
1194        assert!(config.core.guardrails[1].contains("search first"));
1195        assert!(config.core.guardrails[2].contains("Backpressure"));
1196    }
1197
1198    #[test]
1199    fn test_core_config_customizable() {
1200        let yaml = r#"
1201core:
1202  scratchpad: ".workspace/plan.md"
1203  specs_dir: "./specifications/"
1204"#;
1205        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1206        assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1207        assert_eq!(config.core.specs_dir, "./specifications/");
1208        // Guardrails should use defaults when not specified
1209        assert_eq!(config.core.guardrails.len(), 3);
1210    }
1211
1212    #[test]
1213    fn test_core_config_custom_guardrails() {
1214        let yaml = r#"
1215core:
1216  scratchpad: ".agent/scratchpad.md"
1217  specs_dir: "./specs/"
1218  guardrails:
1219    - "Custom rule one"
1220    - "Custom rule two"
1221"#;
1222        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1223        assert_eq!(config.core.guardrails.len(), 2);
1224        assert_eq!(config.core.guardrails[0], "Custom rule one");
1225        assert_eq!(config.core.guardrails[1], "Custom rule two");
1226    }
1227
1228    #[test]
1229    fn test_prompt_and_prompt_file_mutually_exclusive() {
1230        // Both prompt and prompt_file specified in config should error
1231        let yaml = r#"
1232event_loop:
1233  prompt: "inline text"
1234  prompt_file: "custom.md"
1235"#;
1236        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1237        let result = config.validate();
1238
1239        assert!(result.is_err());
1240        let err = result.unwrap_err();
1241        assert!(
1242            matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1243                if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1244            "Expected MutuallyExclusive error, got: {:?}",
1245            err
1246        );
1247    }
1248
1249    #[test]
1250    fn test_prompt_with_default_prompt_file_allowed() {
1251        // Having inline prompt with default prompt_file value should be OK
1252        let yaml = r#"
1253event_loop:
1254  prompt: "inline text"
1255"#;
1256        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1257        let result = config.validate();
1258
1259        assert!(
1260            result.is_ok(),
1261            "Should allow inline prompt with default prompt_file"
1262        );
1263        assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1264        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1265    }
1266
1267    #[test]
1268    fn test_custom_backend_requires_command() {
1269        // Custom backend without command should error
1270        let yaml = r#"
1271cli:
1272  backend: "custom"
1273"#;
1274        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1275        let result = config.validate();
1276
1277        assert!(result.is_err());
1278        let err = result.unwrap_err();
1279        assert!(
1280            matches!(&err, ConfigError::CustomBackendRequiresCommand),
1281            "Expected CustomBackendRequiresCommand error, got: {:?}",
1282            err
1283        );
1284    }
1285
1286    #[test]
1287    fn test_custom_backend_with_empty_command_errors() {
1288        // Custom backend with empty command should error
1289        let yaml = r#"
1290cli:
1291  backend: "custom"
1292  command: ""
1293"#;
1294        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1295        let result = config.validate();
1296
1297        assert!(result.is_err());
1298        let err = result.unwrap_err();
1299        assert!(
1300            matches!(&err, ConfigError::CustomBackendRequiresCommand),
1301            "Expected CustomBackendRequiresCommand error, got: {:?}",
1302            err
1303        );
1304    }
1305
1306    #[test]
1307    fn test_custom_backend_with_command_succeeds() {
1308        // Custom backend with valid command should pass validation
1309        let yaml = r#"
1310cli:
1311  backend: "custom"
1312  command: "my-agent"
1313"#;
1314        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1315        let result = config.validate();
1316
1317        assert!(
1318            result.is_ok(),
1319            "Should allow custom backend with command: {:?}",
1320            result.unwrap_err()
1321        );
1322    }
1323
1324    #[test]
1325    fn test_prompt_file_with_no_inline_allowed() {
1326        // Having only prompt_file specified should be OK
1327        let yaml = r#"
1328event_loop:
1329  prompt_file: "custom.md"
1330"#;
1331        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1332        let result = config.validate();
1333
1334        assert!(
1335            result.is_ok(),
1336            "Should allow prompt_file without inline prompt"
1337        );
1338        assert_eq!(config.event_loop.prompt, None);
1339        assert_eq!(config.event_loop.prompt_file, "custom.md");
1340    }
1341
1342    #[test]
1343    fn test_default_prompt_file_value() {
1344        let config = RalphConfig::default();
1345        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1346        assert_eq!(config.event_loop.prompt, None);
1347    }
1348
1349    #[test]
1350    fn test_tui_config_default() {
1351        let config = RalphConfig::default();
1352        assert_eq!(config.tui.prefix_key, "ctrl-a");
1353    }
1354
1355    #[test]
1356    fn test_tui_config_parse_ctrl_b() {
1357        let yaml = r#"
1358tui:
1359  prefix_key: "ctrl-b"
1360"#;
1361        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1362        let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1363
1364        use crossterm::event::{KeyCode, KeyModifiers};
1365        assert_eq!(key_code, KeyCode::Char('b'));
1366        assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1367    }
1368
1369    #[test]
1370    fn test_tui_config_parse_invalid_format() {
1371        let tui_config = TuiConfig {
1372            prefix_key: "invalid".to_string(),
1373        };
1374        let result = tui_config.parse_prefix();
1375        assert!(result.is_err());
1376        assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1377    }
1378
1379    #[test]
1380    fn test_tui_config_parse_invalid_modifier() {
1381        let tui_config = TuiConfig {
1382            prefix_key: "alt-a".to_string(),
1383        };
1384        let result = tui_config.parse_prefix();
1385        assert!(result.is_err());
1386        assert!(result.unwrap_err().contains("Invalid modifier"));
1387    }
1388
1389    #[test]
1390    fn test_tui_config_parse_invalid_key() {
1391        let tui_config = TuiConfig {
1392            prefix_key: "ctrl-abc".to_string(),
1393        };
1394        let result = tui_config.parse_prefix();
1395        assert!(result.is_err());
1396        assert!(result.unwrap_err().contains("Invalid key"));
1397    }
1398
1399    #[test]
1400    fn test_hat_backend_named() {
1401        let yaml = r#""claude""#;
1402        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1403        assert_eq!(backend.to_cli_backend(), "claude");
1404        match backend {
1405            HatBackend::Named(name) => assert_eq!(name, "claude"),
1406            _ => panic!("Expected Named variant"),
1407        }
1408    }
1409
1410    #[test]
1411    fn test_hat_backend_kiro_agent() {
1412        let yaml = r#"
1413type: "kiro"
1414agent: "builder"
1415"#;
1416        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1417        assert_eq!(backend.to_cli_backend(), "kiro");
1418        match backend {
1419            HatBackend::KiroAgent {
1420                backend_type,
1421                agent,
1422            } => {
1423                assert_eq!(backend_type, "kiro");
1424                assert_eq!(agent, "builder");
1425            }
1426            _ => panic!("Expected KiroAgent variant"),
1427        }
1428    }
1429
1430    #[test]
1431    fn test_hat_backend_custom() {
1432        let yaml = r#"
1433command: "/usr/bin/my-agent"
1434args: ["--flag", "value"]
1435"#;
1436        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1437        assert_eq!(backend.to_cli_backend(), "custom");
1438        match backend {
1439            HatBackend::Custom { command, args } => {
1440                assert_eq!(command, "/usr/bin/my-agent");
1441                assert_eq!(args, vec!["--flag", "value"]);
1442            }
1443            _ => panic!("Expected Custom variant"),
1444        }
1445    }
1446
1447    #[test]
1448    fn test_hat_config_with_backend() {
1449        let yaml = r#"
1450name: "Custom Builder"
1451triggers: ["build.task"]
1452publishes: ["build.done"]
1453instructions: "Build stuff"
1454backend: "gemini"
1455default_publishes: "task.done"
1456"#;
1457        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1458        assert_eq!(hat.name, "Custom Builder");
1459        assert!(hat.backend.is_some());
1460        match hat.backend.unwrap() {
1461            HatBackend::Named(name) => assert_eq!(name, "gemini"),
1462            _ => panic!("Expected Named backend"),
1463        }
1464        assert_eq!(hat.default_publishes, Some("task.done".to_string()));
1465    }
1466
1467    #[test]
1468    fn test_hat_config_without_backend() {
1469        let yaml = r#"
1470name: "Default Hat"
1471triggers: ["task.start"]
1472publishes: ["task.done"]
1473instructions: "Do work"
1474"#;
1475        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1476        assert_eq!(hat.name, "Default Hat");
1477        assert!(hat.backend.is_none());
1478        assert!(hat.default_publishes.is_none());
1479    }
1480
1481    #[test]
1482    fn test_mixed_backends_config() {
1483        let yaml = r#"
1484event_loop:
1485  prompt_file: "TASK.md"
1486  max_iterations: 50
1487
1488cli:
1489  backend: "claude"
1490
1491hats:
1492  planner:
1493    name: "Planner"
1494    triggers: ["task.start"]
1495    publishes: ["build.task"]
1496    instructions: "Plan the work"
1497    backend: "claude"
1498    
1499  builder:
1500    name: "Builder"
1501    triggers: ["build.task"]
1502    publishes: ["build.done"]
1503    instructions: "Build the thing"
1504    backend:
1505      type: "kiro"
1506      agent: "builder"
1507      
1508  reviewer:
1509    name: "Reviewer"
1510    triggers: ["build.done"]
1511    publishes: ["review.complete"]
1512    instructions: "Review the work"
1513    backend:
1514      command: "/usr/local/bin/custom-agent"
1515      args: ["--mode", "review"]
1516    default_publishes: "review.complete"
1517"#;
1518        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1519        assert_eq!(config.hats.len(), 3);
1520
1521        // Check planner (Named backend)
1522        let planner = config.hats.get("planner").unwrap();
1523        assert!(planner.backend.is_some());
1524        match planner.backend.as_ref().unwrap() {
1525            HatBackend::Named(name) => assert_eq!(name, "claude"),
1526            _ => panic!("Expected Named backend for planner"),
1527        }
1528
1529        // Check builder (KiroAgent backend)
1530        let builder = config.hats.get("builder").unwrap();
1531        assert!(builder.backend.is_some());
1532        match builder.backend.as_ref().unwrap() {
1533            HatBackend::KiroAgent {
1534                backend_type,
1535                agent,
1536            } => {
1537                assert_eq!(backend_type, "kiro");
1538                assert_eq!(agent, "builder");
1539            }
1540            _ => panic!("Expected KiroAgent backend for builder"),
1541        }
1542
1543        // Check reviewer (Custom backend)
1544        let reviewer = config.hats.get("reviewer").unwrap();
1545        assert!(reviewer.backend.is_some());
1546        match reviewer.backend.as_ref().unwrap() {
1547            HatBackend::Custom { command, args } => {
1548                assert_eq!(command, "/usr/local/bin/custom-agent");
1549                assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
1550            }
1551            _ => panic!("Expected Custom backend for reviewer"),
1552        }
1553        assert_eq!(
1554            reviewer.default_publishes,
1555            Some("review.complete".to_string())
1556        );
1557    }
1558}