Skip to main content

ralph_core/
config.rs

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