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