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", "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    /// Skills configuration for the skill discovery and injection system.
126    #[serde(default)]
127    pub skills: SkillsConfig,
128
129    /// Feature flags for optional capabilities.
130    #[serde(default)]
131    pub features: FeaturesConfig,
132
133    /// RObot (Ralph-Orchestrator bot) configuration for Telegram-based interaction.
134    #[serde(default, rename = "RObot")]
135    pub robot: RobotConfig,
136}
137
138fn default_true() -> bool {
139    true
140}
141
142#[allow(clippy::derivable_impls)] // Cannot derive due to serde default functions
143impl Default for RalphConfig {
144    fn default() -> Self {
145        Self {
146            event_loop: EventLoopConfig::default(),
147            cli: CliConfig::default(),
148            core: CoreConfig::default(),
149            hats: HashMap::new(),
150            events: HashMap::new(),
151            // V1 compatibility fields
152            agent: None,
153            agent_priority: vec![],
154            prompt_file: None,
155            completion_promise: None,
156            max_iterations: None,
157            max_runtime: None,
158            max_cost: None,
159            // Feature flags
160            verbose: false,
161            archive_prompts: false,
162            enable_metrics: false,
163            // Dropped fields
164            max_tokens: None,
165            retry_delay: None,
166            adapters: AdaptersConfig::default(),
167            // Warning control
168            suppress_warnings: false,
169            // TUI
170            tui: TuiConfig::default(),
171            // Memories
172            memories: MemoriesConfig::default(),
173            // Tasks
174            tasks: TasksConfig::default(),
175            // Skills
176            skills: SkillsConfig::default(),
177            // Features
178            features: FeaturesConfig::default(),
179            // RObot (Ralph-Orchestrator bot)
180            robot: RobotConfig::default(),
181        }
182    }
183}
184
185/// V1 adapter settings per backend.
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187pub struct AdaptersConfig {
188    /// Claude adapter settings.
189    #[serde(default)]
190    pub claude: AdapterSettings,
191
192    /// Gemini adapter settings.
193    #[serde(default)]
194    pub gemini: AdapterSettings,
195
196    /// Kiro adapter settings.
197    #[serde(default)]
198    pub kiro: AdapterSettings,
199
200    /// Codex adapter settings.
201    #[serde(default)]
202    pub codex: AdapterSettings,
203
204    /// Amp adapter settings.
205    #[serde(default)]
206    pub amp: AdapterSettings,
207}
208
209/// Per-adapter settings.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct AdapterSettings {
212    /// CLI execution timeout in seconds.
213    #[serde(default = "default_timeout")]
214    pub timeout: u64,
215
216    /// Include in auto-detection.
217    #[serde(default = "default_true")]
218    pub enabled: bool,
219
220    /// Tool permissions (DROPPED: CLI tool manages its own permissions).
221    #[serde(default)]
222    pub tool_permissions: Option<Vec<String>>,
223}
224
225fn default_timeout() -> u64 {
226    300 // 5 minutes
227}
228
229impl Default for AdapterSettings {
230    fn default() -> Self {
231        Self {
232            timeout: default_timeout(),
233            enabled: true,
234            tool_permissions: None,
235        }
236    }
237}
238
239impl RalphConfig {
240    /// Loads configuration from a YAML file.
241    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
242        let path_ref = path.as_ref();
243        debug!(path = %path_ref.display(), "Loading configuration from file");
244        let content = std::fs::read_to_string(path_ref)?;
245        Self::parse_yaml(&content)
246    }
247
248    /// Parses configuration from a YAML string.
249    pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
250        let config: Self = serde_yaml::from_str(content)?;
251        debug!(
252            backend = %config.cli.backend,
253            has_v1_fields = config.agent.is_some(),
254            custom_hats = config.hats.len(),
255            "Configuration loaded"
256        );
257        Ok(config)
258    }
259
260    /// Normalizes v1 flat fields into v2 nested structure.
261    ///
262    /// V1 flat fields take precedence over v2 nested fields when both are present.
263    /// This allows users to use either format or mix them.
264    pub fn normalize(&mut self) {
265        let mut normalized_count = 0;
266
267        // Map v1 `agent` to v2 `cli.backend`
268        if let Some(ref agent) = self.agent {
269            debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
270            self.cli.backend = agent.clone();
271            normalized_count += 1;
272        }
273
274        // Map v1 `prompt_file` to v2 `event_loop.prompt_file`
275        if let Some(ref pf) = self.prompt_file {
276            debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
277            self.event_loop.prompt_file = pf.clone();
278            normalized_count += 1;
279        }
280
281        // Map v1 `completion_promise` to v2 `event_loop.completion_promise`
282        if let Some(ref cp) = self.completion_promise {
283            debug!(
284                from = "completion_promise",
285                to = "event_loop.completion_promise",
286                "Normalizing v1 field"
287            );
288            self.event_loop.completion_promise = cp.clone();
289            normalized_count += 1;
290        }
291
292        // Map v1 `max_iterations` to v2 `event_loop.max_iterations`
293        if let Some(mi) = self.max_iterations {
294            debug!(
295                from = "max_iterations",
296                to = "event_loop.max_iterations",
297                value = mi,
298                "Normalizing v1 field"
299            );
300            self.event_loop.max_iterations = mi;
301            normalized_count += 1;
302        }
303
304        // Map v1 `max_runtime` to v2 `event_loop.max_runtime_seconds`
305        if let Some(mr) = self.max_runtime {
306            debug!(
307                from = "max_runtime",
308                to = "event_loop.max_runtime_seconds",
309                value = mr,
310                "Normalizing v1 field"
311            );
312            self.event_loop.max_runtime_seconds = mr;
313            normalized_count += 1;
314        }
315
316        // Map v1 `max_cost` to v2 `event_loop.max_cost_usd`
317        if self.max_cost.is_some() {
318            debug!(
319                from = "max_cost",
320                to = "event_loop.max_cost_usd",
321                "Normalizing v1 field"
322            );
323            self.event_loop.max_cost_usd = self.max_cost;
324            normalized_count += 1;
325        }
326
327        if normalized_count > 0 {
328            debug!(
329                fields_normalized = normalized_count,
330                "V1 to V2 config normalization complete"
331            );
332        }
333    }
334
335    /// Validates the configuration and returns warnings.
336    ///
337    /// This method checks for:
338    /// - Deferred features that are enabled (archive_prompts, enable_metrics)
339    /// - Dropped fields that are present (max_tokens, retry_delay, tool_permissions)
340    /// - Ambiguous trigger routing across custom hats
341    /// - Mutual exclusivity of prompt and prompt_file
342    ///
343    /// Returns a list of warnings that should be displayed to the user.
344    pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
345        let mut warnings = Vec::new();
346
347        // Skip all warnings if suppressed
348        if self.suppress_warnings {
349            return Ok(warnings);
350        }
351
352        // Check for mutual exclusivity of prompt and prompt_file in config
353        // Only error if both are explicitly set (not defaults)
354        if self.event_loop.prompt.is_some()
355            && !self.event_loop.prompt_file.is_empty()
356            && self.event_loop.prompt_file != default_prompt_file()
357        {
358            return Err(ConfigError::MutuallyExclusive {
359                field1: "event_loop.prompt".to_string(),
360                field2: "event_loop.prompt_file".to_string(),
361            });
362        }
363
364        // Check custom backend has a command
365        if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
366            return Err(ConfigError::CustomBackendRequiresCommand);
367        }
368
369        // Check for deferred features
370        if self.archive_prompts {
371            warnings.push(ConfigWarning::DeferredFeature {
372                field: "archive_prompts".to_string(),
373                message: "Feature not yet available in v2".to_string(),
374            });
375        }
376
377        if self.enable_metrics {
378            warnings.push(ConfigWarning::DeferredFeature {
379                field: "enable_metrics".to_string(),
380                message: "Feature not yet available in v2".to_string(),
381            });
382        }
383
384        // Check for dropped fields
385        if self.max_tokens.is_some() {
386            warnings.push(ConfigWarning::DroppedField {
387                field: "max_tokens".to_string(),
388                reason: "Token limits are controlled by the CLI tool".to_string(),
389            });
390        }
391
392        if self.retry_delay.is_some() {
393            warnings.push(ConfigWarning::DroppedField {
394                field: "retry_delay".to_string(),
395                reason: "Retry logic handled differently in v2".to_string(),
396            });
397        }
398
399        // Check adapter tool_permissions (dropped field)
400        if self.adapters.claude.tool_permissions.is_some()
401            || self.adapters.gemini.tool_permissions.is_some()
402            || self.adapters.codex.tool_permissions.is_some()
403            || self.adapters.amp.tool_permissions.is_some()
404        {
405            warnings.push(ConfigWarning::DroppedField {
406                field: "adapters.*.tool_permissions".to_string(),
407                reason: "CLI tool manages its own permissions".to_string(),
408            });
409        }
410
411        // Validate RObot config
412        self.robot.validate()?;
413
414        // Check for required description field on all hats
415        for (hat_id, hat_config) in &self.hats {
416            if hat_config
417                .description
418                .as_ref()
419                .is_none_or(|d| d.trim().is_empty())
420            {
421                return Err(ConfigError::MissingDescription {
422                    hat: hat_id.clone(),
423                });
424            }
425        }
426
427        // Check for reserved triggers: task.start and task.resume are reserved for Ralph
428        // Per design: Ralph coordinates first, then delegates to custom hats via events
429        const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
430        for (hat_id, hat_config) in &self.hats {
431            for trigger in &hat_config.triggers {
432                if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
433                    return Err(ConfigError::ReservedTrigger {
434                        trigger: trigger.clone(),
435                        hat: hat_id.clone(),
436                    });
437                }
438            }
439        }
440
441        // Check for ambiguous routing: each trigger topic must map to exactly one hat
442        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
443        if !self.hats.is_empty() {
444            let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
445            for (hat_id, hat_config) in &self.hats {
446                for trigger in &hat_config.triggers {
447                    if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
448                        return Err(ConfigError::AmbiguousRouting {
449                            trigger: trigger.clone(),
450                            hat1: (*existing_hat).to_string(),
451                            hat2: hat_id.clone(),
452                        });
453                    }
454                    trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
455                }
456            }
457        }
458
459        Ok(warnings)
460    }
461
462    /// Gets the effective backend name, resolving "auto" using the priority list.
463    pub fn effective_backend(&self) -> &str {
464        &self.cli.backend
465    }
466
467    /// Returns the agent priority list for auto-detection.
468    /// If empty, returns the default priority order.
469    pub fn get_agent_priority(&self) -> Vec<&str> {
470        if self.agent_priority.is_empty() {
471            vec!["claude", "kiro", "gemini", "codex", "amp"]
472        } else {
473            self.agent_priority.iter().map(String::as_str).collect()
474        }
475    }
476
477    /// Gets the adapter settings for a specific backend.
478    #[allow(clippy::match_same_arms)] // Explicit match arms for each backend improves readability
479    pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
480        match backend {
481            "claude" => &self.adapters.claude,
482            "gemini" => &self.adapters.gemini,
483            "kiro" => &self.adapters.kiro,
484            "codex" => &self.adapters.codex,
485            "amp" => &self.adapters.amp,
486            _ => &self.adapters.claude, // Default fallback
487        }
488    }
489}
490
491/// Configuration warnings emitted during validation.
492#[derive(Debug, Clone)]
493pub enum ConfigWarning {
494    /// Feature is enabled but not yet available in v2.
495    DeferredFeature { field: String, message: String },
496    /// Field is present but ignored in v2.
497    DroppedField { field: String, reason: String },
498    /// Field has an invalid value.
499    InvalidValue { field: String, message: String },
500}
501
502impl std::fmt::Display for ConfigWarning {
503    #[allow(clippy::match_same_arms)] // Different arms have different messages despite similar structure
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        match self {
506            ConfigWarning::DeferredFeature { field, message }
507            | ConfigWarning::InvalidValue { field, message } => {
508                write!(f, "Warning [{field}]: {message}")
509            }
510            ConfigWarning::DroppedField { field, reason } => {
511                write!(f, "Warning [{field}]: Field ignored - {reason}")
512            }
513        }
514    }
515}
516
517/// Event loop configuration.
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct EventLoopConfig {
520    /// Inline prompt text (mutually exclusive with prompt_file).
521    pub prompt: Option<String>,
522
523    /// Path to the prompt file.
524    #[serde(default = "default_prompt_file")]
525    pub prompt_file: String,
526
527    /// String that signals loop completion.
528    #[serde(default = "default_completion_promise")]
529    pub completion_promise: String,
530
531    /// Maximum number of iterations before timeout.
532    #[serde(default = "default_max_iterations")]
533    pub max_iterations: u32,
534
535    /// Maximum runtime in seconds.
536    #[serde(default = "default_max_runtime")]
537    pub max_runtime_seconds: u64,
538
539    /// Maximum cost in USD before stopping.
540    pub max_cost_usd: Option<f64>,
541
542    /// Stop after this many consecutive failures.
543    #[serde(default = "default_max_failures")]
544    pub max_consecutive_failures: u32,
545
546    /// Delay in seconds before starting the next iteration.
547    /// Skipped when the next iteration is triggered by a human event.
548    #[serde(default)]
549    pub cooldown_delay_seconds: u64,
550
551    /// Starting hat for multi-hat mode (deprecated, use starting_event instead).
552    pub starting_hat: Option<String>,
553
554    /// Event to publish after Ralph completes initial coordination.
555    ///
556    /// When custom hats are defined, Ralph handles `task.start` to do gap analysis
557    /// and planning, then publishes this event to delegate to the first hat.
558    ///
559    /// Example: `starting_event: "tdd.start"` for TDD workflow.
560    ///
561    /// If not specified and hats are defined, Ralph will determine the appropriate
562    /// event from the hat topology.
563    pub starting_event: Option<String>,
564}
565
566fn default_prompt_file() -> String {
567    "PROMPT.md".to_string()
568}
569
570fn default_completion_promise() -> String {
571    "LOOP_COMPLETE".to_string()
572}
573
574fn default_max_iterations() -> u32 {
575    100
576}
577
578fn default_max_runtime() -> u64 {
579    14400 // 4 hours
580}
581
582fn default_max_failures() -> u32 {
583    5
584}
585
586impl Default for EventLoopConfig {
587    fn default() -> Self {
588        Self {
589            prompt: None,
590            prompt_file: default_prompt_file(),
591            completion_promise: default_completion_promise(),
592            max_iterations: default_max_iterations(),
593            max_runtime_seconds: default_max_runtime(),
594            max_cost_usd: None,
595            max_consecutive_failures: default_max_failures(),
596            cooldown_delay_seconds: 0,
597            starting_hat: None,
598            starting_event: None,
599        }
600    }
601}
602
603/// Core paths and settings shared across all hats.
604///
605/// Per spec: "Core behaviors (always injected, can customize paths)"
606#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct CoreConfig {
608    /// Path to the scratchpad file (shared state between hats).
609    #[serde(default = "default_scratchpad")]
610    pub scratchpad: String,
611
612    /// Path to the specs directory (source of truth for requirements).
613    #[serde(default = "default_specs_dir")]
614    pub specs_dir: String,
615
616    /// Guardrails injected into every prompt (core behaviors).
617    ///
618    /// Per spec: These are always present regardless of hat.
619    #[serde(default = "default_guardrails")]
620    pub guardrails: Vec<String>,
621
622    /// Root directory for workspace-relative paths (.ralph/, specs, etc.).
623    ///
624    /// All relative paths (scratchpad, specs_dir, memories) are resolved relative
625    /// to this directory. Defaults to the current working directory.
626    ///
627    /// This is especially important for E2E tests that run in isolated workspaces.
628    #[serde(skip)]
629    pub workspace_root: std::path::PathBuf,
630}
631
632fn default_scratchpad() -> String {
633    ".ralph/agent/scratchpad.md".to_string()
634}
635
636fn default_specs_dir() -> String {
637    ".ralph/specs/".to_string()
638}
639
640fn default_guardrails() -> Vec<String> {
641    vec![
642        "Fresh context each iteration - scratchpad is memory".to_string(),
643        "Don't assume 'not implemented' - search first".to_string(),
644        "Backpressure is law - tests/typecheck/lint must pass".to_string(),
645        "Commit atomically - one logical change per commit, capture the why".to_string(),
646    ]
647}
648
649impl Default for CoreConfig {
650    fn default() -> Self {
651        Self {
652            scratchpad: default_scratchpad(),
653            specs_dir: default_specs_dir(),
654            guardrails: default_guardrails(),
655            workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
656                .map(std::path::PathBuf::from)
657                .unwrap_or_else(|_| {
658                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
659                }),
660        }
661    }
662}
663
664impl CoreConfig {
665    /// Sets the workspace root for resolving relative paths.
666    ///
667    /// This is used by E2E tests to point to their isolated test workspace.
668    pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
669        self.workspace_root = root.into();
670        self
671    }
672
673    /// Resolves a relative path against the workspace root.
674    ///
675    /// If the path is already absolute, it is returned as-is.
676    /// Otherwise, it is joined with the workspace root.
677    pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
678        let path = std::path::Path::new(relative);
679        if path.is_absolute() {
680            path.to_path_buf()
681        } else {
682            self.workspace_root.join(path)
683        }
684    }
685}
686
687/// CLI backend configuration.
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct CliConfig {
690    /// Backend to use: "claude", "kiro", "gemini", "codex", "amp", or "custom".
691    #[serde(default = "default_backend")]
692    pub backend: String,
693
694    /// Command override. Required for "custom" backend.
695    /// For named backends, overrides the default binary path.
696    pub command: Option<String>,
697
698    /// How to pass prompts: "arg" or "stdin".
699    #[serde(default = "default_prompt_mode")]
700    pub prompt_mode: String,
701
702    /// Execution mode when --interactive not specified.
703    /// Values: "autonomous" (default), "interactive"
704    #[serde(default = "default_mode")]
705    pub default_mode: String,
706
707    /// Idle timeout in seconds for interactive mode.
708    /// Process is terminated after this many seconds of inactivity (no output AND no user input).
709    /// Set to 0 to disable idle timeout.
710    #[serde(default = "default_idle_timeout")]
711    pub idle_timeout_secs: u32,
712
713    /// Custom arguments to pass to the CLI command (for backend: "custom").
714    /// These are inserted before the prompt argument.
715    #[serde(default)]
716    pub args: Vec<String>,
717
718    /// Custom prompt flag for arg mode (for backend: "custom").
719    /// If None, defaults to "-p" for arg mode.
720    #[serde(default)]
721    pub prompt_flag: Option<String>,
722}
723
724fn default_backend() -> String {
725    "claude".to_string()
726}
727
728fn default_prompt_mode() -> String {
729    "arg".to_string()
730}
731
732fn default_mode() -> String {
733    "autonomous".to_string()
734}
735
736fn default_idle_timeout() -> u32 {
737    30 // 30 seconds per spec
738}
739
740impl Default for CliConfig {
741    fn default() -> Self {
742        Self {
743            backend: default_backend(),
744            command: None,
745            prompt_mode: default_prompt_mode(),
746            default_mode: default_mode(),
747            idle_timeout_secs: default_idle_timeout(),
748            args: Vec::new(),
749            prompt_flag: None,
750        }
751    }
752}
753
754/// TUI configuration.
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct TuiConfig {
757    /// Prefix key combination (e.g., "ctrl-a", "ctrl-b").
758    #[serde(default = "default_prefix_key")]
759    pub prefix_key: String,
760}
761
762/// Memory injection mode.
763///
764/// Controls how memories are injected into agent context.
765#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
766#[serde(rename_all = "lowercase")]
767pub enum InjectMode {
768    /// Ralph automatically injects memories at the start of each iteration.
769    #[default]
770    Auto,
771    /// Agent must explicitly run `ralph memory search` to access memories.
772    Manual,
773    /// Memories feature is disabled.
774    None,
775}
776
777impl std::fmt::Display for InjectMode {
778    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
779        match self {
780            Self::Auto => write!(f, "auto"),
781            Self::Manual => write!(f, "manual"),
782            Self::None => write!(f, "none"),
783        }
784    }
785}
786
787/// Memories configuration.
788///
789/// Controls the persistent learning system that allows Ralph to accumulate
790/// wisdom across sessions. Memories are stored in `.ralph/agent/memories.md`.
791///
792/// When enabled, the memories skill is automatically injected to teach
793/// agents how to create and search memories (skill injection is implicit).
794///
795/// Example configuration:
796/// ```yaml
797/// memories:
798///   enabled: true
799///   inject: auto
800///   budget: 2000
801/// ```
802#[derive(Debug, Clone, Serialize, Deserialize)]
803pub struct MemoriesConfig {
804    /// Whether the memories feature is enabled.
805    ///
806    /// When true, memories are injected and the skill is taught to the agent.
807    #[serde(default)]
808    pub enabled: bool,
809
810    /// How memories are injected into agent context.
811    #[serde(default)]
812    pub inject: InjectMode,
813
814    /// Maximum tokens to inject (0 = unlimited).
815    ///
816    /// When set, memories are truncated to fit within this budget.
817    #[serde(default)]
818    pub budget: usize,
819
820    /// Filter configuration for memory injection.
821    #[serde(default)]
822    pub filter: MemoriesFilter,
823}
824
825impl Default for MemoriesConfig {
826    fn default() -> Self {
827        Self {
828            enabled: true, // Memories enabled by default
829            inject: InjectMode::Auto,
830            budget: 0,
831            filter: MemoriesFilter::default(),
832        }
833    }
834}
835
836/// Filter configuration for memory injection.
837///
838/// Controls which memories are included when priming context.
839#[derive(Debug, Clone, Default, Serialize, Deserialize)]
840pub struct MemoriesFilter {
841    /// Filter by memory types (empty = all types).
842    #[serde(default)]
843    pub types: Vec<String>,
844
845    /// Filter by tags (empty = all tags).
846    #[serde(default)]
847    pub tags: Vec<String>,
848
849    /// Only include memories from the last N days (0 = no time limit).
850    #[serde(default)]
851    pub recent: u32,
852}
853
854/// Tasks configuration.
855///
856/// Controls the runtime task tracking system that allows Ralph to manage
857/// work items across iterations. Tasks are stored in `.ralph/agent/tasks.jsonl`.
858///
859/// When enabled, tasks replace scratchpad for loop completion verification.
860///
861/// Example configuration:
862/// ```yaml
863/// tasks:
864///   enabled: true
865/// ```
866#[derive(Debug, Clone, Serialize, Deserialize)]
867pub struct TasksConfig {
868    /// Whether the tasks feature is enabled.
869    ///
870    /// When true, tasks are used for loop completion verification.
871    #[serde(default = "default_true")]
872    pub enabled: bool,
873}
874
875impl Default for TasksConfig {
876    fn default() -> Self {
877        Self {
878            enabled: true, // Tasks enabled by default
879        }
880    }
881}
882
883/// Skills configuration.
884///
885/// Controls the skill discovery and injection system that makes tool
886/// knowledge and domain expertise available to agents during loops.
887///
888/// Skills use a two-tier injection model: a compact skill index is always
889/// present in every prompt, and the agent loads full skill content on demand
890/// via `ralph tools skill load <name>`.
891///
892/// Example configuration:
893/// ```yaml
894/// skills:
895///   enabled: true
896///   dirs:
897///     - ".claude/skills"
898///   overrides:
899///     pdd:
900///       enabled: false
901///     memories:
902///       auto_inject: true
903///       hats: ["ralph"]
904/// ```
905#[derive(Debug, Clone, Serialize, Deserialize)]
906pub struct SkillsConfig {
907    /// Whether the skills system is enabled.
908    #[serde(default = "default_true")]
909    pub enabled: bool,
910
911    /// Directories to scan for skill files.
912    /// Relative paths resolved against workspace root.
913    #[serde(default)]
914    pub dirs: Vec<PathBuf>,
915
916    /// Per-skill overrides keyed by skill name.
917    #[serde(default)]
918    pub overrides: HashMap<String, SkillOverride>,
919}
920
921impl Default for SkillsConfig {
922    fn default() -> Self {
923        Self {
924            enabled: true, // Skills enabled by default
925            dirs: vec![],
926            overrides: HashMap::new(),
927        }
928    }
929}
930
931/// Per-skill configuration override.
932///
933/// Allows enabling/disabling individual skills and overriding their
934/// frontmatter fields (hats, backends, tags, auto_inject).
935#[derive(Debug, Clone, Default, Serialize, Deserialize)]
936pub struct SkillOverride {
937    /// Disable a discovered skill.
938    #[serde(default)]
939    pub enabled: Option<bool>,
940
941    /// Restrict skill to specific hats.
942    #[serde(default)]
943    pub hats: Vec<String>,
944
945    /// Restrict skill to specific backends.
946    #[serde(default)]
947    pub backends: Vec<String>,
948
949    /// Tags for categorization.
950    #[serde(default)]
951    pub tags: Vec<String>,
952
953    /// Inject full content into prompt (not just index entry).
954    #[serde(default)]
955    pub auto_inject: Option<bool>,
956}
957
958/// Chaos mode configuration.
959///
960/// Chaos mode activates after LOOP_COMPLETE to grow the original objective
961/// into related improvements and learnings.
962///
963/// Example configuration:
964/// ```yaml
965/// features:
966///   chaos_mode:
967///     enabled: false              # Disabled by default (opt-in via --chaos)
968///     max_iterations: 5           # Max chaos iterations (default: 5)
969///     cooldown_seconds: 30        # Cooldown between chaos iterations (default: 30)
970///     completion_promise: "CHAOS_COMPLETE"  # Exit token
971///     research_focus:             # Configurable focus areas
972///       - domain_best_practices   # Web search for domain patterns
973///       - codebase_patterns       # Internal code analysis
974///       - self_improvement        # Meta-prompt and event loop study
975///     outputs:                    # What chaos mode can create
976///       - memories                # Always enabled
977/// ```
978#[derive(Debug, Clone, Serialize, Deserialize)]
979pub struct ChaosModeConfig {
980    /// Whether chaos mode is enabled.
981    #[serde(default)]
982    pub enabled: bool,
983
984    /// Maximum iterations in chaos mode.
985    #[serde(default = "default_chaos_max_iterations")]
986    pub max_iterations: u32,
987
988    /// Cooldown period between chaos iterations (seconds).
989    #[serde(default = "default_chaos_cooldown")]
990    pub cooldown_seconds: u64,
991
992    /// Completion promise for chaos mode exit.
993    #[serde(default = "default_chaos_completion")]
994    pub completion_promise: String,
995
996    /// Configurable research focus areas.
997    #[serde(default = "default_research_focus")]
998    pub research_focus: Vec<ResearchFocus>,
999
1000    /// What outputs chaos mode can create.
1001    #[serde(default = "default_chaos_outputs")]
1002    pub outputs: Vec<ChaosOutput>,
1003}
1004
1005fn default_chaos_max_iterations() -> u32 {
1006    5
1007}
1008
1009fn default_chaos_cooldown() -> u64 {
1010    30 // 30 seconds between iterations
1011}
1012
1013fn default_chaos_completion() -> String {
1014    "CHAOS_COMPLETE".to_string()
1015}
1016
1017fn default_research_focus() -> Vec<ResearchFocus> {
1018    vec![
1019        ResearchFocus::DomainBestPractices,
1020        ResearchFocus::CodebasePatterns,
1021        ResearchFocus::SelfImprovement,
1022    ]
1023}
1024
1025fn default_chaos_outputs() -> Vec<ChaosOutput> {
1026    vec![ChaosOutput::Memories] // Only memories by default
1027}
1028
1029impl Default for ChaosModeConfig {
1030    fn default() -> Self {
1031        Self {
1032            enabled: false,
1033            max_iterations: default_chaos_max_iterations(),
1034            cooldown_seconds: default_chaos_cooldown(),
1035            completion_promise: default_chaos_completion(),
1036            research_focus: default_research_focus(),
1037            outputs: default_chaos_outputs(),
1038        }
1039    }
1040}
1041
1042/// Research focus area for chaos mode.
1043#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1044#[serde(rename_all = "snake_case")]
1045pub enum ResearchFocus {
1046    /// Web search for domain patterns and best practices.
1047    DomainBestPractices,
1048    /// Internal codebase analysis for patterns and antipatterns.
1049    CodebasePatterns,
1050    /// Meta-prompt and event loop study for self-improvement.
1051    SelfImprovement,
1052}
1053
1054/// Output type that chaos mode can create.
1055#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1056#[serde(rename_all = "snake_case")]
1057pub enum ChaosOutput {
1058    /// Persistent learning memories.
1059    Memories,
1060    /// Create tasks for concrete work.
1061    Tasks,
1062    /// Create specs for larger improvements.
1063    Specs,
1064}
1065
1066/// Feature flags for optional Ralph capabilities.
1067///
1068/// Example configuration:
1069/// ```yaml
1070/// features:
1071///   parallel: true  # Enable parallel loops via git worktrees
1072///   auto_merge: false  # Auto-merge worktree branches on completion
1073///   loop_naming:
1074///     format: human-readable  # or "timestamp" for legacy format
1075///     max_length: 50
1076///   chaos_mode:
1077///     enabled: false
1078///     max_iterations: 5
1079/// ```
1080#[derive(Debug, Clone, Serialize, Deserialize)]
1081pub struct FeaturesConfig {
1082    /// Whether parallel loops are enabled.
1083    ///
1084    /// When true (default), if another loop holds the lock, Ralph spawns
1085    /// a parallel loop in a git worktree. When false, Ralph errors instead.
1086    #[serde(default = "default_true")]
1087    pub parallel: bool,
1088
1089    /// Whether to automatically merge worktree branches on completion.
1090    ///
1091    /// When false (default), completed worktree loops queue for manual merge.
1092    /// When true, Ralph automatically merges the worktree branch into the
1093    /// main branch after a parallel loop completes.
1094    #[serde(default)]
1095    pub auto_merge: bool,
1096
1097    /// Loop naming configuration for worktree branches.
1098    ///
1099    /// Controls how loop IDs are generated for parallel loops.
1100    /// Default uses human-readable format: `fix-header-swift-peacock`
1101    /// Legacy timestamp format: `ralph-YYYYMMDD-HHMMSS-XXXX`
1102    #[serde(default)]
1103    pub loop_naming: crate::loop_name::LoopNamingConfig,
1104
1105    /// Chaos mode configuration.
1106    ///
1107    /// Chaos mode activates after LOOP_COMPLETE to explore related
1108    /// improvements and learnings based on the original objective.
1109    #[serde(default)]
1110    pub chaos_mode: ChaosModeConfig,
1111}
1112
1113impl Default for FeaturesConfig {
1114    fn default() -> Self {
1115        Self {
1116            parallel: true,    // Parallel loops enabled by default
1117            auto_merge: false, // Auto-merge disabled by default for safety
1118            loop_naming: crate::loop_name::LoopNamingConfig::default(),
1119            chaos_mode: ChaosModeConfig::default(),
1120        }
1121    }
1122}
1123
1124fn default_prefix_key() -> String {
1125    "ctrl-a".to_string()
1126}
1127
1128impl Default for TuiConfig {
1129    fn default() -> Self {
1130        Self {
1131            prefix_key: default_prefix_key(),
1132        }
1133    }
1134}
1135
1136impl TuiConfig {
1137    /// Parses the prefix_key string into KeyCode and KeyModifiers.
1138    /// Returns an error if the format is invalid.
1139    pub fn parse_prefix(
1140        &self,
1141    ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1142        use crossterm::event::{KeyCode, KeyModifiers};
1143
1144        let parts: Vec<&str> = self.prefix_key.split('-').collect();
1145        if parts.len() != 2 {
1146            return Err(format!(
1147                "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1148                self.prefix_key
1149            ));
1150        }
1151
1152        let modifier = match parts[0].to_lowercase().as_str() {
1153            "ctrl" => KeyModifiers::CONTROL,
1154            _ => {
1155                return Err(format!(
1156                    "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1157                    parts[0]
1158                ));
1159            }
1160        };
1161
1162        let key_str = parts[1];
1163        if key_str.len() != 1 {
1164            return Err(format!(
1165                "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1166                key_str
1167            ));
1168        }
1169
1170        let key_char = key_str.chars().next().unwrap();
1171        let key_code = KeyCode::Char(key_char);
1172
1173        Ok((key_code, modifier))
1174    }
1175}
1176
1177/// Metadata for an event topic.
1178///
1179/// Defines what an event means, enabling auto-derived instructions for hats.
1180/// When a hat triggers on or publishes an event, this metadata is used to
1181/// generate appropriate behavior instructions.
1182///
1183/// Example:
1184/// ```yaml
1185/// events:
1186///   deploy.start:
1187///     description: "Deployment has been requested"
1188///     on_trigger: "Prepare artifacts, validate config, check dependencies"
1189///     on_publish: "Signal that deployment should begin"
1190/// ```
1191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1192pub struct EventMetadata {
1193    /// Brief description of what this event represents.
1194    #[serde(default)]
1195    pub description: String,
1196
1197    /// Instructions for a hat that triggers on (receives) this event.
1198    /// Describes what the hat should do when it receives this event.
1199    #[serde(default)]
1200    pub on_trigger: String,
1201
1202    /// Instructions for a hat that publishes (emits) this event.
1203    /// Describes when/how the hat should emit this event.
1204    #[serde(default)]
1205    pub on_publish: String,
1206}
1207
1208/// Backend configuration for a hat.
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1210#[serde(untagged)]
1211pub enum HatBackend {
1212    // Order matters for serde untagged - most specific first
1213    /// Kiro agent with custom agent name and optional args.
1214    KiroAgent {
1215        #[serde(rename = "type")]
1216        backend_type: String,
1217        agent: String,
1218        #[serde(default)]
1219        args: Vec<String>,
1220    },
1221    /// Named backend with args (has `type` but no `agent`).
1222    NamedWithArgs {
1223        #[serde(rename = "type")]
1224        backend_type: String,
1225        #[serde(default)]
1226        args: Vec<String>,
1227    },
1228    /// Simple named backend (string form).
1229    Named(String),
1230    /// Custom backend with command and args.
1231    Custom {
1232        command: String,
1233        #[serde(default)]
1234        args: Vec<String>,
1235    },
1236}
1237
1238impl HatBackend {
1239    /// Converts to CLI backend string for execution.
1240    pub fn to_cli_backend(&self) -> String {
1241        match self {
1242            HatBackend::Named(name) => name.clone(),
1243            HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1244            HatBackend::KiroAgent { .. } => "kiro".to_string(),
1245            HatBackend::Custom { .. } => "custom".to_string(),
1246        }
1247    }
1248}
1249
1250/// Configuration for a single hat.
1251#[derive(Debug, Clone, Serialize, Deserialize)]
1252pub struct HatConfig {
1253    /// Human-readable name for the hat.
1254    pub name: String,
1255
1256    /// Short description of the hat's purpose (required).
1257    /// Used in the HATS table to help Ralph understand when to delegate to this hat.
1258    pub description: Option<String>,
1259
1260    /// Events that trigger this hat to be worn.
1261    /// Per spec: "Hats define triggers — which events cause Ralph to wear this hat."
1262    #[serde(default)]
1263    pub triggers: Vec<String>,
1264
1265    /// Topics this hat publishes.
1266    #[serde(default)]
1267    pub publishes: Vec<String>,
1268
1269    /// Instructions prepended to prompts.
1270    #[serde(default)]
1271    pub instructions: String,
1272
1273    /// Backend to use for this hat (inherits from cli.backend if not specified).
1274    #[serde(default)]
1275    pub backend: Option<HatBackend>,
1276
1277    /// Default event to publish if hat forgets to write an event.
1278    #[serde(default)]
1279    pub default_publishes: Option<String>,
1280
1281    /// Maximum number of times this hat may be activated in a single loop run.
1282    ///
1283    /// When the limit is exceeded, the orchestrator publishes `<hat_id>.exhausted`
1284    /// instead of activating the hat again.
1285    pub max_activations: Option<u32>,
1286}
1287
1288impl HatConfig {
1289    /// Converts trigger strings to Topic objects.
1290    pub fn trigger_topics(&self) -> Vec<Topic> {
1291        self.triggers.iter().map(|s| Topic::new(s)).collect()
1292    }
1293
1294    /// Converts publish strings to Topic objects.
1295    pub fn publish_topics(&self) -> Vec<Topic> {
1296        self.publishes.iter().map(|s| Topic::new(s)).collect()
1297    }
1298}
1299
1300/// RObot (Ralph-Orchestrator bot) configuration.
1301///
1302/// Enables bidirectional communication between AI agents and humans
1303/// during orchestration loops. When enabled, agents can emit `interact.human`
1304/// events to request clarification (blocking the loop), and humans can
1305/// send proactive guidance via Telegram.
1306///
1307/// Example configuration:
1308/// ```yaml
1309/// RObot:
1310///   enabled: true
1311///   timeout_seconds: 300
1312///   checkin_interval_seconds: 120  # Optional: send status every 2 min
1313///   telegram:
1314///     bot_token: "..."  # Or set RALPH_TELEGRAM_BOT_TOKEN env var
1315/// ```
1316#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1317pub struct RobotConfig {
1318    /// Whether the RObot is enabled.
1319    #[serde(default)]
1320    pub enabled: bool,
1321
1322    /// Timeout in seconds for waiting on human responses.
1323    /// Required when enabled (no default — must be explicit).
1324    pub timeout_seconds: Option<u64>,
1325
1326    /// Interval in seconds between periodic check-in messages sent via Telegram.
1327    /// When set, Ralph sends a status message every N seconds so the human
1328    /// knows it's still working. If `None`, no check-ins are sent.
1329    pub checkin_interval_seconds: Option<u64>,
1330
1331    /// Telegram bot configuration.
1332    #[serde(default)]
1333    pub telegram: Option<TelegramBotConfig>,
1334}
1335
1336impl RobotConfig {
1337    /// Validates the RObot config. Returns an error if enabled but misconfigured.
1338    pub fn validate(&self) -> Result<(), ConfigError> {
1339        if !self.enabled {
1340            return Ok(());
1341        }
1342
1343        if self.timeout_seconds.is_none() {
1344            return Err(ConfigError::RobotMissingField {
1345                field: "RObot.timeout_seconds".to_string(),
1346                hint: "timeout_seconds is required when RObot is enabled".to_string(),
1347            });
1348        }
1349
1350        // Bot token must be available from config, keychain, or env var
1351        if self.resolve_bot_token().is_none() {
1352            return Err(ConfigError::RobotMissingField {
1353                field: "RObot.telegram.bot_token".to_string(),
1354                hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
1355                    .to_string(),
1356            });
1357        }
1358
1359        Ok(())
1360    }
1361
1362    /// Resolves the bot token from multiple sources.
1363    ///
1364    /// Resolution order (highest to lowest priority):
1365    /// 1. `RALPH_TELEGRAM_BOT_TOKEN` environment variable
1366    /// 2. OS keychain (service: "ralph", user: "telegram-bot-token")
1367    /// 3. `RObot.telegram.bot_token` in config file (legacy fallback)
1368    pub fn resolve_bot_token(&self) -> Option<String> {
1369        // 1. Env var (highest priority)
1370        std::env::var("RALPH_TELEGRAM_BOT_TOKEN")
1371            .ok()
1372            // 2. OS keychain
1373            .or_else(|| {
1374                keyring::Entry::new("ralph", "telegram-bot-token")
1375                    .ok()
1376                    .and_then(|e| e.get_password().ok())
1377            })
1378            // 3. Config file (legacy fallback)
1379            .or_else(|| self.telegram.as_ref()?.bot_token.clone())
1380    }
1381}
1382
1383/// Telegram bot configuration.
1384#[derive(Debug, Clone, Serialize, Deserialize)]
1385pub struct TelegramBotConfig {
1386    /// Bot token. Optional if `RALPH_TELEGRAM_BOT_TOKEN` env var is set.
1387    pub bot_token: Option<String>,
1388}
1389
1390/// Configuration errors.
1391#[derive(Debug, thiserror::Error)]
1392pub enum ConfigError {
1393    #[error("IO error: {0}")]
1394    Io(#[from] std::io::Error),
1395
1396    #[error("YAML parse error: {0}")]
1397    Yaml(#[from] serde_yaml::Error),
1398
1399    #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
1400    AmbiguousRouting {
1401        trigger: String,
1402        hat1: String,
1403        hat2: String,
1404    },
1405
1406    #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
1407    MutuallyExclusive { field1: String, field2: String },
1408
1409    #[error("Custom backend requires a command - set 'cli.command' in config")]
1410    CustomBackendRequiresCommand,
1411
1412    #[error(
1413        "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."
1414    )]
1415    ReservedTrigger { trigger: String, hat: String },
1416
1417    #[error(
1418        "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose"
1419    )]
1420    MissingDescription { hat: String },
1421
1422    #[error("RObot config error: {field} - {hint}")]
1423    RobotMissingField { field: String, hint: String },
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428    use super::*;
1429
1430    #[test]
1431    fn test_default_config() {
1432        let config = RalphConfig::default();
1433        // Default config has no custom hats (uses default planner+builder)
1434        assert!(config.hats.is_empty());
1435        assert_eq!(config.event_loop.max_iterations, 100);
1436        assert!(!config.verbose);
1437    }
1438
1439    #[test]
1440    fn test_parse_yaml_with_custom_hats() {
1441        let yaml = r#"
1442event_loop:
1443  prompt_file: "TASK.md"
1444  completion_promise: "DONE"
1445  max_iterations: 50
1446cli:
1447  backend: "claude"
1448hats:
1449  implementer:
1450    name: "Implementer"
1451    triggers: ["task.*", "review.done"]
1452    publishes: ["impl.done"]
1453    instructions: "You are the implementation agent."
1454"#;
1455        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1456        // Custom hats are defined
1457        assert_eq!(config.hats.len(), 1);
1458        assert_eq!(config.event_loop.prompt_file, "TASK.md");
1459
1460        let hat = config.hats.get("implementer").unwrap();
1461        assert_eq!(hat.triggers.len(), 2);
1462    }
1463
1464    #[test]
1465    fn test_parse_yaml_v1_format() {
1466        // V1 flat format - identical to Python v1.x config
1467        let yaml = r#"
1468agent: gemini
1469prompt_file: "TASK.md"
1470completion_promise: "RALPH_DONE"
1471max_iterations: 75
1472max_runtime: 7200
1473max_cost: 10.0
1474verbose: true
1475"#;
1476        let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1477
1478        // Before normalization, v2 fields have defaults
1479        assert_eq!(config.cli.backend, "claude"); // default
1480        assert_eq!(config.event_loop.max_iterations, 100); // default
1481
1482        // Normalize v1 -> v2
1483        config.normalize();
1484
1485        // After normalization, v2 fields have v1 values
1486        assert_eq!(config.cli.backend, "gemini");
1487        assert_eq!(config.event_loop.prompt_file, "TASK.md");
1488        assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
1489        assert_eq!(config.event_loop.max_iterations, 75);
1490        assert_eq!(config.event_loop.max_runtime_seconds, 7200);
1491        assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
1492        assert!(config.verbose);
1493    }
1494
1495    #[test]
1496    fn test_agent_priority() {
1497        let yaml = r"
1498agent: auto
1499agent_priority: [gemini, claude, codex]
1500";
1501        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1502        let priority = config.get_agent_priority();
1503        assert_eq!(priority, vec!["gemini", "claude", "codex"]);
1504    }
1505
1506    #[test]
1507    fn test_default_agent_priority() {
1508        let config = RalphConfig::default();
1509        let priority = config.get_agent_priority();
1510        assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
1511    }
1512
1513    #[test]
1514    fn test_validate_deferred_features() {
1515        let yaml = r"
1516archive_prompts: true
1517enable_metrics: true
1518";
1519        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1520        let warnings = config.validate().unwrap();
1521
1522        assert_eq!(warnings.len(), 2);
1523        assert!(warnings
1524            .iter()
1525            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
1526        assert!(warnings
1527            .iter()
1528            .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
1529    }
1530
1531    #[test]
1532    fn test_validate_dropped_fields() {
1533        let yaml = r#"
1534max_tokens: 4096
1535retry_delay: 5
1536adapters:
1537  claude:
1538    tool_permissions: ["read", "write"]
1539"#;
1540        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1541        let warnings = config.validate().unwrap();
1542
1543        assert_eq!(warnings.len(), 3);
1544        assert!(warnings.iter().any(
1545            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
1546        ));
1547        assert!(warnings.iter().any(
1548            |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
1549        ));
1550        assert!(warnings
1551            .iter()
1552            .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
1553    }
1554
1555    #[test]
1556    fn test_suppress_warnings() {
1557        let yaml = r"
1558_suppress_warnings: true
1559archive_prompts: true
1560max_tokens: 4096
1561";
1562        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1563        let warnings = config.validate().unwrap();
1564
1565        // All warnings should be suppressed
1566        assert!(warnings.is_empty());
1567    }
1568
1569    #[test]
1570    fn test_adapter_settings() {
1571        let yaml = r"
1572adapters:
1573  claude:
1574    timeout: 600
1575    enabled: true
1576  gemini:
1577    timeout: 300
1578    enabled: false
1579";
1580        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1581
1582        let claude = config.adapter_settings("claude");
1583        assert_eq!(claude.timeout, 600);
1584        assert!(claude.enabled);
1585
1586        let gemini = config.adapter_settings("gemini");
1587        assert_eq!(gemini.timeout, 300);
1588        assert!(!gemini.enabled);
1589    }
1590
1591    #[test]
1592    fn test_unknown_fields_ignored() {
1593        // Unknown fields should be silently ignored (forward compatibility)
1594        let yaml = r#"
1595agent: claude
1596unknown_field: "some value"
1597future_feature: true
1598"#;
1599        let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1600        // Should parse successfully, ignoring unknown fields
1601        assert!(result.is_ok());
1602    }
1603
1604    #[test]
1605    fn test_ambiguous_routing_rejected() {
1606        // Per spec: "Every trigger maps to exactly one hat | No ambiguous routing"
1607        // Note: using semantic events since task.start is reserved
1608        let yaml = r#"
1609hats:
1610  planner:
1611    name: "Planner"
1612    description: "Plans tasks"
1613    triggers: ["planning.start", "build.done"]
1614  builder:
1615    name: "Builder"
1616    description: "Builds code"
1617    triggers: ["build.task", "build.done"]
1618"#;
1619        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1620        let result = config.validate();
1621
1622        assert!(result.is_err());
1623        let err = result.unwrap_err();
1624        assert!(
1625            matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1626            "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1627            err
1628        );
1629    }
1630
1631    #[test]
1632    fn test_unique_triggers_accepted() {
1633        // Valid config: each trigger maps to exactly one hat
1634        // Note: task.start is reserved for Ralph, so use semantic events
1635        let yaml = r#"
1636hats:
1637  planner:
1638    name: "Planner"
1639    description: "Plans tasks"
1640    triggers: ["planning.start", "build.done", "build.blocked"]
1641  builder:
1642    name: "Builder"
1643    description: "Builds code"
1644    triggers: ["build.task"]
1645"#;
1646        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1647        let result = config.validate();
1648
1649        assert!(
1650            result.is_ok(),
1651            "Expected valid config, got: {:?}",
1652            result.unwrap_err()
1653        );
1654    }
1655
1656    #[test]
1657    fn test_reserved_trigger_task_start_rejected() {
1658        // Per design: task.start is reserved for Ralph (the coordinator)
1659        let yaml = r#"
1660hats:
1661  my_hat:
1662    name: "My Hat"
1663    description: "Test hat"
1664    triggers: ["task.start"]
1665"#;
1666        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1667        let result = config.validate();
1668
1669        assert!(result.is_err());
1670        let err = result.unwrap_err();
1671        assert!(
1672            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1673                if trigger == "task.start" && hat == "my_hat"),
1674            "Expected ReservedTrigger error for 'task.start', got: {:?}",
1675            err
1676        );
1677    }
1678
1679    #[test]
1680    fn test_reserved_trigger_task_resume_rejected() {
1681        // Per design: task.resume is reserved for Ralph (the coordinator)
1682        let yaml = r#"
1683hats:
1684  my_hat:
1685    name: "My Hat"
1686    description: "Test hat"
1687    triggers: ["task.resume", "other.event"]
1688"#;
1689        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1690        let result = config.validate();
1691
1692        assert!(result.is_err());
1693        let err = result.unwrap_err();
1694        assert!(
1695            matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1696                if trigger == "task.resume" && hat == "my_hat"),
1697            "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1698            err
1699        );
1700    }
1701
1702    #[test]
1703    fn test_missing_description_rejected() {
1704        // Description is required for all hats
1705        let yaml = r#"
1706hats:
1707  my_hat:
1708    name: "My Hat"
1709    triggers: ["build.task"]
1710"#;
1711        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1712        let result = config.validate();
1713
1714        assert!(result.is_err());
1715        let err = result.unwrap_err();
1716        assert!(
1717            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1718            "Expected MissingDescription error, got: {:?}",
1719            err
1720        );
1721    }
1722
1723    #[test]
1724    fn test_empty_description_rejected() {
1725        // Empty description should also be rejected
1726        let yaml = r#"
1727hats:
1728  my_hat:
1729    name: "My Hat"
1730    description: "   "
1731    triggers: ["build.task"]
1732"#;
1733        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1734        let result = config.validate();
1735
1736        assert!(result.is_err());
1737        let err = result.unwrap_err();
1738        assert!(
1739            matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1740            "Expected MissingDescription error for empty description, got: {:?}",
1741            err
1742        );
1743    }
1744
1745    #[test]
1746    fn test_core_config_defaults() {
1747        let config = RalphConfig::default();
1748        assert_eq!(config.core.scratchpad, ".ralph/agent/scratchpad.md");
1749        assert_eq!(config.core.specs_dir, ".ralph/specs/");
1750        // Default guardrails per spec
1751        assert_eq!(config.core.guardrails.len(), 4);
1752        assert!(config.core.guardrails[0].contains("Fresh context"));
1753        assert!(config.core.guardrails[1].contains("search first"));
1754        assert!(config.core.guardrails[2].contains("Backpressure"));
1755        assert!(config.core.guardrails[3].contains("Commit atomically"));
1756    }
1757
1758    #[test]
1759    fn test_core_config_customizable() {
1760        let yaml = r#"
1761core:
1762  scratchpad: ".workspace/plan.md"
1763  specs_dir: "./specifications/"
1764"#;
1765        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1766        assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1767        assert_eq!(config.core.specs_dir, "./specifications/");
1768        // Guardrails should use defaults when not specified
1769        assert_eq!(config.core.guardrails.len(), 4);
1770    }
1771
1772    #[test]
1773    fn test_core_config_custom_guardrails() {
1774        let yaml = r#"
1775core:
1776  scratchpad: ".ralph/agent/scratchpad.md"
1777  specs_dir: "./specs/"
1778  guardrails:
1779    - "Custom rule one"
1780    - "Custom rule two"
1781"#;
1782        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1783        assert_eq!(config.core.guardrails.len(), 2);
1784        assert_eq!(config.core.guardrails[0], "Custom rule one");
1785        assert_eq!(config.core.guardrails[1], "Custom rule two");
1786    }
1787
1788    #[test]
1789    fn test_prompt_and_prompt_file_mutually_exclusive() {
1790        // Both prompt and prompt_file specified in config should error
1791        let yaml = r#"
1792event_loop:
1793  prompt: "inline text"
1794  prompt_file: "custom.md"
1795"#;
1796        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1797        let result = config.validate();
1798
1799        assert!(result.is_err());
1800        let err = result.unwrap_err();
1801        assert!(
1802            matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1803                if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1804            "Expected MutuallyExclusive error, got: {:?}",
1805            err
1806        );
1807    }
1808
1809    #[test]
1810    fn test_prompt_with_default_prompt_file_allowed() {
1811        // Having inline prompt with default prompt_file value should be OK
1812        let yaml = r#"
1813event_loop:
1814  prompt: "inline text"
1815"#;
1816        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1817        let result = config.validate();
1818
1819        assert!(
1820            result.is_ok(),
1821            "Should allow inline prompt with default prompt_file"
1822        );
1823        assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1824        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1825    }
1826
1827    #[test]
1828    fn test_custom_backend_requires_command() {
1829        // Custom backend without command should error
1830        let yaml = r#"
1831cli:
1832  backend: "custom"
1833"#;
1834        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1835        let result = config.validate();
1836
1837        assert!(result.is_err());
1838        let err = result.unwrap_err();
1839        assert!(
1840            matches!(&err, ConfigError::CustomBackendRequiresCommand),
1841            "Expected CustomBackendRequiresCommand error, got: {:?}",
1842            err
1843        );
1844    }
1845
1846    #[test]
1847    fn test_custom_backend_with_empty_command_errors() {
1848        // Custom backend with empty command should error
1849        let yaml = r#"
1850cli:
1851  backend: "custom"
1852  command: ""
1853"#;
1854        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1855        let result = config.validate();
1856
1857        assert!(result.is_err());
1858        let err = result.unwrap_err();
1859        assert!(
1860            matches!(&err, ConfigError::CustomBackendRequiresCommand),
1861            "Expected CustomBackendRequiresCommand error, got: {:?}",
1862            err
1863        );
1864    }
1865
1866    #[test]
1867    fn test_custom_backend_with_command_succeeds() {
1868        // Custom backend with valid command should pass validation
1869        let yaml = r#"
1870cli:
1871  backend: "custom"
1872  command: "my-agent"
1873"#;
1874        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1875        let result = config.validate();
1876
1877        assert!(
1878            result.is_ok(),
1879            "Should allow custom backend with command: {:?}",
1880            result.unwrap_err()
1881        );
1882    }
1883
1884    #[test]
1885    fn test_prompt_file_with_no_inline_allowed() {
1886        // Having only prompt_file specified should be OK
1887        let yaml = r#"
1888event_loop:
1889  prompt_file: "custom.md"
1890"#;
1891        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1892        let result = config.validate();
1893
1894        assert!(
1895            result.is_ok(),
1896            "Should allow prompt_file without inline prompt"
1897        );
1898        assert_eq!(config.event_loop.prompt, None);
1899        assert_eq!(config.event_loop.prompt_file, "custom.md");
1900    }
1901
1902    #[test]
1903    fn test_default_prompt_file_value() {
1904        let config = RalphConfig::default();
1905        assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1906        assert_eq!(config.event_loop.prompt, None);
1907    }
1908
1909    #[test]
1910    fn test_tui_config_default() {
1911        let config = RalphConfig::default();
1912        assert_eq!(config.tui.prefix_key, "ctrl-a");
1913    }
1914
1915    #[test]
1916    fn test_tui_config_parse_ctrl_b() {
1917        let yaml = r#"
1918tui:
1919  prefix_key: "ctrl-b"
1920"#;
1921        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1922        let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1923
1924        use crossterm::event::{KeyCode, KeyModifiers};
1925        assert_eq!(key_code, KeyCode::Char('b'));
1926        assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1927    }
1928
1929    #[test]
1930    fn test_tui_config_parse_invalid_format() {
1931        let tui_config = TuiConfig {
1932            prefix_key: "invalid".to_string(),
1933        };
1934        let result = tui_config.parse_prefix();
1935        assert!(result.is_err());
1936        assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1937    }
1938
1939    #[test]
1940    fn test_tui_config_parse_invalid_modifier() {
1941        let tui_config = TuiConfig {
1942            prefix_key: "alt-a".to_string(),
1943        };
1944        let result = tui_config.parse_prefix();
1945        assert!(result.is_err());
1946        assert!(result.unwrap_err().contains("Invalid modifier"));
1947    }
1948
1949    #[test]
1950    fn test_tui_config_parse_invalid_key() {
1951        let tui_config = TuiConfig {
1952            prefix_key: "ctrl-abc".to_string(),
1953        };
1954        let result = tui_config.parse_prefix();
1955        assert!(result.is_err());
1956        assert!(result.unwrap_err().contains("Invalid key"));
1957    }
1958
1959    #[test]
1960    fn test_hat_backend_named() {
1961        let yaml = r#""claude""#;
1962        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1963        assert_eq!(backend.to_cli_backend(), "claude");
1964        match backend {
1965            HatBackend::Named(name) => assert_eq!(name, "claude"),
1966            _ => panic!("Expected Named variant"),
1967        }
1968    }
1969
1970    #[test]
1971    fn test_hat_backend_kiro_agent() {
1972        let yaml = r#"
1973type: "kiro"
1974agent: "builder"
1975"#;
1976        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1977        assert_eq!(backend.to_cli_backend(), "kiro");
1978        match backend {
1979            HatBackend::KiroAgent {
1980                backend_type,
1981                agent,
1982                args,
1983            } => {
1984                assert_eq!(backend_type, "kiro");
1985                assert_eq!(agent, "builder");
1986                assert!(args.is_empty());
1987            }
1988            _ => panic!("Expected KiroAgent variant"),
1989        }
1990    }
1991
1992    #[test]
1993    fn test_hat_backend_kiro_agent_with_args() {
1994        let yaml = r#"
1995type: "kiro"
1996agent: "builder"
1997args: ["--verbose", "--debug"]
1998"#;
1999        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2000        assert_eq!(backend.to_cli_backend(), "kiro");
2001        match backend {
2002            HatBackend::KiroAgent {
2003                backend_type,
2004                agent,
2005                args,
2006            } => {
2007                assert_eq!(backend_type, "kiro");
2008                assert_eq!(agent, "builder");
2009                assert_eq!(args, vec!["--verbose", "--debug"]);
2010            }
2011            _ => panic!("Expected KiroAgent variant"),
2012        }
2013    }
2014
2015    #[test]
2016    fn test_hat_backend_named_with_args() {
2017        let yaml = r#"
2018type: "claude"
2019args: ["--model", "claude-sonnet-4"]
2020"#;
2021        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2022        assert_eq!(backend.to_cli_backend(), "claude");
2023        match backend {
2024            HatBackend::NamedWithArgs { backend_type, args } => {
2025                assert_eq!(backend_type, "claude");
2026                assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2027            }
2028            _ => panic!("Expected NamedWithArgs variant"),
2029        }
2030    }
2031
2032    #[test]
2033    fn test_hat_backend_named_with_args_empty() {
2034        // type: claude without args should still work (NamedWithArgs with empty args)
2035        let yaml = r#"
2036type: "gemini"
2037"#;
2038        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2039        assert_eq!(backend.to_cli_backend(), "gemini");
2040        match backend {
2041            HatBackend::NamedWithArgs { backend_type, args } => {
2042                assert_eq!(backend_type, "gemini");
2043                assert!(args.is_empty());
2044            }
2045            _ => panic!("Expected NamedWithArgs variant"),
2046        }
2047    }
2048
2049    #[test]
2050    fn test_hat_backend_custom() {
2051        let yaml = r#"
2052command: "/usr/bin/my-agent"
2053args: ["--flag", "value"]
2054"#;
2055        let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2056        assert_eq!(backend.to_cli_backend(), "custom");
2057        match backend {
2058            HatBackend::Custom { command, args } => {
2059                assert_eq!(command, "/usr/bin/my-agent");
2060                assert_eq!(args, vec!["--flag", "value"]);
2061            }
2062            _ => panic!("Expected Custom variant"),
2063        }
2064    }
2065
2066    #[test]
2067    fn test_hat_config_with_backend() {
2068        let yaml = r#"
2069name: "Custom Builder"
2070triggers: ["build.task"]
2071publishes: ["build.done"]
2072instructions: "Build stuff"
2073backend: "gemini"
2074default_publishes: "task.done"
2075"#;
2076        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2077        assert_eq!(hat.name, "Custom Builder");
2078        assert!(hat.backend.is_some());
2079        match hat.backend.unwrap() {
2080            HatBackend::Named(name) => assert_eq!(name, "gemini"),
2081            _ => panic!("Expected Named backend"),
2082        }
2083        assert_eq!(hat.default_publishes, Some("task.done".to_string()));
2084    }
2085
2086    #[test]
2087    fn test_hat_config_without_backend() {
2088        let yaml = r#"
2089name: "Default Hat"
2090triggers: ["task.start"]
2091publishes: ["task.done"]
2092instructions: "Do work"
2093"#;
2094        let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2095        assert_eq!(hat.name, "Default Hat");
2096        assert!(hat.backend.is_none());
2097        assert!(hat.default_publishes.is_none());
2098    }
2099
2100    #[test]
2101    fn test_mixed_backends_config() {
2102        let yaml = r#"
2103event_loop:
2104  prompt_file: "TASK.md"
2105  max_iterations: 50
2106
2107cli:
2108  backend: "claude"
2109
2110hats:
2111  planner:
2112    name: "Planner"
2113    triggers: ["task.start"]
2114    publishes: ["build.task"]
2115    instructions: "Plan the work"
2116    backend: "claude"
2117    
2118  builder:
2119    name: "Builder"
2120    triggers: ["build.task"]
2121    publishes: ["build.done"]
2122    instructions: "Build the thing"
2123    backend:
2124      type: "kiro"
2125      agent: "builder"
2126      
2127  reviewer:
2128    name: "Reviewer"
2129    triggers: ["build.done"]
2130    publishes: ["review.complete"]
2131    instructions: "Review the work"
2132    backend:
2133      command: "/usr/local/bin/custom-agent"
2134      args: ["--mode", "review"]
2135    default_publishes: "review.complete"
2136"#;
2137        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2138        assert_eq!(config.hats.len(), 3);
2139
2140        // Check planner (Named backend)
2141        let planner = config.hats.get("planner").unwrap();
2142        assert!(planner.backend.is_some());
2143        match planner.backend.as_ref().unwrap() {
2144            HatBackend::Named(name) => assert_eq!(name, "claude"),
2145            _ => panic!("Expected Named backend for planner"),
2146        }
2147
2148        // Check builder (KiroAgent backend)
2149        let builder = config.hats.get("builder").unwrap();
2150        assert!(builder.backend.is_some());
2151        match builder.backend.as_ref().unwrap() {
2152            HatBackend::KiroAgent {
2153                backend_type,
2154                agent,
2155                args,
2156            } => {
2157                assert_eq!(backend_type, "kiro");
2158                assert_eq!(agent, "builder");
2159                assert!(args.is_empty());
2160            }
2161            _ => panic!("Expected KiroAgent backend for builder"),
2162        }
2163
2164        // Check reviewer (Custom backend)
2165        let reviewer = config.hats.get("reviewer").unwrap();
2166        assert!(reviewer.backend.is_some());
2167        match reviewer.backend.as_ref().unwrap() {
2168            HatBackend::Custom { command, args } => {
2169                assert_eq!(command, "/usr/local/bin/custom-agent");
2170                assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
2171            }
2172            _ => panic!("Expected Custom backend for reviewer"),
2173        }
2174        assert_eq!(
2175            reviewer.default_publishes,
2176            Some("review.complete".to_string())
2177        );
2178    }
2179
2180    #[test]
2181    fn test_features_config_auto_merge_defaults_to_false() {
2182        // Per spec: auto_merge should default to false for safety
2183        // This prevents automatic merging of parallel loop branches
2184        let config = RalphConfig::default();
2185        assert!(
2186            !config.features.auto_merge,
2187            "auto_merge should default to false"
2188        );
2189    }
2190
2191    #[test]
2192    fn test_features_config_auto_merge_from_yaml() {
2193        // Users can opt into auto_merge via config
2194        let yaml = r"
2195features:
2196  auto_merge: true
2197";
2198        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2199        assert!(
2200            config.features.auto_merge,
2201            "auto_merge should be true when configured"
2202        );
2203    }
2204
2205    #[test]
2206    fn test_features_config_auto_merge_false_from_yaml() {
2207        // Explicit false should work too
2208        let yaml = r"
2209features:
2210  auto_merge: false
2211";
2212        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2213        assert!(
2214            !config.features.auto_merge,
2215            "auto_merge should be false when explicitly configured"
2216        );
2217    }
2218
2219    #[test]
2220    fn test_features_config_preserves_parallel_when_adding_auto_merge() {
2221        // Ensure adding auto_merge doesn't break existing parallel feature
2222        let yaml = r"
2223features:
2224  parallel: false
2225  auto_merge: true
2226";
2227        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2228        assert!(!config.features.parallel, "parallel should be false");
2229        assert!(config.features.auto_merge, "auto_merge should be true");
2230    }
2231
2232    #[test]
2233    fn test_skills_config_defaults_when_absent() {
2234        // Configs without a skills: section should still parse (backwards compat)
2235        let yaml = r"
2236agent: claude
2237";
2238        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2239        assert!(config.skills.enabled);
2240        assert!(config.skills.dirs.is_empty());
2241        assert!(config.skills.overrides.is_empty());
2242    }
2243
2244    #[test]
2245    fn test_skills_config_deserializes_all_fields() {
2246        let yaml = r#"
2247skills:
2248  enabled: true
2249  dirs:
2250    - ".claude/skills"
2251    - "/shared/skills"
2252  overrides:
2253    pdd:
2254      enabled: false
2255    memories:
2256      auto_inject: true
2257      hats: ["ralph"]
2258      backends: ["claude"]
2259      tags: ["core"]
2260"#;
2261        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2262        assert!(config.skills.enabled);
2263        assert_eq!(config.skills.dirs.len(), 2);
2264        assert_eq!(
2265            config.skills.dirs[0],
2266            std::path::PathBuf::from(".claude/skills")
2267        );
2268        assert_eq!(config.skills.overrides.len(), 2);
2269
2270        let pdd = config.skills.overrides.get("pdd").unwrap();
2271        assert_eq!(pdd.enabled, Some(false));
2272
2273        let memories = config.skills.overrides.get("memories").unwrap();
2274        assert_eq!(memories.auto_inject, Some(true));
2275        assert_eq!(memories.hats, vec!["ralph"]);
2276        assert_eq!(memories.backends, vec!["claude"]);
2277        assert_eq!(memories.tags, vec!["core"]);
2278    }
2279
2280    #[test]
2281    fn test_skills_config_disabled() {
2282        let yaml = r"
2283skills:
2284  enabled: false
2285";
2286        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2287        assert!(!config.skills.enabled);
2288        assert!(config.skills.dirs.is_empty());
2289    }
2290
2291    #[test]
2292    fn test_skill_override_partial_fields() {
2293        let yaml = r#"
2294skills:
2295  overrides:
2296    my-skill:
2297      hats: ["builder", "reviewer"]
2298"#;
2299        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2300        let override_ = config.skills.overrides.get("my-skill").unwrap();
2301        assert_eq!(override_.enabled, None);
2302        assert_eq!(override_.auto_inject, None);
2303        assert_eq!(override_.hats, vec!["builder", "reviewer"]);
2304        assert!(override_.backends.is_empty());
2305        assert!(override_.tags.is_empty());
2306    }
2307
2308    // ─────────────────────────────────────────────────────────────────────────
2309    // ROBOT CONFIG TESTS
2310    // ─────────────────────────────────────────────────────────────────────────
2311
2312    #[test]
2313    fn test_robot_config_defaults_disabled() {
2314        let config = RalphConfig::default();
2315        assert!(!config.robot.enabled);
2316        assert!(config.robot.timeout_seconds.is_none());
2317        assert!(config.robot.telegram.is_none());
2318    }
2319
2320    #[test]
2321    fn test_robot_config_absent_parses_as_default() {
2322        // Existing configs without RObot: section should still parse
2323        let yaml = r"
2324agent: claude
2325";
2326        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2327        assert!(!config.robot.enabled);
2328        assert!(config.robot.timeout_seconds.is_none());
2329    }
2330
2331    #[test]
2332    fn test_robot_config_valid_full() {
2333        let yaml = r#"
2334RObot:
2335  enabled: true
2336  timeout_seconds: 300
2337  telegram:
2338    bot_token: "123456:ABC-DEF"
2339"#;
2340        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2341        assert!(config.robot.enabled);
2342        assert_eq!(config.robot.timeout_seconds, Some(300));
2343        let telegram = config.robot.telegram.as_ref().unwrap();
2344        assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
2345
2346        // Validation should pass
2347        assert!(config.validate().is_ok());
2348    }
2349
2350    #[test]
2351    fn test_robot_config_disabled_skips_validation() {
2352        // Disabled RObot config should pass validation even with missing fields
2353        let yaml = r"
2354RObot:
2355  enabled: false
2356";
2357        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2358        assert!(!config.robot.enabled);
2359        assert!(config.validate().is_ok());
2360    }
2361
2362    #[test]
2363    fn test_robot_config_enabled_missing_timeout_fails() {
2364        let yaml = r#"
2365RObot:
2366  enabled: true
2367  telegram:
2368    bot_token: "123456:ABC-DEF"
2369"#;
2370        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2371        let result = config.validate();
2372        assert!(result.is_err());
2373        let err = result.unwrap_err();
2374        assert!(
2375            matches!(&err, ConfigError::RobotMissingField { field, .. }
2376                if field == "RObot.timeout_seconds"),
2377            "Expected RobotMissingField for timeout_seconds, got: {:?}",
2378            err
2379        );
2380    }
2381
2382    #[test]
2383    fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
2384        // Both timeout and token are missing, but timeout is checked first
2385        let robot = RobotConfig {
2386            enabled: true,
2387            timeout_seconds: None,
2388            checkin_interval_seconds: None,
2389            telegram: None,
2390        };
2391        let result = robot.validate();
2392        assert!(result.is_err());
2393        let err = result.unwrap_err();
2394        assert!(
2395            matches!(&err, ConfigError::RobotMissingField { field, .. }
2396                if field == "RObot.timeout_seconds"),
2397            "Expected timeout validation failure first, got: {:?}",
2398            err
2399        );
2400    }
2401
2402    #[test]
2403    fn test_robot_config_resolve_bot_token_from_config() {
2404        // Config has a token — resolve_bot_token returns it
2405        // (env var behavior is tested separately via integration tests since
2406        // forbid(unsafe_code) prevents env var manipulation in unit tests)
2407        let config = RobotConfig {
2408            enabled: true,
2409            timeout_seconds: Some(300),
2410            checkin_interval_seconds: None,
2411            telegram: Some(TelegramBotConfig {
2412                bot_token: Some("config-token".to_string()),
2413            }),
2414        };
2415
2416        // When RALPH_TELEGRAM_BOT_TOKEN is not set, config token is returned
2417        // (Can't set/unset env vars in tests due to forbid(unsafe_code))
2418        let resolved = config.resolve_bot_token();
2419        // The result depends on whether RALPH_TELEGRAM_BOT_TOKEN is set in the
2420        // test environment. We can at least assert it's Some.
2421        assert!(resolved.is_some());
2422    }
2423
2424    #[test]
2425    fn test_robot_config_resolve_bot_token_none_without_config() {
2426        // No config token and no telegram section
2427        let config = RobotConfig {
2428            enabled: true,
2429            timeout_seconds: Some(300),
2430            checkin_interval_seconds: None,
2431            telegram: None,
2432        };
2433
2434        // Without env var AND without config token, resolve returns None
2435        // (unless RALPH_TELEGRAM_BOT_TOKEN happens to be set in test env)
2436        let resolved = config.resolve_bot_token();
2437        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
2438            assert!(resolved.is_none());
2439        }
2440    }
2441
2442    #[test]
2443    fn test_robot_config_validate_with_config_token() {
2444        // Validation passes when bot_token is in config
2445        let robot = RobotConfig {
2446            enabled: true,
2447            timeout_seconds: Some(300),
2448            checkin_interval_seconds: None,
2449            telegram: Some(TelegramBotConfig {
2450                bot_token: Some("test-token".to_string()),
2451            }),
2452        };
2453        assert!(robot.validate().is_ok());
2454    }
2455
2456    #[test]
2457    fn test_robot_config_validate_missing_telegram_section() {
2458        // No telegram section at all and no env var → fails
2459        // (Skip if env var happens to be set)
2460        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
2461            return;
2462        }
2463
2464        let robot = RobotConfig {
2465            enabled: true,
2466            timeout_seconds: Some(300),
2467            checkin_interval_seconds: None,
2468            telegram: None,
2469        };
2470        let result = robot.validate();
2471        assert!(result.is_err());
2472        let err = result.unwrap_err();
2473        assert!(
2474            matches!(&err, ConfigError::RobotMissingField { field, .. }
2475                if field == "RObot.telegram.bot_token"),
2476            "Expected bot_token validation failure, got: {:?}",
2477            err
2478        );
2479    }
2480
2481    #[test]
2482    fn test_robot_config_validate_empty_bot_token() {
2483        // telegram section present but bot_token is None
2484        // (Skip if env var happens to be set)
2485        if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
2486            return;
2487        }
2488
2489        let robot = RobotConfig {
2490            enabled: true,
2491            timeout_seconds: Some(300),
2492            checkin_interval_seconds: None,
2493            telegram: Some(TelegramBotConfig { bot_token: None }),
2494        };
2495        let result = robot.validate();
2496        assert!(result.is_err());
2497        let err = result.unwrap_err();
2498        assert!(
2499            matches!(&err, ConfigError::RobotMissingField { field, .. }
2500                if field == "RObot.telegram.bot_token"),
2501            "Expected bot_token validation failure, got: {:?}",
2502            err
2503        );
2504    }
2505}