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