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