1use ralph_proto::Topic;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18#[allow(clippy::struct_excessive_bools)] pub struct RalphConfig {
20 #[serde(default)]
22 pub event_loop: EventLoopConfig,
23
24 #[serde(default)]
26 pub cli: CliConfig,
27
28 #[serde(default)]
30 pub core: CoreConfig,
31
32 #[serde(default)]
35 pub hats: HashMap<String, HatConfig>,
36
37 #[serde(default)]
41 pub events: HashMap<String, EventMetadata>,
42
43 #[serde(default)]
50 pub agent: Option<String>,
51
52 #[serde(default)]
54 pub agent_priority: Vec<String>,
55
56 #[serde(default)]
58 pub prompt_file: Option<String>,
59
60 #[serde(default)]
62 pub completion_promise: Option<String>,
63
64 #[serde(default)]
66 pub max_iterations: Option<u32>,
67
68 #[serde(default)]
70 pub max_runtime: Option<u64>,
71
72 #[serde(default)]
74 pub max_cost: Option<f64>,
75
76 #[serde(default)]
81 pub verbose: bool,
82
83 #[serde(default)]
85 pub archive_prompts: bool,
86
87 #[serde(default)]
89 pub enable_metrics: bool,
90
91 #[serde(default)]
96 pub max_tokens: Option<u32>,
97
98 #[serde(default)]
100 pub retry_delay: Option<u32>,
101
102 #[serde(default)]
104 pub adapters: AdaptersConfig,
105
106 #[serde(default, rename = "_suppress_warnings")]
111 pub suppress_warnings: bool,
112
113 #[serde(default)]
115 pub tui: TuiConfig,
116
117 #[serde(default)]
119 pub memories: MemoriesConfig,
120
121 #[serde(default)]
123 pub tasks: TasksConfig,
124
125 #[serde(default)]
127 pub hooks: HooksConfig,
128
129 #[serde(default)]
131 pub skills: SkillsConfig,
132
133 #[serde(default)]
135 pub features: FeaturesConfig,
136
137 #[serde(default, rename = "RObot")]
139 pub robot: RobotConfig,
140}
141
142fn default_true() -> bool {
143 true
144}
145
146#[allow(clippy::derivable_impls)] impl Default for RalphConfig {
148 fn default() -> Self {
149 Self {
150 event_loop: EventLoopConfig::default(),
151 cli: CliConfig::default(),
152 core: CoreConfig::default(),
153 hats: HashMap::new(),
154 events: HashMap::new(),
155 agent: None,
157 agent_priority: vec![],
158 prompt_file: None,
159 completion_promise: None,
160 max_iterations: None,
161 max_runtime: None,
162 max_cost: None,
163 verbose: false,
165 archive_prompts: false,
166 enable_metrics: false,
167 max_tokens: None,
169 retry_delay: None,
170 adapters: AdaptersConfig::default(),
171 suppress_warnings: false,
173 tui: TuiConfig::default(),
175 memories: MemoriesConfig::default(),
177 tasks: TasksConfig::default(),
179 hooks: HooksConfig::default(),
181 skills: SkillsConfig::default(),
183 features: FeaturesConfig::default(),
185 robot: RobotConfig::default(),
187 }
188 }
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
193pub struct AdaptersConfig {
194 #[serde(default)]
196 pub claude: AdapterSettings,
197
198 #[serde(default)]
200 pub gemini: AdapterSettings,
201
202 #[serde(default)]
204 pub kiro: AdapterSettings,
205
206 #[serde(default)]
208 pub codex: AdapterSettings,
209
210 #[serde(default)]
212 pub amp: AdapterSettings,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct AdapterSettings {
218 #[serde(default = "default_timeout")]
220 pub timeout: u64,
221
222 #[serde(default = "default_true")]
224 pub enabled: bool,
225
226 #[serde(default)]
228 pub tool_permissions: Option<Vec<String>>,
229}
230
231fn default_timeout() -> u64 {
232 300 }
234
235impl Default for AdapterSettings {
236 fn default() -> Self {
237 Self {
238 timeout: default_timeout(),
239 enabled: true,
240 tool_permissions: None,
241 }
242 }
243}
244
245impl RalphConfig {
246 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
248 let path_ref = path.as_ref();
249 debug!(path = %path_ref.display(), "Loading configuration from file");
250 let content = std::fs::read_to_string(path_ref)?;
251 Self::parse_yaml(&content)
252 }
253
254 pub fn parse_yaml(content: &str) -> Result<Self, ConfigError> {
256 let value: serde_yaml::Value = serde_yaml::from_str(content)?;
258 if let Some(map) = value.as_mapping()
259 && map.contains_key(serde_yaml::Value::String("project".to_string()))
260 {
261 return Err(ConfigError::DeprecatedProjectKey);
262 }
263
264 validate_hooks_phase_event_keys(&value)?;
265
266 let config: Self = serde_yaml::from_value(value)?;
267 debug!(
268 backend = %config.cli.backend,
269 has_v1_fields = config.agent.is_some(),
270 custom_hats = config.hats.len(),
271 "Configuration loaded"
272 );
273 Ok(config)
274 }
275
276 pub fn normalize(&mut self) {
281 let mut normalized_count = 0;
282
283 if let Some(ref agent) = self.agent {
285 debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
286 self.cli.backend = agent.clone();
287 normalized_count += 1;
288 }
289
290 if let Some(ref pf) = self.prompt_file {
292 debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
293 self.event_loop.prompt_file = pf.clone();
294 normalized_count += 1;
295 }
296
297 if let Some(ref cp) = self.completion_promise {
299 debug!(
300 from = "completion_promise",
301 to = "event_loop.completion_promise",
302 "Normalizing v1 field"
303 );
304 self.event_loop.completion_promise = cp.clone();
305 normalized_count += 1;
306 }
307
308 if let Some(mi) = self.max_iterations {
310 debug!(
311 from = "max_iterations",
312 to = "event_loop.max_iterations",
313 value = mi,
314 "Normalizing v1 field"
315 );
316 self.event_loop.max_iterations = mi;
317 normalized_count += 1;
318 }
319
320 if let Some(mr) = self.max_runtime {
322 debug!(
323 from = "max_runtime",
324 to = "event_loop.max_runtime_seconds",
325 value = mr,
326 "Normalizing v1 field"
327 );
328 self.event_loop.max_runtime_seconds = mr;
329 normalized_count += 1;
330 }
331
332 if self.max_cost.is_some() {
334 debug!(
335 from = "max_cost",
336 to = "event_loop.max_cost_usd",
337 "Normalizing v1 field"
338 );
339 self.event_loop.max_cost_usd = self.max_cost;
340 normalized_count += 1;
341 }
342
343 for (hat_id, hat) in &mut self.hats {
345 if !hat.extra_instructions.is_empty() {
346 for fragment in hat.extra_instructions.drain(..) {
347 if !hat.instructions.ends_with('\n') {
348 hat.instructions.push('\n');
349 }
350 hat.instructions.push_str(&fragment);
351 }
352 debug!(hat = %hat_id, "Merged extra_instructions into hat instructions");
353 normalized_count += 1;
354 }
355 }
356
357 if normalized_count > 0 {
358 debug!(
359 fields_normalized = normalized_count,
360 "V1 to V2 config normalization complete"
361 );
362 }
363 }
364
365 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
375 let mut warnings = Vec::new();
376
377 if self.suppress_warnings {
379 return Ok(warnings);
380 }
381
382 if self.event_loop.prompt.is_some()
385 && !self.event_loop.prompt_file.is_empty()
386 && self.event_loop.prompt_file != default_prompt_file()
387 {
388 return Err(ConfigError::MutuallyExclusive {
389 field1: "event_loop.prompt".to_string(),
390 field2: "event_loop.prompt_file".to_string(),
391 });
392 }
393 if self.event_loop.completion_promise.trim().is_empty() {
394 return Err(ConfigError::InvalidCompletionPromise);
395 }
396
397 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
399 return Err(ConfigError::CustomBackendRequiresCommand);
400 }
401
402 if self.archive_prompts {
404 warnings.push(ConfigWarning::DeferredFeature {
405 field: "archive_prompts".to_string(),
406 message: "Feature not yet available in v2".to_string(),
407 });
408 }
409
410 if self.enable_metrics {
411 warnings.push(ConfigWarning::DeferredFeature {
412 field: "enable_metrics".to_string(),
413 message: "Feature not yet available in v2".to_string(),
414 });
415 }
416
417 if self.max_tokens.is_some() {
419 warnings.push(ConfigWarning::DroppedField {
420 field: "max_tokens".to_string(),
421 reason: "Token limits are controlled by the CLI tool".to_string(),
422 });
423 }
424
425 if self.retry_delay.is_some() {
426 warnings.push(ConfigWarning::DroppedField {
427 field: "retry_delay".to_string(),
428 reason: "Retry logic handled differently in v2".to_string(),
429 });
430 }
431
432 if let Some(threshold) = self.event_loop.mutation_score_warn_threshold
433 && !(0.0..=100.0).contains(&threshold)
434 {
435 warnings.push(ConfigWarning::InvalidValue {
436 field: "event_loop.mutation_score_warn_threshold".to_string(),
437 message: "Value must be between 0 and 100".to_string(),
438 });
439 }
440
441 if self.adapters.claude.tool_permissions.is_some()
443 || self.adapters.gemini.tool_permissions.is_some()
444 || self.adapters.codex.tool_permissions.is_some()
445 || self.adapters.amp.tool_permissions.is_some()
446 {
447 warnings.push(ConfigWarning::DroppedField {
448 field: "adapters.*.tool_permissions".to_string(),
449 reason: "CLI tool manages its own permissions".to_string(),
450 });
451 }
452
453 self.robot.validate()?;
455
456 self.validate_hooks()?;
458
459 for (hat_id, hat_config) in &self.hats {
461 if hat_config
462 .description
463 .as_ref()
464 .is_none_or(|d| d.trim().is_empty())
465 {
466 return Err(ConfigError::MissingDescription {
467 hat: hat_id.clone(),
468 });
469 }
470 }
471
472 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
475 for (hat_id, hat_config) in &self.hats {
476 for trigger in &hat_config.triggers {
477 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
478 return Err(ConfigError::ReservedTrigger {
479 trigger: trigger.clone(),
480 hat: hat_id.clone(),
481 });
482 }
483 }
484 }
485
486 if !self.hats.is_empty() {
489 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
490 for (hat_id, hat_config) in &self.hats {
491 for trigger in &hat_config.triggers {
492 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
493 return Err(ConfigError::AmbiguousRouting {
494 trigger: trigger.clone(),
495 hat1: (*existing_hat).to_string(),
496 hat2: hat_id.clone(),
497 });
498 }
499 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
500 }
501 }
502 }
503
504 Ok(warnings)
505 }
506
507 fn validate_hooks(&self) -> Result<(), ConfigError> {
508 Self::validate_non_v1_hook_fields("hooks", &self.hooks.extra)?;
509
510 if self.hooks.defaults.timeout_seconds == 0 {
511 return Err(ConfigError::HookValidation {
512 field: "hooks.defaults.timeout_seconds".to_string(),
513 message: "must be greater than 0".to_string(),
514 });
515 }
516
517 if self.hooks.defaults.max_output_bytes == 0 {
518 return Err(ConfigError::HookValidation {
519 field: "hooks.defaults.max_output_bytes".to_string(),
520 message: "must be greater than 0".to_string(),
521 });
522 }
523
524 for (phase_event, hook_specs) in &self.hooks.events {
525 for (index, hook) in hook_specs.iter().enumerate() {
526 let hook_field_base = format!("hooks.events.{phase_event}[{index}]");
527
528 if hook.name.trim().is_empty() {
529 return Err(ConfigError::HookValidation {
530 field: format!("{hook_field_base}.name"),
531 message: "is required and must be non-empty".to_string(),
532 });
533 }
534
535 if hook
536 .command
537 .first()
538 .is_none_or(|command| command.trim().is_empty())
539 {
540 return Err(ConfigError::HookValidation {
541 field: format!("{hook_field_base}.command"),
542 message: "is required and must include an executable at command[0]"
543 .to_string(),
544 });
545 }
546
547 if hook.on_error.is_none() {
548 return Err(ConfigError::HookValidation {
549 field: format!("{hook_field_base}.on_error"),
550 message: "is required in v1 (warn | block | suspend)".to_string(),
551 });
552 }
553
554 if let Some(timeout_seconds) = hook.timeout_seconds
555 && timeout_seconds == 0
556 {
557 return Err(ConfigError::HookValidation {
558 field: format!("{hook_field_base}.timeout_seconds"),
559 message: "must be greater than 0 when specified".to_string(),
560 });
561 }
562
563 if let Some(max_output_bytes) = hook.max_output_bytes
564 && max_output_bytes == 0
565 {
566 return Err(ConfigError::HookValidation {
567 field: format!("{hook_field_base}.max_output_bytes"),
568 message: "must be greater than 0 when specified".to_string(),
569 });
570 }
571
572 if hook.suspend_mode.is_some() && hook.on_error != Some(HookOnError::Suspend) {
573 return Err(ConfigError::HookValidation {
574 field: format!("{hook_field_base}.suspend_mode"),
575 message: "requires on_error: suspend".to_string(),
576 });
577 }
578
579 Self::validate_non_v1_hook_fields(&hook_field_base, &hook.extra)?;
580 Self::validate_mutation_contract(&hook_field_base, &hook.mutate)?;
581 }
582 }
583
584 Ok(())
585 }
586
587 fn validate_non_v1_hook_fields(
588 path_prefix: &str,
589 fields: &HashMap<String, serde_yaml::Value>,
590 ) -> Result<(), ConfigError> {
591 for key in fields.keys() {
592 let field = format!("{path_prefix}.{key}");
593 match key.as_str() {
594 "global" | "globals" | "global_defaults" | "global_hooks" | "scope" => {
595 return Err(ConfigError::UnsupportedHookField {
596 field,
597 reason: "Global hooks are out of scope for v1; use per-project hooks only"
598 .to_string(),
599 });
600 }
601 "parallel" | "parallelism" | "max_parallel" | "concurrency" | "run_in_parallel" => {
602 return Err(ConfigError::UnsupportedHookField {
603 field,
604 reason:
605 "Parallel hook execution is out of scope for v1; hooks must run sequentially"
606 .to_string(),
607 });
608 }
609 _ => {}
610 }
611 }
612
613 Ok(())
614 }
615
616 fn validate_mutation_contract(
617 hook_field_base: &str,
618 mutate: &HookMutationConfig,
619 ) -> Result<(), ConfigError> {
620 let mutate_field_base = format!("{hook_field_base}.mutate");
621
622 if !mutate.enabled {
623 if mutate.format.is_some() || !mutate.extra.is_empty() {
624 return Err(ConfigError::HookValidation {
625 field: mutate_field_base,
626 message: "mutation settings require mutate.enabled: true".to_string(),
627 });
628 }
629 return Ok(());
630 }
631
632 if let Some(format) = mutate.format.as_deref()
633 && !format.eq_ignore_ascii_case("json")
634 {
635 return Err(ConfigError::HookValidation {
636 field: format!("{mutate_field_base}.format"),
637 message: "only 'json' is supported for v1 mutation payloads".to_string(),
638 });
639 }
640
641 if let Some(key) = mutate.extra.keys().next() {
642 let field = format!("{mutate_field_base}.{key}");
643 let reason = match key.as_str() {
644 "prompt" | "prompt_mutation" | "events" | "event" | "config" | "full_context" => {
645 "v1 allows metadata-only mutation; prompt/event/config mutation is unsupported"
646 .to_string()
647 }
648 "xml" => "v1 mutation payloads are JSON-only".to_string(),
649 _ => "unsupported mutate field in v1 (supported keys: enabled, format)".to_string(),
650 };
651
652 return Err(ConfigError::UnsupportedHookField { field, reason });
653 }
654
655 Ok(())
656 }
657
658 pub fn effective_backend(&self) -> &str {
660 &self.cli.backend
661 }
662
663 pub fn get_agent_priority(&self) -> Vec<&str> {
666 if self.agent_priority.is_empty() {
667 vec!["claude", "kiro", "gemini", "codex", "amp"]
668 } else {
669 self.agent_priority.iter().map(String::as_str).collect()
670 }
671 }
672
673 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
676 match backend {
677 "claude" => &self.adapters.claude,
678 "gemini" => &self.adapters.gemini,
679 "kiro" => &self.adapters.kiro,
680 "codex" => &self.adapters.codex,
681 "amp" => &self.adapters.amp,
682 _ => &self.adapters.claude, }
684 }
685}
686
687#[derive(Debug, Clone)]
689pub enum ConfigWarning {
690 DeferredFeature { field: String, message: String },
692 DroppedField { field: String, reason: String },
694 InvalidValue { field: String, message: String },
696}
697
698impl std::fmt::Display for ConfigWarning {
699 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
701 match self {
702 ConfigWarning::DeferredFeature { field, message }
703 | ConfigWarning::InvalidValue { field, message } => {
704 write!(f, "Warning [{field}]: {message}")
705 }
706 ConfigWarning::DroppedField { field, reason } => {
707 write!(f, "Warning [{field}]: Field ignored - {reason}")
708 }
709 }
710 }
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct EventLoopConfig {
716 pub prompt: Option<String>,
718
719 #[serde(default = "default_prompt_file")]
721 pub prompt_file: String,
722
723 #[serde(default = "default_completion_promise")]
725 pub completion_promise: String,
726
727 #[serde(default = "default_max_iterations")]
729 pub max_iterations: u32,
730
731 #[serde(default = "default_max_runtime")]
733 pub max_runtime_seconds: u64,
734
735 pub max_cost_usd: Option<f64>,
737
738 #[serde(default = "default_max_failures")]
740 pub max_consecutive_failures: u32,
741
742 #[serde(default)]
745 pub cooldown_delay_seconds: u64,
746
747 pub starting_hat: Option<String>,
749
750 pub starting_event: Option<String>,
760
761 #[serde(default)]
765 pub mutation_score_warn_threshold: Option<f64>,
766
767 #[serde(default)]
774 pub persistent: bool,
775
776 #[serde(default)]
780 pub required_events: Vec<String>,
781
782 #[serde(default)]
786 pub cancellation_promise: String,
787
788 #[serde(default)]
792 pub enforce_hat_scope: bool,
793}
794
795fn default_prompt_file() -> String {
796 "PROMPT.md".to_string()
797}
798
799fn default_completion_promise() -> String {
800 "LOOP_COMPLETE".to_string()
801}
802
803fn default_max_iterations() -> u32 {
804 100
805}
806
807fn default_max_runtime() -> u64 {
808 14400 }
810
811fn default_max_failures() -> u32 {
812 5
813}
814
815impl Default for EventLoopConfig {
816 fn default() -> Self {
817 Self {
818 prompt: None,
819 prompt_file: default_prompt_file(),
820 completion_promise: default_completion_promise(),
821 max_iterations: default_max_iterations(),
822 max_runtime_seconds: default_max_runtime(),
823 max_cost_usd: None,
824 max_consecutive_failures: default_max_failures(),
825 cooldown_delay_seconds: 0,
826 starting_hat: None,
827 starting_event: None,
828 mutation_score_warn_threshold: None,
829 persistent: false,
830 required_events: Vec::new(),
831 cancellation_promise: String::new(),
832 enforce_hat_scope: false,
833 }
834 }
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize)]
841pub struct CoreConfig {
842 #[serde(default = "default_scratchpad")]
844 pub scratchpad: String,
845
846 #[serde(default = "default_specs_dir")]
848 pub specs_dir: String,
849
850 #[serde(default = "default_guardrails")]
854 pub guardrails: Vec<String>,
855
856 #[serde(skip)]
863 pub workspace_root: std::path::PathBuf,
864}
865
866fn default_scratchpad() -> String {
867 ".ralph/agent/scratchpad.md".to_string()
868}
869
870fn default_specs_dir() -> String {
871 ".ralph/specs/".to_string()
872}
873
874fn default_guardrails() -> Vec<String> {
875 vec![
876 "Fresh context each iteration - scratchpad is memory".to_string(),
877 "Don't assume 'not implemented' - search first".to_string(),
878 "Backpressure is law - tests/typecheck/lint/audit must pass".to_string(),
879 "Confidence protocol: score decisions 0-100. >80 proceed autonomously; 50-80 proceed + document in .ralph/agent/decisions.md; <50 choose safe default + document".to_string(),
880 "Commit atomically - one logical change per commit, capture the why".to_string(),
881 ]
882}
883
884impl Default for CoreConfig {
885 fn default() -> Self {
886 Self {
887 scratchpad: default_scratchpad(),
888 specs_dir: default_specs_dir(),
889 guardrails: default_guardrails(),
890 workspace_root: std::env::var("RALPH_WORKSPACE_ROOT")
891 .map(std::path::PathBuf::from)
892 .unwrap_or_else(|_| {
893 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
894 }),
895 }
896 }
897}
898
899impl CoreConfig {
900 pub fn with_workspace_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
904 self.workspace_root = root.into();
905 self
906 }
907
908 pub fn resolve_path(&self, relative: &str) -> std::path::PathBuf {
913 let path = std::path::Path::new(relative);
914 if path.is_absolute() {
915 path.to_path_buf()
916 } else {
917 self.workspace_root.join(path)
918 }
919 }
920}
921
922#[derive(Debug, Clone, Serialize, Deserialize)]
924pub struct CliConfig {
925 #[serde(default = "default_backend")]
927 pub backend: String,
928
929 pub command: Option<String>,
932
933 #[serde(default = "default_prompt_mode")]
935 pub prompt_mode: String,
936
937 #[serde(default = "default_mode")]
940 pub default_mode: String,
941
942 #[serde(default = "default_idle_timeout")]
946 pub idle_timeout_secs: u32,
947
948 #[serde(default)]
951 pub args: Vec<String>,
952
953 #[serde(default)]
956 pub prompt_flag: Option<String>,
957}
958
959fn default_backend() -> String {
960 "claude".to_string()
961}
962
963fn default_prompt_mode() -> String {
964 "arg".to_string()
965}
966
967fn default_mode() -> String {
968 "autonomous".to_string()
969}
970
971fn default_idle_timeout() -> u32 {
972 30 }
974
975impl Default for CliConfig {
976 fn default() -> Self {
977 Self {
978 backend: default_backend(),
979 command: None,
980 prompt_mode: default_prompt_mode(),
981 default_mode: default_mode(),
982 idle_timeout_secs: default_idle_timeout(),
983 args: Vec::new(),
984 prompt_flag: None,
985 }
986 }
987}
988
989#[derive(Debug, Clone, Serialize, Deserialize)]
991pub struct TuiConfig {
992 #[serde(default = "default_prefix_key")]
994 pub prefix_key: String,
995}
996
997#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1001#[serde(rename_all = "lowercase")]
1002pub enum InjectMode {
1003 #[default]
1005 Auto,
1006 Manual,
1008 None,
1010}
1011
1012impl std::fmt::Display for InjectMode {
1013 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1014 match self {
1015 Self::Auto => write!(f, "auto"),
1016 Self::Manual => write!(f, "manual"),
1017 Self::None => write!(f, "none"),
1018 }
1019 }
1020}
1021
1022#[derive(Debug, Clone, Serialize, Deserialize)]
1038pub struct MemoriesConfig {
1039 #[serde(default)]
1043 pub enabled: bool,
1044
1045 #[serde(default)]
1047 pub inject: InjectMode,
1048
1049 #[serde(default)]
1053 pub budget: usize,
1054
1055 #[serde(default)]
1057 pub filter: MemoriesFilter,
1058}
1059
1060impl Default for MemoriesConfig {
1061 fn default() -> Self {
1062 Self {
1063 enabled: true, inject: InjectMode::Auto,
1065 budget: 0,
1066 filter: MemoriesFilter::default(),
1067 }
1068 }
1069}
1070
1071#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1075pub struct MemoriesFilter {
1076 #[serde(default)]
1078 pub types: Vec<String>,
1079
1080 #[serde(default)]
1082 pub tags: Vec<String>,
1083
1084 #[serde(default)]
1086 pub recent: u32,
1087}
1088
1089#[derive(Debug, Clone, Serialize, Deserialize)]
1102pub struct TasksConfig {
1103 #[serde(default = "default_true")]
1107 pub enabled: bool,
1108}
1109
1110impl Default for TasksConfig {
1111 fn default() -> Self {
1112 Self {
1113 enabled: true, }
1115 }
1116}
1117
1118#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1138pub struct HooksConfig {
1139 #[serde(default)]
1141 pub enabled: bool,
1142
1143 #[serde(default)]
1145 pub defaults: HookDefaults,
1146
1147 #[serde(default)]
1149 pub events: HashMap<HookPhaseEvent, Vec<HookSpec>>,
1150
1151 #[serde(default, flatten)]
1153 pub extra: HashMap<String, serde_yaml::Value>,
1154}
1155
1156#[derive(Debug, Clone, Serialize, Deserialize)]
1158pub struct HookDefaults {
1159 #[serde(default = "default_hook_timeout_seconds")]
1161 pub timeout_seconds: u64,
1162
1163 #[serde(default = "default_hook_max_output_bytes")]
1165 pub max_output_bytes: u64,
1166
1167 #[serde(default)]
1169 pub suspend_mode: HookSuspendMode,
1170}
1171
1172fn default_hook_timeout_seconds() -> u64 {
1173 30
1174}
1175
1176fn default_hook_max_output_bytes() -> u64 {
1177 8192
1178}
1179
1180impl Default for HookDefaults {
1181 fn default() -> Self {
1182 Self {
1183 timeout_seconds: default_hook_timeout_seconds(),
1184 max_output_bytes: default_hook_max_output_bytes(),
1185 suspend_mode: HookSuspendMode::default(),
1186 }
1187 }
1188}
1189
1190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1192pub enum HookPhaseEvent {
1193 #[serde(rename = "pre.loop.start")]
1194 PreLoopStart,
1195 #[serde(rename = "post.loop.start")]
1196 PostLoopStart,
1197 #[serde(rename = "pre.iteration.start")]
1198 PreIterationStart,
1199 #[serde(rename = "post.iteration.start")]
1200 PostIterationStart,
1201 #[serde(rename = "pre.plan.created")]
1202 PrePlanCreated,
1203 #[serde(rename = "post.plan.created")]
1204 PostPlanCreated,
1205 #[serde(rename = "pre.human.interact")]
1206 PreHumanInteract,
1207 #[serde(rename = "post.human.interact")]
1208 PostHumanInteract,
1209 #[serde(rename = "pre.loop.complete")]
1210 PreLoopComplete,
1211 #[serde(rename = "post.loop.complete")]
1212 PostLoopComplete,
1213 #[serde(rename = "pre.loop.error")]
1214 PreLoopError,
1215 #[serde(rename = "post.loop.error")]
1216 PostLoopError,
1217}
1218
1219impl HookPhaseEvent {
1220 pub fn as_str(self) -> &'static str {
1222 match self {
1223 Self::PreLoopStart => "pre.loop.start",
1224 Self::PostLoopStart => "post.loop.start",
1225 Self::PreIterationStart => "pre.iteration.start",
1226 Self::PostIterationStart => "post.iteration.start",
1227 Self::PrePlanCreated => "pre.plan.created",
1228 Self::PostPlanCreated => "post.plan.created",
1229 Self::PreHumanInteract => "pre.human.interact",
1230 Self::PostHumanInteract => "post.human.interact",
1231 Self::PreLoopComplete => "pre.loop.complete",
1232 Self::PostLoopComplete => "post.loop.complete",
1233 Self::PreLoopError => "pre.loop.error",
1234 Self::PostLoopError => "post.loop.error",
1235 }
1236 }
1237
1238 pub fn parse(value: &str) -> Option<Self> {
1240 match value {
1241 "pre.loop.start" => Some(Self::PreLoopStart),
1242 "post.loop.start" => Some(Self::PostLoopStart),
1243 "pre.iteration.start" => Some(Self::PreIterationStart),
1244 "post.iteration.start" => Some(Self::PostIterationStart),
1245 "pre.plan.created" => Some(Self::PrePlanCreated),
1246 "post.plan.created" => Some(Self::PostPlanCreated),
1247 "pre.human.interact" => Some(Self::PreHumanInteract),
1248 "post.human.interact" => Some(Self::PostHumanInteract),
1249 "pre.loop.complete" => Some(Self::PreLoopComplete),
1250 "post.loop.complete" => Some(Self::PostLoopComplete),
1251 "pre.loop.error" => Some(Self::PreLoopError),
1252 "post.loop.error" => Some(Self::PostLoopError),
1253 _ => None,
1254 }
1255 }
1256}
1257
1258impl std::fmt::Display for HookPhaseEvent {
1259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1260 f.write_str((*self).as_str())
1261 }
1262}
1263
1264fn validate_hooks_phase_event_keys(value: &serde_yaml::Value) -> Result<(), ConfigError> {
1265 let Some(root) = value.as_mapping() else {
1266 return Ok(());
1267 };
1268
1269 let Some(hooks) = root.get(serde_yaml::Value::String("hooks".to_string())) else {
1270 return Ok(());
1271 };
1272
1273 let Some(hooks_map) = hooks.as_mapping() else {
1274 return Ok(());
1275 };
1276
1277 let Some(events) = hooks_map.get(serde_yaml::Value::String("events".to_string())) else {
1278 return Ok(());
1279 };
1280
1281 let Some(events_map) = events.as_mapping() else {
1282 return Ok(());
1283 };
1284
1285 for key in events_map.keys() {
1286 if let Some(phase_event) = key.as_str()
1287 && HookPhaseEvent::parse(phase_event).is_none()
1288 {
1289 return Err(ConfigError::InvalidHookPhaseEvent {
1290 phase_event: phase_event.to_string(),
1291 });
1292 }
1293 }
1294
1295 Ok(())
1296}
1297
1298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1300#[serde(rename_all = "snake_case")]
1301pub enum HookOnError {
1302 Warn,
1304 Block,
1306 Suspend,
1308}
1309
1310#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1312#[serde(rename_all = "snake_case")]
1313pub enum HookSuspendMode {
1314 #[default]
1316 WaitForResume,
1317 RetryBackoff,
1319 WaitThenRetry,
1321}
1322
1323#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1325pub struct HookMutationConfig {
1326 #[serde(default)]
1328 pub enabled: bool,
1329
1330 #[serde(default)]
1332 pub format: Option<String>,
1333
1334 #[serde(default, flatten)]
1336 pub extra: HashMap<String, serde_yaml::Value>,
1337}
1338
1339#[derive(Debug, Clone, Serialize, Deserialize)]
1341pub struct HookSpec {
1342 #[serde(default)]
1344 pub name: String,
1345
1346 #[serde(default)]
1348 pub command: Vec<String>,
1349
1350 #[serde(default)]
1352 pub cwd: Option<PathBuf>,
1353
1354 #[serde(default)]
1356 pub env: HashMap<String, String>,
1357
1358 #[serde(default)]
1360 pub timeout_seconds: Option<u64>,
1361
1362 #[serde(default)]
1364 pub max_output_bytes: Option<u64>,
1365
1366 #[serde(default)]
1368 pub on_error: Option<HookOnError>,
1369
1370 #[serde(default)]
1372 pub suspend_mode: Option<HookSuspendMode>,
1373
1374 #[serde(default)]
1376 pub mutate: HookMutationConfig,
1377
1378 #[serde(default, flatten)]
1380 pub extra: HashMap<String, serde_yaml::Value>,
1381}
1382
1383#[derive(Debug, Clone, Serialize, Deserialize)]
1406pub struct SkillsConfig {
1407 #[serde(default = "default_true")]
1409 pub enabled: bool,
1410
1411 #[serde(default)]
1414 pub dirs: Vec<PathBuf>,
1415
1416 #[serde(default)]
1418 pub overrides: HashMap<String, SkillOverride>,
1419}
1420
1421impl Default for SkillsConfig {
1422 fn default() -> Self {
1423 Self {
1424 enabled: true, dirs: vec![],
1426 overrides: HashMap::new(),
1427 }
1428 }
1429}
1430
1431#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1436pub struct SkillOverride {
1437 #[serde(default)]
1439 pub enabled: Option<bool>,
1440
1441 #[serde(default)]
1443 pub hats: Vec<String>,
1444
1445 #[serde(default)]
1447 pub backends: Vec<String>,
1448
1449 #[serde(default)]
1451 pub tags: Vec<String>,
1452
1453 #[serde(default)]
1455 pub auto_inject: Option<bool>,
1456}
1457
1458#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1460pub struct PreflightConfig {
1461 #[serde(default)]
1463 pub enabled: bool,
1464
1465 #[serde(default)]
1467 pub strict: bool,
1468
1469 #[serde(default)]
1471 pub skip: Vec<String>,
1472}
1473
1474#[derive(Debug, Clone, Serialize, Deserialize)]
1490pub struct FeaturesConfig {
1491 #[serde(default = "default_true")]
1496 pub parallel: bool,
1497
1498 #[serde(default)]
1504 pub auto_merge: bool,
1505
1506 #[serde(default)]
1512 pub loop_naming: crate::loop_name::LoopNamingConfig,
1513
1514 #[serde(default)]
1516 pub preflight: PreflightConfig,
1517}
1518
1519impl Default for FeaturesConfig {
1520 fn default() -> Self {
1521 Self {
1522 parallel: true, auto_merge: false, loop_naming: crate::loop_name::LoopNamingConfig::default(),
1525 preflight: PreflightConfig::default(),
1526 }
1527 }
1528}
1529
1530fn default_prefix_key() -> String {
1531 "ctrl-a".to_string()
1532}
1533
1534impl Default for TuiConfig {
1535 fn default() -> Self {
1536 Self {
1537 prefix_key: default_prefix_key(),
1538 }
1539 }
1540}
1541
1542impl TuiConfig {
1543 pub fn parse_prefix(
1546 &self,
1547 ) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
1548 use crossterm::event::{KeyCode, KeyModifiers};
1549
1550 let parts: Vec<&str> = self.prefix_key.split('-').collect();
1551 if parts.len() != 2 {
1552 return Err(format!(
1553 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
1554 self.prefix_key
1555 ));
1556 }
1557
1558 let modifier = match parts[0].to_lowercase().as_str() {
1559 "ctrl" => KeyModifiers::CONTROL,
1560 _ => {
1561 return Err(format!(
1562 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
1563 parts[0]
1564 ));
1565 }
1566 };
1567
1568 let key_str = parts[1];
1569 if key_str.len() != 1 {
1570 return Err(format!(
1571 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
1572 key_str
1573 ));
1574 }
1575
1576 let key_char = key_str.chars().next().unwrap();
1577 let key_code = KeyCode::Char(key_char);
1578
1579 Ok((key_code, modifier))
1580 }
1581}
1582
1583#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1598pub struct EventMetadata {
1599 #[serde(default)]
1601 pub description: String,
1602
1603 #[serde(default)]
1606 pub on_trigger: String,
1607
1608 #[serde(default)]
1611 pub on_publish: String,
1612}
1613
1614#[derive(Debug, Clone, Serialize, Deserialize)]
1616#[serde(untagged)]
1617pub enum HatBackend {
1618 KiroAgent {
1621 #[serde(rename = "type")]
1622 backend_type: String,
1623 agent: String,
1624 #[serde(default)]
1625 args: Vec<String>,
1626 },
1627 NamedWithArgs {
1629 #[serde(rename = "type")]
1630 backend_type: String,
1631 #[serde(default)]
1632 args: Vec<String>,
1633 },
1634 Named(String),
1636 Custom {
1638 command: String,
1639 #[serde(default)]
1640 args: Vec<String>,
1641 },
1642}
1643
1644impl HatBackend {
1645 pub fn to_cli_backend(&self) -> String {
1647 match self {
1648 HatBackend::Named(name) => name.clone(),
1649 HatBackend::NamedWithArgs { backend_type, .. } => backend_type.clone(),
1650 HatBackend::KiroAgent { backend_type, .. } => backend_type.clone(),
1651 HatBackend::Custom { .. } => "custom".to_string(),
1652 }
1653 }
1654}
1655
1656#[derive(Debug, Clone, Serialize, Deserialize)]
1658pub struct HatConfig {
1659 pub name: String,
1661
1662 pub description: Option<String>,
1665
1666 #[serde(default)]
1669 pub triggers: Vec<String>,
1670
1671 #[serde(default)]
1673 pub publishes: Vec<String>,
1674
1675 #[serde(default)]
1677 pub instructions: String,
1678
1679 #[serde(default)]
1696 pub extra_instructions: Vec<String>,
1697
1698 #[serde(default)]
1700 pub backend: Option<HatBackend>,
1701
1702 #[serde(default, alias = "args")]
1706 pub backend_args: Option<Vec<String>>,
1707
1708 #[serde(default)]
1710 pub default_publishes: Option<String>,
1711
1712 pub max_activations: Option<u32>,
1717
1718 #[serde(default)]
1724 pub disallowed_tools: Vec<String>,
1725}
1726
1727impl HatConfig {
1728 pub fn trigger_topics(&self) -> Vec<Topic> {
1730 self.triggers.iter().map(|s| Topic::new(s)).collect()
1731 }
1732
1733 pub fn publish_topics(&self) -> Vec<Topic> {
1735 self.publishes.iter().map(|s| Topic::new(s)).collect()
1736 }
1737}
1738
1739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1756pub struct RobotConfig {
1757 #[serde(default)]
1759 pub enabled: bool,
1760
1761 pub timeout_seconds: Option<u64>,
1764
1765 pub checkin_interval_seconds: Option<u64>,
1769
1770 #[serde(default)]
1772 pub telegram: Option<TelegramBotConfig>,
1773}
1774
1775impl RobotConfig {
1776 pub fn validate(&self) -> Result<(), ConfigError> {
1778 if !self.enabled {
1779 return Ok(());
1780 }
1781
1782 if self.timeout_seconds.is_none() {
1783 return Err(ConfigError::RobotMissingField {
1784 field: "RObot.timeout_seconds".to_string(),
1785 hint: "timeout_seconds is required when RObot is enabled".to_string(),
1786 });
1787 }
1788
1789 if self.resolve_bot_token().is_none() {
1791 return Err(ConfigError::RobotMissingField {
1792 field: "RObot.telegram.bot_token".to_string(),
1793 hint: "Run `ralph bot onboard --telegram`, set RALPH_TELEGRAM_BOT_TOKEN env var, or set RObot.telegram.bot_token in config"
1794 .to_string(),
1795 });
1796 }
1797
1798 Ok(())
1799 }
1800
1801 pub fn resolve_bot_token(&self) -> Option<String> {
1808 let env_token = std::env::var("RALPH_TELEGRAM_BOT_TOKEN").ok();
1810 let config_token = self
1811 .telegram
1812 .as_ref()
1813 .and_then(|telegram| telegram.bot_token.clone());
1814
1815 if cfg!(test) {
1816 return env_token.or(config_token);
1817 }
1818
1819 env_token
1820 .or(config_token)
1822 .or_else(|| {
1824 std::panic::catch_unwind(|| {
1825 keyring::Entry::new("ralph", "telegram-bot-token")
1826 .ok()
1827 .and_then(|e| e.get_password().ok())
1828 })
1829 .ok()
1830 .flatten()
1831 })
1832 }
1833}
1834
1835#[derive(Debug, Clone, Serialize, Deserialize)]
1837pub struct TelegramBotConfig {
1838 pub bot_token: Option<String>,
1840}
1841
1842#[derive(Debug, thiserror::Error)]
1844pub enum ConfigError {
1845 #[error("IO error: {0}")]
1846 Io(#[from] std::io::Error),
1847
1848 #[error("YAML parse error: {0}")]
1849 Yaml(#[from] serde_yaml::Error),
1850
1851 #[error(
1852 "Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'.\nFix: ensure only one hat claims this trigger or delegate with a new event.\nSee: docs/reference/troubleshooting.md#ambiguous-routing"
1853 )]
1854 AmbiguousRouting {
1855 trigger: String,
1856 hat1: String,
1857 hat2: String,
1858 },
1859
1860 #[error(
1861 "Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified.\nFix: remove one field or split into separate configs.\nSee: docs/reference/troubleshooting.md#mutually-exclusive-fields"
1862 )]
1863 MutuallyExclusive { field1: String, field2: String },
1864
1865 #[error("Invalid completion_promise: must be non-empty and non-whitespace")]
1866 InvalidCompletionPromise,
1867
1868 #[error(
1869 "Custom backend requires a command.\nFix: set 'cli.command' in your config (or run `ralph init --backend custom`).\nSee: docs/reference/troubleshooting.md#custom-backend-command"
1870 )]
1871 CustomBackendRequiresCommand,
1872
1873 #[error(
1874 "Reserved trigger '{trigger}' used by hat '{hat}' - task.start and task.resume are reserved for Ralph (the coordinator). Use a delegated event like 'work.start' instead.\nSee: docs/reference/troubleshooting.md#reserved-trigger"
1875 )]
1876 ReservedTrigger { trigger: String, hat: String },
1877
1878 #[error(
1879 "Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose.\nSee: docs/reference/troubleshooting.md#missing-hat-description"
1880 )]
1881 MissingDescription { hat: String },
1882
1883 #[error(
1884 "RObot config error: {field} - {hint}\nSee: docs/reference/troubleshooting.md#robot-config"
1885 )]
1886 RobotMissingField { field: String, hint: String },
1887
1888 #[error(
1889 "Invalid hooks phase-event '{phase_event}'. Supported v1 phase-events: pre.loop.start, post.loop.start, pre.iteration.start, post.iteration.start, pre.plan.created, post.plan.created, pre.human.interact, post.human.interact, pre.loop.complete, post.loop.complete, pre.loop.error, post.loop.error.\nFix: use one of the supported keys under hooks.events."
1890 )]
1891 InvalidHookPhaseEvent { phase_event: String },
1892
1893 #[error(
1894 "Hook config validation error at '{field}': {message}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#hookspec-fields-v1"
1895 )]
1896 HookValidation { field: String, message: String },
1897
1898 #[error(
1899 "Unsupported hooks field '{field}' for v1. {reason}\nSee: specs/add-hooks-to-ralph-orchestrator-lifecycle/design.md#out-of-scope-v1-non-goals"
1900 )]
1901 UnsupportedHookField { field: String, reason: String },
1902
1903 #[error(
1904 "Invalid config key 'project'. Use 'core' instead (e.g. 'core.specs_dir' instead of 'project.specs_dir').\nSee: docs/guide/configuration.md"
1905 )]
1906 DeprecatedProjectKey,
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911 use super::*;
1912
1913 #[test]
1914 fn test_default_config() {
1915 let config = RalphConfig::default();
1916 assert!(config.hats.is_empty());
1918 assert_eq!(config.event_loop.max_iterations, 100);
1919 assert!(!config.verbose);
1920 assert!(!config.features.preflight.enabled);
1921 assert!(!config.features.preflight.strict);
1922 assert!(config.features.preflight.skip.is_empty());
1923 }
1924
1925 #[test]
1926 fn test_parse_yaml_with_custom_hats() {
1927 let yaml = r#"
1928event_loop:
1929 prompt_file: "TASK.md"
1930 completion_promise: "DONE"
1931 max_iterations: 50
1932cli:
1933 backend: "claude"
1934hats:
1935 implementer:
1936 name: "Implementer"
1937 triggers: ["task.*", "review.done"]
1938 publishes: ["impl.done"]
1939 instructions: "You are the implementation agent."
1940"#;
1941 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1942 assert_eq!(config.hats.len(), 1);
1944 assert_eq!(config.event_loop.prompt_file, "TASK.md");
1945
1946 let hat = config.hats.get("implementer").unwrap();
1947 assert_eq!(hat.triggers.len(), 2);
1948 }
1949
1950 #[test]
1951 fn test_preflight_config_deserialize() {
1952 let yaml = r#"
1953features:
1954 preflight:
1955 enabled: true
1956 strict: true
1957 skip: ["telegram", "git"]
1958"#;
1959 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1960 assert!(config.features.preflight.enabled);
1961 assert!(config.features.preflight.strict);
1962 assert_eq!(
1963 config.features.preflight.skip,
1964 vec!["telegram".to_string(), "git".to_string()]
1965 );
1966 }
1967
1968 #[test]
1969 fn test_parse_yaml_v1_format() {
1970 let yaml = r#"
1972agent: gemini
1973prompt_file: "TASK.md"
1974completion_promise: "RALPH_DONE"
1975max_iterations: 75
1976max_runtime: 7200
1977max_cost: 10.0
1978verbose: true
1979"#;
1980 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1981
1982 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
1988
1989 assert_eq!(config.cli.backend, "gemini");
1991 assert_eq!(config.event_loop.prompt_file, "TASK.md");
1992 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
1993 assert_eq!(config.event_loop.max_iterations, 75);
1994 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
1995 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
1996 assert!(config.verbose);
1997 }
1998
1999 #[test]
2000 fn test_agent_priority() {
2001 let yaml = r"
2002agent: auto
2003agent_priority: [gemini, claude, codex]
2004";
2005 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2006 let priority = config.get_agent_priority();
2007 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
2008 }
2009
2010 #[test]
2011 fn test_default_agent_priority() {
2012 let config = RalphConfig::default();
2013 let priority = config.get_agent_priority();
2014 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
2015 }
2016
2017 #[test]
2018 fn test_validate_deferred_features() {
2019 let yaml = r"
2020archive_prompts: true
2021enable_metrics: true
2022";
2023 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2024 let warnings = config.validate().unwrap();
2025
2026 assert_eq!(warnings.len(), 2);
2027 assert!(warnings
2028 .iter()
2029 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
2030 assert!(warnings
2031 .iter()
2032 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
2033 }
2034
2035 #[test]
2036 fn test_validate_dropped_fields() {
2037 let yaml = r#"
2038max_tokens: 4096
2039retry_delay: 5
2040adapters:
2041 claude:
2042 tool_permissions: ["read", "write"]
2043"#;
2044 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2045 let warnings = config.validate().unwrap();
2046
2047 assert_eq!(warnings.len(), 3);
2048 assert!(warnings.iter().any(
2049 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")
2050 ));
2051 assert!(warnings.iter().any(
2052 |w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")
2053 ));
2054 assert!(warnings
2055 .iter()
2056 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
2057 }
2058
2059 #[test]
2060 fn test_suppress_warnings() {
2061 let yaml = r"
2062_suppress_warnings: true
2063archive_prompts: true
2064max_tokens: 4096
2065";
2066 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2067 let warnings = config.validate().unwrap();
2068
2069 assert!(warnings.is_empty());
2071 }
2072
2073 #[test]
2074 fn test_adapter_settings() {
2075 let yaml = r"
2076adapters:
2077 claude:
2078 timeout: 600
2079 enabled: true
2080 gemini:
2081 timeout: 300
2082 enabled: false
2083";
2084 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2085
2086 let claude = config.adapter_settings("claude");
2087 assert_eq!(claude.timeout, 600);
2088 assert!(claude.enabled);
2089
2090 let gemini = config.adapter_settings("gemini");
2091 assert_eq!(gemini.timeout, 300);
2092 assert!(!gemini.enabled);
2093 }
2094
2095 #[test]
2096 fn test_unknown_fields_ignored() {
2097 let yaml = r#"
2099agent: claude
2100unknown_field: "some value"
2101future_feature: true
2102"#;
2103 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
2104 assert!(result.is_ok());
2106 }
2107
2108 #[test]
2109 fn test_custom_backend_args_shorthand() {
2110 let yaml = r#"
2111hats:
2112 opencode_builder:
2113 name: "Opencode"
2114 description: "Opencode hat"
2115 backend: "opencode"
2116 args: ["-m", "model"]
2117"#;
2118 let config = RalphConfig::parse_yaml(yaml).unwrap();
2119 let hat = config.hats.get("opencode_builder").unwrap();
2120 assert!(hat.backend_args.is_some());
2121 assert_eq!(
2122 hat.backend_args.as_ref().unwrap(),
2123 &vec!["-m".to_string(), "model".to_string()]
2124 );
2125 }
2126
2127 #[test]
2128 fn test_custom_backend_args_explicit_key() {
2129 let yaml = r#"
2130hats:
2131 opencode_builder:
2132 name: "Opencode"
2133 description: "Opencode hat"
2134 backend: "opencode"
2135 backend_args: ["-m", "model"]
2136"#;
2137 let config = RalphConfig::parse_yaml(yaml).unwrap();
2138 let hat = config.hats.get("opencode_builder").unwrap();
2139 assert!(hat.backend_args.is_some());
2140 assert_eq!(
2141 hat.backend_args.as_ref().unwrap(),
2142 &vec!["-m".to_string(), "model".to_string()]
2143 );
2144 }
2145
2146 #[test]
2147 fn test_project_key_rejected() {
2148 let yaml = r#"
2149project:
2150 specs_dir: "my_specs"
2151"#;
2152 let result = RalphConfig::parse_yaml(yaml);
2153 assert!(result.is_err());
2154 assert!(matches!(
2155 result.unwrap_err(),
2156 ConfigError::DeprecatedProjectKey
2157 ));
2158 }
2159
2160 #[test]
2161 fn test_ambiguous_routing_rejected() {
2162 let yaml = r#"
2165hats:
2166 planner:
2167 name: "Planner"
2168 description: "Plans tasks"
2169 triggers: ["planning.start", "build.done"]
2170 builder:
2171 name: "Builder"
2172 description: "Builds code"
2173 triggers: ["build.task", "build.done"]
2174"#;
2175 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2176 let result = config.validate();
2177
2178 assert!(result.is_err());
2179 let err = result.unwrap_err();
2180 assert!(
2181 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
2182 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
2183 err
2184 );
2185 }
2186
2187 #[test]
2188 fn test_unique_triggers_accepted() {
2189 let yaml = r#"
2192hats:
2193 planner:
2194 name: "Planner"
2195 description: "Plans tasks"
2196 triggers: ["planning.start", "build.done", "build.blocked"]
2197 builder:
2198 name: "Builder"
2199 description: "Builds code"
2200 triggers: ["build.task"]
2201"#;
2202 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2203 let result = config.validate();
2204
2205 assert!(
2206 result.is_ok(),
2207 "Expected valid config, got: {:?}",
2208 result.unwrap_err()
2209 );
2210 }
2211
2212 #[test]
2213 fn test_reserved_trigger_task_start_rejected() {
2214 let yaml = r#"
2216hats:
2217 my_hat:
2218 name: "My Hat"
2219 description: "Test hat"
2220 triggers: ["task.start"]
2221"#;
2222 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2223 let result = config.validate();
2224
2225 assert!(result.is_err());
2226 let err = result.unwrap_err();
2227 assert!(
2228 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2229 if trigger == "task.start" && hat == "my_hat"),
2230 "Expected ReservedTrigger error for 'task.start', got: {:?}",
2231 err
2232 );
2233 }
2234
2235 #[test]
2236 fn test_reserved_trigger_task_resume_rejected() {
2237 let yaml = r#"
2239hats:
2240 my_hat:
2241 name: "My Hat"
2242 description: "Test hat"
2243 triggers: ["task.resume", "other.event"]
2244"#;
2245 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2246 let result = config.validate();
2247
2248 assert!(result.is_err());
2249 let err = result.unwrap_err();
2250 assert!(
2251 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
2252 if trigger == "task.resume" && hat == "my_hat"),
2253 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
2254 err
2255 );
2256 }
2257
2258 #[test]
2259 fn test_missing_description_rejected() {
2260 let yaml = r#"
2262hats:
2263 my_hat:
2264 name: "My Hat"
2265 triggers: ["build.task"]
2266"#;
2267 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2268 let result = config.validate();
2269
2270 assert!(result.is_err());
2271 let err = result.unwrap_err();
2272 assert!(
2273 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2274 "Expected MissingDescription error, got: {:?}",
2275 err
2276 );
2277 }
2278
2279 #[test]
2280 fn test_empty_description_rejected() {
2281 let yaml = r#"
2283hats:
2284 my_hat:
2285 name: "My Hat"
2286 description: " "
2287 triggers: ["build.task"]
2288"#;
2289 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2290 let result = config.validate();
2291
2292 assert!(result.is_err());
2293 let err = result.unwrap_err();
2294 assert!(
2295 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
2296 "Expected MissingDescription error for empty description, got: {:?}",
2297 err
2298 );
2299 }
2300
2301 #[test]
2302 fn test_core_config_defaults() {
2303 let config = RalphConfig::default();
2304 assert_eq!(config.core.scratchpad, ".ralph/agent/scratchpad.md");
2305 assert_eq!(config.core.specs_dir, ".ralph/specs/");
2306 assert_eq!(config.core.guardrails.len(), 5);
2308 assert!(config.core.guardrails[0].contains("Fresh context"));
2309 assert!(config.core.guardrails[1].contains("search first"));
2310 assert!(config.core.guardrails[2].contains("Backpressure"));
2311 assert!(config.core.guardrails[3].contains("Confidence protocol"));
2312 assert!(config.core.guardrails[4].contains("Commit atomically"));
2313 }
2314
2315 #[test]
2316 fn test_core_config_customizable() {
2317 let yaml = r#"
2318core:
2319 scratchpad: ".workspace/plan.md"
2320 specs_dir: "./specifications/"
2321"#;
2322 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2323 assert_eq!(config.core.scratchpad, ".workspace/plan.md");
2324 assert_eq!(config.core.specs_dir, "./specifications/");
2325 assert_eq!(config.core.guardrails.len(), 5);
2327 }
2328
2329 #[test]
2330 fn test_core_config_custom_guardrails() {
2331 let yaml = r#"
2332core:
2333 scratchpad: ".ralph/agent/scratchpad.md"
2334 specs_dir: "./specs/"
2335 guardrails:
2336 - "Custom rule one"
2337 - "Custom rule two"
2338"#;
2339 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2340 assert_eq!(config.core.guardrails.len(), 2);
2341 assert_eq!(config.core.guardrails[0], "Custom rule one");
2342 assert_eq!(config.core.guardrails[1], "Custom rule two");
2343 }
2344
2345 #[test]
2346 fn test_prompt_and_prompt_file_mutually_exclusive() {
2347 let yaml = r#"
2349event_loop:
2350 prompt: "inline text"
2351 prompt_file: "custom.md"
2352"#;
2353 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2354 let result = config.validate();
2355
2356 assert!(result.is_err());
2357 let err = result.unwrap_err();
2358 assert!(
2359 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
2360 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
2361 "Expected MutuallyExclusive error, got: {:?}",
2362 err
2363 );
2364 }
2365
2366 #[test]
2367 fn test_prompt_with_default_prompt_file_allowed() {
2368 let yaml = r#"
2370event_loop:
2371 prompt: "inline text"
2372"#;
2373 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2374 let result = config.validate();
2375
2376 assert!(
2377 result.is_ok(),
2378 "Should allow inline prompt with default prompt_file"
2379 );
2380 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
2381 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2382 }
2383
2384 #[test]
2385 fn test_custom_backend_requires_command() {
2386 let yaml = r#"
2388cli:
2389 backend: "custom"
2390"#;
2391 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2392 let result = config.validate();
2393
2394 assert!(result.is_err());
2395 let err = result.unwrap_err();
2396 assert!(
2397 matches!(&err, ConfigError::CustomBackendRequiresCommand),
2398 "Expected CustomBackendRequiresCommand error, got: {:?}",
2399 err
2400 );
2401 }
2402
2403 #[test]
2404 fn test_empty_completion_promise_rejected() {
2405 let yaml = r#"
2406event_loop:
2407 completion_promise: " "
2408"#;
2409 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2410 let result = config.validate();
2411
2412 assert!(result.is_err());
2413 let err = result.unwrap_err();
2414 assert!(
2415 matches!(&err, ConfigError::InvalidCompletionPromise),
2416 "Expected InvalidCompletionPromise error, got: {:?}",
2417 err
2418 );
2419 }
2420
2421 #[test]
2422 fn test_custom_backend_with_empty_command_errors() {
2423 let yaml = r#"
2425cli:
2426 backend: "custom"
2427 command: ""
2428"#;
2429 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2430 let result = config.validate();
2431
2432 assert!(result.is_err());
2433 let err = result.unwrap_err();
2434 assert!(
2435 matches!(&err, ConfigError::CustomBackendRequiresCommand),
2436 "Expected CustomBackendRequiresCommand error, got: {:?}",
2437 err
2438 );
2439 }
2440
2441 #[test]
2442 fn test_custom_backend_with_command_succeeds() {
2443 let yaml = r#"
2445cli:
2446 backend: "custom"
2447 command: "my-agent"
2448"#;
2449 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2450 let result = config.validate();
2451
2452 assert!(
2453 result.is_ok(),
2454 "Should allow custom backend with command: {:?}",
2455 result.unwrap_err()
2456 );
2457 }
2458
2459 #[test]
2460 fn test_custom_backend_requires_command_message_actionable() {
2461 let err = ConfigError::CustomBackendRequiresCommand;
2462 let msg = err.to_string();
2463 assert!(msg.contains("cli.command"));
2464 assert!(msg.contains("ralph init --backend custom"));
2465 assert!(msg.contains("docs/reference/troubleshooting.md#custom-backend-command"));
2466 }
2467
2468 #[test]
2469 fn test_reserved_trigger_message_actionable() {
2470 let err = ConfigError::ReservedTrigger {
2471 trigger: "task.start".to_string(),
2472 hat: "builder".to_string(),
2473 };
2474 let msg = err.to_string();
2475 assert!(msg.contains("Reserved trigger"));
2476 assert!(msg.contains("docs/reference/troubleshooting.md#reserved-trigger"));
2477 }
2478
2479 #[test]
2480 fn test_prompt_file_with_no_inline_allowed() {
2481 let yaml = r#"
2483event_loop:
2484 prompt_file: "custom.md"
2485"#;
2486 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2487 let result = config.validate();
2488
2489 assert!(
2490 result.is_ok(),
2491 "Should allow prompt_file without inline prompt"
2492 );
2493 assert_eq!(config.event_loop.prompt, None);
2494 assert_eq!(config.event_loop.prompt_file, "custom.md");
2495 }
2496
2497 #[test]
2498 fn test_default_prompt_file_value() {
2499 let config = RalphConfig::default();
2500 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
2501 assert_eq!(config.event_loop.prompt, None);
2502 }
2503
2504 #[test]
2505 fn test_tui_config_default() {
2506 let config = RalphConfig::default();
2507 assert_eq!(config.tui.prefix_key, "ctrl-a");
2508 }
2509
2510 #[test]
2511 fn test_tui_config_parse_ctrl_b() {
2512 let yaml = r#"
2513tui:
2514 prefix_key: "ctrl-b"
2515"#;
2516 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2517 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
2518
2519 use crossterm::event::{KeyCode, KeyModifiers};
2520 assert_eq!(key_code, KeyCode::Char('b'));
2521 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
2522 }
2523
2524 #[test]
2525 fn test_tui_config_parse_invalid_format() {
2526 let tui_config = TuiConfig {
2527 prefix_key: "invalid".to_string(),
2528 };
2529 let result = tui_config.parse_prefix();
2530 assert!(result.is_err());
2531 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
2532 }
2533
2534 #[test]
2535 fn test_tui_config_parse_invalid_modifier() {
2536 let tui_config = TuiConfig {
2537 prefix_key: "alt-a".to_string(),
2538 };
2539 let result = tui_config.parse_prefix();
2540 assert!(result.is_err());
2541 assert!(result.unwrap_err().contains("Invalid modifier"));
2542 }
2543
2544 #[test]
2545 fn test_tui_config_parse_invalid_key() {
2546 let tui_config = TuiConfig {
2547 prefix_key: "ctrl-abc".to_string(),
2548 };
2549 let result = tui_config.parse_prefix();
2550 assert!(result.is_err());
2551 assert!(result.unwrap_err().contains("Invalid key"));
2552 }
2553
2554 #[test]
2555 fn test_hat_backend_named() {
2556 let yaml = r#""claude""#;
2557 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2558 assert_eq!(backend.to_cli_backend(), "claude");
2559 match backend {
2560 HatBackend::Named(name) => assert_eq!(name, "claude"),
2561 _ => panic!("Expected Named variant"),
2562 }
2563 }
2564
2565 #[test]
2566 fn test_hat_backend_kiro_agent() {
2567 let yaml = r#"
2568type: "kiro"
2569agent: "builder"
2570"#;
2571 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2572 assert_eq!(backend.to_cli_backend(), "kiro");
2573 match backend {
2574 HatBackend::KiroAgent {
2575 backend_type,
2576 agent,
2577 args,
2578 } => {
2579 assert_eq!(backend_type, "kiro");
2580 assert_eq!(agent, "builder");
2581 assert!(args.is_empty());
2582 }
2583 _ => panic!("Expected KiroAgent variant"),
2584 }
2585 }
2586
2587 #[test]
2588 fn test_hat_backend_kiro_agent_with_args() {
2589 let yaml = r#"
2590type: "kiro"
2591agent: "builder"
2592args: ["--verbose", "--debug"]
2593"#;
2594 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2595 assert_eq!(backend.to_cli_backend(), "kiro");
2596 match backend {
2597 HatBackend::KiroAgent {
2598 backend_type,
2599 agent,
2600 args,
2601 } => {
2602 assert_eq!(backend_type, "kiro");
2603 assert_eq!(agent, "builder");
2604 assert_eq!(args, vec!["--verbose", "--debug"]);
2605 }
2606 _ => panic!("Expected KiroAgent variant"),
2607 }
2608 }
2609
2610 #[test]
2611 fn test_hat_backend_named_with_args() {
2612 let yaml = r#"
2613type: "claude"
2614args: ["--model", "claude-sonnet-4"]
2615"#;
2616 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2617 assert_eq!(backend.to_cli_backend(), "claude");
2618 match backend {
2619 HatBackend::NamedWithArgs { backend_type, args } => {
2620 assert_eq!(backend_type, "claude");
2621 assert_eq!(args, vec!["--model", "claude-sonnet-4"]);
2622 }
2623 _ => panic!("Expected NamedWithArgs variant"),
2624 }
2625 }
2626
2627 #[test]
2628 fn test_hat_backend_named_with_args_empty() {
2629 let yaml = r#"
2631type: "gemini"
2632"#;
2633 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2634 assert_eq!(backend.to_cli_backend(), "gemini");
2635 match backend {
2636 HatBackend::NamedWithArgs { backend_type, args } => {
2637 assert_eq!(backend_type, "gemini");
2638 assert!(args.is_empty());
2639 }
2640 _ => panic!("Expected NamedWithArgs variant"),
2641 }
2642 }
2643
2644 #[test]
2645 fn test_hat_backend_custom() {
2646 let yaml = r#"
2647command: "/usr/bin/my-agent"
2648args: ["--flag", "value"]
2649"#;
2650 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
2651 assert_eq!(backend.to_cli_backend(), "custom");
2652 match backend {
2653 HatBackend::Custom { command, args } => {
2654 assert_eq!(command, "/usr/bin/my-agent");
2655 assert_eq!(args, vec!["--flag", "value"]);
2656 }
2657 _ => panic!("Expected Custom variant"),
2658 }
2659 }
2660
2661 #[test]
2662 fn test_hat_config_with_backend() {
2663 let yaml = r#"
2664name: "Custom Builder"
2665triggers: ["build.task"]
2666publishes: ["build.done"]
2667instructions: "Build stuff"
2668backend: "gemini"
2669default_publishes: "task.done"
2670"#;
2671 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2672 assert_eq!(hat.name, "Custom Builder");
2673 assert!(hat.backend.is_some());
2674 match hat.backend.unwrap() {
2675 HatBackend::Named(name) => assert_eq!(name, "gemini"),
2676 _ => panic!("Expected Named backend"),
2677 }
2678 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
2679 }
2680
2681 #[test]
2682 fn test_hat_config_without_backend() {
2683 let yaml = r#"
2684name: "Default Hat"
2685triggers: ["task.start"]
2686publishes: ["task.done"]
2687instructions: "Do work"
2688"#;
2689 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
2690 assert_eq!(hat.name, "Default Hat");
2691 assert!(hat.backend.is_none());
2692 assert!(hat.default_publishes.is_none());
2693 }
2694
2695 #[test]
2696 fn test_mixed_backends_config() {
2697 let yaml = r#"
2698event_loop:
2699 prompt_file: "TASK.md"
2700 max_iterations: 50
2701
2702cli:
2703 backend: "claude"
2704
2705hats:
2706 planner:
2707 name: "Planner"
2708 triggers: ["task.start"]
2709 publishes: ["build.task"]
2710 instructions: "Plan the work"
2711 backend: "claude"
2712
2713 builder:
2714 name: "Builder"
2715 triggers: ["build.task"]
2716 publishes: ["build.done"]
2717 instructions: "Build the thing"
2718 backend:
2719 type: "kiro"
2720 agent: "builder"
2721
2722 reviewer:
2723 name: "Reviewer"
2724 triggers: ["build.done"]
2725 publishes: ["review.complete"]
2726 instructions: "Review the work"
2727 backend:
2728 command: "/usr/local/bin/custom-agent"
2729 args: ["--mode", "review"]
2730 default_publishes: "review.complete"
2731"#;
2732 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2733 assert_eq!(config.hats.len(), 3);
2734
2735 let planner = config.hats.get("planner").unwrap();
2737 assert!(planner.backend.is_some());
2738 match planner.backend.as_ref().unwrap() {
2739 HatBackend::Named(name) => assert_eq!(name, "claude"),
2740 _ => panic!("Expected Named backend for planner"),
2741 }
2742
2743 let builder = config.hats.get("builder").unwrap();
2745 assert!(builder.backend.is_some());
2746 match builder.backend.as_ref().unwrap() {
2747 HatBackend::KiroAgent {
2748 backend_type,
2749 agent,
2750 args,
2751 } => {
2752 assert_eq!(backend_type, "kiro");
2753 assert_eq!(agent, "builder");
2754 assert!(args.is_empty());
2755 }
2756 _ => panic!("Expected KiroAgent backend for builder"),
2757 }
2758
2759 let reviewer = config.hats.get("reviewer").unwrap();
2761 assert!(reviewer.backend.is_some());
2762 match reviewer.backend.as_ref().unwrap() {
2763 HatBackend::Custom { command, args } => {
2764 assert_eq!(command, "/usr/local/bin/custom-agent");
2765 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
2766 }
2767 _ => panic!("Expected Custom backend for reviewer"),
2768 }
2769 assert_eq!(
2770 reviewer.default_publishes,
2771 Some("review.complete".to_string())
2772 );
2773 }
2774
2775 #[test]
2776 fn test_features_config_auto_merge_defaults_to_false() {
2777 let config = RalphConfig::default();
2780 assert!(
2781 !config.features.auto_merge,
2782 "auto_merge should default to false"
2783 );
2784 }
2785
2786 #[test]
2787 fn test_features_config_auto_merge_from_yaml() {
2788 let yaml = r"
2790features:
2791 auto_merge: true
2792";
2793 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2794 assert!(
2795 config.features.auto_merge,
2796 "auto_merge should be true when configured"
2797 );
2798 }
2799
2800 #[test]
2801 fn test_features_config_auto_merge_false_from_yaml() {
2802 let yaml = r"
2804features:
2805 auto_merge: false
2806";
2807 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2808 assert!(
2809 !config.features.auto_merge,
2810 "auto_merge should be false when explicitly configured"
2811 );
2812 }
2813
2814 #[test]
2815 fn test_features_config_preserves_parallel_when_adding_auto_merge() {
2816 let yaml = r"
2818features:
2819 parallel: false
2820 auto_merge: true
2821";
2822 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2823 assert!(!config.features.parallel, "parallel should be false");
2824 assert!(config.features.auto_merge, "auto_merge should be true");
2825 }
2826
2827 #[test]
2828 fn test_skills_config_defaults_when_absent() {
2829 let yaml = r"
2831agent: claude
2832";
2833 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2834 assert!(config.skills.enabled);
2835 assert!(config.skills.dirs.is_empty());
2836 assert!(config.skills.overrides.is_empty());
2837 }
2838
2839 #[test]
2840 fn test_skills_config_deserializes_all_fields() {
2841 let yaml = r#"
2842skills:
2843 enabled: true
2844 dirs:
2845 - ".claude/skills"
2846 - "/shared/skills"
2847 overrides:
2848 pdd:
2849 enabled: false
2850 memories:
2851 auto_inject: true
2852 hats: ["ralph"]
2853 backends: ["claude"]
2854 tags: ["core"]
2855"#;
2856 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2857 assert!(config.skills.enabled);
2858 assert_eq!(config.skills.dirs.len(), 2);
2859 assert_eq!(
2860 config.skills.dirs[0],
2861 std::path::PathBuf::from(".claude/skills")
2862 );
2863 assert_eq!(config.skills.overrides.len(), 2);
2864
2865 let pdd = config.skills.overrides.get("pdd").unwrap();
2866 assert_eq!(pdd.enabled, Some(false));
2867
2868 let memories = config.skills.overrides.get("memories").unwrap();
2869 assert_eq!(memories.auto_inject, Some(true));
2870 assert_eq!(memories.hats, vec!["ralph"]);
2871 assert_eq!(memories.backends, vec!["claude"]);
2872 assert_eq!(memories.tags, vec!["core"]);
2873 }
2874
2875 #[test]
2876 fn test_skills_config_disabled() {
2877 let yaml = r"
2878skills:
2879 enabled: false
2880";
2881 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2882 assert!(!config.skills.enabled);
2883 assert!(config.skills.dirs.is_empty());
2884 }
2885
2886 #[test]
2887 fn test_skill_override_partial_fields() {
2888 let yaml = r#"
2889skills:
2890 overrides:
2891 my-skill:
2892 hats: ["builder", "reviewer"]
2893"#;
2894 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2895 let override_ = config.skills.overrides.get("my-skill").unwrap();
2896 assert_eq!(override_.enabled, None);
2897 assert_eq!(override_.auto_inject, None);
2898 assert_eq!(override_.hats, vec!["builder", "reviewer"]);
2899 assert!(override_.backends.is_empty());
2900 assert!(override_.tags.is_empty());
2901 }
2902
2903 #[test]
2904 fn test_hooks_config_valid_yaml_parses_and_validates() {
2905 let yaml = r#"
2906hooks:
2907 enabled: true
2908 defaults:
2909 timeout_seconds: 45
2910 max_output_bytes: 16384
2911 suspend_mode: wait_for_resume
2912 events:
2913 pre.loop.start:
2914 - name: env-guard
2915 command: ["./scripts/hooks/env-guard.sh", "--check"]
2916 on_error: block
2917 post.loop.complete:
2918 - name: notify
2919 command: ["./scripts/hooks/notify.sh"]
2920 on_error: warn
2921 mutate:
2922 enabled: true
2923 format: json
2924"#;
2925 let config = RalphConfig::parse_yaml(yaml).unwrap();
2926
2927 assert!(config.hooks.enabled);
2928 assert_eq!(config.hooks.defaults.timeout_seconds, 45);
2929 assert_eq!(config.hooks.defaults.max_output_bytes, 16384);
2930 assert_eq!(config.hooks.events.len(), 2);
2931
2932 let warnings = config.validate().unwrap();
2933 assert!(warnings.is_empty());
2934 }
2935
2936 #[test]
2937 fn test_hooks_parse_rejects_invalid_phase_event_key() {
2938 let yaml = r#"
2939hooks:
2940 enabled: true
2941 events:
2942 pre.loop.launch:
2943 - name: bad-phase
2944 command: ["./scripts/hooks/bad-phase.sh"]
2945 on_error: warn
2946"#;
2947
2948 let result = RalphConfig::parse_yaml(yaml);
2949 assert!(result.is_err());
2950
2951 let err = result.unwrap_err();
2952 assert!(matches!(
2953 &err,
2954 ConfigError::InvalidHookPhaseEvent { phase_event }
2955 if phase_event == "pre.loop.launch"
2956 ));
2957 }
2958
2959 #[test]
2960 fn test_hooks_parse_rejects_backpressure_phase_event_keys_in_v1() {
2961 let yaml = r#"
2962hooks:
2963 enabled: true
2964 events:
2965 pre.backpressure.triggered:
2966 - name: unsupported-backpressure
2967 command: ["./scripts/hooks/backpressure.sh"]
2968 on_error: warn
2969"#;
2970
2971 let result = RalphConfig::parse_yaml(yaml);
2972 assert!(result.is_err());
2973
2974 let err = result.unwrap_err();
2975 assert!(matches!(
2976 &err,
2977 ConfigError::InvalidHookPhaseEvent { phase_event }
2978 if phase_event == "pre.backpressure.triggered"
2979 ));
2980
2981 let message = err.to_string();
2982 assert!(message.contains("Supported v1 phase-events"));
2983 assert!(message.contains("pre.plan.created"));
2984 assert!(message.contains("post.loop.error"));
2985 }
2986
2987 #[test]
2988 fn test_hooks_parse_rejects_invalid_on_error_enum_value() {
2989 let yaml = r#"
2990hooks:
2991 enabled: true
2992 events:
2993 pre.loop.start:
2994 - name: bad-on-error
2995 command: ["./scripts/hooks/bad-on-error.sh"]
2996 on_error: explode
2997"#;
2998
2999 let result = RalphConfig::parse_yaml(yaml);
3000 assert!(result.is_err());
3001
3002 let err = result.unwrap_err();
3003 assert!(matches!(&err, ConfigError::Yaml(_)));
3004
3005 let message = err.to_string();
3006 assert!(message.contains("unknown variant `explode`"));
3007 assert!(message.contains("warn"));
3008 assert!(message.contains("block"));
3009 assert!(message.contains("suspend"));
3010 }
3011
3012 #[test]
3013 fn test_hooks_validate_rejects_missing_name() {
3014 let yaml = r#"
3015hooks:
3016 enabled: true
3017 events:
3018 pre.loop.start:
3019 - command: ["./scripts/hooks/no-name.sh"]
3020 on_error: block
3021"#;
3022 let config = RalphConfig::parse_yaml(yaml).unwrap();
3023
3024 let result = config.validate();
3025 assert!(result.is_err());
3026
3027 let err = result.unwrap_err();
3028 assert!(matches!(
3029 &err,
3030 ConfigError::HookValidation { field, .. }
3031 if field == "hooks.events.pre.loop.start[0].name"
3032 ));
3033 }
3034
3035 #[test]
3036 fn test_hooks_validate_rejects_missing_command() {
3037 let yaml = r"
3038hooks:
3039 enabled: true
3040 events:
3041 pre.loop.start:
3042 - name: missing-command
3043 on_error: block
3044";
3045 let config = RalphConfig::parse_yaml(yaml).unwrap();
3046
3047 let result = config.validate();
3048 assert!(result.is_err());
3049
3050 let err = result.unwrap_err();
3051 assert!(matches!(
3052 &err,
3053 ConfigError::HookValidation { field, .. }
3054 if field == "hooks.events.pre.loop.start[0].command"
3055 ));
3056 }
3057
3058 #[test]
3059 fn test_hooks_validate_rejects_missing_on_error() {
3060 let yaml = r#"
3061hooks:
3062 enabled: true
3063 events:
3064 pre.loop.start:
3065 - name: missing-on-error
3066 command: ["./scripts/hooks/no-on-error.sh"]
3067"#;
3068 let config = RalphConfig::parse_yaml(yaml).unwrap();
3069
3070 let result = config.validate();
3071 assert!(result.is_err());
3072
3073 let err = result.unwrap_err();
3074 assert!(matches!(
3075 &err,
3076 ConfigError::HookValidation { field, .. }
3077 if field == "hooks.events.pre.loop.start[0].on_error"
3078 ));
3079 }
3080
3081 #[test]
3082 fn test_hooks_validate_rejects_zero_timeout_seconds() {
3083 let yaml = r"
3084hooks:
3085 enabled: true
3086 defaults:
3087 timeout_seconds: 0
3088";
3089 let config = RalphConfig::parse_yaml(yaml).unwrap();
3090
3091 let result = config.validate();
3092 assert!(result.is_err());
3093
3094 let err = result.unwrap_err();
3095 assert!(matches!(
3096 &err,
3097 ConfigError::HookValidation { field, .. }
3098 if field == "hooks.defaults.timeout_seconds"
3099 ));
3100 }
3101
3102 #[test]
3103 fn test_hooks_validate_rejects_zero_max_output_bytes() {
3104 let yaml = r"
3105hooks:
3106 enabled: true
3107 defaults:
3108 max_output_bytes: 0
3109";
3110 let config = RalphConfig::parse_yaml(yaml).unwrap();
3111
3112 let result = config.validate();
3113 assert!(result.is_err());
3114
3115 let err = result.unwrap_err();
3116 assert!(matches!(
3117 &err,
3118 ConfigError::HookValidation { field, .. }
3119 if field == "hooks.defaults.max_output_bytes"
3120 ));
3121 }
3122
3123 #[test]
3124 fn test_hooks_validate_rejects_parallel_non_v1_field() {
3125 let yaml = r"
3126hooks:
3127 enabled: true
3128 parallel: true
3129";
3130 let config = RalphConfig::parse_yaml(yaml).unwrap();
3131
3132 let result = config.validate();
3133 assert!(result.is_err());
3134
3135 let err = result.unwrap_err();
3136 assert!(matches!(
3137 &err,
3138 ConfigError::UnsupportedHookField { field, .. }
3139 if field == "hooks.parallel"
3140 ));
3141 }
3142
3143 #[test]
3144 fn test_hooks_validate_rejects_global_scope_non_v1_field() {
3145 let yaml = r#"
3146hooks:
3147 enabled: true
3148 events:
3149 pre.loop.start:
3150 - name: global-scope
3151 command: ["./scripts/hooks/global.sh"]
3152 on_error: warn
3153 scope: global
3154"#;
3155 let config = RalphConfig::parse_yaml(yaml).unwrap();
3156
3157 let result = config.validate();
3158 assert!(result.is_err());
3159
3160 let err = result.unwrap_err();
3161 assert!(matches!(
3162 &err,
3163 ConfigError::UnsupportedHookField { field, .. }
3164 if field == "hooks.events.pre.loop.start[0].scope"
3165 ));
3166 }
3167
3168 #[test]
3173 fn test_robot_config_defaults_disabled() {
3174 let config = RalphConfig::default();
3175 assert!(!config.robot.enabled);
3176 assert!(config.robot.timeout_seconds.is_none());
3177 assert!(config.robot.telegram.is_none());
3178 }
3179
3180 #[test]
3181 fn test_robot_config_absent_parses_as_default() {
3182 let yaml = r"
3184agent: claude
3185";
3186 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3187 assert!(!config.robot.enabled);
3188 assert!(config.robot.timeout_seconds.is_none());
3189 }
3190
3191 #[test]
3192 fn test_robot_config_valid_full() {
3193 let yaml = r#"
3194RObot:
3195 enabled: true
3196 timeout_seconds: 300
3197 telegram:
3198 bot_token: "123456:ABC-DEF"
3199"#;
3200 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3201 assert!(config.robot.enabled);
3202 assert_eq!(config.robot.timeout_seconds, Some(300));
3203 let telegram = config.robot.telegram.as_ref().unwrap();
3204 assert_eq!(telegram.bot_token, Some("123456:ABC-DEF".to_string()));
3205
3206 assert!(config.validate().is_ok());
3208 }
3209
3210 #[test]
3211 fn test_robot_config_disabled_skips_validation() {
3212 let yaml = r"
3214RObot:
3215 enabled: false
3216";
3217 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3218 assert!(!config.robot.enabled);
3219 assert!(config.validate().is_ok());
3220 }
3221
3222 #[test]
3223 fn test_robot_config_enabled_missing_timeout_fails() {
3224 let yaml = r#"
3225RObot:
3226 enabled: true
3227 telegram:
3228 bot_token: "123456:ABC-DEF"
3229"#;
3230 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3231 let result = config.validate();
3232 assert!(result.is_err());
3233 let err = result.unwrap_err();
3234 assert!(
3235 matches!(&err, ConfigError::RobotMissingField { field, .. }
3236 if field == "RObot.timeout_seconds"),
3237 "Expected RobotMissingField for timeout_seconds, got: {:?}",
3238 err
3239 );
3240 }
3241
3242 #[test]
3243 fn test_robot_config_enabled_missing_timeout_and_token_fails_on_timeout_first() {
3244 let robot = RobotConfig {
3246 enabled: true,
3247 timeout_seconds: None,
3248 checkin_interval_seconds: None,
3249 telegram: None,
3250 };
3251 let result = robot.validate();
3252 assert!(result.is_err());
3253 let err = result.unwrap_err();
3254 assert!(
3255 matches!(&err, ConfigError::RobotMissingField { field, .. }
3256 if field == "RObot.timeout_seconds"),
3257 "Expected timeout validation failure first, got: {:?}",
3258 err
3259 );
3260 }
3261
3262 #[test]
3263 fn test_robot_config_resolve_bot_token_from_config() {
3264 let config = RobotConfig {
3268 enabled: true,
3269 timeout_seconds: Some(300),
3270 checkin_interval_seconds: None,
3271 telegram: Some(TelegramBotConfig {
3272 bot_token: Some("config-token".to_string()),
3273 }),
3274 };
3275
3276 let resolved = config.resolve_bot_token();
3279 assert!(resolved.is_some());
3282 }
3283
3284 #[test]
3285 fn test_robot_config_resolve_bot_token_none_without_config() {
3286 let config = RobotConfig {
3288 enabled: true,
3289 timeout_seconds: Some(300),
3290 checkin_interval_seconds: None,
3291 telegram: None,
3292 };
3293
3294 let resolved = config.resolve_bot_token();
3297 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_err() {
3298 assert!(resolved.is_none());
3299 }
3300 }
3301
3302 #[test]
3303 fn test_robot_config_validate_with_config_token() {
3304 let robot = RobotConfig {
3306 enabled: true,
3307 timeout_seconds: Some(300),
3308 checkin_interval_seconds: None,
3309 telegram: Some(TelegramBotConfig {
3310 bot_token: Some("test-token".to_string()),
3311 }),
3312 };
3313 assert!(robot.validate().is_ok());
3314 }
3315
3316 #[test]
3317 fn test_robot_config_validate_missing_telegram_section() {
3318 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3321 return;
3322 }
3323
3324 let robot = RobotConfig {
3325 enabled: true,
3326 timeout_seconds: Some(300),
3327 checkin_interval_seconds: None,
3328 telegram: None,
3329 };
3330 let result = robot.validate();
3331 assert!(result.is_err());
3332 let err = result.unwrap_err();
3333 assert!(
3334 matches!(&err, ConfigError::RobotMissingField { field, .. }
3335 if field == "RObot.telegram.bot_token"),
3336 "Expected bot_token validation failure, got: {:?}",
3337 err
3338 );
3339 }
3340
3341 #[test]
3342 fn test_robot_config_validate_empty_bot_token() {
3343 if std::env::var("RALPH_TELEGRAM_BOT_TOKEN").is_ok() {
3346 return;
3347 }
3348
3349 let robot = RobotConfig {
3350 enabled: true,
3351 timeout_seconds: Some(300),
3352 checkin_interval_seconds: None,
3353 telegram: Some(TelegramBotConfig { bot_token: None }),
3354 };
3355 let result = robot.validate();
3356 assert!(result.is_err());
3357 let err = result.unwrap_err();
3358 assert!(
3359 matches!(&err, ConfigError::RobotMissingField { field, .. }
3360 if field == "RObot.telegram.bot_token"),
3361 "Expected bot_token validation failure, got: {:?}",
3362 err
3363 );
3364 }
3365
3366 #[test]
3367 fn test_extra_instructions_merged_during_normalize() {
3368 let yaml = r#"
3369_fragments:
3370 shared_protocol: &shared_protocol |
3371 ### Shared Protocol
3372 Follow this protocol.
3373
3374hats:
3375 builder:
3376 name: "Builder"
3377 triggers: ["build.start"]
3378 instructions: |
3379 ## BUILDER MODE
3380 Build things.
3381 extra_instructions:
3382 - *shared_protocol
3383"#;
3384 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3385 let hat = config.hats.get("builder").unwrap();
3386
3387 assert_eq!(hat.extra_instructions.len(), 1);
3389 assert!(!hat.instructions.contains("Shared Protocol"));
3390
3391 config.normalize();
3392
3393 let hat = config.hats.get("builder").unwrap();
3394 assert!(hat.extra_instructions.is_empty());
3396 assert!(hat.instructions.contains("## BUILDER MODE"));
3397 assert!(hat.instructions.contains("### Shared Protocol"));
3398 assert!(hat.instructions.contains("Follow this protocol."));
3399 }
3400
3401 #[test]
3402 fn test_extra_instructions_empty_by_default() {
3403 let yaml = r#"
3404hats:
3405 simple:
3406 name: "Simple"
3407 triggers: ["start"]
3408 instructions: "Do the thing."
3409"#;
3410 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
3411 let hat = config.hats.get("simple").unwrap();
3412 assert!(hat.extra_instructions.is_empty());
3413 }
3414}