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::{AgentDrain, FallbackConfig, ResolvedDrainConfig};
11use crate::agents::opencode_api::ApiCatalog;
12use crate::agents::opencode_resolver::OpenCodeResolver;
13use std::collections::BTreeSet;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum OpenCodeValidationError {
17    InvalidReferences { messages: Vec<String> },
18}
19
20impl std::fmt::Display for OpenCodeValidationError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Self::InvalidReferences { messages } => write!(f, "{}", messages.join("\n\n")),
24        }
25    }
26}
27
28impl std::error::Error for OpenCodeValidationError {}
29
30/// Validate all `OpenCode` agent references in resolved drain bindings.
31///
32/// This function checks that all `opencode/provider/model` references in the
33/// configured drain bindings have valid providers and models in the API catalog.
34///
35/// Returns `Ok(())` if all references are valid, or `Err(String)` with a
36/// user-friendly error message if any validation fails.
37///
38/// # Errors
39///
40/// Returns error if the operation fails.
41pub fn validate_opencode_agents(
42    resolved: &ResolvedDrainConfig,
43    catalog: &ApiCatalog,
44) -> Result<(), OpenCodeValidationError> {
45    validate_opencode_agents_in_resolved_drains(resolved, catalog)
46}
47
48/// Validate all `OpenCode` agent references from the legacy fallback config.
49///
50/// This compatibility wrapper exists at the config boundary only.
51///
52/// # Errors
53///
54/// Returns error if the operation fails.
55pub fn validate_opencode_agents_legacy(
56    fallback: &FallbackConfig,
57    catalog: &ApiCatalog,
58) -> Result<(), OpenCodeValidationError> {
59    validate_opencode_agents_in_resolved_drains(&fallback.resolve_drains(), catalog)
60}
61
62/// Validate all `OpenCode` agent references in resolved drain bindings.
63///
64/// # Errors
65///
66/// Returns error if the operation fails.
67pub fn validate_opencode_agents_in_resolved_drains(
68    resolved: &ResolvedDrainConfig,
69    catalog: &ApiCatalog,
70) -> Result<(), OpenCodeValidationError> {
71    let opencode_resolver = OpenCodeResolver::new(catalog.clone());
72
73    let all_agents = get_opencode_refs_in_resolved_drains(resolved);
74
75    // Validate each opencode/* agent - collect errors using iterator
76    let errors: Vec<_> = all_agents
77        .iter()
78        .filter_map(|agent_name| {
79            parse_opencode_ref(agent_name).and_then(|(provider, model)| {
80                opencode_resolver
81                    .validate(&provider, &model)
82                    .err()
83                    .map(|e| opencode_resolver.format_error(&e, agent_name))
84            })
85        })
86        .collect();
87
88    if errors.is_empty() {
89        Ok(())
90    } else {
91        Err(OpenCodeValidationError::InvalidReferences { messages: errors })
92    }
93}
94
95/// Parse an `opencode/provider/model` reference into `(provider, model)`.
96///
97/// Returns `None` if the reference doesn't match the expected pattern.
98fn parse_opencode_ref(agent_name: &str) -> Option<(String, String)> {
99    if !agent_name.starts_with("opencode/") {
100        return None;
101    }
102
103    let parts: Vec<&str> = agent_name.split('/').collect();
104    if parts.len() != 3 {
105        return None;
106    }
107
108    let provider = parts.get(1)?.to_string();
109    let model = parts.get(2)?.to_string();
110
111    Some((provider, model))
112}
113
114/// Get all `OpenCode` agent references from resolved drain bindings.
115#[must_use]
116pub fn get_opencode_refs(resolved: &ResolvedDrainConfig) -> Vec<String> {
117    get_opencode_refs_in_resolved_drains(resolved)
118}
119
120/// Get all `OpenCode` agent references from the legacy fallback configuration.
121#[must_use]
122pub fn get_opencode_refs_legacy(fallback: &FallbackConfig) -> Vec<String> {
123    get_opencode_refs_in_resolved_drains(&fallback.resolve_drains())
124}
125
126/// Get all `OpenCode` agent references from resolved drain bindings.
127#[must_use]
128pub fn get_opencode_refs_in_resolved_drains(resolved: &ResolvedDrainConfig) -> Vec<String> {
129    let unique = AgentDrain::all()
130        .into_iter()
131        .filter_map(|drain| resolved.binding(drain))
132        .flat_map(|binding| binding.agents.iter().cloned())
133        .filter(|name| name.starts_with("opencode/"))
134        .collect::<BTreeSet<_>>();
135
136    unique.into_iter().collect()
137}
138
139/// Count the number of `OpenCode` agent references in the resolved drain bindings.
140#[cfg(test)]
141fn count_opencode_refs(resolved: &ResolvedDrainConfig) -> usize {
142    AgentDrain::all()
143        .into_iter()
144        .filter_map(|drain| resolved.binding(drain))
145        .flat_map(|binding| binding.agents.iter())
146        .filter(|name| name.starts_with("opencode/"))
147        .cloned()
148        .collect::<BTreeSet<_>>()
149        .len()
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::agents::opencode_api::{Model, Provider};
156    use std::collections::HashMap;
157
158    fn mock_catalog() -> ApiCatalog {
159        let providers = HashMap::from([(
160            "anthropic".to_string(),
161            Provider {
162                id: "anthropic".to_string(),
163                name: "Anthropic".to_string(),
164                description: "Anthropic Claude models".to_string(),
165            },
166        )]);
167
168        let models = HashMap::from([(
169            "anthropic".to_string(),
170            vec![Model {
171                id: "claude-sonnet-4-5".to_string(),
172                name: "Claude Sonnet 4.5".to_string(),
173                description: "Latest Claude Sonnet".to_string(),
174                context_length: Some(200_000),
175            }],
176        )]);
177
178        ApiCatalog {
179            providers,
180            models,
181            cached_at: Some(chrono::Utc::now()),
182            ttl_seconds: 86400,
183        }
184    }
185
186    fn create_fallback_with_refs(refs: &[&str]) -> FallbackConfig {
187        FallbackConfig {
188            developer: refs.iter().map(|s| (*s).to_string()).collect(),
189            ..FallbackConfig::default()
190        }
191    }
192
193    #[test]
194    fn test_parse_opencode_ref_valid() {
195        let result = parse_opencode_ref("opencode/anthropic/claude-sonnet-4-5");
196        assert_eq!(
197            result,
198            Some(("anthropic".to_string(), "claude-sonnet-4-5".to_string()))
199        );
200    }
201
202    #[test]
203    fn test_parse_opencode_ref_invalid() {
204        assert_eq!(parse_opencode_ref("claude"), None);
205        assert_eq!(parse_opencode_ref("opencode"), None);
206        assert_eq!(parse_opencode_ref("opencode/anthropic"), None);
207        assert_eq!(parse_opencode_ref("ccs/glm"), None);
208    }
209
210    #[test]
211    fn test_validate_opencode_agents_valid() {
212        let catalog = mock_catalog();
213        let fallback = create_fallback_with_refs(&["opencode/anthropic/claude-sonnet-4-5"]);
214        let resolved = fallback.resolve_drains();
215
216        let result = validate_opencode_agents(&resolved, &catalog);
217        assert!(result.is_ok());
218    }
219
220    #[test]
221    fn test_validate_opencode_agents_invalid_provider() {
222        let catalog = mock_catalog();
223        let fallback = create_fallback_with_refs(&["opencode/unknown/claude-sonnet-4-5"]);
224        let resolved = fallback.resolve_drains();
225
226        let result = validate_opencode_agents(&resolved, &catalog);
227        assert!(result.is_err());
228        assert!(result
229            .expect_err("expected invalid provider error")
230            .to_string()
231            .contains("unknown"));
232    }
233
234    #[test]
235    fn test_validate_opencode_agents_returns_typed_error() {
236        let catalog = mock_catalog();
237        let fallback = create_fallback_with_refs(&["opencode/unknown/claude-sonnet-4-5"]);
238        let resolved = fallback.resolve_drains();
239
240        let error = validate_opencode_agents(&resolved, &catalog)
241            .expect_err("invalid provider should return a typed validation error");
242
243        assert!(matches!(
244            error,
245            OpenCodeValidationError::InvalidReferences { .. }
246        ));
247    }
248
249    #[test]
250    fn test_validate_opencode_agents_invalid_model() {
251        let catalog = mock_catalog();
252        let fallback = create_fallback_with_refs(&["opencode/anthropic/unknown-model"]);
253        let resolved = fallback.resolve_drains();
254
255        let result = validate_opencode_agents(&resolved, &catalog);
256        assert!(result.is_err());
257        assert!(result
258            .expect_err("expected invalid model error")
259            .to_string()
260            .contains("unknown-model"));
261    }
262
263    #[test]
264    fn test_count_opencode_refs() {
265        let fallback = create_fallback_with_refs(&[
266            "opencode/anthropic/claude-sonnet-4-5",
267            "claude",
268            "opencode/openai/gpt-4",
269        ]);
270        let resolved = fallback.resolve_drains();
271
272        let count = count_opencode_refs(&resolved);
273        assert_eq!(count, 2);
274    }
275
276    #[test]
277    fn test_get_opencode_refs() {
278        let fallback = create_fallback_with_refs(&[
279            "opencode/anthropic/claude-sonnet-4-5",
280            "claude",
281            "opencode/openai/gpt-4",
282        ]);
283        let resolved = fallback.resolve_drains();
284
285        let refs = get_opencode_refs(&resolved);
286        assert_eq!(refs.len(), 2);
287        assert!(refs.contains(&"opencode/anthropic/claude-sonnet-4-5".to_string()));
288        assert!(refs.contains(&"opencode/openai/gpt-4".to_string()));
289    }
290}