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    /// Enable experimental TUI mode.
641    /// When false (default), interactive mode (-i/--interactive) falls back to autonomous.
642    /// Set to true to enable interactive TUI mode.
643    #[serde(default)]
644    pub experimental_tui: bool,
645}
646
647fn default_backend() -> String {
648    "claude".to_string()
649}
650
651fn default_prompt_mode() -> String {
652    "arg".to_string()
653}
654
655fn default_mode() -> String {
656    "autonomous".to_string()
657}
658
659fn default_idle_timeout() -> u32 {
660    30 // 30 seconds per spec
661}
662
663impl Default for CliConfig {
664    fn default() -> Self {
665        Self {
666            backend: default_backend(),
667            command: None,
668            prompt_mode: default_prompt_mode(),
669            default_mode: default_mode(),
670            idle_timeout_secs: default_idle_timeout(),
671            args: Vec::new(),
672            prompt_flag: None,
673            experimental_tui: false,
674        }
675    }
676}
677
678/// TUI configuration.
679#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct TuiConfig {
681    /// Prefix key combination (e.g., "ctrl-a", "ctrl-b").
682    #[serde(default = "default_prefix_key")]
683    pub prefix_key: String,
684}
685
686fn default_prefix_key() -> String {
687    "ctrl-a".to_string()
688}
689
690impl Default for TuiConfig {
691    fn default() -> Self {
692        Self {
693            prefix_key: default_prefix_key(),
694        }
695    }
696}
697
698impl TuiConfig {
699    /// Parses the prefix_key string into KeyCode and KeyModifiers.
700    /// Returns an error if the format is invalid.
701    pub fn parse_prefix(
702        &self,
703    ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
704        use crossterm::event::{KeyCode, KeyModifiers};
705
706        let parts: Vec<&str> = self.prefix_key.split('-').collect();
707        if parts.len() != 2 {
708            return Err(format!(
709                "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
710                self.prefix_key
711            ));
712        }
713
714        let modifier = match parts[0].to_lowercase().as_str() {
715            "ctrl" => KeyModifiers::CONTROL,
716            _ => {
717                return Err(format!(
718                    "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
719                    parts[0]
720                ));
721            }
722        };
723
724        let key_str = parts[1];
725        if key_str.len() != 1 {
726            return Err(format!(
727                "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
728                key_str
729            ));
730        }
731
732        let key_char = key_str.chars().next().unwrap();
733        let key_code = KeyCode::Char(key_char);
734
735        Ok((key_code, modifier))
736    }
737}
738
739/// Metadata for an event topic.
740///
741/// Defines what an event means, enabling auto-derived instructions for hats.
742/// When a hat triggers on or publishes an event, this metadata is used to
743/// generate appropriate behavior instructions.
744///
745/// Example:
746/// ```yaml
747/// events:
748///   deploy.start:
749///     description: "Deployment has been requested"
750///     on_trigger: "Prepare artifacts, validate config, check dependencies"
751///     on_publish: "Signal that deployment should begin"
752/// ```
753#[derive(Debug, Clone, Default, Serialize, Deserialize)]
754pub struct EventMetadata {
755    /// Brief description of what this event represents.
756    #[serde(default)]
757    pub description: String,
758
759    /// Instructions for a hat that triggers on (receives) this event.
760    /// Describes what the hat should do when it receives this event.
761    #[serde(default)]
762    pub on_trigger: String,
763
764    /// Instructions for a hat that publishes (emits) this event.
765    /// Describes when/how the hat should emit this event.
766    #[serde(default)]
767    pub on_publish: String,
768}
769
770/// Backend configuration for a hat.
771#[derive(Debug, Clone, Serialize, Deserialize)]
772#[serde(untagged)]
773pub enum HatBackend {
774    /// Named backend (e.g., "claude", "gemini", "kiro").
775    Named(String),
776    /// Kiro agent with custom agent name.
777    KiroAgent {
778        #[serde(rename = "type")]
779        backend_type: String,
780        agent: String,
781    },
782    /// Custom backend with command and args.
783    Custom { command: String, args: Vec<String> },
784}
785
786impl HatBackend {
787    /// Converts to CLI backend string for execution.
788    pub fn to_cli_backend(&self) -> String {
789        match self {
790            HatBackend::Named(name) => name.clone(),
791            HatBackend::KiroAgent { .. } => "kiro".to_string(),
792            HatBackend::Custom { .. } => "custom".to_string(),
793        }
794    }
795}
796
797/// Configuration for a single hat.
798#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct HatConfig {
800    /// Human-readable name for the hat.
801    pub name: String,
802
803    /// Short description of the hat's purpose (required).
804    /// Used in the HATS table to help Ralph understand when to delegate to this hat.
805    pub description: Option<String>,
806
807    /// Events that trigger this hat to be worn.
808    /// Per spec: "Hats define triggers — which events cause Ralph to wear this hat."
809    #[serde(default)]
810    pub triggers: Vec<String>,
811
812    /// Topics this hat publishes.
813    #[serde(default)]
814    pub publishes: Vec<String>,
815
816    /// Instructions prepended to prompts.
817    #[serde(default)]
818    pub instructions: String,
819
820    /// Backend to use for this hat (inherits from cli.backend if not specified).
821    #[serde(default)]
822    pub backend: Option<HatBackend>,
823
824    /// Default event to publish if hat forgets to write an event.
825    #[serde(default)]
826    pub default_publishes: Option<String>,
827}
828
829impl HatConfig {
830    /// Converts trigger strings to Topic objects.
831    pub fn trigger_topics(&self) -> Vec<Topic> {
832        self.triggers.iter().map(|s| Topic::new(s)).collect()
833    }
834
835    /// Converts publish strings to Topic objects.
836    pub fn publish_topics(&self) -> Vec<Topic> {
837        self.publishes.iter().map(|s| Topic::new(s)).collect()
838    }
839}
840
841/// Configuration errors.
842#[derive(Debug, thiserror::Error)]
843pub enum ConfigError {
844    #[error("IO error: {0}")]
845    Io(#[from] std::io::Error),
846
847    #[error("YAML parse error: {0}")]
848    Yaml(#[from] serde_yaml::Error),
849
850    #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
851    AmbiguousRouting {
852        trigger: String,
853        hat1: String,
854        hat2: String,
855    },
856
857    #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
858    MutuallyExclusive { field1: String, field2: String },
859
860    #[error("Custom backend requires a command - set 'cli.command' in config")]
861    CustomBackendRequiresCommand,
862
863    #[error(
864        "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."
865    )]
866    ReservedTrigger { trigger: String, hat: String },
867
868    #[error(
869        "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose"
870    )]
871    MissingDescription { hat: String },
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877
878    #[test]
879    fn test_default_config() {
880        let config = RalphConfig::default();
881        // Default config has no custom hats (uses default planner+builder)
882        assert!(config.hats.is_empty());
883        assert_eq!(config.event_loop.max_iterations, 100);
884        assert!(!config.verbose);
885    }
886
887    #[test]
888    fn test_parse_yaml_with_custom_hats() {
889        let yaml = r#"
890event_loop:
891  prompt_file: "TASK.md"
892  completion_promise: "DONE"
893  max_iterations: 50
894cli:
895  backend: "claude"
896hats:
897  implementer:
898    name: "Implementer"
899    triggers: ["task.*", "review.done"]
900    publishes: ["impl.done"]
901    instructions: "You are the implementation agent."
902"#;
903        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
904        // Custom hats are defined
905        assert_eq!(config.hats.len(), 1);
906        assert_eq!(config.event_loop.prompt_file, "TASK.md");
907
908        let hat = config.hats.get("implementer").unwrap();
909        assert_eq!(hat.triggers.len(), 2);
910    }
911
912    #[test]
913    fn test_parse_yaml_v1_format() {
914        // V1 flat format - identical to Python v1.x config
915        let yaml = r#"
916agent: gemini
917prompt_file: "TASK.md"
918completion_promise: "RALPH_DONE"
919max_iterations: 75
920max_runtime: 7200
921max_cost: 10.0
922verbose: true
923"#;
924        let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
925
926        // Before normalization, v2 fields have defaults
927        assert_eq!(config.cli.backend, "claude"); // default
928        assert_eq!(config.event_loop.max_iterations, 100); // default
929
930        // Normalize v1 -> v2
931        config.normalize();
932
933        // After normalization, v2 fields have v1 values
934        assert_eq!(config.cli.backend, "gemini");
935        assert_eq!(config.event_loop.prompt_file, "TASK.md");
936        assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
937        assert_eq!(config.event_loop.max_iterations, 75);
938        assert_eq!(config.event_loop.max_runtime_seconds, 7200);
939        assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
940        assert!(config.verbose);
941    }
942
943    #[test]
944    fn test_agent_priority() {
945        let yaml = r"
946agent: auto
947agent_priority: [gemini, claude, codex]
948";
949        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
950        let priority = config.get_agent_priority();
951        assert_eq!(priority, vec!["gemini", "claude", "codex"]);
952    }
953
954    #[test]
955    fn test_default_agent_priority() {
956        let config = RalphConfig::default();
957        let priority = config.get_agent_priority();
958        assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
959    }
960
961    #[test]
962    fn test_validate_deferred_features() {
963        let yaml = r"
964archive_prompts: true
965enable_metrics: true
966";
967        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
968        let warnings = config.validate().unwrap();
969
970        assert_eq!(warnings.len(), 2);
971        assert!(warnings
972            .iter()
973            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
974        assert!(warnings
975            .iter()
976            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
977    }
978
979    #[test]
980    fn test_validate_dropped_fields() {
981        let yaml = r#"
982max_tokens: 4096
983retry_delay: 5
984adapters:
985  claude:
986    tool_permissions: ["read", "write"]
987"#;
988        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
989        let warnings = config.validate().unwrap();
990
991        assert_eq!(warnings.len(), 3);
992        assert!(warnings.iter().any(
993            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
994        ));
995        assert!(warnings.iter().any(
996            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
997        ));
998        assert!(warnings
999            .iter()
1000            .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
1001    }
1002
1003    #[test]
1004    fn test_suppress_warnings() {
1005        let yaml = r"
1006_suppress_warnings: true
1007archive_prompts: true
1008max_tokens: 4096
1009";
1010        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1011        let warnings = config.validate().unwrap();
1012
1013        // All warnings should be suppressed
1014        assert!(warnings.is_empty());
1015    }
1016
1017    #[test]
1018    fn test_adapter_settings() {
1019        let yaml = r"
1020adapters:
1021  claude:
1022    timeout: 600
1023    enabled: true
1024  gemini:
1025    timeout: 300
1026    enabled: false
1027";
1028        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1029
1030        let claude = config.adapter_settings("claude");
1031        assert_eq!(claude.timeout, 600);
1032        assert!(claude.enabled);
1033
1034        let gemini = config.adapter_settings("gemini");
1035        assert_eq!(gemini.timeout, 300);
1036        assert!(!gemini.enabled);
1037    }
1038
1039    #[test]
1040    fn test_unknown_fields_ignored() {
1041        // Unknown fields should be silently ignored (forward compatibility)
1042        let yaml = r#"
1043agent: claude
1044unknown_field: "some value"
1045future_feature: true
1046"#;
1047        let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1048        // Should parse successfully, ignoring unknown fields
1049        assert!(result.is_ok());
1050    }
1051
1052    #[test]
1053    fn test_ambiguous_routing_rejected() {
1054        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
1055        // Note: using semantic events since task.start is reserved
1056        let yaml = r#"
1057hats:
1058  planner:
1059    name: "Planner"
1060    description: "Plans tasks"
1061    triggers: ["planning.start", "build.done"]
1062  builder:
1063    name: "Builder"
1064    description: "Builds code"
1065    triggers: ["build.task", "build.done"]
1066"#;
1067        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1068        let result = config.validate();
1069
1070        assert!(result.is_err());
1071        let err = result.unwrap_err();
1072        assert!(
1073            matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1074            "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1075            err
1076        );
1077    }
1078
1079    #[test]
1080    fn test_unique_triggers_accepted() {
1081        // Valid config: each trigger maps to exactly one hat
1082        // Note: task.start is reserved for Ralph, so use semantic events
1083        let yaml = r#"
1084hats:
1085  planner:
1086    name: "Planner"
1087    description: "Plans tasks"
1088    triggers: ["planning.start", "build.done", "build.blocked"]
1089  builder:
1090    name: "Builder"
1091    description: "Builds code"
1092    triggers: ["build.task"]
1093"#;
1094        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1095        let result = config.validate();
1096
1097        assert!(
1098            result.is_ok(),
1099            "Expected valid config, got: {:?}",
1100            result.unwrap_err()
1101        );
1102    }
1103
1104    #[test]
1105    fn test_reserved_trigger_task_start_rejected() {
1106        // Per design: task.start is reserved for Ralph (the coordinator)
1107        let yaml = r#"
1108hats:
1109  my_hat:
1110    name: "My Hat"
1111    description: "Test hat"
1112    triggers: ["task.start"]
1113"#;
1114        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1115        let result = config.validate();
1116
1117        assert!(result.is_err());
1118        let err = result.unwrap_err();
1119        assert!(
1120            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1121                if trigger == "task.start" && hat == "my_hat"),
1122            "Expected ReservedTrigger error for 'task.start', got: {:?}",
1123            err
1124        );
1125    }
1126
1127    #[test]
1128    fn test_reserved_trigger_task_resume_rejected() {
1129        // Per design: task.resume is reserved for Ralph (the coordinator)
1130        let yaml = r#"
1131hats:
1132  my_hat:
1133    name: "My Hat"
1134    description: "Test hat"
1135    triggers: ["task.resume", "other.event"]
1136"#;
1137        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1138        let result = config.validate();
1139
1140        assert!(result.is_err());
1141        let err = result.unwrap_err();
1142        assert!(
1143            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1144                if trigger == "task.resume" && hat == "my_hat"),
1145            "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1146            err
1147        );
1148    }
1149
1150    #[test]
1151    fn test_missing_description_rejected() {
1152        // Description is required for all hats
1153        let yaml = r#"
1154hats:
1155  my_hat:
1156    name: "My Hat"
1157    triggers: ["build.task"]
1158"#;
1159        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1160        let result = config.validate();
1161
1162        assert!(result.is_err());
1163        let err = result.unwrap_err();
1164        assert!(
1165            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1166            "Expected MissingDescription error, got: {:?}",
1167            err
1168        );
1169    }
1170
1171    #[test]
1172    fn test_empty_description_rejected() {
1173        // Empty description should also be rejected
1174        let yaml = r#"
1175hats:
1176  my_hat:
1177    name: "My Hat"
1178    description: "   "
1179    triggers: ["build.task"]
1180"#;
1181        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1182        let result = config.validate();
1183
1184        assert!(result.is_err());
1185        let err = result.unwrap_err();
1186        assert!(
1187            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1188            "Expected MissingDescription error for empty description, got: {:?}",
1189            err
1190        );
1191    }
1192
1193    #[test]
1194    fn test_core_config_defaults() {
1195        let config = RalphConfig::default();
1196        assert_eq!(config.core.scratchpad, ".agent/scratchpad.md");
1197        assert_eq!(config.core.specs_dir, "./specs/");
1198        // Default guardrails per spec
1199        assert_eq!(config.core.guardrails.len(), 3);
1200        assert!(config.core.guardrails[0].contains("Fresh context"));
1201        assert!(config.core.guardrails[1].contains("search first"));
1202        assert!(config.core.guardrails[2].contains("Backpressure"));
1203    }
1204
1205    #[test]
1206    fn test_core_config_customizable() {
1207        let yaml = r#"
1208core:
1209  scratchpad: ".workspace/plan.md"
1210  specs_dir: "./specifications/"
1211"#;
1212        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1213        assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1214        assert_eq!(config.core.specs_dir, "./specifications/");
1215        // Guardrails should use defaults when not specified
1216        assert_eq!(config.core.guardrails.len(), 3);
1217    }
1218
1219    #[test]
1220    fn test_core_config_custom_guardrails() {
1221        let yaml = r#"
1222core:
1223  scratchpad: ".agent/scratchpad.md"
1224  specs_dir: "./specs/"
1225  guardrails:
1226    - "Custom rule one"
1227    - "Custom rule two"
1228"#;
1229        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1230        assert_eq!(config.core.guardrails.len(), 2);
1231        assert_eq!(config.core.guardrails[0], "Custom rule one");
1232        assert_eq!(config.core.guardrails[1], "Custom rule two");
1233    }
1234
1235    #[test]
1236    fn test_prompt_and_prompt_file_mutually_exclusive() {
1237        // Both prompt and prompt_file specified in config should error
1238        let yaml = r#"
1239event_loop:
1240  prompt: "inline text"
1241  prompt_file: "custom.md"
1242"#;
1243        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1244        let result = config.validate();
1245
1246        assert!(result.is_err());
1247        let err = result.unwrap_err();
1248        assert!(
1249            matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1250                if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1251            "Expected MutuallyExclusive error, got: {:?}",
1252            err
1253        );
1254    }
1255
1256    #[test]
1257    fn test_prompt_with_default_prompt_file_allowed() {
1258        // Having inline prompt with default prompt_file value should be OK
1259        let yaml = r#"
1260event_loop:
1261  prompt: "inline text"
1262"#;
1263        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1264        let result = config.validate();
1265
1266        assert!(
1267            result.is_ok(),
1268            "Should allow inline prompt with default prompt_file"
1269        );
1270        assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1271        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1272    }
1273
1274    #[test]
1275    fn test_custom_backend_requires_command() {
1276        // Custom backend without command should error
1277        let yaml = r#"
1278cli:
1279  backend: "custom"
1280"#;
1281        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1282        let result = config.validate();
1283
1284        assert!(result.is_err());
1285        let err = result.unwrap_err();
1286        assert!(
1287            matches!(&err, ConfigError::CustomBackendRequiresCommand),
1288            "Expected CustomBackendRequiresCommand error, got: {:?}",
1289            err
1290        );
1291    }
1292
1293    #[test]
1294    fn test_custom_backend_with_empty_command_errors() {
1295        // Custom backend with empty command should error
1296        let yaml = r#"
1297cli:
1298  backend: "custom"
1299  command: ""
1300"#;
1301        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1302        let result = config.validate();
1303
1304        assert!(result.is_err());
1305        let err = result.unwrap_err();
1306        assert!(
1307            matches!(&err, ConfigError::CustomBackendRequiresCommand),
1308            "Expected CustomBackendRequiresCommand error, got: {:?}",
1309            err
1310        );
1311    }
1312
1313    #[test]
1314    fn test_custom_backend_with_command_succeeds() {
1315        // Custom backend with valid command should pass validation
1316        let yaml = r#"
1317cli:
1318  backend: "custom"
1319  command: "my-agent"
1320"#;
1321        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1322        let result = config.validate();
1323
1324        assert!(
1325            result.is_ok(),
1326            "Should allow custom backend with command: {:?}",
1327            result.unwrap_err()
1328        );
1329    }
1330
1331    #[test]
1332    fn test_prompt_file_with_no_inline_allowed() {
1333        // Having only prompt_file specified should be OK
1334        let yaml = r#"
1335event_loop:
1336  prompt_file: "custom.md"
1337"#;
1338        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1339        let result = config.validate();
1340
1341        assert!(
1342            result.is_ok(),
1343            "Should allow prompt_file without inline prompt"
1344        );
1345        assert_eq!(config.event_loop.prompt, None);
1346        assert_eq!(config.event_loop.prompt_file, "custom.md");
1347    }
1348
1349    #[test]
1350    fn test_default_prompt_file_value() {
1351        let config = RalphConfig::default();
1352        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1353        assert_eq!(config.event_loop.prompt, None);
1354    }
1355
1356    #[test]
1357    fn test_tui_config_default() {
1358        let config = RalphConfig::default();
1359        assert_eq!(config.tui.prefix_key, "ctrl-a");
1360    }
1361
1362    #[test]
1363    fn test_tui_config_parse_ctrl_b() {
1364        let yaml = r#"
1365tui:
1366  prefix_key: "ctrl-b"
1367"#;
1368        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1369        let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1370
1371        use crossterm::event::{KeyCode, KeyModifiers};
1372        assert_eq!(key_code, KeyCode::Char('b'));
1373        assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1374    }
1375
1376    #[test]
1377    fn test_tui_config_parse_invalid_format() {
1378        let tui_config = TuiConfig {
1379            prefix_key: "invalid".to_string(),
1380        };
1381        let result = tui_config.parse_prefix();
1382        assert!(result.is_err());
1383        assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1384    }
1385
1386    #[test]
1387    fn test_tui_config_parse_invalid_modifier() {
1388        let tui_config = TuiConfig {
1389            prefix_key: "alt-a".to_string(),
1390        };
1391        let result = tui_config.parse_prefix();
1392        assert!(result.is_err());
1393        assert!(result.unwrap_err().contains("Invalid modifier"));
1394    }
1395
1396    #[test]
1397    fn test_tui_config_parse_invalid_key() {
1398        let tui_config = TuiConfig {
1399            prefix_key: "ctrl-abc".to_string(),
1400        };
1401        let result = tui_config.parse_prefix();
1402        assert!(result.is_err());
1403        assert!(result.unwrap_err().contains("Invalid key"));
1404    }
1405
1406    #[test]
1407    fn test_hat_backend_named() {
1408        let yaml = r#""claude""#;
1409        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1410        assert_eq!(backend.to_cli_backend(), "claude");
1411        match backend {
1412            HatBackend::Named(name) => assert_eq!(name, "claude"),
1413            _ => panic!("Expected Named variant"),
1414        }
1415    }
1416
1417    #[test]
1418    fn test_hat_backend_kiro_agent() {
1419        let yaml = r#"
1420type: "kiro"
1421agent: "builder"
1422"#;
1423        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1424        assert_eq!(backend.to_cli_backend(), "kiro");
1425        match backend {
1426            HatBackend::KiroAgent {
1427                backend_type,
1428                agent,
1429            } => {
1430                assert_eq!(backend_type, "kiro");
1431                assert_eq!(agent, "builder");
1432            }
1433            _ => panic!("Expected KiroAgent variant"),
1434        }
1435    }
1436
1437    #[test]
1438    fn test_hat_backend_custom() {
1439        let yaml = r#"
1440command: "/usr/bin/my-agent"
1441args: ["--flag", "value"]
1442"#;
1443        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1444        assert_eq!(backend.to_cli_backend(), "custom");
1445        match backend {
1446            HatBackend::Custom { command, args } => {
1447                assert_eq!(command, "/usr/bin/my-agent");
1448                assert_eq!(args, vec!["--flag", "value"]);
1449            }
1450            _ => panic!("Expected Custom variant"),
1451        }
1452    }
1453
1454    #[test]
1455    fn test_hat_config_with_backend() {
1456        let yaml = r#"
1457name: "Custom Builder"
1458triggers: ["build.task"]
1459publishes: ["build.done"]
1460instructions: "Build stuff"
1461backend: "gemini"
1462default_publishes: "task.done"
1463"#;
1464        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1465        assert_eq!(hat.name, "Custom Builder");
1466        assert!(hat.backend.is_some());
1467        match hat.backend.unwrap() {
1468            HatBackend::Named(name) => assert_eq!(name, "gemini"),
1469            _ => panic!("Expected Named backend"),
1470        }
1471        assert_eq!(hat.default_publishes, Some("task.done".to_string()));
1472    }
1473
1474    #[test]
1475    fn test_hat_config_without_backend() {
1476        let yaml = r#"
1477name: "Default Hat"
1478triggers: ["task.start"]
1479publishes: ["task.done"]
1480instructions: "Do work"
1481"#;
1482        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1483        assert_eq!(hat.name, "Default Hat");
1484        assert!(hat.backend.is_none());
1485        assert!(hat.default_publishes.is_none());
1486    }
1487
1488    #[test]
1489    fn test_mixed_backends_config() {
1490        let yaml = r#"
1491event_loop:
1492  prompt_file: "TASK.md"
1493  max_iterations: 50
1494
1495cli:
1496  backend: "claude"
1497
1498hats:
1499  planner:
1500    name: "Planner"
1501    triggers: ["task.start"]
1502    publishes: ["build.task"]
1503    instructions: "Plan the work"
1504    backend: "claude"
1505    
1506  builder:
1507    name: "Builder"
1508    triggers: ["build.task"]
1509    publishes: ["build.done"]
1510    instructions: "Build the thing"
1511    backend:
1512      type: "kiro"
1513      agent: "builder"
1514      
1515  reviewer:
1516    name: "Reviewer"
1517    triggers: ["build.done"]
1518    publishes: ["review.complete"]
1519    instructions: "Review the work"
1520    backend:
1521      command: "/usr/local/bin/custom-agent"
1522      args: ["--mode", "review"]
1523    default_publishes: "review.complete"
1524"#;
1525        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1526        assert_eq!(config.hats.len(), 3);
1527
1528        // Check planner (Named backend)
1529        let planner = config.hats.get("planner").unwrap();
1530        assert!(planner.backend.is_some());
1531        match planner.backend.as_ref().unwrap() {
1532            HatBackend::Named(name) => assert_eq!(name, "claude"),
1533            _ => panic!("Expected Named backend for planner"),
1534        }
1535
1536        // Check builder (KiroAgent backend)
1537        let builder = config.hats.get("builder").unwrap();
1538        assert!(builder.backend.is_some());
1539        match builder.backend.as_ref().unwrap() {
1540            HatBackend::KiroAgent {
1541                backend_type,
1542                agent,
1543            } => {
1544                assert_eq!(backend_type, "kiro");
1545                assert_eq!(agent, "builder");
1546            }
1547            _ => panic!("Expected KiroAgent backend for builder"),
1548        }
1549
1550        // Check reviewer (Custom backend)
1551        let reviewer = config.hats.get("reviewer").unwrap();
1552        assert!(reviewer.backend.is_some());
1553        match reviewer.backend.as_ref().unwrap() {
1554            HatBackend::Custom { command, args } => {
1555                assert_eq!(command, "/usr/local/bin/custom-agent");
1556                assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
1557            }
1558            _ => panic!("Expected Custom backend for reviewer"),
1559        }
1560        assert_eq!(
1561            reviewer.default_publishes,
1562            Some("review.complete".to_string())
1563        );
1564    }
1565}