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