1use ralph_proto::Topic;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
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)]
51 pub agent: Option<String>,
52
53 #[serde(default)]
55 pub agent_priority: Vec<String>,
56
57 #[serde(default)]
59 pub prompt_file: Option<String>,
60
61 #[serde(default)]
63 pub completion_promise: Option<String>,
64
65 #[serde(default)]
67 pub max_iterations: Option<u32>,
68
69 #[serde(default)]
71 pub max_runtime: Option<u64>,
72
73 #[serde(default)]
75 pub max_cost: Option<f64>,
76
77 #[serde(default)]
83 pub verbose: bool,
84
85 #[serde(default)]
87 pub archive_prompts: bool,
88
89 #[serde(default)]
91 pub enable_metrics: bool,
92
93 #[serde(default)]
99 pub max_tokens: Option<u32>,
100
101 #[serde(default)]
103 pub retry_delay: Option<u32>,
104
105 #[serde(default)]
107 pub adapters: AdaptersConfig,
108
109 #[serde(default, rename = "_suppress_warnings")]
115 pub suppress_warnings: bool,
116
117 #[serde(default)]
119 pub tui: TuiConfig,
120}
121
122fn default_true() -> bool {
123 true
124}
125
126#[allow(clippy::derivable_impls)] impl Default for RalphConfig {
128 fn default() -> Self {
129 Self {
130 event_loop: EventLoopConfig::default(),
131 cli: CliConfig::default(),
132 core: CoreConfig::default(),
133 hats: HashMap::new(),
134 events: HashMap::new(),
135 agent: None,
137 agent_priority: vec![],
138 prompt_file: None,
139 completion_promise: None,
140 max_iterations: None,
141 max_runtime: None,
142 max_cost: None,
143 verbose: false,
145 archive_prompts: false,
146 enable_metrics: false,
147 max_tokens: None,
149 retry_delay: None,
150 adapters: AdaptersConfig::default(),
151 suppress_warnings: false,
153 tui: TuiConfig::default(),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Default, Serialize, Deserialize)]
161pub struct AdaptersConfig {
162 #[serde(default)]
164 pub claude: AdapterSettings,
165
166 #[serde(default)]
168 pub gemini: AdapterSettings,
169
170 #[serde(default)]
172 pub kiro: AdapterSettings,
173
174 #[serde(default)]
176 pub codex: AdapterSettings,
177
178 #[serde(default)]
180 pub amp: AdapterSettings,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct AdapterSettings {
186 #[serde(default = "default_timeout")]
188 pub timeout: u64,
189
190 #[serde(default = "default_true")]
192 pub enabled: bool,
193
194 #[serde(default)]
196 pub tool_permissions: Option<Vec<String>>,
197}
198
199fn default_timeout() -> u64 {
200 300 }
202
203impl Default for AdapterSettings {
204 fn default() -> Self {
205 Self {
206 timeout: default_timeout(),
207 enabled: true,
208 tool_permissions: None,
209 }
210 }
211}
212
213impl RalphConfig {
214 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
216 let path_ref = path.as_ref();
217 debug!(path = %path_ref.display(), "Loading configuration from file");
218 let content = std::fs::read_to_string(path_ref)?;
219 let config: Self = serde_yaml::from_str(&content)?;
220 debug!(
221 backend = %config.cli.backend,
222 has_v1_fields = config.agent.is_some(),
223 custom_hats = config.hats.len(),
224 "Configuration loaded"
225 );
226 Ok(config)
227 }
228
229 pub fn normalize(&mut self) {
234 let mut normalized_count = 0;
235
236 if let Some(ref agent) = self.agent {
238 debug!(from = "agent", to = "cli.backend", value = %agent, "Normalizing v1 field");
239 self.cli.backend = agent.clone();
240 normalized_count += 1;
241 }
242
243 if let Some(ref pf) = self.prompt_file {
245 debug!(from = "prompt_file", to = "event_loop.prompt_file", value = %pf, "Normalizing v1 field");
246 self.event_loop.prompt_file = pf.clone();
247 normalized_count += 1;
248 }
249
250 if let Some(ref cp) = self.completion_promise {
252 debug!(from = "completion_promise", to = "event_loop.completion_promise", "Normalizing v1 field");
253 self.event_loop.completion_promise = cp.clone();
254 normalized_count += 1;
255 }
256
257 if let Some(mi) = self.max_iterations {
259 debug!(from = "max_iterations", to = "event_loop.max_iterations", value = mi, "Normalizing v1 field");
260 self.event_loop.max_iterations = mi;
261 normalized_count += 1;
262 }
263
264 if let Some(mr) = self.max_runtime {
266 debug!(from = "max_runtime", to = "event_loop.max_runtime_seconds", value = mr, "Normalizing v1 field");
267 self.event_loop.max_runtime_seconds = mr;
268 normalized_count += 1;
269 }
270
271 if self.max_cost.is_some() {
273 debug!(from = "max_cost", to = "event_loop.max_cost_usd", "Normalizing v1 field");
274 self.event_loop.max_cost_usd = self.max_cost;
275 normalized_count += 1;
276 }
277
278 if normalized_count > 0 {
279 debug!(fields_normalized = normalized_count, "V1 to V2 config normalization complete");
280 }
281 }
282
283 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
293 let mut warnings = Vec::new();
294
295 if self.suppress_warnings {
297 return Ok(warnings);
298 }
299
300 if self.event_loop.prompt.is_some() && !self.event_loop.prompt_file.is_empty() && self.event_loop.prompt_file != default_prompt_file() {
303 return Err(ConfigError::MutuallyExclusive {
304 field1: "event_loop.prompt".to_string(),
305 field2: "event_loop.prompt_file".to_string(),
306 });
307 }
308
309 if self.cli.backend == "custom" && self.cli.command.as_ref().is_none_or(String::is_empty) {
311 return Err(ConfigError::CustomBackendRequiresCommand);
312 }
313
314 if self.archive_prompts {
316 warnings.push(ConfigWarning::DeferredFeature {
317 field: "archive_prompts".to_string(),
318 message: "Feature not yet available in v2".to_string(),
319 });
320 }
321
322 if self.enable_metrics {
323 warnings.push(ConfigWarning::DeferredFeature {
324 field: "enable_metrics".to_string(),
325 message: "Feature not yet available in v2".to_string(),
326 });
327 }
328
329 if self.max_tokens.is_some() {
331 warnings.push(ConfigWarning::DroppedField {
332 field: "max_tokens".to_string(),
333 reason: "Token limits are controlled by the CLI tool".to_string(),
334 });
335 }
336
337 if self.retry_delay.is_some() {
338 warnings.push(ConfigWarning::DroppedField {
339 field: "retry_delay".to_string(),
340 reason: "Retry logic handled differently in v2".to_string(),
341 });
342 }
343
344 if self.adapters.claude.tool_permissions.is_some()
346 || self.adapters.gemini.tool_permissions.is_some()
347 || self.adapters.codex.tool_permissions.is_some()
348 || self.adapters.amp.tool_permissions.is_some()
349 {
350 warnings.push(ConfigWarning::DroppedField {
351 field: "adapters.*.tool_permissions".to_string(),
352 reason: "CLI tool manages its own permissions".to_string(),
353 });
354 }
355
356 for (hat_id, hat_config) in &self.hats {
358 if hat_config.description.as_ref().is_none_or(|d| d.trim().is_empty()) {
359 return Err(ConfigError::MissingDescription {
360 hat: hat_id.clone(),
361 });
362 }
363 }
364
365 const RESERVED_TRIGGERS: &[&str] = &["task.start", "task.resume"];
368 for (hat_id, hat_config) in &self.hats {
369 for trigger in &hat_config.triggers {
370 if RESERVED_TRIGGERS.contains(&trigger.as_str()) {
371 return Err(ConfigError::ReservedTrigger {
372 trigger: trigger.clone(),
373 hat: hat_id.clone(),
374 });
375 }
376 }
377 }
378
379 if !self.hats.is_empty() {
382 let mut trigger_to_hat: HashMap<&str, &str> = HashMap::new();
383 for (hat_id, hat_config) in &self.hats {
384 for trigger in &hat_config.triggers {
385 if let Some(existing_hat) = trigger_to_hat.get(trigger.as_str()) {
386 return Err(ConfigError::AmbiguousRouting {
387 trigger: trigger.clone(),
388 hat1: (*existing_hat).to_string(),
389 hat2: hat_id.clone(),
390 });
391 }
392 trigger_to_hat.insert(trigger.as_str(), hat_id.as_str());
393 }
394 }
395 }
396
397 Ok(warnings)
398 }
399
400 pub fn effective_backend(&self) -> &str {
402 &self.cli.backend
403 }
404
405 pub fn get_agent_priority(&self) -> Vec<&str> {
408 if self.agent_priority.is_empty() {
409 vec!["claude", "kiro", "gemini", "codex", "amp"]
410 } else {
411 self.agent_priority.iter().map(String::as_str).collect()
412 }
413 }
414
415 #[allow(clippy::match_same_arms)] pub fn adapter_settings(&self, backend: &str) -> &AdapterSettings {
418 match backend {
419 "claude" => &self.adapters.claude,
420 "gemini" => &self.adapters.gemini,
421 "kiro" => &self.adapters.kiro,
422 "codex" => &self.adapters.codex,
423 "amp" => &self.adapters.amp,
424 _ => &self.adapters.claude, }
426 }
427}
428
429#[derive(Debug, Clone)]
431pub enum ConfigWarning {
432 DeferredFeature { field: String, message: String },
434 DroppedField { field: String, reason: String },
436 InvalidValue { field: String, message: String },
438}
439
440impl std::fmt::Display for ConfigWarning {
441 #[allow(clippy::match_same_arms)] fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443 match self {
444 ConfigWarning::DeferredFeature { field, message }
445 | ConfigWarning::InvalidValue { field, message } => {
446 write!(f, "Warning [{field}]: {message}")
447 }
448 ConfigWarning::DroppedField { field, reason } => {
449 write!(f, "Warning [{field}]: Field ignored - {reason}")
450 }
451 }
452 }
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct EventLoopConfig {
458 pub prompt: Option<String>,
460
461 #[serde(default = "default_prompt_file")]
463 pub prompt_file: String,
464
465 #[serde(default = "default_completion_promise")]
467 pub completion_promise: String,
468
469 #[serde(default = "default_max_iterations")]
471 pub max_iterations: u32,
472
473 #[serde(default = "default_max_runtime")]
475 pub max_runtime_seconds: u64,
476
477 pub max_cost_usd: Option<f64>,
479
480 #[serde(default = "default_max_failures")]
482 pub max_consecutive_failures: u32,
483
484 pub starting_hat: Option<String>,
486
487 pub starting_event: Option<String>,
497}
498
499fn default_prompt_file() -> String {
500 "PROMPT.md".to_string()
501}
502
503fn default_completion_promise() -> String {
504 "LOOP_COMPLETE".to_string()
505}
506
507fn default_max_iterations() -> u32 {
508 100
509}
510
511fn default_max_runtime() -> u64 {
512 14400 }
514
515fn default_max_failures() -> u32 {
516 5
517}
518
519impl Default for EventLoopConfig {
520 fn default() -> Self {
521 Self {
522 prompt: None,
523 prompt_file: default_prompt_file(),
524 completion_promise: default_completion_promise(),
525 max_iterations: default_max_iterations(),
526 max_runtime_seconds: default_max_runtime(),
527 max_cost_usd: None,
528 max_consecutive_failures: default_max_failures(),
529 starting_hat: None,
530 starting_event: None,
531 }
532 }
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct CoreConfig {
540 #[serde(default = "default_scratchpad")]
542 pub scratchpad: String,
543
544 #[serde(default = "default_specs_dir")]
546 pub specs_dir: String,
547
548 #[serde(default = "default_guardrails")]
552 pub guardrails: Vec<String>,
553}
554
555fn default_scratchpad() -> String {
556 ".agent/scratchpad.md".to_string()
557}
558
559fn default_specs_dir() -> String {
560 "./specs/".to_string()
561}
562
563fn default_guardrails() -> Vec<String> {
564 vec![
565 "Fresh context each iteration - scratchpad is memory".to_string(),
566 "Don't assume 'not implemented' - search first".to_string(),
567 "Backpressure is law - tests/typecheck/lint must pass".to_string(),
568 ]
569}
570
571impl Default for CoreConfig {
572 fn default() -> Self {
573 Self {
574 scratchpad: default_scratchpad(),
575 specs_dir: default_specs_dir(),
576 guardrails: default_guardrails(),
577 }
578 }
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct CliConfig {
584 #[serde(default = "default_backend")]
586 pub backend: String,
587
588 pub command: Option<String>,
590
591 #[serde(default = "default_prompt_mode")]
593 pub prompt_mode: String,
594
595 #[serde(default = "default_mode")]
598 pub default_mode: String,
599
600 #[serde(default = "default_idle_timeout")]
604 pub idle_timeout_secs: u32,
605
606 #[serde(default)]
609 pub args: Vec<String>,
610
611 #[serde(default)]
614 pub prompt_flag: Option<String>,
615
616 #[serde(default)]
620 pub experimental_tui: bool,
621}
622
623fn default_backend() -> String {
624 "claude".to_string()
625}
626
627fn default_prompt_mode() -> String {
628 "arg".to_string()
629}
630
631fn default_mode() -> String {
632 "autonomous".to_string()
633}
634
635fn default_idle_timeout() -> u32 {
636 30 }
638
639impl Default for CliConfig {
640 fn default() -> Self {
641 Self {
642 backend: default_backend(),
643 command: None,
644 prompt_mode: default_prompt_mode(),
645 default_mode: default_mode(),
646 idle_timeout_secs: default_idle_timeout(),
647 args: Vec::new(),
648 prompt_flag: None,
649 experimental_tui: false,
650 }
651 }
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize)]
656pub struct TuiConfig {
657 #[serde(default = "default_prefix_key")]
659 pub prefix_key: String,
660}
661
662fn default_prefix_key() -> String {
663 "ctrl-a".to_string()
664}
665
666impl Default for TuiConfig {
667 fn default() -> Self {
668 Self {
669 prefix_key: default_prefix_key(),
670 }
671 }
672}
673
674impl TuiConfig {
675 pub fn parse_prefix(&self) -> Result<(crossterm::event::KeyCode, crossterm::event::KeyModifiers), String> {
678 use crossterm::event::{KeyCode, KeyModifiers};
679
680 let parts: Vec<&str> = self.prefix_key.split('-').collect();
681 if parts.len() != 2 {
682 return Err(format!(
683 "Invalid prefix_key format: '{}'. Expected format: 'ctrl-<key>' (e.g., 'ctrl-a', 'ctrl-b')",
684 self.prefix_key
685 ));
686 }
687
688 let modifier = match parts[0].to_lowercase().as_str() {
689 "ctrl" => KeyModifiers::CONTROL,
690 _ => {
691 return Err(format!(
692 "Invalid modifier: '{}'. Only 'ctrl' is supported (e.g., 'ctrl-a')",
693 parts[0]
694 ));
695 }
696 };
697
698 let key_str = parts[1];
699 if key_str.len() != 1 {
700 return Err(format!(
701 "Invalid key: '{}'. Expected a single character (e.g., 'a', 'b')",
702 key_str
703 ));
704 }
705
706 let key_char = key_str.chars().next().unwrap();
707 let key_code = KeyCode::Char(key_char);
708
709 Ok((key_code, modifier))
710 }
711}
712
713#[derive(Debug, Clone, Default, Serialize, Deserialize)]
728pub struct EventMetadata {
729 #[serde(default)]
731 pub description: String,
732
733 #[serde(default)]
736 pub on_trigger: String,
737
738 #[serde(default)]
741 pub on_publish: String,
742}
743
744#[derive(Debug, Clone, Serialize, Deserialize)]
746#[serde(untagged)]
747pub enum HatBackend {
748 Named(String),
750 KiroAgent {
752 #[serde(rename = "type")]
753 backend_type: String,
754 agent: String,
755 },
756 Custom { command: String, args: Vec<String> },
758}
759
760impl HatBackend {
761 pub fn to_cli_backend(&self) -> String {
763 match self {
764 HatBackend::Named(name) => name.clone(),
765 HatBackend::KiroAgent { .. } => "kiro".to_string(),
766 HatBackend::Custom { .. } => "custom".to_string(),
767 }
768 }
769}
770
771#[derive(Debug, Clone, Serialize, Deserialize)]
773pub struct HatConfig {
774 pub name: String,
776
777 pub description: Option<String>,
780
781 #[serde(default)]
784 pub triggers: Vec<String>,
785
786 #[serde(default)]
788 pub publishes: Vec<String>,
789
790 #[serde(default)]
792 pub instructions: String,
793
794 #[serde(default)]
796 pub backend: Option<HatBackend>,
797
798 #[serde(default)]
800 pub default_publishes: Option<String>,
801}
802
803impl HatConfig {
804 pub fn trigger_topics(&self) -> Vec<Topic> {
806 self.triggers.iter().map(|s| Topic::new(s)).collect()
807 }
808
809 pub fn publish_topics(&self) -> Vec<Topic> {
811 self.publishes.iter().map(|s| Topic::new(s)).collect()
812 }
813}
814
815#[derive(Debug, thiserror::Error)]
817pub enum ConfigError {
818 #[error("IO error: {0}")]
819 Io(#[from] std::io::Error),
820
821 #[error("YAML parse error: {0}")]
822 Yaml(#[from] serde_yaml::Error),
823
824 #[error("Ambiguous routing: trigger '{trigger}' is claimed by both '{hat1}' and '{hat2}'")]
825 AmbiguousRouting {
826 trigger: String,
827 hat1: String,
828 hat2: String,
829 },
830
831 #[error("Mutually exclusive fields: '{field1}' and '{field2}' cannot both be specified")]
832 MutuallyExclusive {
833 field1: String,
834 field2: String,
835 },
836
837 #[error("Custom backend requires a command - set 'cli.command' in config")]
838 CustomBackendRequiresCommand,
839
840 #[error("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.")]
841 ReservedTrigger { trigger: String, hat: String },
842
843 #[error("Hat '{hat}' is missing required 'description' field - add a short description of the hat's purpose")]
844 MissingDescription { hat: String },
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850
851 #[test]
852 fn test_default_config() {
853 let config = RalphConfig::default();
854 assert!(config.hats.is_empty());
856 assert_eq!(config.event_loop.max_iterations, 100);
857 assert!(!config.verbose);
858 }
859
860 #[test]
861 fn test_parse_yaml_with_custom_hats() {
862 let yaml = r#"
863event_loop:
864 prompt_file: "TASK.md"
865 completion_promise: "DONE"
866 max_iterations: 50
867cli:
868 backend: "claude"
869hats:
870 implementer:
871 name: "Implementer"
872 triggers: ["task.*", "review.done"]
873 publishes: ["impl.done"]
874 instructions: "You are the implementation agent."
875"#;
876 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
877 assert_eq!(config.hats.len(), 1);
879 assert_eq!(config.event_loop.prompt_file, "TASK.md");
880
881 let hat = config.hats.get("implementer").unwrap();
882 assert_eq!(hat.triggers.len(), 2);
883 }
884
885 #[test]
886 fn test_parse_yaml_v1_format() {
887 let yaml = r#"
889agent: gemini
890prompt_file: "TASK.md"
891completion_promise: "RALPH_DONE"
892max_iterations: 75
893max_runtime: 7200
894max_cost: 10.0
895verbose: true
896"#;
897 let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
898
899 assert_eq!(config.cli.backend, "claude"); assert_eq!(config.event_loop.max_iterations, 100); config.normalize();
905
906 assert_eq!(config.cli.backend, "gemini");
908 assert_eq!(config.event_loop.prompt_file, "TASK.md");
909 assert_eq!(config.event_loop.completion_promise, "RALPH_DONE");
910 assert_eq!(config.event_loop.max_iterations, 75);
911 assert_eq!(config.event_loop.max_runtime_seconds, 7200);
912 assert_eq!(config.event_loop.max_cost_usd, Some(10.0));
913 assert!(config.verbose);
914 }
915
916 #[test]
917 fn test_agent_priority() {
918 let yaml = r"
919agent: auto
920agent_priority: [gemini, claude, codex]
921";
922 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
923 let priority = config.get_agent_priority();
924 assert_eq!(priority, vec!["gemini", "claude", "codex"]);
925 }
926
927 #[test]
928 fn test_default_agent_priority() {
929 let config = RalphConfig::default();
930 let priority = config.get_agent_priority();
931 assert_eq!(priority, vec!["claude", "kiro", "gemini", "codex", "amp"]);
932 }
933
934 #[test]
935 fn test_validate_deferred_features() {
936 let yaml = r"
937archive_prompts: true
938enable_metrics: true
939";
940 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
941 let warnings = config.validate().unwrap();
942
943 assert_eq!(warnings.len(), 2);
944 assert!(warnings
945 .iter()
946 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "archive_prompts")));
947 assert!(warnings
948 .iter()
949 .any(|w| matches!(w, ConfigWarning::DeferredFeature { field, .. } if field == "enable_metrics")));
950 }
951
952 #[test]
953 fn test_validate_dropped_fields() {
954 let yaml = r#"
955max_tokens: 4096
956retry_delay: 5
957adapters:
958 claude:
959 tool_permissions: ["read", "write"]
960"#;
961 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
962 let warnings = config.validate().unwrap();
963
964 assert_eq!(warnings.len(), 3);
965 assert!(warnings
966 .iter()
967 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "max_tokens")));
968 assert!(warnings
969 .iter()
970 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "retry_delay")));
971 assert!(warnings
972 .iter()
973 .any(|w| matches!(w, ConfigWarning::DroppedField { field, .. } if field == "adapters.*.tool_permissions")));
974 }
975
976 #[test]
977 fn test_suppress_warnings() {
978 let yaml = r"
979_suppress_warnings: true
980archive_prompts: true
981max_tokens: 4096
982";
983 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
984 let warnings = config.validate().unwrap();
985
986 assert!(warnings.is_empty());
988 }
989
990 #[test]
991 fn test_adapter_settings() {
992 let yaml = r"
993adapters:
994 claude:
995 timeout: 600
996 enabled: true
997 gemini:
998 timeout: 300
999 enabled: false
1000";
1001 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1002
1003 let claude = config.adapter_settings("claude");
1004 assert_eq!(claude.timeout, 600);
1005 assert!(claude.enabled);
1006
1007 let gemini = config.adapter_settings("gemini");
1008 assert_eq!(gemini.timeout, 300);
1009 assert!(!gemini.enabled);
1010 }
1011
1012 #[test]
1013 fn test_unknown_fields_ignored() {
1014 let yaml = r#"
1016agent: claude
1017unknown_field: "some value"
1018future_feature: true
1019"#;
1020 let result: Result<RalphConfig, _> = serde_yaml::from_str(yaml);
1021 assert!(result.is_ok());
1023 }
1024
1025 #[test]
1026 fn test_ambiguous_routing_rejected() {
1027 let yaml = r#"
1030hats:
1031 planner:
1032 name: "Planner"
1033 description: "Plans tasks"
1034 triggers: ["planning.start", "build.done"]
1035 builder:
1036 name: "Builder"
1037 description: "Builds code"
1038 triggers: ["build.task", "build.done"]
1039"#;
1040 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1041 let result = config.validate();
1042
1043 assert!(result.is_err());
1044 let err = result.unwrap_err();
1045 assert!(
1046 matches!(&err, ConfigError::AmbiguousRouting { trigger, .. } if trigger == "build.done"),
1047 "Expected AmbiguousRouting error for 'build.done', got: {:?}",
1048 err
1049 );
1050 }
1051
1052 #[test]
1053 fn test_unique_triggers_accepted() {
1054 let yaml = r#"
1057hats:
1058 planner:
1059 name: "Planner"
1060 description: "Plans tasks"
1061 triggers: ["planning.start", "build.done", "build.blocked"]
1062 builder:
1063 name: "Builder"
1064 description: "Builds code"
1065 triggers: ["build.task"]
1066"#;
1067 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1068 let result = config.validate();
1069
1070 assert!(result.is_ok(), "Expected valid config, got: {:?}", result.unwrap_err());
1071 }
1072
1073 #[test]
1074 fn test_reserved_trigger_task_start_rejected() {
1075 let yaml = r#"
1077hats:
1078 my_hat:
1079 name: "My Hat"
1080 description: "Test hat"
1081 triggers: ["task.start"]
1082"#;
1083 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1084 let result = config.validate();
1085
1086 assert!(result.is_err());
1087 let err = result.unwrap_err();
1088 assert!(
1089 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1090 if trigger == "task.start" && hat == "my_hat"),
1091 "Expected ReservedTrigger error for 'task.start', got: {:?}",
1092 err
1093 );
1094 }
1095
1096 #[test]
1097 fn test_reserved_trigger_task_resume_rejected() {
1098 let yaml = r#"
1100hats:
1101 my_hat:
1102 name: "My Hat"
1103 description: "Test hat"
1104 triggers: ["task.resume", "other.event"]
1105"#;
1106 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1107 let result = config.validate();
1108
1109 assert!(result.is_err());
1110 let err = result.unwrap_err();
1111 assert!(
1112 matches!(&err, ConfigError::ReservedTrigger { trigger, hat }
1113 if trigger == "task.resume" && hat == "my_hat"),
1114 "Expected ReservedTrigger error for 'task.resume', got: {:?}",
1115 err
1116 );
1117 }
1118
1119 #[test]
1120 fn test_missing_description_rejected() {
1121 let yaml = r#"
1123hats:
1124 my_hat:
1125 name: "My Hat"
1126 triggers: ["build.task"]
1127"#;
1128 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1129 let result = config.validate();
1130
1131 assert!(result.is_err());
1132 let err = result.unwrap_err();
1133 assert!(
1134 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1135 "Expected MissingDescription error, got: {:?}",
1136 err
1137 );
1138 }
1139
1140 #[test]
1141 fn test_empty_description_rejected() {
1142 let yaml = r#"
1144hats:
1145 my_hat:
1146 name: "My Hat"
1147 description: " "
1148 triggers: ["build.task"]
1149"#;
1150 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1151 let result = config.validate();
1152
1153 assert!(result.is_err());
1154 let err = result.unwrap_err();
1155 assert!(
1156 matches!(&err, ConfigError::MissingDescription { hat } if hat == "my_hat"),
1157 "Expected MissingDescription error for empty description, got: {:?}",
1158 err
1159 );
1160 }
1161
1162 #[test]
1163 fn test_core_config_defaults() {
1164 let config = RalphConfig::default();
1165 assert_eq!(config.core.scratchpad, ".agent/scratchpad.md");
1166 assert_eq!(config.core.specs_dir, "./specs/");
1167 assert_eq!(config.core.guardrails.len(), 3);
1169 assert!(config.core.guardrails[0].contains("Fresh context"));
1170 assert!(config.core.guardrails[1].contains("search first"));
1171 assert!(config.core.guardrails[2].contains("Backpressure"));
1172 }
1173
1174 #[test]
1175 fn test_core_config_customizable() {
1176 let yaml = r#"
1177core:
1178 scratchpad: ".workspace/plan.md"
1179 specs_dir: "./specifications/"
1180"#;
1181 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1182 assert_eq!(config.core.scratchpad, ".workspace/plan.md");
1183 assert_eq!(config.core.specs_dir, "./specifications/");
1184 assert_eq!(config.core.guardrails.len(), 3);
1186 }
1187
1188 #[test]
1189 fn test_core_config_custom_guardrails() {
1190 let yaml = r#"
1191core:
1192 scratchpad: ".agent/scratchpad.md"
1193 specs_dir: "./specs/"
1194 guardrails:
1195 - "Custom rule one"
1196 - "Custom rule two"
1197"#;
1198 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1199 assert_eq!(config.core.guardrails.len(), 2);
1200 assert_eq!(config.core.guardrails[0], "Custom rule one");
1201 assert_eq!(config.core.guardrails[1], "Custom rule two");
1202 }
1203
1204 #[test]
1205 fn test_prompt_and_prompt_file_mutually_exclusive() {
1206 let yaml = r#"
1208event_loop:
1209 prompt: "inline text"
1210 prompt_file: "custom.md"
1211"#;
1212 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1213 let result = config.validate();
1214
1215 assert!(result.is_err());
1216 let err = result.unwrap_err();
1217 assert!(
1218 matches!(&err, ConfigError::MutuallyExclusive { field1, field2 }
1219 if field1 == "event_loop.prompt" && field2 == "event_loop.prompt_file"),
1220 "Expected MutuallyExclusive error, got: {:?}",
1221 err
1222 );
1223 }
1224
1225 #[test]
1226 fn test_prompt_with_default_prompt_file_allowed() {
1227 let yaml = r#"
1229event_loop:
1230 prompt: "inline text"
1231"#;
1232 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1233 let result = config.validate();
1234
1235 assert!(result.is_ok(), "Should allow inline prompt with default prompt_file");
1236 assert_eq!(config.event_loop.prompt, Some("inline text".to_string()));
1237 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1238 }
1239
1240 #[test]
1241 fn test_custom_backend_requires_command() {
1242 let yaml = r#"
1244cli:
1245 backend: "custom"
1246"#;
1247 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1248 let result = config.validate();
1249
1250 assert!(result.is_err());
1251 let err = result.unwrap_err();
1252 assert!(
1253 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1254 "Expected CustomBackendRequiresCommand error, got: {:?}",
1255 err
1256 );
1257 }
1258
1259 #[test]
1260 fn test_custom_backend_with_empty_command_errors() {
1261 let yaml = r#"
1263cli:
1264 backend: "custom"
1265 command: ""
1266"#;
1267 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1268 let result = config.validate();
1269
1270 assert!(result.is_err());
1271 let err = result.unwrap_err();
1272 assert!(
1273 matches!(&err, ConfigError::CustomBackendRequiresCommand),
1274 "Expected CustomBackendRequiresCommand error, got: {:?}",
1275 err
1276 );
1277 }
1278
1279 #[test]
1280 fn test_custom_backend_with_command_succeeds() {
1281 let yaml = r#"
1283cli:
1284 backend: "custom"
1285 command: "my-agent"
1286"#;
1287 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1288 let result = config.validate();
1289
1290 assert!(result.is_ok(), "Should allow custom backend with command: {:?}", result.unwrap_err());
1291 }
1292
1293 #[test]
1294 fn test_prompt_file_with_no_inline_allowed() {
1295 let yaml = r#"
1297event_loop:
1298 prompt_file: "custom.md"
1299"#;
1300 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1301 let result = config.validate();
1302
1303 assert!(result.is_ok(), "Should allow prompt_file without inline prompt");
1304 assert_eq!(config.event_loop.prompt, None);
1305 assert_eq!(config.event_loop.prompt_file, "custom.md");
1306 }
1307
1308 #[test]
1309 fn test_default_prompt_file_value() {
1310 let config = RalphConfig::default();
1311 assert_eq!(config.event_loop.prompt_file, "PROMPT.md");
1312 assert_eq!(config.event_loop.prompt, None);
1313 }
1314
1315 #[test]
1316 fn test_tui_config_default() {
1317 let config = RalphConfig::default();
1318 assert_eq!(config.tui.prefix_key, "ctrl-a");
1319 }
1320
1321 #[test]
1322 fn test_tui_config_parse_ctrl_b() {
1323 let yaml = r#"
1324tui:
1325 prefix_key: "ctrl-b"
1326"#;
1327 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1328 let (key_code, key_modifiers) = config.tui.parse_prefix().unwrap();
1329
1330 use crossterm::event::{KeyCode, KeyModifiers};
1331 assert_eq!(key_code, KeyCode::Char('b'));
1332 assert_eq!(key_modifiers, KeyModifiers::CONTROL);
1333 }
1334
1335 #[test]
1336 fn test_tui_config_parse_invalid_format() {
1337 let tui_config = TuiConfig {
1338 prefix_key: "invalid".to_string(),
1339 };
1340 let result = tui_config.parse_prefix();
1341 assert!(result.is_err());
1342 assert!(result.unwrap_err().contains("Invalid prefix_key format"));
1343 }
1344
1345 #[test]
1346 fn test_tui_config_parse_invalid_modifier() {
1347 let tui_config = TuiConfig {
1348 prefix_key: "alt-a".to_string(),
1349 };
1350 let result = tui_config.parse_prefix();
1351 assert!(result.is_err());
1352 assert!(result.unwrap_err().contains("Invalid modifier"));
1353 }
1354
1355 #[test]
1356 fn test_tui_config_parse_invalid_key() {
1357 let tui_config = TuiConfig {
1358 prefix_key: "ctrl-abc".to_string(),
1359 };
1360 let result = tui_config.parse_prefix();
1361 assert!(result.is_err());
1362 assert!(result.unwrap_err().contains("Invalid key"));
1363 }
1364
1365 #[test]
1366 fn test_hat_backend_named() {
1367 let yaml = r#""claude""#;
1368 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1369 assert_eq!(backend.to_cli_backend(), "claude");
1370 match backend {
1371 HatBackend::Named(name) => assert_eq!(name, "claude"),
1372 _ => panic!("Expected Named variant"),
1373 }
1374 }
1375
1376 #[test]
1377 fn test_hat_backend_kiro_agent() {
1378 let yaml = r#"
1379type: "kiro"
1380agent: "builder"
1381"#;
1382 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1383 assert_eq!(backend.to_cli_backend(), "kiro");
1384 match backend {
1385 HatBackend::KiroAgent { backend_type, agent } => {
1386 assert_eq!(backend_type, "kiro");
1387 assert_eq!(agent, "builder");
1388 }
1389 _ => panic!("Expected KiroAgent variant"),
1390 }
1391 }
1392
1393 #[test]
1394 fn test_hat_backend_custom() {
1395 let yaml = r#"
1396command: "/usr/bin/my-agent"
1397args: ["--flag", "value"]
1398"#;
1399 let backend: HatBackend = serde_yaml::from_str(yaml).unwrap();
1400 assert_eq!(backend.to_cli_backend(), "custom");
1401 match backend {
1402 HatBackend::Custom { command, args } => {
1403 assert_eq!(command, "/usr/bin/my-agent");
1404 assert_eq!(args, vec!["--flag", "value"]);
1405 }
1406 _ => panic!("Expected Custom variant"),
1407 }
1408 }
1409
1410 #[test]
1411 fn test_hat_config_with_backend() {
1412 let yaml = r#"
1413name: "Custom Builder"
1414triggers: ["build.task"]
1415publishes: ["build.done"]
1416instructions: "Build stuff"
1417backend: "gemini"
1418default_publishes: "task.done"
1419"#;
1420 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1421 assert_eq!(hat.name, "Custom Builder");
1422 assert!(hat.backend.is_some());
1423 match hat.backend.unwrap() {
1424 HatBackend::Named(name) => assert_eq!(name, "gemini"),
1425 _ => panic!("Expected Named backend"),
1426 }
1427 assert_eq!(hat.default_publishes, Some("task.done".to_string()));
1428 }
1429
1430 #[test]
1431 fn test_hat_config_without_backend() {
1432 let yaml = r#"
1433name: "Default Hat"
1434triggers: ["task.start"]
1435publishes: ["task.done"]
1436instructions: "Do work"
1437"#;
1438 let hat: HatConfig = serde_yaml::from_str(yaml).unwrap();
1439 assert_eq!(hat.name, "Default Hat");
1440 assert!(hat.backend.is_none());
1441 assert!(hat.default_publishes.is_none());
1442 }
1443
1444 #[test]
1445 fn test_mixed_backends_config() {
1446 let yaml = r#"
1447event_loop:
1448 prompt_file: "TASK.md"
1449 max_iterations: 50
1450
1451cli:
1452 backend: "claude"
1453
1454hats:
1455 planner:
1456 name: "Planner"
1457 triggers: ["task.start"]
1458 publishes: ["build.task"]
1459 instructions: "Plan the work"
1460 backend: "claude"
1461
1462 builder:
1463 name: "Builder"
1464 triggers: ["build.task"]
1465 publishes: ["build.done"]
1466 instructions: "Build the thing"
1467 backend:
1468 type: "kiro"
1469 agent: "builder"
1470
1471 reviewer:
1472 name: "Reviewer"
1473 triggers: ["build.done"]
1474 publishes: ["review.complete"]
1475 instructions: "Review the work"
1476 backend:
1477 command: "/usr/local/bin/custom-agent"
1478 args: ["--mode", "review"]
1479 default_publishes: "review.complete"
1480"#;
1481 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1482 assert_eq!(config.hats.len(), 3);
1483
1484 let planner = config.hats.get("planner").unwrap();
1486 assert!(planner.backend.is_some());
1487 match planner.backend.as_ref().unwrap() {
1488 HatBackend::Named(name) => assert_eq!(name, "claude"),
1489 _ => panic!("Expected Named backend for planner"),
1490 }
1491
1492 let builder = config.hats.get("builder").unwrap();
1494 assert!(builder.backend.is_some());
1495 match builder.backend.as_ref().unwrap() {
1496 HatBackend::KiroAgent { backend_type, agent } => {
1497 assert_eq!(backend_type, "kiro");
1498 assert_eq!(agent, "builder");
1499 }
1500 _ => panic!("Expected KiroAgent backend for builder"),
1501 }
1502
1503 let reviewer = config.hats.get("reviewer").unwrap();
1505 assert!(reviewer.backend.is_some());
1506 match reviewer.backend.as_ref().unwrap() {
1507 HatBackend::Custom { command, args } => {
1508 assert_eq!(command, "/usr/local/bin/custom-agent");
1509 assert_eq!(args, &vec!["--mode".to_string(), "review".to_string()]);
1510 }
1511 _ => panic!("Expected Custom backend for reviewer"),
1512 }
1513 assert_eq!(reviewer.default_publishes, Some("review.complete".to_string()));
1514 }
1515}