Skip to main content

ralph_workflow/agents/
validation.rs

1//! Startup validation for OpenCode agent references.
2//!
3//! This module provides validation logic for checking that all `opencode/*`
4//! agent references in configured agent chains are valid (i.e., the provider
5//! and model exist in the OpenCode API catalog).
6//!
7//! Validation errors include helpful suggestions for typos using Levenshtein
8//! distance matching.
9
10use crate::agents::fallback::FallbackConfig;
11use crate::agents::opencode_api::ApiCatalog;
12use crate::agents::opencode_resolver::OpenCodeResolver;
13
14/// Validate all OpenCode agent references in the fallback configuration.
15///
16/// This function checks that all `opencode/provider/model` references in the
17/// configured agent chains have valid providers and models in the API catalog.
18///
19/// Returns `Ok(())` if all references are valid, or `Err(String)` with a
20/// user-friendly error message if any validation fails.
21pub fn validate_opencode_agents(
22    fallback: &FallbackConfig,
23    catalog: &ApiCatalog,
24) -> Result<(), String> {
25    let resolver = OpenCodeResolver::new(catalog.clone());
26    let mut errors = Vec::new();
27
28    // Collect all agent names from both roles
29    let all_agents: Vec<&str> = fallback
30        .get_fallbacks(crate::agents::AgentRole::Developer)
31        .iter()
32        .chain(
33            fallback
34                .get_fallbacks(crate::agents::AgentRole::Reviewer)
35                .iter(),
36        )
37        .map(|s| s.as_str())
38        .collect();
39
40    // Validate each opencode/* agent
41    for agent_name in all_agents {
42        if let Some((provider, model)) = parse_opencode_ref(agent_name) {
43            if let Err(e) = resolver.validate(&provider, &model) {
44                let msg = resolver.format_error(&e, agent_name);
45                errors.push(msg);
46            }
47        }
48    }
49
50    if errors.is_empty() {
51        Ok(())
52    } else {
53        Err(errors.join("\n\n"))
54    }
55}
56
57/// Parse an `opencode/provider/model` reference into `(provider, model)`.
58///
59/// Returns `None` if the reference doesn't match the expected pattern.
60fn parse_opencode_ref(agent_name: &str) -> Option<(String, String)> {
61    if !agent_name.starts_with("opencode/") {
62        return None;
63    }
64
65    let parts: Vec<&str> = agent_name.split('/').collect();
66    if parts.len() != 3 {
67        return None;
68    }
69
70    let _opencode = parts[0];
71    let provider = parts[1].to_string();
72    let model = parts[2].to_string();
73
74    Some((provider, model))
75}
76
77/// Get all OpenCode agent references from the fallback configuration.
78pub fn get_opencode_refs(fallback: &FallbackConfig) -> Vec<String> {
79    fallback
80        .get_fallbacks(crate::agents::AgentRole::Developer)
81        .iter()
82        .chain(
83            fallback
84                .get_fallbacks(crate::agents::AgentRole::Reviewer)
85                .iter(),
86        )
87        .filter(|name| name.starts_with("opencode/"))
88        .cloned()
89        .collect()
90}
91
92/// Count the number of OpenCode agent references in the fallback configuration.
93#[cfg(test)]
94fn count_opencode_refs(fallback: &FallbackConfig) -> usize {
95    fallback
96        .get_fallbacks(crate::agents::AgentRole::Developer)
97        .iter()
98        .chain(
99            fallback
100                .get_fallbacks(crate::agents::AgentRole::Reviewer)
101                .iter(),
102        )
103        .filter(|name| name.starts_with("opencode/"))
104        .count()
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::agents::opencode_api::{Model, Provider};
111    use std::collections::HashMap;
112
113    fn mock_catalog() -> ApiCatalog {
114        let mut providers = HashMap::new();
115        providers.insert(
116            "anthropic".to_string(),
117            Provider {
118                id: "anthropic".to_string(),
119                name: "Anthropic".to_string(),
120                description: "Anthropic Claude models".to_string(),
121            },
122        );
123
124        let mut models = HashMap::new();
125        models.insert(
126            "anthropic".to_string(),
127            vec![Model {
128                id: "claude-sonnet-4-5".to_string(),
129                name: "Claude Sonnet 4.5".to_string(),
130                description: "Latest Claude Sonnet".to_string(),
131                context_length: Some(200000),
132            }],
133        );
134
135        ApiCatalog {
136            providers,
137            models,
138            cached_at: Some(chrono::Utc::now()),
139            ttl_seconds: 86400,
140        }
141    }
142
143    fn create_fallback_with_refs(refs: Vec<&str>) -> FallbackConfig {
144        let mut config = FallbackConfig::default();
145        config.developer = refs.iter().map(|s| s.to_string()).collect();
146        config
147    }
148
149    #[test]
150    fn test_parse_opencode_ref_valid() {
151        let result = parse_opencode_ref("opencode/anthropic/claude-sonnet-4-5");
152        assert_eq!(
153            result,
154            Some(("anthropic".to_string(), "claude-sonnet-4-5".to_string()))
155        );
156    }
157
158    #[test]
159    fn test_parse_opencode_ref_invalid() {
160        assert_eq!(parse_opencode_ref("claude"), None);
161        assert_eq!(parse_opencode_ref("opencode"), None);
162        assert_eq!(parse_opencode_ref("opencode/anthropic"), None);
163        assert_eq!(parse_opencode_ref("ccs/glm"), None);
164    }
165
166    #[test]
167    fn test_validate_opencode_agents_valid() {
168        let catalog = mock_catalog();
169        let fallback = create_fallback_with_refs(vec!["opencode/anthropic/claude-sonnet-4-5"]);
170
171        let result = validate_opencode_agents(&fallback, &catalog);
172        assert!(result.is_ok());
173    }
174
175    #[test]
176    fn test_validate_opencode_agents_invalid_provider() {
177        let catalog = mock_catalog();
178        let fallback = create_fallback_with_refs(vec!["opencode/unknown/claude-sonnet-4-5"]);
179
180        let result = validate_opencode_agents(&fallback, &catalog);
181        assert!(result.is_err());
182        assert!(result.unwrap_err().contains("unknown"));
183    }
184
185    #[test]
186    fn test_validate_opencode_agents_invalid_model() {
187        let catalog = mock_catalog();
188        let fallback = create_fallback_with_refs(vec!["opencode/anthropic/unknown-model"]);
189
190        let result = validate_opencode_agents(&fallback, &catalog);
191        assert!(result.is_err());
192        assert!(result.unwrap_err().contains("unknown-model"));
193    }
194
195    #[test]
196    fn test_count_opencode_refs() {
197        let fallback = create_fallback_with_refs(vec![
198            "opencode/anthropic/claude-sonnet-4-5",
199            "claude",
200            "opencode/openai/gpt-4",
201        ]);
202
203        let count = count_opencode_refs(&fallback);
204        assert_eq!(count, 2);
205    }
206
207    #[test]
208    fn test_get_opencode_refs() {
209        let fallback = create_fallback_with_refs(vec![
210            "opencode/anthropic/claude-sonnet-4-5",
211            "claude",
212            "opencode/openai/gpt-4",
213        ]);
214
215        let refs = get_opencode_refs(&fallback);
216        assert_eq!(refs.len(), 2);
217        assert!(refs.contains(&"opencode/anthropic/claude-sonnet-4-5".to_string()));
218        assert!(refs.contains(&"opencode/openai/gpt-4".to_string()));
219    }
220}