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, PathBuf};
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", "pi", "auto", or "custom".
49    #[serde(default)]
50    pub agent: Option<String>,
51
52    /// V1 field: Fallback order for auto-detection.
53    #[serde(default)]
54    pub agent_priority: Vec<String>,
55
56    /// V1 field: Path to prompt file (maps to `event_loop.prompt_file`).
57    #[serde(default)]
58    pub prompt_file: Option<String>,
59
60    /// V1 field: Completion detection string (maps to event_loop.completion_promise).
61    #[serde(default)]
62    pub completion_promise: Option<String>,
63
64    /// V1 field: Maximum loop iterations (maps to event_loop.max_iterations).
65    #[serde(default)]
66    pub max_iterations: Option<u32>,
67
68    /// V1 field: Maximum runtime in seconds (maps to event_loop.max_runtime_seconds).
69    #[serde(default)]
70    pub max_runtime: Option<u64>,
71
72    /// V1 field: Maximum cost in USD (maps to event_loop.max_cost_usd).
73    #[serde(default)]
74    pub max_cost: Option<f64>,
75
76    // ─────────────────────────────────────────────────────────────────────────
77    // FEATURE FLAGS
78    // ─────────────────────────────────────────────────────────────────────────
79    /// Enable verbose output.
80    #[serde(default)]
81    pub verbose: bool,
82
83    /// Archive prompts after completion (DEFERRED: warn if enabled).
84    #[serde(default)]
85    pub archive_prompts: bool,
86
87    /// Enable metrics collection (DEFERRED: warn if enabled).
88    #[serde(default)]
89    pub enable_metrics: bool,
90
91    // ─────────────────────────────────────────────────────────────────────────
92    // DROPPED FIELDS (accepted but ignored with warning)
93    // ─────────────────────────────────────────────────────────────────────────
94    /// V1 field: Token limits (DROPPED: controlled by CLI tool).
95    #[serde(default)]
96    pub max_tokens: Option<u32>,
97
98    /// V1 field: Retry delay (DROPPED: handled differently in v2).
99    #[serde(default)]
100    pub retry_delay: Option<u32>,
101
102    /// V1 adapter settings (partially supported).
103    #[serde(default)]
104    pub adapters: AdaptersConfig,
105
106    // ─────────────────────────────────────────────────────────────────────────
107    // WARNING CONTROL
108    // ─────────────────────────────────────────────────────────────────────────
109    /// Suppress all warnings (for CI environments).
110    #[serde(default, rename = "_suppress_warnings")]
111    pub suppress_warnings: bool,
112
113    /// TUI configuration.
114    #[serde(default)]
115    pub tui: TuiConfig,
116
117    /// Memories configuration for persistent learning across sessions.
118    #[serde(default)]
119    pub memories: MemoriesConfig,
120
121    /// Tasks configuration for runtime work tracking.
122    #[serde(default)]
123    pub tasks: TasksConfig,
124
125    /// Lifecycle hooks configuration.
126    #[serde(default)]
127    pub hooks: HooksConfig,
128
129    /// Skills configuration for the skill discovery and injection system.
130    #[serde(default)]
131    pub skills: SkillsConfig,
132
133    /// Feature flags for optional capabilities.
134    #[serde(default)]
135    pub features: FeaturesConfig,
136
137    /// RObot (Ralph-Orchestrator bot) configuration for Telegram-based interaction.
138    #[serde(default, rename = "RObot")]
139    pub robot: RobotConfig,
140}
141
142fn default_true() -> bool {
143    true
144}
145
146#[allow(clippy::derivable_impls)] // Cannot derive due to serde default functions
147impl Default for RalphConfig {
148    fn default() -> Self {
149        Self {
150            event_loop: EventLoopConfig::default(),
151            cli: CliConfig::default(),
152            core: CoreConfig::default(),
153            hats: HashMap::new(),
154            events: HashMap::new(),
155            // V1 compatibility fields
156            agent: None,
157            agent_priority: vec![],
158            prompt_file: None,
159            completion_promise: None,
160            max_iterations: None,
161            max_runtime: None,
162            max_cost: None,
163            // Feature flags
164            verbose: false,
165            archive_prompts: false,
166            enable_metrics: false,
167            // Dropped fields
168            max_tokens: None,
169            retry_delay: None,
170            adapters: AdaptersConfig::default(),
171            // Warning control
172            suppress_warnings: false,
173            // TUI
174            tui: TuiConfig::default(),
175            // Memories
176            memories: MemoriesConfig::default(),
177            // Tasks
178            tasks: TasksConfig::default(),
179            // Hooks
180            hooks: HooksConfig::default(),
181            // Skills
182            skills: SkillsConfig::default(),
183            // Features
184            features: FeaturesConfig::default(),
185            // RObot (Ralph-Orchestrator bot)
186            robot: RobotConfig::default(),
187        }
188    }
189}
190
191/// V1 adapter settings per backend.
192#[derive(Debug, Clone, Default, Serialize, Deserialize)]
193pub struct AdaptersConfig {
194    /// Claude adapter settings.
195    #[serde(default)]
196    pub claude: AdapterSettings,
197
198    /// Gemini adapter settings.
199    #[serde(default)]
200    pub gemini: AdapterSettings,
201
202    /// Kiro adapter settings.
203    #[serde(default)]
204    pub kiro: AdapterSettings,
205
206    /// Codex adapter settings.
207    #[serde(default)]
208    pub codex: AdapterSettings,
209
210    /// Amp adapter settings.
211    #[serde(default)]
212    pub amp: AdapterSettings,
213}
214
215/// Per-adapter settings.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct AdapterSettings {
218    /// CLI execution inactivity timeout in seconds.
219    #[serde(default = "default_timeout")]
220    pub timeout: u64,
221
222    /// Include in auto-detection.
223    #[serde(default = "default_true")]
224    pub enabled: bool,
225
226    /// Tool permissions (DROPPED: CLI tool manages its own permissions).
227    #[serde(default)]
228    pub tool_permissions: Option<Vec<String>>,
229}
230
231fn default_timeout() -> u64 {
232    300 // 5 minutes
233}
234
235impl Default for AdapterSettings {
236    fn default() -> Self {
237        Self {
238            timeout: default_timeout(),
239            enabled: true,
240            tool_permissions: None,
241        }
242    }
243}
244
245impl RalphConfig {
246    /// Loads configuration from a YAML file.
247    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
248        let path_ref = path.as_ref();
249        debug!(path = %path_ref.display(), "Loading configuration from file");
250        let content = std::fs::read_to_string(path_ref)?;
251        Self::parse_yaml(&content)
252    }
253
254    /// Parses configuration from a YAML string.
255    pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
256        // Pre-flight check for deprecated/invalid keys to improve UX.
257        let value: serde_yaml::Value = serde_yaml::from_str(content)?;
258        if let Some(map) = value.as_mapping()
259            && map.contains_key(serde_yaml::Value::String("project".to_string()))
260        {
261            return Err(ConfigError::DeprecatedProjectKey);
262        }
263
264        validate_hooks_phase_event_keys(&value)?;
265
266        let config: Self = serde_yaml::from_value(value)?;
267        debug!(
268            backend = %config.cli.backend,
269            has_v1_fields = config.agent.is_some(),
270            custom_hats = config.hats.len(),
271            "Configuration loaded"
272        );
273        Ok(config)
274    }
275
276    /// Normalizes v1 flat fields into v2 nested structure.
277    ///
278    /// V1 flat fields take precedence over v2 nested fields when both are present.
279    /// This allows users to use either format or mix them.
280    pub fn normalize(&mut self) {
281        let mut normalized_count = 0;
282
283        // Map v1 `agent` to v2 `cli.backend`
284        if let Some(ref agent) = self.agent {
285            debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
286            self.cli.backend = agent.clone();
287            normalized_count += 1;
288        }
289
290        // Map v1 `prompt_file` to v2 `event_loop.prompt_file`
291        if let Some(ref pf) = self.prompt_file {
292            debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
293            self.event_loop.prompt_file = pf.clone();
294            normalized_count += 1;
295        }
296
297        // Map v1 `completion_promise` to v2 `event_loop.completion_promise`
298        if let Some(ref cp) = self.completion_promise {
299            debug!(
300                from = "completion_promise",
301                to = "event_loop.completion_promise",
302                "Normalizing v1 field"
303            );
304            self.event_loop.completion_promise = cp.clone();
305            normalized_count += 1;
306        }
307
308        // Map v1 `max_iterations` to v2 `event_loop.max_iterations`
309        if let Some(mi) = self.max_iterations {
310            debug!(
311                from = "max_iterations",
312                to = "event_loop.max_iterations",
313                value = mi,
314                "Normalizing v1 field"
315            );
316            self.event_loop.max_iterations = mi;
317            normalized_count += 1;
318        }
319
320        // Map v1 `max_runtime` to v2 `event_loop.max_runtime_seconds`
321        if let Some(mr) = self.max_runtime {
322            debug!(
323                from = "max_runtime",
324                to = "event_loop.max_runtime_seconds",
325                value = mr,
326                "Normalizing v1 field"
327            );
328            self.event_loop.max_runtime_seconds = mr;
329            normalized_count += 1;
330        }
331
332        // Map v1 `max_cost` to v2 `event_loop.max_cost_usd`
333        if self.max_cost.is_some() {
334            debug!(
335                from = "max_cost",
336                to = "event_loop.max_cost_usd",
337                "Normalizing v1 field"
338            );
339            self.event_loop.max_cost_usd = self.max_cost;
340            normalized_count += 1;
341        }
342
343        // Merge extra_instructions into instructions for each hat
344        for (hat_id, hat) in &mut self.hats {
345            if !hat.extra_instructions.is_empty() {
346                for fragment in hat.extra_instructions.drain(..) {
347                    if !hat.instructions.ends_with('\n') {
348                        hat.instructions.push('\n');
349                    }
350                    hat.instructions.push_str(&fragment);
351                }
352                debug!(hat = %hat_id, "Merged extra_instructions into hat instructions");
353                normalized_count += 1;
354            }
355        }
356
357        if normalized_count > 0 {
358            debug!(
359                fields_normalized = normalized_count,
360                "V1 to V2 config normalization complete"
361            );
362        }
363    }
364
365    /// Validates the configuration and returns warnings.
366    ///
367    /// This method checks for:
368    /// - Deferred features that are enabled (archive_prompts, enable_metrics)
369    /// - Dropped fields that are present (max_tokens, retry_delay, tool_permissions)
370    /// - Ambiguous trigger routing across custom hats
371    /// - Mutual exclusivity of prompt and prompt_file
372    ///
373    /// Returns a list of warnings that should be displayed to the user.
374    pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
375        let mut warnings = Vec::new();
376
377        // Skip all warnings if suppressed
378        if self.suppress_warnings {
379            return Ok(warnings);
380        }
381
382        // Check for mutual exclusivity of prompt and prompt_file in config
383        // Only error if both are explicitly set (not defaults)
384        if self.event_loop.prompt.is_some()
385            && !self.event_loop.prompt_file.is_empty()
386            && self.event_loop.prompt_file != default_prompt_file()
387        {
388            return Err(ConfigError::MutuallyExclusive {
389                field1: "event_loop.prompt".to_string(),
390                field2: "event_loop.prompt_file".to_string(),
391            });
392        }
393        if self.event_loop.completion_promise.trim().is_empty() {
394            return Err(ConfigError::InvalidCompletionPromise);
395        }
396
397        // Check custom backend has a command
398        if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
399            return Err(ConfigError::CustomBackendRequiresCommand);
400        }
401
402        // Check for deferred features
403        if self.archive_prompts {
404            warnings.push(ConfigWarning::DeferredFeature {
405                field: "archive_prompts".to_string(),
406                message: "Feature not yet available in v2".to_string(),
407            });
408        }
409
410        if self.enable_metrics {
411            warnings.push(ConfigWarning::DeferredFeature {
412                field: "enable_metrics".to_string(),
413                message: "Feature not yet available in v2".to_string(),
414            });
415        }
416
417        // Check for dropped fields
418        if self.max_tokens.is_some() {
419            warnings.push(ConfigWarning::DroppedField {
420                field: "max_tokens".to_string(),
421                reason: "Token limits are controlled by the CLI tool".to_string(),
422            });
423        }
424
425        if self.retry_delay.is_some() {
426            warnings.push(ConfigWarning::DroppedField {
427                field: "retry_delay".to_string(),
428                reason: "Retry logic handled differently in v2".to_string(),
429            });
430        }
431
432        if let Some(threshold) = self.event_loop.mutation_score_warn_threshold
433            && !(0.0..=100.0).contains(&threshold)
434        {
435            warnings.push(ConfigWarning::InvalidValue {
436                field: "event_loop.mutation_score_warn_threshold".to_string(),
437                message: "Value must be between 0 and 100".to_string(),
438            });
439        }
440
441        // Check adapter tool_permissions (dropped field)
442        if self.adapters.claude.tool_permissions.is_some()
443            || self.adapters.gemini.tool_permissions.is_some()
444            || self.adapters.codex.tool_permissions.is_some()
445            || self.adapters.amp.tool_permissions.is_some()
446        {
447            warnings.push(ConfigWarning::DroppedField {
448                field: "adapters.*.tool_permissions".to_string(),
449                reason: "CLI tool manages its own permissions".to_string(),
450            });
451        }
452
453        // Validate RObot config
454        self.robot.validate()?;
455
456        // Validate hooks config semantics (v1 guardrails)
457        self.validate_hooks()?;
458
459        // Check for required description field on all hats
460        for (hat_id, hat_config) in &self.hats {
461            if hat_config
462                .description
463                .as_ref()
464                .is_none_or(|d| d.trim().is_empty())
465            {
466                return Err(ConfigError::MissingDescription {
467                    hat: hat_id.clone(),
468                });
469            }
470        }
471
472        // Check wave config validity
473        for (hat_id, hat_config) in &self.hats {
474            if hat_config.concurrency == 0 {
475                return Err(ConfigError::InvalidConcurrency {
476                    hat: hat_id.clone(),
477                    value: 0,
478                });
479            }
480            if hat_config.aggregate.is_some() && hat_config.concurrency > 1 {
481                return Err(ConfigError::AggregateOnConcurrentHat {
482                    hat: hat_id.clone(),
483                });
484            }
485        }
486
487        // Check for reserved triggers: task.start and task.resume are reserved for Ralph
488        // Per design: Ralph coordinates first, then delegates to custom hats via events
489        const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
490        for (hat_id, hat_config) in &self.hats {
491            for trigger in &hat_config.triggers {
492                if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
493                    return Err(ConfigError::ReservedTrigger {
494                        trigger: trigger.clone(),
495                        hat: hat_id.clone(),
496                    });
497                }
498            }
499        }
500
501        // Check for ambiguous routing: each trigger topic must map to exactly one hat
502        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
503        if !self.hats.is_empty() {
504            let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
505            for (hat_id, hat_config) in &self.hats {
506                for trigger in &hat_config.triggers {
507                    if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
508                        return Err(ConfigError::AmbiguousRouting {
509                            trigger: trigger.clone(),
510                            hat1: (*existing_hat).to_string(),
511                            hat2: hat_id.clone(),
512                        });
513                    }
514                    trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
515                }
516            }
517        }
518
519        Ok(warnings)
520    }
521
522    fn validate_hooks(&self) -> Result<(), ConfigError> {
523        Self::validate_non_v1_hook_fields("hooks", &self.hooks.extra)?;
524
525        if self.hooks.defaults.timeout_seconds == 0 {
526            return Err(ConfigError::HookValidation {
527                field: "hooks.defaults.timeout_seconds".to_string(),
528                message: "must be greater than 0".to_string(),
529            });
530        }
531
532        if self.hooks.defaults.max_output_bytes == 0 {
533            return Err(ConfigError::HookValidation {
534                field: "hooks.defaults.max_output_bytes".to_string(),
535                message: "must be greater than 0".to_string(),
536            });
537        }
538
539        for (phase_event, hook_specs) in &self.hooks.events {
540            for (index, hook) in hook_specs.iter().enumerate() {
541                let hook_field_base = format!("hooks.events.{phase_event}[{index}]");
542
543                if hook.name.trim().is_empty() {
544                    return Err(ConfigError::HookValidation {
545                        field: format!("{hook_field_base}.name"),
546                        message: "is required and must be non-empty".to_string(),
547                    });
548                }
549
550                if hook
551                    .command
552                    .first()
553                    .is_none_or(|command| command.trim().is_empty())
554                {
555                    return Err(ConfigError::HookValidation {
556                        field: format!("{hook_field_base}.command"),
557                        message: "is required and must include an executable at command[0]"
558                            .to_string(),
559                    });
560                }
561
562                if hook.on_error.is_none() {
563                    return Err(ConfigError::HookValidation {
564                        field: format!("{hook_field_base}.on_error"),
565                        message: "is required in v1 (warn | block | suspend)".to_string(),
566                    });
567                }
568
569                if let Some(timeout_seconds) = hook.timeout_seconds
570                    && timeout_seconds == 0
571                {
572                    return Err(ConfigError::HookValidation {
573                        field: format!("{hook_field_base}.timeout_seconds"),
574                        message: "must be greater than 0 when specified".to_string(),
575                    });
576                }
577
578                if let Some(max_output_bytes) = hook.max_output_bytes
579                    && max_output_bytes == 0
580                {
581                    return Err(ConfigError::HookValidation {
582                        field: format!("{hook_field_base}.max_output_bytes"),
583                        message: "must be greater than 0 when specified".to_string(),
584                    });
585                }
586
587                if hook.suspend_mode.is_some() && hook.on_error != Some(HookOnError::Suspend) {
588                    return Err(ConfigError::HookValidation {
589                        field: format!("{hook_field_base}.suspend_mode"),
590                        message: "requires on_error: suspend".to_string(),
591                    });
592                }
593
594                Self::validate_non_v1_hook_fields(&hook_field_base, &hook.extra)?;
595                Self::validate_mutation_contract(&hook_field_base, &hook.mutate)?;
596            }
597        }
598
599        Ok(())
600    }
601
602    fn validate_non_v1_hook_fields(
603        path_prefix: &str,
604        fields: &HashMap<String, serde_yaml::Value>,
605    ) -> Result<(), ConfigError> {
606        for key in fields.keys() {
607            let field = format!("{path_prefix}.{key}");
608            match key.as_str() {
609                "global" | "globals" | "global_defaults" | "global_hooks" | "scope" => {
610                    return Err(ConfigError::UnsupportedHookField {
611                        field,
612                        reason: "Global hooks are out of scope for v1; use per-project hooks only"
613                            .to_string(),
614                    });
615                }
616                "parallel" | "parallelism" | "max_parallel" | "concurrency" | "run_in_parallel" => {
617                    return Err(ConfigError::UnsupportedHookField {
618                        field,
619                        reason:
620                            "Parallel hook execution is out of scope for v1; hooks must run sequentially"
621                                .to_string(),
622                    });
623                }
624                _ => {}
625            }
626        }
627
628        Ok(())
629    }
630
631    fn validate_mutation_contract(
632        hook_field_base: &str,
633        mutate: &HookMutationConfig,
634    ) -> Result<(), ConfigError> {
635        let mutate_field_base = format!("{hook_field_base}.mutate");
636
637        if !mutate.enabled {
638            if mutate.format.is_some() || !mutate.extra.is_empty() {
639                return Err(ConfigError::HookValidation {
640                    field: mutate_field_base,
641                    message: "mutation settings require mutate.enabled: true".to_string(),
642                });
643            }
644            return Ok(());
645        }
646
647        if let Some(format) = mutate.format.as_deref()
648            && !format.eq_ignore_ascii_case("json")
649        {
650            return Err(ConfigError::HookValidation {
651                field: format!("{mutate_field_base}.format"),
652                message: "only 'json' is supported for v1 mutation payloads".to_string(),
653            });
654        }
655
656        if let Some(key) = mutate.extra.keys().next() {
657            let field = format!("{mutate_field_base}.{key}");
658            let reason = match key.as_str() {
659                "prompt" | "prompt_mutation" | "events" | "event" | "config" | "full_context" => {
660                    "v1 allows metadata-only mutation; prompt/event/config mutation is unsupported"
661                        .to_string()
662                }
663                "xml" => "v1 mutation payloads are JSON-only".to_string(),
664                _ => "unsupported mutate field in v1 (supported keys: enabled, format)".to_string(),
665            };
666
667            return Err(ConfigError::UnsupportedHookField { field, reason });
668        }
669
670        Ok(())
671    }
672
673    /// Gets the effective backend name, resolving "auto" using the priority list.
674    pub fn effective_backend(&self) -> &str {
675        &self.cli.backend
676    }
677
678    /// Returns the agent priority list for auto-detection.
679    /// If empty, returns the default priority order.
680    pub fn get_agent_priority(&self) -> Vec<&str> {
681        if self.agent_priority.is_empty() {
682            vec!["claude", "kiro", "gemini", "codex", "amp"]
683        } else {
684            self.agent_priority.iter().map(String::as_str).collect()
685        }
686    }
687
688    /// Gets the adapter settings for a specific backend.
689    #[allow(clippy::match_same_arms)] // Explicit match arms for each backend improves readability
690    pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
691        match backend {
692            "claude" => &self.adapters.claude,
693            "gemini" => &self.adapters.gemini,
694            "kiro" => &self.adapters.kiro,
695            "codex" => &self.adapters.codex,
696            "amp" => &self.adapters.amp,
697            _ => &self.adapters.claude, // Default fallback
698        }
699    }
700}
701
702/// Configuration warnings emitted during validation.
703#[derive(Debug, Clone)]
704pub enum ConfigWarning {
705    /// Feature is enabled but not yet available in v2.
706    DeferredFeature { field: String, message: String },
707    /// Field is present but ignored in v2.
708    DroppedField { field: String, reason: String },
709    /// Field has an invalid value.
710    InvalidValue { field: String, message: String },
711}
712
713impl std::fmt::Display for ConfigWarning {
714    #[allow(clippy::match_same_arms)] // Different arms have different messages despite similar structure
715    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716        match self {
717            ConfigWarning::DeferredFeature { field, message }
718            | ConfigWarning::InvalidValue { field, message } => {
719                write!(f, "Warning [{field}]: {message}")
720            }
721            ConfigWarning::DroppedField { field, reason } => {
722                write!(f, "Warning [{field}]: Field ignored - {reason}")
723            }
724        }
725    }
726}
727
728/// Event loop configuration.
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct EventLoopConfig {
731    /// Inline prompt text (mutually exclusive with prompt_file).
732    pub prompt: Option<String>,
733
734    /// Path to the prompt file.
735    #[serde(default = "default_prompt_file")]
736    pub prompt_file: String,
737
738    /// Event topic that signals loop completion (must be emitted via `ralph emit`).
739    #[serde(default = "default_completion_promise")]
740    pub completion_promise: String,
741
742    /// Maximum number of iterations before timeout.
743    #[serde(default = "default_max_iterations")]
744    pub max_iterations: u32,
745
746    /// Maximum runtime in seconds.
747    #[serde(default = "default_max_runtime")]
748    pub max_runtime_seconds: u64,
749
750    /// Maximum cost in USD before stopping.
751    pub max_cost_usd: Option<f64>,
752
753    /// Stop after this many consecutive failures.
754    #[serde(default = "default_max_failures")]
755    pub max_consecutive_failures: u32,
756
757    /// Delay in seconds before starting the next iteration.
758    /// Skipped when the next iteration is triggered by a human event.
759    #[serde(default)]
760    pub cooldown_delay_seconds: u64,
761
762    /// Starting hat for multi-hat mode (deprecated, use starting_event instead).
763    pub starting_hat: Option<String>,
764
765    /// Event to publish after Ralph completes initial coordination.
766    ///
767    /// When custom hats are defined, Ralph handles `task.start` to do gap analysis
768    /// and planning, then publishes this event to delegate to the first hat.
769    ///
770    /// Example: `starting_event: "tdd.start"` for TDD workflow.
771    ///
772    /// If not specified and hats are defined, Ralph will determine the appropriate
773    /// event from the hat topology.
774    pub starting_event: Option<String>,
775
776    /// Warn when mutation testing score drops below this percentage (0-100).
777    ///
778    /// Warning-only: build.done is still accepted even if below threshold.
779    #[serde(default)]
780    pub mutation_score_warn_threshold: Option<f64>,
781
782    /// When true, LOOP_COMPLETE does not terminate the loop.
783    ///
784    /// Instead of exiting, the loop injects a `task.resume` event and continues
785    /// idling until new work arrives (human guidance, Telegram commands, etc.).
786    /// The loop will only terminate on hard limits (max_iterations, max_runtime,
787    /// max_cost), consecutive failures, or explicit interrupt/stop.
788    #[serde(default)]
789    pub persistent: bool,
790
791    /// Event topics that must have been seen before LOOP_COMPLETE is accepted.
792    /// If any required event has not been seen during the loop's lifetime,
793    /// completion is rejected and a task.resume event is injected.
794    #[serde(default)]
795    pub required_events: Vec<String>,
796
797    /// Event topic that triggers graceful early termination WITHOUT chain validation.
798    /// Use this for human rejection, timeout escalation, or other abort paths.
799    /// Defaults to "" (disabled). Set to "loop.cancel" to enable.
800    #[serde(default)]
801    pub cancellation_promise: String,
802
803    /// When true, events emitted by a hat are validated against its declared
804    /// `publishes` list. Out-of-scope events are dropped and replaced with
805    /// `{hat_id}.scope_violation` diagnostic events. Defaults to false (permissive).
806    #[serde(default)]
807    pub enforce_hat_scope: bool,
808}
809
810fn default_prompt_file() -> String {
811    "PROMPT.md".to_string()
812}
813
814fn default_completion_promise() -> String {
815    "LOOP_COMPLETE".to_string()
816}
817
818fn default_max_iterations() -> u32 {
819    100
820}
821
822fn default_max_runtime() -> u64 {
823    14400 // 4 hours
824}
825
826fn default_max_failures() -> u32 {
827    5
828}
829
830impl Default for EventLoopConfig {
831    fn default() -> Self {
832        Self {
833            prompt: None,
834            prompt_file: default_prompt_file(),
835            completion_promise: default_completion_promise(),
836            max_iterations: default_max_iterations(),
837            max_runtime_seconds: default_max_runtime(),
838            max_cost_usd: None,
839            max_consecutive_failures: default_max_failures(),
840            cooldown_delay_seconds: 0,
841            starting_hat: None,
842            starting_event: None,
843            mutation_score_warn_threshold: None,
844            persistent: false,
845            required_events: Vec::new(),
846            cancellation_promise: String::new(),
847            enforce_hat_scope: false,
848        }
849    }
850}
851
852/// Core paths and settings shared across all hats.
853///
854/// Per spec: "Core behaviors (always injected, can customize paths)"
855#[derive(Debug, Clone, Serialize, Deserialize)]
856pub struct CoreConfig {
857    /// Path to the scratchpad file (shared state between hats).
858    #[serde(default = "default_scratchpad")]
859    pub scratchpad: String,
860
861    /// Path to the specs directory (source of truth for requirements).
862    #[serde(default = "default_specs_dir")]
863    pub specs_dir: String,
864
865    /// Guardrails injected into every prompt (core behaviors).
866    ///
867    /// Per spec: These are always present regardless of hat.
868    #[serde(default = "default_guardrails")]
869    pub guardrails: Vec<String>,
870
871    /// Root directory for workspace-relative paths (.ralph/, specs, etc.).
872    ///
873    /// All relative paths (scratchpad, specs_dir, memories) are resolved relative
874    /// to this directory. Defaults to the current working directory.
875    ///
876    /// This is especially important for E2E tests that run in isolated workspaces.
877    #[serde(skip)]
878    pub workspace_root: std::path::PathBuf,
879}
880
881fn default_scratchpad() -> String {
882    ".ralph/agent/scratchpad.md".to_string()
883}
884
885fn default_specs_dir() -> String {
886    ".ralph/specs/".to_string()
887}
888
889fn default_guardrails() -> Vec<String> {
890    vec![
891        "Fresh context each iteration - scratchpad is memory".to_string(),
892        "Don't assume 'not implemented' - search first".to_string(),
893        "Backpressure is law - tests/typecheck/lint/audit must pass".to_string(),
894        "When behavior is runnable or user-facing, exercise the real app with the strongest available harness (Playwright, tmux, real CLI/API) and try at least one adversarial path before reporting done".to_string(),
895        "Confidence protocol: score decisions 0-100. >80 proceed autonomously; 50-80 proceed + document in .ralph/agent/decisions.md; <50 choose safe default + document".to_string(),
896        "Commit atomically - one logical change per commit, capture the why".to_string(),
897    ]
898}
899
900impl Default for CoreConfig {
901    fn default() -> Self {
902        Self {
903            scratchpad: default_scratchpad(),
904            specs_dir: default_specs_dir(),
905            guardrails: default_guardrails(),
906            workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
907                .map(std::path::PathBuf::from)
908                .unwrap_or_else(|_| {
909                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
910                }),
911        }
912    }
913}
914
915impl CoreConfig {
916    /// Sets the workspace root for resolving relative paths.
917    ///
918    /// This is used by E2E tests to point to their isolated test workspace.
919    pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
920        self.workspace_root = root.into();
921        self
922    }
923
924    /// Resolves a relative path against the workspace root.
925    ///
926    /// If the path is already absolute, it is returned as-is.
927    /// Otherwise, it is joined with the workspace root.
928    pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
929        let path = std::path::Path::new(relative);
930        if path.is_absolute() {
931            path.to_path_buf()
932        } else {
933            self.workspace_root.join(path)
934        }
935    }
936}
937
938/// CLI backend configuration.
939#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct CliConfig {
941    /// Backend to use: "claude", "kiro", "gemini", "codex", "amp", "pi", or "custom".
942    #[serde(default = "default_backend")]
943    pub backend: String,
944
945    /// Command override. Required for "custom" backend.
946    /// For named backends, overrides the default binary path.
947    pub command: Option<String>,
948
949    /// How to pass prompts: "arg" or "stdin".
950    #[serde(default = "default_prompt_mode")]
951    pub prompt_mode: String,
952
953    /// Execution mode when --interactive not specified.
954    /// Values: "autonomous" (default), "interactive"
955    #[serde(default = "default_mode")]
956    pub default_mode: String,
957
958    /// Idle timeout in seconds for interactive mode.
959    /// Process is terminated after this many seconds of inactivity (no output AND no user input).
960    /// Set to 0 to disable idle timeout.
961    #[serde(default = "default_idle_timeout")]
962    pub idle_timeout_secs: u32,
963
964    /// Custom arguments to pass to the CLI command (for backend: "custom").
965    /// These are inserted before the prompt argument.
966    #[serde(default)]
967    pub args: Vec<String>,
968
969    /// Custom prompt flag for arg mode (for backend: "custom").
970    /// If None, defaults to "-p" for arg mode.
971    #[serde(default)]
972    pub prompt_flag: Option<String>,
973}
974
975fn default_backend() -> String {
976    "claude".to_string()
977}
978
979fn default_prompt_mode() -> String {
980    "arg".to_string()
981}
982
983fn default_mode() -> String {
984    "autonomous".to_string()
985}
986
987fn default_idle_timeout() -> u32 {
988    30 // 30 seconds per spec
989}
990
991impl Default for CliConfig {
992    fn default() -> Self {
993        Self {
994            backend: default_backend(),
995            command: None,
996            prompt_mode: default_prompt_mode(),
997            default_mode: default_mode(),
998            idle_timeout_secs: default_idle_timeout(),
999            args: Vec::new(),
1000            prompt_flag: None,
1001        }
1002    }
1003}
1004
1005/// TUI configuration.
1006#[derive(Debug, Clone, Serialize, Deserialize)]
1007pub struct TuiConfig {
1008    /// Prefix key combination (e.g., "ctrl-a", "ctrl-b").
1009    #[serde(default = "default_prefix_key")]
1010    pub prefix_key: String,
1011}
1012
1013/// Memory injection mode.
1014///
1015/// Controls how memories are injected into agent context.
1016#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1017#[serde(rename_all = "lowercase")]
1018pub enum InjectMode {
1019    /// Ralph automatically injects memories at the start of each iteration.
1020    #[default]
1021    Auto,
1022    /// Agent must explicitly run `ralph memory search` to access memories.
1023    Manual,
1024    /// Memories feature is disabled.
1025    None,
1026}
1027
1028impl std::fmt::Display for InjectMode {
1029    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1030        match self {
1031            Self::Auto => write!(f, "auto"),
1032            Self::Manual => write!(f, "manual"),
1033            Self::None => write!(f, "none"),
1034        }
1035    }
1036}
1037
1038/// Memories configuration.
1039///
1040/// Controls the persistent learning system that allows Ralph to accumulate
1041/// wisdom across sessions. Memories are stored in `.ralph/agent/memories.md`.
1042///
1043/// When enabled, the memories skill is automatically injected to teach
1044/// agents how to create and search memories (skill injection is implicit).
1045///
1046/// Example configuration:
1047/// ```yaml
1048/// memories:
1049///   enabled: true
1050///   inject: auto
1051///   budget: 2000
1052/// ```
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1054pub struct MemoriesConfig {
1055    /// Whether the memories feature is enabled.
1056    ///
1057    /// When true, memories are injected and the skill is taught to the agent.
1058    #[serde(default)]
1059    pub enabled: bool,
1060
1061    /// How memories are injected into agent context.
1062    #[serde(default)]
1063    pub inject: InjectMode,
1064
1065    /// Maximum tokens to inject (0 = unlimited).
1066    ///
1067    /// When set, memories are truncated to fit within this budget.
1068    #[serde(default)]
1069    pub budget: usize,
1070
1071    /// Filter configuration for memory injection.
1072    #[serde(default)]
1073    pub filter: MemoriesFilter,
1074}
1075
1076impl Default for MemoriesConfig {
1077    fn default() -> Self {
1078        Self {
1079            enabled: true, // Memories enabled by default
1080            inject: InjectMode::Auto,
1081            budget: 0,
1082            filter: MemoriesFilter::default(),
1083        }
1084    }
1085}
1086
1087/// Filter configuration for memory injection.
1088///
1089/// Controls which memories are included when priming context.
1090#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1091pub struct MemoriesFilter {
1092    /// Filter by memory types (empty = all types).
1093    #[serde(default)]
1094    pub types: Vec<String>,
1095
1096    /// Filter by tags (empty = all tags).
1097    #[serde(default)]
1098    pub tags: Vec<String>,
1099
1100    /// Only include memories from the last N days (0 = no time limit).
1101    #[serde(default)]
1102    pub recent: u32,
1103}
1104
1105/// Tasks configuration.
1106///
1107/// Controls the runtime task tracking system that allows Ralph to manage
1108/// work items across iterations. Tasks are stored in `.ralph/agent/tasks.jsonl`.
1109///
1110/// When enabled, tasks replace scratchpad for loop completion verification.
1111///
1112/// Example configuration:
1113/// ```yaml
1114/// tasks:
1115///   enabled: true
1116/// ```
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1118pub struct TasksConfig {
1119    /// Whether the tasks feature is enabled.
1120    ///
1121    /// When true, tasks are used for loop completion verification.
1122    #[serde(default = "default_true")]
1123    pub enabled: bool,
1124}
1125
1126impl Default for TasksConfig {
1127    fn default() -> Self {
1128        Self {
1129            enabled: true, // Tasks enabled by default
1130        }
1131    }
1132}
1133
1134/// Hooks configuration.
1135///
1136/// Controls per-project orchestrator lifecycle hooks. Hooks are disabled by
1137/// default and are inert until explicitly enabled.
1138///
1139/// Example configuration:
1140/// ```yaml
1141/// hooks:
1142///   enabled: true
1143///   defaults:
1144///     timeout_seconds: 30
1145///     max_output_bytes: 8192
1146///     suspend_mode: wait_for_resume
1147///   events:
1148///     pre.loop.start:
1149///       - name: env-guard
1150///         command: ["./scripts/hooks/env-guard.sh"]
1151///         on_error: block
1152/// ```
1153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1154pub struct HooksConfig {
1155    /// Whether lifecycle hooks are enabled.
1156    #[serde(default)]
1157    pub enabled: bool,
1158
1159    /// Default guardrails applied to hook specs when per-hook values are absent.
1160    #[serde(default)]
1161    pub defaults: HookDefaults,
1162
1163    /// Hook lists by lifecycle phase-event key.
1164    #[serde(default)]
1165    pub events: HashMap<HookPhaseEvent, Vec<HookSpec>>,
1166
1167    /// Unknown keys captured for v1 guardrails.
1168    #[serde(default, flatten)]
1169    pub extra: HashMap<String, serde_yaml::Value>,
1170}
1171
1172/// Hook defaults applied when a hook spec omits optional limits.
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct HookDefaults {
1175    /// Maximum execution time per hook in seconds.
1176    #[serde(default = "default_hook_timeout_seconds")]
1177    pub timeout_seconds: u64,
1178
1179    /// Maximum stdout/stderr bytes stored per stream.
1180    #[serde(default = "default_hook_max_output_bytes")]
1181    pub max_output_bytes: u64,
1182
1183    /// Suspend strategy used when `on_error: suspend` and no per-hook mode is set.
1184    #[serde(default)]
1185    pub suspend_mode: HookSuspendMode,
1186}
1187
1188fn default_hook_timeout_seconds() -> u64 {
1189    30
1190}
1191
1192fn default_hook_max_output_bytes() -> u64 {
1193    8192
1194}
1195
1196impl Default for HookDefaults {
1197    fn default() -> Self {
1198        Self {
1199            timeout_seconds: default_hook_timeout_seconds(),
1200            max_output_bytes: default_hook_max_output_bytes(),
1201            suspend_mode: HookSuspendMode::default(),
1202        }
1203    }
1204}
1205
1206/// Supported lifecycle phase-event keys for v1 hooks.
1207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1208pub enum HookPhaseEvent {
1209    #[serde(rename = "pre.loop.start")]
1210    PreLoopStart,
1211    #[serde(rename = "post.loop.start")]
1212    PostLoopStart,
1213    #[serde(rename = "pre.iteration.start")]
1214    PreIterationStart,
1215    #[serde(rename = "post.iteration.start")]
1216    PostIterationStart,
1217    #[serde(rename = "pre.plan.created")]
1218    PrePlanCreated,
1219    #[serde(rename = "post.plan.created")]
1220    PostPlanCreated,
1221    #[serde(rename = "pre.human.interact")]
1222    PreHumanInteract,
1223    #[serde(rename = "post.human.interact")]
1224    PostHumanInteract,
1225    #[serde(rename = "pre.loop.complete")]
1226    PreLoopComplete,
1227    #[serde(rename = "post.loop.complete")]
1228    PostLoopComplete,
1229    #[serde(rename = "pre.loop.error")]
1230    PreLoopError,
1231    #[serde(rename = "post.loop.error")]
1232    PostLoopError,
1233}
1234
1235impl HookPhaseEvent {
1236    /// Canonical string value used in YAML keys and telemetry.
1237    pub fn as_str(self) -> &'static str {
1238        match self {
1239            Self::PreLoopStart => "pre.loop.start",
1240            Self::PostLoopStart => "post.loop.start",
1241            Self::PreIterationStart => "pre.iteration.start",
1242            Self::PostIterationStart => "post.iteration.start",
1243            Self::PrePlanCreated => "pre.plan.created",
1244            Self::PostPlanCreated => "post.plan.created",
1245            Self::PreHumanInteract => "pre.human.interact",
1246            Self::PostHumanInteract => "post.human.interact",
1247            Self::PreLoopComplete => "pre.loop.complete",
1248            Self::PostLoopComplete => "post.loop.complete",
1249            Self::PreLoopError => "pre.loop.error",
1250            Self::PostLoopError => "post.loop.error",
1251        }
1252    }
1253
1254    /// Parses a phase-event string to the canonical enum.
1255    pub fn parse(value: &str) -> Option<Self> {
1256        match value {
1257            "pre.loop.start" => Some(Self::PreLoopStart),
1258            "post.loop.start" => Some(Self::PostLoopStart),
1259            "pre.iteration.start" => Some(Self::PreIterationStart),
1260            "post.iteration.start" => Some(Self::PostIterationStart),
1261            "pre.plan.created" => Some(Self::PrePlanCreated),
1262            "post.plan.created" => Some(Self::PostPlanCreated),
1263            "pre.human.interact" => Some(Self::PreHumanInteract),
1264            "post.human.interact" => Some(Self::PostHumanInteract),
1265            "pre.loop.complete" => Some(Self::PreLoopComplete),
1266            "post.loop.complete" => Some(Self::PostLoopComplete),
1267            "pre.loop.error" => Some(Self::PreLoopError),
1268            "post.loop.error" => Some(Self::PostLoopError),
1269            _ => None,
1270        }
1271    }
1272}
1273
1274impl std::fmt::Display for HookPhaseEvent {
1275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1276        f.write_str((*self).as_str())
1277    }
1278}
1279
1280fn validate_hooks_phase_event_keys(value: &serde_yaml::Value) -> Result<(), ConfigError> {
1281    let Some(root) = value.as_mapping() else {
1282        return Ok(());
1283    };
1284
1285    let Some(hooks) = root.get(serde_yaml::Value::String("hooks".to_string())) else {
1286        return Ok(());
1287    };
1288
1289    let Some(hooks_map) = hooks.as_mapping() else {
1290        return Ok(());
1291    };
1292
1293    let Some(events) = hooks_map.get(serde_yaml::Value::String("events".to_string())) else {
1294        return Ok(());
1295    };
1296
1297    let Some(events_map) = events.as_mapping() else {
1298        return Ok(());
1299    };
1300
1301    for key in events_map.keys() {
1302        if let Some(phase_event) = key.as_str()
1303            && HookPhaseEvent::parse(phase_event).is_none()
1304        {
1305            return Err(ConfigError::InvalidHookPhaseEvent {
1306                phase_event: phase_event.to_string(),
1307            });
1308        }
1309    }
1310
1311    Ok(())
1312}
1313
1314/// Per-hook failure disposition.
1315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1316#[serde(rename_all = "snake_case")]
1317pub enum HookOnError {
1318    /// Continue orchestration and record warning telemetry.
1319    Warn,
1320    /// Stop the current lifecycle action as a failure.
1321    Block,
1322    /// Suspend orchestration and await policy-specific recovery.
1323    Suspend,
1324}
1325
1326/// Suspend mode used for `on_error: suspend`.
1327#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1328#[serde(rename_all = "snake_case")]
1329pub enum HookSuspendMode {
1330    /// Pause the loop until an explicit operator resume signal is received.
1331    #[default]
1332    WaitForResume,
1333    /// Retry automatically with bounded backoff.
1334    RetryBackoff,
1335    /// Wait for resume, then retry once.
1336    WaitThenRetry,
1337}
1338
1339/// Mutation settings for a hook.
1340#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1341pub struct HookMutationConfig {
1342    /// Opt-in flag for parsing stdout as mutation JSON.
1343    #[serde(default)]
1344    pub enabled: bool,
1345
1346    /// Optional explicit payload format guardrail. Only `json` is valid in v1.
1347    #[serde(default)]
1348    pub format: Option<String>,
1349
1350    /// Unknown keys captured for v1 mutation guardrails.
1351    #[serde(default, flatten)]
1352    pub extra: HashMap<String, serde_yaml::Value>,
1353}
1354
1355/// Hook specification for a single lifecycle event mapping.
1356#[derive(Debug, Clone, Serialize, Deserialize)]
1357pub struct HookSpec {
1358    /// Stable hook identifier used in telemetry and diagnostics.
1359    #[serde(default)]
1360    pub name: String,
1361
1362    /// Command argv form (`command[0]` executable + args).
1363    #[serde(default)]
1364    pub command: Vec<String>,
1365
1366    /// Optional working directory override.
1367    #[serde(default)]
1368    pub cwd: Option<PathBuf>,
1369
1370    /// Optional environment variable overrides.
1371    #[serde(default)]
1372    pub env: HashMap<String, String>,
1373
1374    /// Per-hook timeout override in seconds.
1375    #[serde(default)]
1376    pub timeout_seconds: Option<u64>,
1377
1378    /// Per-hook output cap override in bytes (applies per stream).
1379    #[serde(default)]
1380    pub max_output_bytes: Option<u64>,
1381
1382    /// Failure behavior (`warn`, `block`, `suspend`). Required in v1.
1383    #[serde(default)]
1384    pub on_error: Option<HookOnError>,
1385
1386    /// Optional suspend strategy override for `on_error: suspend`.
1387    #[serde(default)]
1388    pub suspend_mode: Option<HookSuspendMode>,
1389
1390    /// Mutation policy (opt-in, JSON-only contract enforced by validation/runtime).
1391    #[serde(default)]
1392    pub mutate: HookMutationConfig,
1393
1394    /// Unknown keys captured for v1 guardrails.
1395    #[serde(default, flatten)]
1396    pub extra: HashMap<String, serde_yaml::Value>,
1397}
1398
1399/// Skills configuration.
1400///
1401/// Controls the skill discovery and injection system that makes tool
1402/// knowledge and domain expertise available to agents during loops.
1403///
1404/// Skills use a two-tier injection model: a compact skill index is always
1405/// present in every prompt, and the agent loads full skill content on demand
1406/// via `ralph tools skill load <name>`.
1407///
1408/// Example configuration:
1409/// ```yaml
1410/// skills:
1411///   enabled: true
1412///   dirs:
1413///     - ".claude/skills"
1414///   overrides:
1415///     pdd:
1416///       enabled: false
1417///     memories:
1418///       auto_inject: true
1419///       hats: ["ralph"]
1420/// ```
1421#[derive(Debug, Clone, Serialize, Deserialize)]
1422pub struct SkillsConfig {
1423    /// Whether the skills system is enabled.
1424    #[serde(default = "default_true")]
1425    pub enabled: bool,
1426
1427    /// Directories to scan for skill files.
1428    /// Relative paths resolved against workspace root.
1429    #[serde(default)]
1430    pub dirs: Vec<PathBuf>,
1431
1432    /// Per-skill overrides keyed by skill name.
1433    #[serde(default)]
1434    pub overrides: HashMap<String, SkillOverride>,
1435}
1436
1437impl Default for SkillsConfig {
1438    fn default() -> Self {
1439        Self {
1440            enabled: true, // Skills enabled by default
1441            dirs: vec![],
1442            overrides: HashMap::new(),
1443        }
1444    }
1445}
1446
1447/// Per-skill configuration override.
1448///
1449/// Allows enabling/disabling individual skills and overriding their
1450/// frontmatter fields (hats, backends, tags, auto_inject).
1451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1452pub struct SkillOverride {
1453    /// Disable a discovered skill.
1454    #[serde(default)]
1455    pub enabled: Option<bool>,
1456
1457    /// Restrict skill to specific hats.
1458    #[serde(default)]
1459    pub hats: Vec<String>,
1460
1461    /// Restrict skill to specific backends.
1462    #[serde(default)]
1463    pub backends: Vec<String>,
1464
1465    /// Tags for categorization.
1466    #[serde(default)]
1467    pub tags: Vec<String>,
1468
1469    /// Inject full content into prompt (not just index entry).
1470    #[serde(default)]
1471    pub auto_inject: Option<bool>,
1472}
1473
1474/// Preflight check configuration.
1475#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1476pub struct PreflightConfig {
1477    /// Whether to run preflight checks before `ralph run`.
1478    #[serde(default)]
1479    pub enabled: bool,
1480
1481    /// Whether to treat warnings as failures.
1482    #[serde(default)]
1483    pub strict: bool,
1484
1485    /// Specific checks to skip (by name). Empty = run all checks.
1486    #[serde(default)]
1487    pub skip: Vec<String>,
1488}
1489
1490/// Feature flags for optional Ralph capabilities.
1491///
1492/// Example configuration:
1493/// ```yaml
1494/// features:
1495///   parallel: true  # Enable parallel loops via git worktrees
1496///   auto_merge: false  # Auto-merge worktree branches on completion
1497///   preflight:
1498///     enabled: false      # Opt-in: run preflight checks before `ralph run`
1499///     strict: false       # Treat warnings as failures
1500///     skip: ["telegram"]  # Skip specific checks by name
1501///   loop_naming:
1502///     format: human-readable  # or "timestamp" for legacy format
1503///     max_length: 50
1504/// ```
1505#[derive(Debug, Clone, Serialize, Deserialize)]
1506pub struct FeaturesConfig {
1507    /// Whether parallel loops are enabled.
1508    ///
1509    /// When true (default), if another loop holds the lock, Ralph spawns
1510    /// a parallel loop in a git worktree. When false, Ralph errors instead.
1511    #[serde(default = "default_true")]
1512    pub parallel: bool,
1513
1514    /// Whether to automatically merge worktree branches on completion.
1515    ///
1516    /// When false (default), completed worktree loops queue for manual merge.
1517    /// When true, Ralph automatically merges the worktree branch into the
1518    /// main branch after a parallel loop completes.
1519    #[serde(default)]
1520    pub auto_merge: bool,
1521
1522    /// Loop naming configuration for worktree branches.
1523    ///
1524    /// Controls how loop IDs are generated for parallel loops.
1525    /// Default uses human-readable format: `fix-header-swift-peacock`
1526    /// Legacy timestamp format: `ralph-YYYYMMDD-HHMMSS-XXXX`
1527    #[serde(default)]
1528    pub loop_naming: crate::loop_name::LoopNamingConfig,
1529
1530    /// Preflight check configuration.
1531    #[serde(default)]
1532    pub preflight: PreflightConfig,
1533}
1534
1535impl Default for FeaturesConfig {
1536    fn default() -> Self {
1537        Self {
1538            parallel: true,    // Parallel loops enabled by default
1539            auto_merge: false, // Auto-merge disabled by default for safety
1540            loop_naming: crate::loop_name::LoopNamingConfig::default(),
1541            preflight: PreflightConfig::default(),
1542        }
1543    }
1544}
1545
1546fn default_prefix_key() -> String {
1547    "ctrl-a".to_string()
1548}
1549
1550impl Default for TuiConfig {
1551    fn default() -> Self {
1552        Self {
1553            prefix_key: default_prefix_key(),
1554        }
1555    }
1556}
1557
1558impl TuiConfig {
1559    /// Parses the prefix_key string into KeyCode and KeyModifiers.
1560    /// Returns an error if the format is invalid.
1561    pub fn parse_prefix(
1562        &self,
1563    ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1564        use crossterm::event::{KeyCode, KeyModifiers};
1565
1566        let parts: Vec<&str> = self.prefix_key.split('-').collect();
1567        if parts.len() != 2 {
1568            return Err(format!(
1569                "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1570                self.prefix_key
1571            ));
1572        }
1573
1574        let modifier = match parts[0].to_lowercase().as_str() {
1575            "ctrl" => KeyModifiers::CONTROL,
1576            _ => {
1577                return Err(format!(
1578                    "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1579                    parts[0]
1580                ));
1581            }
1582        };
1583
1584        let key_str = parts[1];
1585        if key_str.len() != 1 {
1586            return Err(format!(
1587                "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1588                key_str
1589            ));
1590        }
1591
1592        let key_char = key_str.chars().next().unwrap();
1593        let key_code = KeyCode::Char(key_char);
1594
1595        Ok((key_code, modifier))
1596    }
1597}
1598
1599/// Metadata for an event topic.
1600///
1601/// Defines what an event means, enabling auto-derived instructions for hats.
1602/// When a hat triggers on or publishes an event, this metadata is used to
1603/// generate appropriate behavior instructions.
1604///
1605/// Example:
1606/// ```yaml
1607/// events:
1608///   deploy.start:
1609///     description: "Deployment has been requested"
1610///     on_trigger: "Prepare artifacts, validate config, check dependencies"
1611///     on_publish: "Signal that deployment should begin"
1612/// ```
1613#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1614pub struct EventMetadata {
1615    /// Brief description of what this event represents.
1616    #[serde(default)]
1617    pub description: String,
1618
1619    /// Instructions for a hat that triggers on (receives) this event.
1620    /// Describes what the hat should do when it receives this event.
1621    #[serde(default)]
1622    pub on_trigger: String,
1623
1624    /// Instructions for a hat that publishes (emits) this event.
1625    /// Describes when/how the hat should emit this event.
1626    #[serde(default)]
1627    pub on_publish: String,
1628}
1629
1630/// Backend configuration for a hat.
1631#[derive(Debug, Clone, Serialize, Deserialize)]
1632#[serde(untagged)]
1633pub enum HatBackend {
1634    // Order matters for serde untagged - most specific first
1635    /// Kiro agent with custom agent name and optional args.
1636    KiroAgent {
1637        #[serde(rename = "type")]
1638        backend_type: String,
1639        agent: String,
1640        #[serde(default)]
1641        args: Vec<String>,
1642    },
1643    /// Named backend with args (has `type` but no `agent`).
1644    NamedWithArgs {
1645        #[serde(rename = "type")]
1646        backend_type: String,
1647        #[serde(default)]
1648        args: Vec<String>,
1649    },
1650    /// Simple named backend (string form).
1651    Named(String),
1652    /// Custom backend with command and args.
1653    Custom {
1654        command: String,
1655        #[serde(default)]
1656        args: Vec<String>,
1657    },
1658}
1659
1660impl HatBackend {
1661    /// Converts to CLI backend string for execution.
1662    pub fn to_cli_backend(&self) -> String {
1663        match self {
1664            HatBackend::Named(name) => name.clone(),
1665            HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1666            HatBackend::KiroAgent { backend_type, .. } => backend_type.clone(),
1667            HatBackend::Custom { .. } => "custom".to_string(),
1668        }
1669    }
1670}
1671
1672/// Configuration for a single hat.
1673#[derive(Debug, Clone, Serialize, Deserialize)]
1674pub struct HatConfig {
1675    /// Human-readable name for the hat.
1676    pub name: String,
1677
1678    /// Short description of the hat's purpose (required).
1679    /// Used in the HATS table to help Ralph understand when to delegate to this hat.
1680    pub description: Option<String>,
1681
1682    /// Events that trigger this hat to be worn.
1683    /// Per spec: "Hats define triggers — which events cause Ralph to wear this hat."
1684    #[serde(default)]
1685    pub triggers: Vec<String>,
1686
1687    /// Topics this hat publishes.
1688    #[serde(default)]
1689    pub publishes: Vec<String>,
1690
1691    /// Instructions prepended to prompts.
1692    #[serde(default)]
1693    pub instructions: String,
1694
1695    /// Additional instruction fragments appended to `instructions`.
1696    ///
1697    /// Use with YAML anchors to share common instruction blocks across hats:
1698    /// ```yaml
1699    /// _confidence_protocol: &confidence_protocol |
1700    ///   ### Confidence-Based Decision Protocol
1701    ///   ...
1702    ///
1703    /// hats:
1704    ///   architect:
1705    ///     instructions: |
1706    ///       ## ARCHITECT MODE
1707    ///       ...
1708    ///     extra_instructions:
1709    ///       - *confidence_protocol
1710    /// ```
1711    #[serde(default)]
1712    pub extra_instructions: Vec<String>,
1713
1714    /// Backend to use for this hat (inherits from cli.backend if not specified).
1715    #[serde(default)]
1716    pub backend: Option<HatBackend>,
1717
1718    /// Custom args to append to the backend CLI when this hat is active.
1719    ///
1720    /// Accepts both `backend_args:` and shorthand `args:`.
1721    #[serde(default, alias = "args")]
1722    pub backend_args: Option<Vec<String>>,
1723
1724    /// Default event to publish if hat forgets to write an event.
1725    #[serde(default)]
1726    pub default_publishes: Option<String>,
1727
1728    /// Maximum number of times this hat may be activated in a single loop run.
1729    ///
1730    /// When the limit is exceeded, the orchestrator publishes `<hat_id>.exhausted`
1731    /// instead of activating the hat again.
1732    pub max_activations: Option<u32>,
1733
1734    /// Tools the hat is not allowed to use.
1735    ///
1736    /// Injected as a TOOL RESTRICTIONS section in the prompt (soft enforcement).
1737    /// After each iteration, a file-modification audit checks compliance when
1738    /// `Edit` or `Write` are disallowed (hard enforcement via scope_violation event).
1739    #[serde(default)]
1740    pub disallowed_tools: Vec<String>,
1741
1742    /// Execution timeout in seconds for this hat.
1743    ///
1744    /// For wave workers, this controls how long each parallel worker can run.
1745    /// Defaults to the adapter-level timeout (typically 300s) if not set.
1746    #[serde(default)]
1747    pub timeout: Option<u32>,
1748
1749    /// Maximum concurrent wave instances for this hat.
1750    ///
1751    /// When > 1, the loop runner spawns multiple backend instances in parallel
1752    /// for wave events targeting this hat. Default is 1 (sequential execution).
1753    #[serde(default = "default_concurrency")]
1754    pub concurrency: u32,
1755
1756    /// Aggregation configuration for this hat.
1757    ///
1758    /// When set, this hat acts as an aggregator — it buffers wave results and
1759    /// activates only when all correlated results have arrived (or timeout).
1760    /// Cannot be set on a hat with `concurrency > 1`.
1761    #[serde(default)]
1762    pub aggregate: Option<AggregateConfig>,
1763}
1764
1765fn default_concurrency() -> u32 {
1766    1
1767}
1768
1769/// Configuration for wave result aggregation.
1770#[derive(Debug, Clone, Serialize, Deserialize)]
1771pub struct AggregateConfig {
1772    /// Aggregation mode.
1773    pub mode: AggregateMode,
1774
1775    /// Timeout in seconds for waiting on all wave results.
1776    /// After this timeout, the aggregator activates with whatever results are available.
1777    pub timeout: u32,
1778}
1779
1780/// Aggregation mode for wave results.
1781#[derive(Debug, Clone, Serialize, Deserialize)]
1782#[serde(rename_all = "snake_case")]
1783pub enum AggregateMode {
1784    /// Wait for all wave instances to complete before activating the aggregator.
1785    WaitForAll,
1786}
1787
1788impl HatConfig {
1789    /// Converts trigger strings to Topic objects.
1790    pub fn trigger_topics(&self) -> Vec<Topic> {
1791        self.triggers.iter().map(|s| Topic::new(s)).collect()
1792    }
1793
1794    /// Converts publish strings to Topic objects.
1795    pub fn publish_topics(&self) -> Vec<Topic> {
1796        self.publishes.iter().map(|s| Topic::new(s)).collect()
1797    }
1798}
1799
1800/// RObot (Ralph-Orchestrator bot) configuration.
1801///
1802/// Enables bidirectional communication between AI agents and humans
1803/// during orchestration loops. When enabled, agents can emit `human.interact`
1804/// events to request clarification (blocking the loop), and humans can
1805/// send proactive guidance via Telegram.
1806///
1807/// Example configuration:
1808/// ```yaml
1809/// RObot:
1810///   enabled: true
1811///   timeout_seconds: 300
1812///   checkin_interval_seconds: 120  # Optional: send status every 2 min
1813///   telegram:
1814///     bot_token: "..."  # Or set RALPH_TELEGRAM_BOT_TOKEN env var
1815/// ```
1816#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1817pub struct RobotConfig {
1818    /// Whether the RObot is enabled.
1819    #[serde(default)]
1820    pub enabled: bool,
1821
1822    /// Timeout in seconds for waiting on human responses.
1823    /// Required when enabled (no default — must be explicit).
1824    pub timeout_seconds: Option<u64>,
1825
1826    /// Interval in seconds between periodic check-in messages sent via Telegram.
1827    /// When set, Ralph sends a status message every N seconds so the human
1828    /// knows it's still working. If `None`, no check-ins are sent.
1829    pub checkin_interval_seconds: Option<u64>,
1830
1831    /// Telegram bot configuration.
1832    #[serde(default)]
1833    pub telegram: Option<TelegramBotConfig>,
1834}
1835
1836impl RobotConfig {
1837    /// Validates the RObot config. Returns an error if enabled but misconfigured.
1838    pub fn validate(&self) -> Result<(), ConfigError> {
1839        if !self.enabled {
1840            return Ok(());
1841        }
1842
1843        if self.timeout_seconds.is_none() {
1844            return Err(ConfigError::RobotMissingField {
1845                field: "RObot.timeout_seconds".to_string(),
1846                hint: "timeout_seconds is required when RObot is enabled".to_string(),
1847            });
1848        }
1849
1850        // Bot token must be available from config, keychain, or env var
1851        if self.resolve_bot_token().is_none() {
1852            return Err(ConfigError::RobotMissingField {
1853                field: "RObot.telegram.bot_token".to_string(),
1854                hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
1855                    .to_string(),
1856            });
1857        }
1858
1859        Ok(())
1860    }
1861
1862    /// Resolves the bot token from multiple sources.
1863    ///
1864    /// Resolution order (highest to lowest priority):
1865    /// 1. `RALPH_TELEGRAM_BOT_TOKEN` environment variable
1866    /// 2. `RObot.telegram.bot_token` in config file (explicit project override)
1867    /// 3. OS keychain (service: "ralph", user: "telegram-bot-token")
1868    pub fn resolve_bot_token(&self) -> Option<String> {
1869        // 1. Env var (highest priority)
1870        let env_token = std::env::var("RALPH_TELEGRAM_BOT_TOKEN").ok();
1871        let config_token = self
1872            .telegram
1873            .as_ref()
1874            .and_then(|telegram| telegram.bot_token.clone());
1875
1876        if cfg!(test) {
1877            return env_token.or(config_token);
1878        }
1879
1880        env_token
1881            // 2. Config file (explicit override)
1882            .or(config_token)
1883            // 3. OS keychain (best effort)
1884            .or_else(|| {
1885                std::panic::catch_unwind(|| {
1886                    keyring::Entry::new("ralph", "telegram-bot-token")
1887                        .ok()
1888                        .and_then(|e| e.get_password().ok())
1889                })
1890                .ok()
1891                .flatten()
1892            })
1893    }
1894
1895    /// Resolves the custom Telegram API URL from multiple sources.
1896    ///
1897    /// Resolution order (highest to lowest priority):
1898    /// 1. `RALPH_TELEGRAM_API_URL` environment variable
1899    /// 2. `RObot.telegram.api_url` in config file
1900    pub fn resolve_api_url(&self) -> Option<String> {
1901        std::env::var("RALPH_TELEGRAM_API_URL").ok().or_else(|| {
1902            self.telegram
1903                .as_ref()
1904                .and_then(|telegram| telegram.api_url.clone())
1905        })
1906    }
1907}
1908
1909/// Telegram bot configuration.
1910#[derive(Debug, Clone, Serialize, Deserialize)]
1911pub struct TelegramBotConfig {
1912    /// Bot token. Optional if `RALPH_TELEGRAM_BOT_TOKEN` env var is set.
1913    pub bot_token: Option<String>,
1914
1915    /// Custom Telegram Bot API URL. Optional; when set, all API requests
1916    /// are sent to this URL instead of the default `https://api.telegram.org`.
1917    /// Useful for targeting a local mock server (e.g., `telegram-test-api`)
1918    /// in CI/CD. Can also be set via `RALPH_TELEGRAM_API_URL` env var.
1919    pub api_url: Option<String>,
1920}
1921
1922/// Configuration errors.
1923#[derive(Debug, thiserror::Error)]
1924pub enum ConfigError {
1925    #[error("IO error: {0}")]
1926    Io(#[from] std::io::Error),
1927
1928    #[error("YAML parse error: {0}")]
1929    Yaml(#[from] serde_yaml::Error),
1930
1931    #[error(
1932        "Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'.\nFix: ensure only one hat claims this trigger or delegate with a new event.\nSee: docs/reference/troubleshooting.md#ambiguous-routing"
1933    )]
1934    AmbiguousRouting {
1935        trigger: String,
1936        hat1: String,
1937        hat2: String,
1938    },
1939
1940    #[error(
1941        "Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified.\nFix: remove one field or split into separate configs.\nSee: docs/reference/troubleshooting.md#mutually-exclusive-fields"
1942    )]
1943    MutuallyExclusive { field1: String, field2: String },
1944
1945    #[error("Invalid completion_promise: must be non-empty and non-whitespace")]
1946    InvalidCompletionPromise,
1947
1948    #[error(
1949        "Custom backend requires a command.\nFix: set 'cli.command' in your config (or run `ralph init --backend custom`).\nSee: docs/reference/troubleshooting.md#custom-backend-command"
1950    )]
1951    CustomBackendRequiresCommand,
1952
1953    #[error(
1954        "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.\nSee: docs/reference/troubleshooting.md#reserved-trigger"
1955    )]
1956    ReservedTrigger { trigger: String, hat: String },
1957
1958    #[error(
1959        "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose.\nSee: docs/reference/troubleshooting.md#missing-hat-description"
1960    )]
1961    MissingDescription { hat: String },
1962
1963    #[error(
1964        "RObot config error: {field} - {hint}\nSee: docs/reference/troubleshooting.md#robot-config"
1965    )]
1966    RobotMissingField { field: String, hint: String },
1967
1968    #[error(
1969        "Invalid hooks phase-event '{phase_event}'. Supported v1 phase-events: pre.loop.start, post.loop.start, pre.iteration.start, post.iteration.start, pre.plan.created, post.plan.created, pre.human.interact, post.human.interact, pre.loop.complete, post.loop.complete, pre.loop.error, post.loop.error.\nFix: use one of the supported keys under hooks.events."
1970    )]
1971    InvalidHookPhaseEvent { phase_event: String },
1972
1973    #[error(
1974        "Hook config validation error at '{field}': {message}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#hookspec-fields-v1"
1975    )]
1976    HookValidation { field: String, message: String },
1977
1978    #[error(
1979        "Unsupported hooks field '{field}' for v1. {reason}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#out-of-scope-v1-non-goals"
1980    )]
1981    UnsupportedHookField { field: String, reason: String },
1982
1983    #[error(
1984        "Invalid config key 'project'. Use 'core' instead (e.g. 'core.specs_dir' instead of 'project.specs_dir').\nSee: docs/guide/configuration.md"
1985    )]
1986    DeprecatedProjectKey,
1987
1988    #[error(
1989        "Hat '{hat}' has invalid concurrency: {value}. Must be >= 1.\nFix: set 'concurrency' to 1 or higher."
1990    )]
1991    InvalidConcurrency { hat: String, value: u32 },
1992
1993    #[error(
1994        "Hat '{hat}' has both 'aggregate' and 'concurrency > 1'. An aggregator hat cannot also be a concurrent worker.\nFix: remove 'aggregate' or set 'concurrency' to 1."
1995    )]
1996    AggregateOnConcurrentHat { hat: String },
1997}
1998
1999#[cfg(test)]
2000mod tests {
2001    use super::*;
2002
2003    #[test]
2004    fn test_default_config() {
2005        let config = RalphConfig::default();
2006        // Default config has no custom hats (uses default planner+builder)
2007        assert!(config.hats.is_empty());
2008        assert_eq!(config.event_loop.max_iterations, 100);
2009        assert!(!config.verbose);
2010        assert!(!config.features.preflight.enabled);
2011        assert!(!config.features.preflight.strict);
2012        assert!(config.features.preflight.skip.is_empty());
2013    }
2014
2015    #[test]
2016    fn test_parse_yaml_with_custom_hats() {
2017        let yaml = r#"
2018event_loop:
2019  prompt_file: "TASK.md"
2020  completion_promise: "DONE"
2021  max_iterations: 50
2022cli:
2023  backend: "claude"
2024hats:
2025  implementer:
2026    name: "Implementer"
2027    triggers: ["task.*", "review.done"]
2028    publishes: ["impl.done"]
2029    instructions: "You are the implementation agent."
2030"#;
2031        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2032        // Custom hats are defined
2033        assert_eq!(config.hats.len(), 1);
2034        assert_eq!(config.event_loop.prompt_file, "TASK.md");
2035
2036        let hat = config.hats.get("implementer").unwrap();
2037        assert_eq!(hat.triggers.len(), 2);
2038    }
2039
2040    #[test]
2041    fn test_preflight_config_deserialize() {
2042        let yaml = r#"
2043features:
2044  preflight:
2045    enabled: true
2046    strict: true
2047    skip: ["telegram", "git"]
2048"#;
2049        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2050        assert!(config.features.preflight.enabled);
2051        assert!(config.features.preflight.strict);
2052        assert_eq!(
2053            config.features.preflight.skip,
2054            vec!["telegram".to_string(), "git".to_string()]
2055        );
2056    }
2057
2058    #[test]
2059    fn test_parse_yaml_v1_format() {
2060        // V1 flat format - identical to Python v1.x config
2061        let yaml = r#"
2062agent: gemini
2063prompt_file: "TASK.md"
2064completion_promise: "RALPH_DONE"
2065max_iterations: 75
2066max_runtime: 7200
2067max_cost: 10.0
2068verbose: true
2069"#;
2070        let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2071
2072        // Before normalization, v2 fields have defaults
2073        assert_eq!(config.cli.backend, "claude"); // default
2074        assert_eq!(config.event_loop.max_iterations, 100); // default
2075
2076        // Normalize v1 -> v2
2077        config.normalize();
2078
2079        // After normalization, v2 fields have v1 values
2080        assert_eq!(config.cli.backend, "gemini");
2081        assert_eq!(config.event_loop.prompt_file, "TASK.md");
2082        assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
2083        assert_eq!(config.event_loop.max_iterations, 75);
2084        assert_eq!(config.event_loop.max_runtime_seconds, 7200);
2085        assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
2086        assert!(config.verbose);
2087    }
2088
2089    #[test]
2090    fn test_agent_priority() {
2091        let yaml = r"
2092agent: auto
2093agent_priority: [gemini, claude, codex]
2094";
2095        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2096        let priority = config.get_agent_priority();
2097        assert_eq!(priority, vec!["gemini", "claude", "codex"]);
2098    }
2099
2100    #[test]
2101    fn test_default_agent_priority() {
2102        let config = RalphConfig::default();
2103        let priority = config.get_agent_priority();
2104        assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
2105    }
2106
2107    #[test]
2108    fn test_validate_deferred_features() {
2109        let yaml = r"
2110archive_prompts: true
2111enable_metrics: true
2112";
2113        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2114        let warnings = config.validate().unwrap();
2115
2116        assert_eq!(warnings.len(), 2);
2117        assert!(warnings
2118            .iter()
2119            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
2120        assert!(warnings
2121            .iter()
2122            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
2123    }
2124
2125    #[test]
2126    fn test_validate_dropped_fields() {
2127        let yaml = r#"
2128max_tokens: 4096
2129retry_delay: 5
2130adapters:
2131  claude:
2132    tool_permissions: ["read", "write"]
2133"#;
2134        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2135        let warnings = config.validate().unwrap();
2136
2137        assert_eq!(warnings.len(), 3);
2138        assert!(warnings.iter().any(
2139            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
2140        ));
2141        assert!(warnings.iter().any(
2142            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
2143        ));
2144        assert!(warnings
2145            .iter()
2146            .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
2147    }
2148
2149    #[test]
2150    fn test_suppress_warnings() {
2151        let yaml = r"
2152_suppress_warnings: true
2153archive_prompts: true
2154max_tokens: 4096
2155";
2156        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2157        let warnings = config.validate().unwrap();
2158
2159        // All warnings should be suppressed
2160        assert!(warnings.is_empty());
2161    }
2162
2163    #[test]
2164    fn test_adapter_settings() {
2165        let yaml = r"
2166adapters:
2167  claude:
2168    timeout: 600
2169    enabled: true
2170  gemini:
2171    timeout: 300
2172    enabled: false
2173";
2174        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2175
2176        let claude = config.adapter_settings("claude");
2177        assert_eq!(claude.timeout, 600);
2178        assert!(claude.enabled);
2179
2180        let gemini = config.adapter_settings("gemini");
2181        assert_eq!(gemini.timeout, 300);
2182        assert!(!gemini.enabled);
2183    }
2184
2185    #[test]
2186    fn test_unknown_fields_ignored() {
2187        // Unknown fields should be silently ignored (forward compatibility)
2188        let yaml = r#"
2189agent: claude
2190unknown_field: "some value"
2191future_feature: true
2192"#;
2193        let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
2194        // Should parse successfully, ignoring unknown fields
2195        assert!(result.is_ok());
2196    }
2197
2198    #[test]
2199    fn test_custom_backend_args_shorthand() {
2200        let yaml = r#"
2201hats:
2202  opencode_builder:
2203    name: "Opencode"
2204    description: "Opencode hat"
2205    backend: "opencode"
2206    args: ["-m", "model"]
2207"#;
2208        let config = RalphConfig::parse_yaml(yaml).unwrap();
2209        let hat = config.hats.get("opencode_builder").unwrap();
2210        assert!(hat.backend_args.is_some());
2211        assert_eq!(
2212            hat.backend_args.as_ref().unwrap(),
2213            &vec!["-m".to_string(), "model".to_string()]
2214        );
2215    }
2216
2217    #[test]
2218    fn test_custom_backend_args_explicit_key() {
2219        let yaml = r#"
2220hats:
2221  opencode_builder:
2222    name: "Opencode"
2223    description: "Opencode hat"
2224    backend: "opencode"
2225    backend_args: ["-m", "model"]
2226"#;
2227        let config = RalphConfig::parse_yaml(yaml).unwrap();
2228        let hat = config.hats.get("opencode_builder").unwrap();
2229        assert!(hat.backend_args.is_some());
2230        assert_eq!(
2231            hat.backend_args.as_ref().unwrap(),
2232            &vec!["-m".to_string(), "model".to_string()]
2233        );
2234    }
2235
2236    #[test]
2237    fn test_project_key_rejected() {
2238        let yaml = r#"
2239project:
2240  specs_dir: "my_specs"
2241"#;
2242        let result = RalphConfig::parse_yaml(yaml);
2243        assert!(result.is_err());
2244        assert!(matches!(
2245            result.unwrap_err(),
2246            ConfigError::DeprecatedProjectKey
2247        ));
2248    }
2249
2250    #[test]
2251    fn test_ambiguous_routing_rejected() {
2252        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
2253        // Note: using semantic events since task.start is reserved
2254        let yaml = r#"
2255hats:
2256  planner:
2257    name: "Planner"
2258    description: "Plans tasks"
2259    triggers: ["planning.start", "build.done"]
2260  builder:
2261    name: "Builder"
2262    description: "Builds code"
2263    triggers: ["build.task", "build.done"]
2264"#;
2265        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2266        let result = config.validate();
2267
2268        assert!(result.is_err());
2269        let err = result.unwrap_err();
2270        assert!(
2271            matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
2272            "Expected AmbiguousRouting error for 'build.done', got: {:?}",
2273            err
2274        );
2275    }
2276
2277    #[test]
2278    fn test_unique_triggers_accepted() {
2279        // Valid config: each trigger maps to exactly one hat
2280        // Note: task.start is reserved for Ralph, so use semantic events
2281        let yaml = r#"
2282hats:
2283  planner:
2284    name: "Planner"
2285    description: "Plans tasks"
2286    triggers: ["planning.start", "build.done", "build.blocked"]
2287  builder:
2288    name: "Builder"
2289    description: "Builds code"
2290    triggers: ["build.task"]
2291"#;
2292        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2293        let result = config.validate();
2294
2295        assert!(
2296            result.is_ok(),
2297            "Expected valid config, got: {:?}",
2298            result.unwrap_err()
2299        );
2300    }
2301
2302    #[test]
2303    fn test_reserved_trigger_task_start_rejected() {
2304        // Per design: task.start is reserved for Ralph (the coordinator)
2305        let yaml = r#"
2306hats:
2307  my_hat:
2308    name: "My Hat"
2309    description: "Test hat"
2310    triggers: ["task.start"]
2311"#;
2312        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2313        let result = config.validate();
2314
2315        assert!(result.is_err());
2316        let err = result.unwrap_err();
2317        assert!(
2318            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2319                if trigger == "task.start" && hat == "my_hat"),
2320            "Expected ReservedTrigger error for 'task.start', got: {:?}",
2321            err
2322        );
2323    }
2324
2325    #[test]
2326    fn test_reserved_trigger_task_resume_rejected() {
2327        // Per design: task.resume is reserved for Ralph (the coordinator)
2328        let yaml = r#"
2329hats:
2330  my_hat:
2331    name: "My Hat"
2332    description: "Test hat"
2333    triggers: ["task.resume", "other.event"]
2334"#;
2335        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2336        let result = config.validate();
2337
2338        assert!(result.is_err());
2339        let err = result.unwrap_err();
2340        assert!(
2341            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2342                if trigger == "task.resume" && hat == "my_hat"),
2343            "Expected ReservedTrigger error for 'task.resume', got: {:?}",
2344            err
2345        );
2346    }
2347
2348    #[test]
2349    fn test_missing_description_rejected() {
2350        // Description is required for all hats
2351        let yaml = r#"
2352hats:
2353  my_hat:
2354    name: "My Hat"
2355    triggers: ["build.task"]
2356"#;
2357        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2358        let result = config.validate();
2359
2360        assert!(result.is_err());
2361        let err = result.unwrap_err();
2362        assert!(
2363            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2364            "Expected MissingDescription error, got: {:?}",
2365            err
2366        );
2367    }
2368
2369    #[test]
2370    fn test_empty_description_rejected() {
2371        // Empty description should also be rejected
2372        let yaml = r#"
2373hats:
2374  my_hat:
2375    name: "My Hat"
2376    description: "   "
2377    triggers: ["build.task"]
2378"#;
2379        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2380        let result = config.validate();
2381
2382        assert!(result.is_err());
2383        let err = result.unwrap_err();
2384        assert!(
2385            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2386            "Expected MissingDescription error for empty description, got: {:?}",
2387            err
2388        );
2389    }
2390
2391    #[test]
2392    fn test_core_config_defaults() {
2393        let config = RalphConfig::default();
2394        assert_eq!(config.core.scratchpad, ".ralph/agent/scratchpad.md");
2395        assert_eq!(config.core.specs_dir, ".ralph/specs/");
2396        // Default guardrails per spec
2397        assert_eq!(config.core.guardrails.len(), 6);
2398        assert!(config.core.guardrails[0].contains("Fresh context"));
2399        assert!(config.core.guardrails[1].contains("search first"));
2400        assert!(config.core.guardrails[2].contains("Backpressure"));
2401        assert!(config.core.guardrails[3].contains("strongest available harness"));
2402        assert!(config.core.guardrails[4].contains("Confidence protocol"));
2403        assert!(config.core.guardrails[5].contains("Commit atomically"));
2404    }
2405
2406    #[test]
2407    fn test_core_config_customizable() {
2408        let yaml = r#"
2409core:
2410  scratchpad: ".workspace/plan.md"
2411  specs_dir: "./specifications/"
2412"#;
2413        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2414        assert_eq!(config.core.scratchpad, ".workspace/plan.md");
2415        assert_eq!(config.core.specs_dir, "./specifications/");
2416        // Guardrails should use defaults when not specified
2417        assert_eq!(config.core.guardrails.len(), 6);
2418    }
2419
2420    #[test]
2421    fn test_core_config_custom_guardrails() {
2422        let yaml = r#"
2423core:
2424  scratchpad: ".ralph/agent/scratchpad.md"
2425  specs_dir: "./specs/"
2426  guardrails:
2427    - "Custom rule one"
2428    - "Custom rule two"
2429"#;
2430        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2431        assert_eq!(config.core.guardrails.len(), 2);
2432        assert_eq!(config.core.guardrails[0], "Custom rule one");
2433        assert_eq!(config.core.guardrails[1], "Custom rule two");
2434    }
2435
2436    #[test]
2437    fn test_prompt_and_prompt_file_mutually_exclusive() {
2438        // Both prompt and prompt_file specified in config should error
2439        let yaml = r#"
2440event_loop:
2441  prompt: "inline text"
2442  prompt_file: "custom.md"
2443"#;
2444        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2445        let result = config.validate();
2446
2447        assert!(result.is_err());
2448        let err = result.unwrap_err();
2449        assert!(
2450            matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
2451                if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
2452            "Expected MutuallyExclusive error, got: {:?}",
2453            err
2454        );
2455    }
2456
2457    #[test]
2458    fn test_prompt_with_default_prompt_file_allowed() {
2459        // Having inline prompt with default prompt_file value should be OK
2460        let yaml = r#"
2461event_loop:
2462  prompt: "inline text"
2463"#;
2464        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2465        let result = config.validate();
2466
2467        assert!(
2468            result.is_ok(),
2469            "Should allow inline prompt with default prompt_file"
2470        );
2471        assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
2472        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2473    }
2474
2475    #[test]
2476    fn test_custom_backend_requires_command() {
2477        // Custom backend without command should error
2478        let yaml = r#"
2479cli:
2480  backend: "custom"
2481"#;
2482        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2483        let result = config.validate();
2484
2485        assert!(result.is_err());
2486        let err = result.unwrap_err();
2487        assert!(
2488            matches!(&err, ConfigError::CustomBackendRequiresCommand),
2489            "Expected CustomBackendRequiresCommand error, got: {:?}",
2490            err
2491        );
2492    }
2493
2494    #[test]
2495    fn test_empty_completion_promise_rejected() {
2496        let yaml = r#"
2497event_loop:
2498  completion_promise: "   "
2499"#;
2500        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2501        let result = config.validate();
2502
2503        assert!(result.is_err());
2504        let err = result.unwrap_err();
2505        assert!(
2506            matches!(&err, ConfigError::InvalidCompletionPromise),
2507            "Expected InvalidCompletionPromise error, got: {:?}",
2508            err
2509        );
2510    }
2511
2512    #[test]
2513    fn test_custom_backend_with_empty_command_errors() {
2514        // Custom backend with empty command should error
2515        let yaml = r#"
2516cli:
2517  backend: "custom"
2518  command: ""
2519"#;
2520        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2521        let result = config.validate();
2522
2523        assert!(result.is_err());
2524        let err = result.unwrap_err();
2525        assert!(
2526            matches!(&err, ConfigError::CustomBackendRequiresCommand),
2527            "Expected CustomBackendRequiresCommand error, got: {:?}",
2528            err
2529        );
2530    }
2531
2532    #[test]
2533    fn test_custom_backend_with_command_succeeds() {
2534        // Custom backend with valid command should pass validation
2535        let yaml = r#"
2536cli:
2537  backend: "custom"
2538  command: "my-agent"
2539"#;
2540        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2541        let result = config.validate();
2542
2543        assert!(
2544            result.is_ok(),
2545            "Should allow custom backend with command: {:?}",
2546            result.unwrap_err()
2547        );
2548    }
2549
2550    #[test]
2551    fn test_custom_backend_requires_command_message_actionable() {
2552        let err = ConfigError::CustomBackendRequiresCommand;
2553        let msg = err.to_string();
2554        assert!(msg.contains("cli.command"));
2555        assert!(msg.contains("ralph init --backend custom"));
2556        assert!(msg.contains("docs/reference/troubleshooting.md#custom-backend-command"));
2557    }
2558
2559    #[test]
2560    fn test_reserved_trigger_message_actionable() {
2561        let err = ConfigError::ReservedTrigger {
2562            trigger: "task.start".to_string(),
2563            hat: "builder".to_string(),
2564        };
2565        let msg = err.to_string();
2566        assert!(msg.contains("Reserved trigger"));
2567        assert!(msg.contains("docs/reference/troubleshooting.md#reserved-trigger"));
2568    }
2569
2570    #[test]
2571    fn test_prompt_file_with_no_inline_allowed() {
2572        // Having only prompt_file specified should be OK
2573        let yaml = r#"
2574event_loop:
2575  prompt_file: "custom.md"
2576"#;
2577        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2578        let result = config.validate();
2579
2580        assert!(
2581            result.is_ok(),
2582            "Should allow prompt_file without inline prompt"
2583        );
2584        assert_eq!(config.event_loop.prompt, None);
2585        assert_eq!(config.event_loop.prompt_file, "custom.md");
2586    }
2587
2588    #[test]
2589    fn test_default_prompt_file_value() {
2590        let config = RalphConfig::default();
2591        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2592        assert_eq!(config.event_loop.prompt, None);
2593    }
2594
2595    #[test]
2596    fn test_tui_config_default() {
2597        let config = RalphConfig::default();
2598        assert_eq!(config.tui.prefix_key, "ctrl-a");
2599    }
2600
2601    #[test]
2602    fn test_tui_config_parse_ctrl_b() {
2603        let yaml = r#"
2604tui:
2605  prefix_key: "ctrl-b"
2606"#;
2607        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2608        let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
2609
2610        use crossterm::event::{KeyCode, KeyModifiers};
2611        assert_eq!(key_code, KeyCode::Char('b'));
2612        assert_eq!(key_modifiers, KeyModifiers::CONTROL);
2613    }
2614
2615    #[test]
2616    fn test_tui_config_parse_invalid_format() {
2617        let tui_config = TuiConfig {
2618            prefix_key: "invalid".to_string(),
2619        };
2620        let result = tui_config.parse_prefix();
2621        assert!(result.is_err());
2622        assert!(result.unwrap_err().contains("Invalid prefix_key format"));
2623    }
2624
2625    #[test]
2626    fn test_tui_config_parse_invalid_modifier() {
2627        let tui_config = TuiConfig {
2628            prefix_key: "alt-a".to_string(),
2629        };
2630        let result = tui_config.parse_prefix();
2631        assert!(result.is_err());
2632        assert!(result.unwrap_err().contains("Invalid modifier"));
2633    }
2634
2635    #[test]
2636    fn test_tui_config_parse_invalid_key() {
2637        let tui_config = TuiConfig {
2638            prefix_key: "ctrl-abc".to_string(),
2639        };
2640        let result = tui_config.parse_prefix();
2641        assert!(result.is_err());
2642        assert!(result.unwrap_err().contains("Invalid key"));
2643    }
2644
2645    #[test]
2646    fn test_hat_backend_named() {
2647        let yaml = r#""claude""#;
2648        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2649        assert_eq!(backend.to_cli_backend(), "claude");
2650        match backend {
2651            HatBackend::Named(name) => assert_eq!(name, "claude"),
2652            _ => panic!("Expected Named variant"),
2653        }
2654    }
2655
2656    #[test]
2657    fn test_hat_backend_kiro_agent() {
2658        let yaml = r#"
2659type: "kiro"
2660agent: "builder"
2661"#;
2662        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2663        assert_eq!(backend.to_cli_backend(), "kiro");
2664        match backend {
2665            HatBackend::KiroAgent {
2666                backend_type,
2667                agent,
2668                args,
2669            } => {
2670                assert_eq!(backend_type, "kiro");
2671                assert_eq!(agent, "builder");
2672                assert!(args.is_empty());
2673            }
2674            _ => panic!("Expected KiroAgent variant"),
2675        }
2676    }
2677
2678    #[test]
2679    fn test_hat_backend_kiro_agent_with_args() {
2680        let yaml = r#"
2681type: "kiro"
2682agent: "builder"
2683args: ["--verbose", "--debug"]
2684"#;
2685        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2686        assert_eq!(backend.to_cli_backend(), "kiro");
2687        match backend {
2688            HatBackend::KiroAgent {
2689                backend_type,
2690                agent,
2691                args,
2692            } => {
2693                assert_eq!(backend_type, "kiro");
2694                assert_eq!(agent, "builder");
2695                assert_eq!(args, vec!["--verbose", "--debug"]);
2696            }
2697            _ => panic!("Expected KiroAgent variant"),
2698        }
2699    }
2700
2701    #[test]
2702    fn test_hat_backend_named_with_args() {
2703        let yaml = r#"
2704type: "claude"
2705args: ["--model", "claude-sonnet-4"]
2706"#;
2707        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2708        assert_eq!(backend.to_cli_backend(), "claude");
2709        match backend {
2710            HatBackend::NamedWithArgs { backend_type, args } => {
2711                assert_eq!(backend_type, "claude");
2712                assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2713            }
2714            _ => panic!("Expected NamedWithArgs variant"),
2715        }
2716    }
2717
2718    #[test]
2719    fn test_hat_backend_named_with_args_empty() {
2720        // type: claude without args should still work (NamedWithArgs with empty args)
2721        let yaml = r#"
2722type: "gemini"
2723"#;
2724        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2725        assert_eq!(backend.to_cli_backend(), "gemini");
2726        match backend {
2727            HatBackend::NamedWithArgs { backend_type, args } => {
2728                assert_eq!(backend_type, "gemini");
2729                assert!(args.is_empty());
2730            }
2731            _ => panic!("Expected NamedWithArgs variant"),
2732        }
2733    }
2734
2735    #[test]
2736    fn test_hat_backend_custom() {
2737        let yaml = r#"
2738command: "/usr/bin/my-agent"
2739args: ["--flag", "value"]
2740"#;
2741        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2742        assert_eq!(backend.to_cli_backend(), "custom");
2743        match backend {
2744            HatBackend::Custom { command, args } => {
2745                assert_eq!(command, "/usr/bin/my-agent");
2746                assert_eq!(args, vec!["--flag", "value"]);
2747            }
2748            _ => panic!("Expected Custom variant"),
2749        }
2750    }
2751
2752    #[test]
2753    fn test_hat_config_with_backend() {
2754        let yaml = r#"
2755name: "Custom Builder"
2756triggers: ["build.task"]
2757publishes: ["build.done"]
2758instructions: "Build stuff"
2759backend: "gemini"
2760default_publishes: "task.done"
2761"#;
2762        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2763        assert_eq!(hat.name, "Custom Builder");
2764        assert!(hat.backend.is_some());
2765        match hat.backend.unwrap() {
2766            HatBackend::Named(name) => assert_eq!(name, "gemini"),
2767            _ => panic!("Expected Named backend"),
2768        }
2769        assert_eq!(hat.default_publishes, Some("task.done".to_string()));
2770    }
2771
2772    #[test]
2773    fn test_hat_config_without_backend() {
2774        let yaml = r#"
2775name: "Default Hat"
2776triggers: ["task.start"]
2777publishes: ["task.done"]
2778instructions: "Do work"
2779"#;
2780        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2781        assert_eq!(hat.name, "Default Hat");
2782        assert!(hat.backend.is_none());
2783        assert!(hat.default_publishes.is_none());
2784    }
2785
2786    #[test]
2787    fn test_mixed_backends_config() {
2788        let yaml = r#"
2789event_loop:
2790  prompt_file: "TASK.md"
2791  max_iterations: 50
2792
2793cli:
2794  backend: "claude"
2795
2796hats:
2797  planner:
2798    name: "Planner"
2799    triggers: ["task.start"]
2800    publishes: ["build.task"]
2801    instructions: "Plan the work"
2802    backend: "claude"
2803    
2804  builder:
2805    name: "Builder"
2806    triggers: ["build.task"]
2807    publishes: ["build.done"]
2808    instructions: "Build the thing"
2809    backend:
2810      type: "kiro"
2811      agent: "builder"
2812      
2813  reviewer:
2814    name: "Reviewer"
2815    triggers: ["build.done"]
2816    publishes: ["review.complete"]
2817    instructions: "Review the work"
2818    backend:
2819      command: "/usr/local/bin/custom-agent"
2820      args: ["--mode", "review"]
2821    default_publishes: "review.complete"
2822"#;
2823        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2824        assert_eq!(config.hats.len(), 3);
2825
2826        // Check planner (Named backend)
2827        let planner = config.hats.get("planner").unwrap();
2828        assert!(planner.backend.is_some());
2829        match planner.backend.as_ref().unwrap() {
2830            HatBackend::Named(name) => assert_eq!(name, "claude"),
2831            _ => panic!("Expected Named backend for planner"),
2832        }
2833
2834        // Check builder (KiroAgent backend)
2835        let builder = config.hats.get("builder").unwrap();
2836        assert!(builder.backend.is_some());
2837        match builder.backend.as_ref().unwrap() {
2838            HatBackend::KiroAgent {
2839                backend_type,
2840                agent,
2841                args,
2842            } => {
2843                assert_eq!(backend_type, "kiro");
2844                assert_eq!(agent, "builder");
2845                assert!(args.is_empty());
2846            }
2847            _ => panic!("Expected KiroAgent backend for builder"),
2848        }
2849
2850        // Check reviewer (Custom backend)
2851        let reviewer = config.hats.get("reviewer").unwrap();
2852        assert!(reviewer.backend.is_some());
2853        match reviewer.backend.as_ref().unwrap() {
2854            HatBackend::Custom { command, args } => {
2855                assert_eq!(command, "/usr/local/bin/custom-agent");
2856                assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
2857            }
2858            _ => panic!("Expected Custom backend for reviewer"),
2859        }
2860        assert_eq!(
2861            reviewer.default_publishes,
2862            Some("review.complete".to_string())
2863        );
2864    }
2865
2866    #[test]
2867    fn test_features_config_auto_merge_defaults_to_false() {
2868        // Per spec: auto_merge should default to false for safety
2869        // This prevents automatic merging of parallel loop branches
2870        let config = RalphConfig::default();
2871        assert!(
2872            !config.features.auto_merge,
2873            "auto_merge should default to false"
2874        );
2875    }
2876
2877    #[test]
2878    fn test_features_config_auto_merge_from_yaml() {
2879        // Users can opt into auto_merge via config
2880        let yaml = r"
2881features:
2882  auto_merge: true
2883";
2884        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2885        assert!(
2886            config.features.auto_merge,
2887            "auto_merge should be true when configured"
2888        );
2889    }
2890
2891    #[test]
2892    fn test_features_config_auto_merge_false_from_yaml() {
2893        // Explicit false should work too
2894        let yaml = r"
2895features:
2896  auto_merge: false
2897";
2898        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2899        assert!(
2900            !config.features.auto_merge,
2901            "auto_merge should be false when explicitly configured"
2902        );
2903    }
2904
2905    #[test]
2906    fn test_features_config_preserves_parallel_when_adding_auto_merge() {
2907        // Ensure adding auto_merge doesn't break existing parallel feature
2908        let yaml = r"
2909features:
2910  parallel: false
2911  auto_merge: true
2912";
2913        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2914        assert!(!config.features.parallel, "parallel should be false");
2915        assert!(config.features.auto_merge, "auto_merge should be true");
2916    }
2917
2918    #[test]
2919    fn test_skills_config_defaults_when_absent() {
2920        // Configs without a skills: section should still parse (backwards compat)
2921        let yaml = r"
2922agent: claude
2923";
2924        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2925        assert!(config.skills.enabled);
2926        assert!(config.skills.dirs.is_empty());
2927        assert!(config.skills.overrides.is_empty());
2928    }
2929
2930    #[test]
2931    fn test_skills_config_deserializes_all_fields() {
2932        let yaml = r#"
2933skills:
2934  enabled: true
2935  dirs:
2936    - ".claude/skills"
2937    - "/shared/skills"
2938  overrides:
2939    pdd:
2940      enabled: false
2941    memories:
2942      auto_inject: true
2943      hats: ["ralph"]
2944      backends: ["claude"]
2945      tags: ["core"]
2946"#;
2947        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2948        assert!(config.skills.enabled);
2949        assert_eq!(config.skills.dirs.len(), 2);
2950        assert_eq!(
2951            config.skills.dirs[0],
2952            std::path::PathBuf::from(".claude/skills")
2953        );
2954        assert_eq!(config.skills.overrides.len(), 2);
2955
2956        let pdd = config.skills.overrides.get("pdd").unwrap();
2957        assert_eq!(pdd.enabled, Some(false));
2958
2959        let memories = config.skills.overrides.get("memories").unwrap();
2960        assert_eq!(memories.auto_inject, Some(true));
2961        assert_eq!(memories.hats, vec!["ralph"]);
2962        assert_eq!(memories.backends, vec!["claude"]);
2963        assert_eq!(memories.tags, vec!["core"]);
2964    }
2965
2966    #[test]
2967    fn test_skills_config_disabled() {
2968        let yaml = r"
2969skills:
2970  enabled: false
2971";
2972        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2973        assert!(!config.skills.enabled);
2974        assert!(config.skills.dirs.is_empty());
2975    }
2976
2977    #[test]
2978    fn test_skill_override_partial_fields() {
2979        let yaml = r#"
2980skills:
2981  overrides:
2982    my-skill:
2983      hats: ["builder", "reviewer"]
2984"#;
2985        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2986        let override_ = config.skills.overrides.get("my-skill").unwrap();
2987        assert_eq!(override_.enabled, None);
2988        assert_eq!(override_.auto_inject, None);
2989        assert_eq!(override_.hats, vec!["builder", "reviewer"]);
2990        assert!(override_.backends.is_empty());
2991        assert!(override_.tags.is_empty());
2992    }
2993
2994    #[test]
2995    fn test_hooks_config_valid_yaml_parses_and_validates() {
2996        let yaml = r#"
2997hooks:
2998  enabled: true
2999  defaults:
3000    timeout_seconds: 45
3001    max_output_bytes: 16384
3002    suspend_mode: wait_for_resume
3003  events:
3004    pre.loop.start:
3005      - name: env-guard
3006        command: ["./scripts/hooks/env-guard.sh", "--check"]
3007        on_error: block
3008    post.loop.complete:
3009      - name: notify
3010        command: ["./scripts/hooks/notify.sh"]
3011        on_error: warn
3012        mutate:
3013          enabled: true
3014          format: json
3015"#;
3016        let config = RalphConfig::parse_yaml(yaml).unwrap();
3017
3018        assert!(config.hooks.enabled);
3019        assert_eq!(config.hooks.defaults.timeout_seconds, 45);
3020        assert_eq!(config.hooks.defaults.max_output_bytes, 16384);
3021        assert_eq!(config.hooks.events.len(), 2);
3022
3023        let warnings = config.validate().unwrap();
3024        assert!(warnings.is_empty());
3025    }
3026
3027    #[test]
3028    fn test_hooks_parse_rejects_invalid_phase_event_key() {
3029        let yaml = r#"
3030hooks:
3031  enabled: true
3032  events:
3033    pre.loop.launch:
3034      - name: bad-phase
3035        command: ["./scripts/hooks/bad-phase.sh"]
3036        on_error: warn
3037"#;
3038
3039        let result = RalphConfig::parse_yaml(yaml);
3040        assert!(result.is_err());
3041
3042        let err = result.unwrap_err();
3043        assert!(matches!(
3044            &err,
3045            ConfigError::InvalidHookPhaseEvent { phase_event }
3046            if phase_event == "pre.loop.launch"
3047        ));
3048    }
3049
3050    #[test]
3051    fn test_hooks_parse_rejects_backpressure_phase_event_keys_in_v1() {
3052        let yaml = r#"
3053hooks:
3054  enabled: true
3055  events:
3056    pre.backpressure.triggered:
3057      - name: unsupported-backpressure
3058        command: ["./scripts/hooks/backpressure.sh"]
3059        on_error: warn
3060"#;
3061
3062        let result = RalphConfig::parse_yaml(yaml);
3063        assert!(result.is_err());
3064
3065        let err = result.unwrap_err();
3066        assert!(matches!(
3067            &err,
3068            ConfigError::InvalidHookPhaseEvent { phase_event }
3069            if phase_event == "pre.backpressure.triggered"
3070        ));
3071
3072        let message = err.to_string();
3073        assert!(message.contains("Supported v1 phase-events"));
3074        assert!(message.contains("pre.plan.created"));
3075        assert!(message.contains("post.loop.error"));
3076    }
3077
3078    #[test]
3079    fn test_hooks_parse_rejects_invalid_on_error_enum_value() {
3080        let yaml = r#"
3081hooks:
3082  enabled: true
3083  events:
3084    pre.loop.start:
3085      - name: bad-on-error
3086        command: ["./scripts/hooks/bad-on-error.sh"]
3087        on_error: explode
3088"#;
3089
3090        let result = RalphConfig::parse_yaml(yaml);
3091        assert!(result.is_err());
3092
3093        let err = result.unwrap_err();
3094        assert!(matches!(&err, ConfigError::Yaml(_)));
3095
3096        let message = err.to_string();
3097        assert!(message.contains("unknown variant `explode`"));
3098        assert!(message.contains("warn"));
3099        assert!(message.contains("block"));
3100        assert!(message.contains("suspend"));
3101    }
3102
3103    #[test]
3104    fn test_hooks_validate_rejects_missing_name() {
3105        let yaml = r#"
3106hooks:
3107  enabled: true
3108  events:
3109    pre.loop.start:
3110      - command: ["./scripts/hooks/no-name.sh"]
3111        on_error: block
3112"#;
3113        let config = RalphConfig::parse_yaml(yaml).unwrap();
3114
3115        let result = config.validate();
3116        assert!(result.is_err());
3117
3118        let err = result.unwrap_err();
3119        assert!(matches!(
3120            &err,
3121            ConfigError::HookValidation { field, .. }
3122            if field == "hooks.events.pre.loop.start[0].name"
3123        ));
3124    }
3125
3126    #[test]
3127    fn test_hooks_validate_rejects_missing_command() {
3128        let yaml = r"
3129hooks:
3130  enabled: true
3131  events:
3132    pre.loop.start:
3133      - name: missing-command
3134        on_error: block
3135";
3136        let config = RalphConfig::parse_yaml(yaml).unwrap();
3137
3138        let result = config.validate();
3139        assert!(result.is_err());
3140
3141        let err = result.unwrap_err();
3142        assert!(matches!(
3143            &err,
3144            ConfigError::HookValidation { field, .. }
3145            if field == "hooks.events.pre.loop.start[0].command"
3146        ));
3147    }
3148
3149    #[test]
3150    fn test_hooks_validate_rejects_missing_on_error() {
3151        let yaml = r#"
3152hooks:
3153  enabled: true
3154  events:
3155    pre.loop.start:
3156      - name: missing-on-error
3157        command: ["./scripts/hooks/no-on-error.sh"]
3158"#;
3159        let config = RalphConfig::parse_yaml(yaml).unwrap();
3160
3161        let result = config.validate();
3162        assert!(result.is_err());
3163
3164        let err = result.unwrap_err();
3165        assert!(matches!(
3166            &err,
3167            ConfigError::HookValidation { field, .. }
3168            if field == "hooks.events.pre.loop.start[0].on_error"
3169        ));
3170    }
3171
3172    #[test]
3173    fn test_hooks_validate_rejects_zero_timeout_seconds() {
3174        let yaml = r"
3175hooks:
3176  enabled: true
3177  defaults:
3178    timeout_seconds: 0
3179";
3180        let config = RalphConfig::parse_yaml(yaml).unwrap();
3181
3182        let result = config.validate();
3183        assert!(result.is_err());
3184
3185        let err = result.unwrap_err();
3186        assert!(matches!(
3187            &err,
3188            ConfigError::HookValidation { field, .. }
3189            if field == "hooks.defaults.timeout_seconds"
3190        ));
3191    }
3192
3193    #[test]
3194    fn test_hooks_validate_rejects_zero_max_output_bytes() {
3195        let yaml = r"
3196hooks:
3197  enabled: true
3198  defaults:
3199    max_output_bytes: 0
3200";
3201        let config = RalphConfig::parse_yaml(yaml).unwrap();
3202
3203        let result = config.validate();
3204        assert!(result.is_err());
3205
3206        let err = result.unwrap_err();
3207        assert!(matches!(
3208            &err,
3209            ConfigError::HookValidation { field, .. }
3210            if field == "hooks.defaults.max_output_bytes"
3211        ));
3212    }
3213
3214    #[test]
3215    fn test_hooks_validate_rejects_parallel_non_v1_field() {
3216        let yaml = r"
3217hooks:
3218  enabled: true
3219  parallel: true
3220";
3221        let config = RalphConfig::parse_yaml(yaml).unwrap();
3222
3223        let result = config.validate();
3224        assert!(result.is_err());
3225
3226        let err = result.unwrap_err();
3227        assert!(matches!(
3228            &err,
3229            ConfigError::UnsupportedHookField { field, .. }
3230            if field == "hooks.parallel"
3231        ));
3232    }
3233
3234    #[test]
3235    fn test_hooks_validate_rejects_global_scope_non_v1_field() {
3236        let yaml = r#"
3237hooks:
3238  enabled: true
3239  events:
3240    pre.loop.start:
3241      - name: global-scope
3242        command: ["./scripts/hooks/global.sh"]
3243        on_error: warn
3244        scope: global
3245"#;
3246        let config = RalphConfig::parse_yaml(yaml).unwrap();
3247
3248        let result = config.validate();
3249        assert!(result.is_err());
3250
3251        let err = result.unwrap_err();
3252        assert!(matches!(
3253            &err,
3254            ConfigError::UnsupportedHookField { field, .. }
3255            if field == "hooks.events.pre.loop.start[0].scope"
3256        ));
3257    }
3258
3259    // ─────────────────────────────────────────────────────────────────────────
3260    // ROBOT CONFIG TESTS
3261    // ─────────────────────────────────────────────────────────────────────────
3262
3263    #[test]
3264    fn test_robot_config_defaults_disabled() {
3265        let config = RalphConfig::default();
3266        assert!(!config.robot.enabled);
3267        assert!(config.robot.timeout_seconds.is_none());
3268        assert!(config.robot.telegram.is_none());
3269    }
3270
3271    #[test]
3272    fn test_robot_config_absent_parses_as_default() {
3273        // Existing configs without RObot: section should still parse
3274        let yaml = r"
3275agent: claude
3276";
3277        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3278        assert!(!config.robot.enabled);
3279        assert!(config.robot.timeout_seconds.is_none());
3280    }
3281
3282    #[test]
3283    fn test_robot_config_valid_full() {
3284        let yaml = r#"
3285RObot:
3286  enabled: true
3287  timeout_seconds: 300
3288  telegram:
3289    bot_token: "123456:ABC-DEF"
3290"#;
3291        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3292        assert!(config.robot.enabled);
3293        assert_eq!(config.robot.timeout_seconds, Some(300));
3294        let telegram = config.robot.telegram.as_ref().unwrap();
3295        assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
3296
3297        // Validation should pass
3298        assert!(config.validate().is_ok());
3299    }
3300
3301    #[test]
3302    fn test_robot_config_disabled_skips_validation() {
3303        // Disabled RObot config should pass validation even with missing fields
3304        let yaml = r"
3305RObot:
3306  enabled: false
3307";
3308        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3309        assert!(!config.robot.enabled);
3310        assert!(config.validate().is_ok());
3311    }
3312
3313    #[test]
3314    fn test_robot_config_enabled_missing_timeout_fails() {
3315        let yaml = r#"
3316RObot:
3317  enabled: true
3318  telegram:
3319    bot_token: "123456:ABC-DEF"
3320"#;
3321        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3322        let result = config.validate();
3323        assert!(result.is_err());
3324        let err = result.unwrap_err();
3325        assert!(
3326            matches!(&err, ConfigError::RobotMissingField { field, .. }
3327                if field == "RObot.timeout_seconds"),
3328            "Expected RobotMissingField for timeout_seconds, got: {:?}",
3329            err
3330        );
3331    }
3332
3333    #[test]
3334    fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
3335        // Both timeout and token are missing, but timeout is checked first
3336        let robot = RobotConfig {
3337            enabled: true,
3338            timeout_seconds: None,
3339            checkin_interval_seconds: None,
3340            telegram: None,
3341        };
3342        let result = robot.validate();
3343        assert!(result.is_err());
3344        let err = result.unwrap_err();
3345        assert!(
3346            matches!(&err, ConfigError::RobotMissingField { field, .. }
3347                if field == "RObot.timeout_seconds"),
3348            "Expected timeout validation failure first, got: {:?}",
3349            err
3350        );
3351    }
3352
3353    #[test]
3354    fn test_robot_config_resolve_bot_token_from_config() {
3355        // Config has a token — resolve_bot_token returns it
3356        // (env var behavior is tested separately via integration tests since
3357        // forbid(unsafe_code) prevents env var manipulation in unit tests)
3358        let config = RobotConfig {
3359            enabled: true,
3360            timeout_seconds: Some(300),
3361            checkin_interval_seconds: None,
3362            telegram: Some(TelegramBotConfig {
3363                bot_token: Some("config-token".to_string()),
3364                api_url: None,
3365            }),
3366        };
3367
3368        // When RALPH_TELEGRAM_BOT_TOKEN is not set, config token is returned
3369        // (Can't set/unset env vars in tests due to forbid(unsafe_code))
3370        let resolved = config.resolve_bot_token();
3371        // The result depends on whether RALPH_TELEGRAM_BOT_TOKEN is set in the
3372        // test environment. We can at least assert it's Some.
3373        assert!(resolved.is_some());
3374    }
3375
3376    #[test]
3377    fn test_robot_config_resolve_bot_token_none_without_config() {
3378        // No config token and no telegram section
3379        let config = RobotConfig {
3380            enabled: true,
3381            timeout_seconds: Some(300),
3382            checkin_interval_seconds: None,
3383            telegram: None,
3384        };
3385
3386        // Without env var AND without config token, resolve returns None
3387        // (unless RALPH_TELEGRAM_BOT_TOKEN happens to be set in test env)
3388        let resolved = config.resolve_bot_token();
3389        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
3390            assert!(resolved.is_none());
3391        }
3392    }
3393
3394    #[test]
3395    fn test_robot_config_validate_with_config_token() {
3396        // Validation passes when bot_token is in config
3397        let robot = RobotConfig {
3398            enabled: true,
3399            timeout_seconds: Some(300),
3400            checkin_interval_seconds: None,
3401            telegram: Some(TelegramBotConfig {
3402                bot_token: Some("test-token".to_string()),
3403                api_url: None,
3404            }),
3405        };
3406        assert!(robot.validate().is_ok());
3407    }
3408
3409    #[test]
3410    fn test_robot_config_validate_missing_telegram_section() {
3411        // No telegram section at all and no env var → fails
3412        // (Skip if env var happens to be set)
3413        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3414            return;
3415        }
3416
3417        let robot = RobotConfig {
3418            enabled: true,
3419            timeout_seconds: Some(300),
3420            checkin_interval_seconds: None,
3421            telegram: None,
3422        };
3423        let result = robot.validate();
3424        assert!(result.is_err());
3425        let err = result.unwrap_err();
3426        assert!(
3427            matches!(&err, ConfigError::RobotMissingField { field, .. }
3428                if field == "RObot.telegram.bot_token"),
3429            "Expected bot_token validation failure, got: {:?}",
3430            err
3431        );
3432    }
3433
3434    #[test]
3435    fn test_robot_config_validate_empty_bot_token() {
3436        // telegram section present but bot_token is None
3437        // (Skip if env var happens to be set)
3438        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3439            return;
3440        }
3441
3442        let robot = RobotConfig {
3443            enabled: true,
3444            timeout_seconds: Some(300),
3445            checkin_interval_seconds: None,
3446            telegram: Some(TelegramBotConfig {
3447                bot_token: None,
3448                api_url: None,
3449            }),
3450        };
3451        let result = robot.validate();
3452        assert!(result.is_err());
3453        let err = result.unwrap_err();
3454        assert!(
3455            matches!(&err, ConfigError::RobotMissingField { field, .. }
3456                if field == "RObot.telegram.bot_token"),
3457            "Expected bot_token validation failure, got: {:?}",
3458            err
3459        );
3460    }
3461
3462    #[test]
3463    fn test_extra_instructions_merged_during_normalize() {
3464        let yaml = r#"
3465_fragments:
3466  shared_protocol: &shared_protocol |
3467    ### Shared Protocol
3468    Follow this protocol.
3469
3470hats:
3471  builder:
3472    name: "Builder"
3473    triggers: ["build.start"]
3474    instructions: |
3475      ## BUILDER MODE
3476      Build things.
3477    extra_instructions:
3478      - *shared_protocol
3479"#;
3480        let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3481        let hat = config.hats.get("builder").unwrap();
3482
3483        // Before normalize: extra_instructions has content, instructions does not include it
3484        assert_eq!(hat.extra_instructions.len(), 1);
3485        assert!(!hat.instructions.contains("Shared Protocol"));
3486
3487        config.normalize();
3488
3489        let hat = config.hats.get("builder").unwrap();
3490        // After normalize: extra_instructions drained, instructions includes the fragment
3491        assert!(hat.extra_instructions.is_empty());
3492        assert!(hat.instructions.contains("## BUILDER MODE"));
3493        assert!(hat.instructions.contains("### Shared Protocol"));
3494        assert!(hat.instructions.contains("Follow this protocol."));
3495    }
3496
3497    #[test]
3498    fn test_extra_instructions_empty_by_default() {
3499        let yaml = r#"
3500hats:
3501  simple:
3502    name: "Simple"
3503    triggers: ["start"]
3504    instructions: "Do the thing."
3505"#;
3506        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3507        let hat = config.hats.get("simple").unwrap();
3508        assert!(hat.extra_instructions.is_empty());
3509    }
3510
3511    // ── Wave config tests (Step 2: HatConfig extensions) ──
3512
3513    #[test]
3514    fn test_wave_config_concurrency_and_aggregate_parse() {
3515        let yaml = r#"
3516hats:
3517  reviewer:
3518    name: "Reviewer"
3519    description: "Reviews files in parallel"
3520    triggers: ["review.file"]
3521    publishes: ["review.done"]
3522    instructions: "Review the file."
3523    concurrency: 3
3524  aggregator:
3525    name: "Aggregator"
3526    description: "Aggregates review results"
3527    triggers: ["review.done"]
3528    publishes: ["review.complete"]
3529    instructions: "Aggregate results."
3530    aggregate:
3531      mode: wait_for_all
3532      timeout: 600
3533"#;
3534        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3535
3536        let reviewer = config.hats.get("reviewer").unwrap();
3537        assert_eq!(reviewer.concurrency, 3);
3538        assert!(reviewer.aggregate.is_none());
3539
3540        let aggregator = config.hats.get("aggregator").unwrap();
3541        assert_eq!(aggregator.concurrency, 1); // default
3542        let agg = aggregator.aggregate.as_ref().unwrap();
3543        assert!(matches!(agg.mode, AggregateMode::WaitForAll));
3544        assert_eq!(agg.timeout, 600);
3545    }
3546
3547    #[test]
3548    fn test_wave_config_defaults_without_new_fields() {
3549        // Existing YAML without concurrency/aggregate should parse with defaults
3550        let yaml = r#"
3551hats:
3552  builder:
3553    name: "Builder"
3554    description: "Builds code"
3555    triggers: ["build.task"]
3556    publishes: ["build.done"]
3557    instructions: "Build stuff."
3558"#;
3559        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3560        let hat = config.hats.get("builder").unwrap();
3561        assert_eq!(hat.concurrency, 1);
3562        assert!(hat.aggregate.is_none());
3563    }
3564
3565    #[test]
3566    fn test_wave_config_concurrency_zero_rejected() {
3567        let yaml = r#"
3568hats:
3569  worker:
3570    name: "Worker"
3571    description: "Parallel worker"
3572    triggers: ["work.item"]
3573    publishes: ["work.done"]
3574    instructions: "Do work."
3575    concurrency: 0
3576"#;
3577        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3578        let result = config.validate();
3579
3580        assert!(result.is_err());
3581        let err = result.unwrap_err();
3582        assert!(
3583            matches!(&err, ConfigError::InvalidConcurrency { hat, .. } if hat == "worker"),
3584            "Expected InvalidConcurrency error, got: {:?}",
3585            err
3586        );
3587    }
3588
3589    #[test]
3590    fn test_wave_config_aggregate_on_concurrent_hat_rejected() {
3591        // A hat cannot be both concurrent (concurrency > 1) and an aggregator
3592        let yaml = r#"
3593hats:
3594  hybrid:
3595    name: "Hybrid"
3596    description: "Invalid: both concurrent and aggregator"
3597    triggers: ["work.item"]
3598    publishes: ["work.done"]
3599    instructions: "Invalid config."
3600    concurrency: 3
3601    aggregate:
3602      mode: wait_for_all
3603      timeout: 300
3604"#;
3605        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3606        let result = config.validate();
3607
3608        assert!(result.is_err());
3609        let err = result.unwrap_err();
3610        assert!(
3611            matches!(&err, ConfigError::AggregateOnConcurrentHat { hat, .. } if hat == "hybrid"),
3612            "Expected AggregateOnConcurrentHat error, got: {:?}",
3613            err
3614        );
3615    }
3616
3617    #[test]
3618    fn test_wave_config_aggregate_on_non_concurrent_hat_valid() {
3619        // Aggregate on a hat with concurrency=1 (default) is valid
3620        let yaml = r#"
3621hats:
3622  aggregator:
3623    name: "Aggregator"
3624    description: "Collects results"
3625    triggers: ["work.done"]
3626    publishes: ["work.complete"]
3627    instructions: "Aggregate."
3628    aggregate:
3629      mode: wait_for_all
3630      timeout: 300
3631"#;
3632        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3633        let result = config.validate();
3634
3635        assert!(
3636            result.is_ok(),
3637            "Aggregate on non-concurrent hat should be valid: {:?}",
3638            result.unwrap_err()
3639        );
3640    }
3641}