//! Pure model-catalogue compatibility types. use core::cmp::Ordering; use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::profile::StructuredAdapter; /// Parsed CLI version used for local compatibility checks. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CliVersion { /// Original version string reported by the CLI. pub raw: String, parts: Vec, } impl CliVersion { /// Parses a version from a string containing at least one digit. /// /// # Errors /// Returns [`ModelCatalogueError::InvalidVersion`] when no numeric version /// segment can be found. pub fn parse(raw: impl Into) -> Result { let raw = raw.into(); let start = raw .char_indices() .find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx)) .ok_or_else(|| ModelCatalogueError::InvalidVersion(raw.clone()))?; let version = raw[start..] .chars() .take_while(|ch| ch.is_ascii_digit() || *ch == '.') .collect::(); let parts = version .split('.') .filter(|part| !part.is_empty()) .map(str::parse::) .collect::, _>>() .map_err(|_| ModelCatalogueError::InvalidVersion(raw.clone()))?; if parts.is_empty() { return Err(ModelCatalogueError::InvalidVersion(raw)); } Ok(Self { raw, parts }) } } impl PartialOrd for CliVersion { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for CliVersion { fn cmp(&self, other: &Self) -> Ordering { let max_len = self.parts.len().max(other.parts.len()); for idx in 0..max_len { match self .parts .get(idx) .copied() .unwrap_or(0) .cmp(&other.parts.get(idx).copied().unwrap_or(0)) { Ordering::Equal => {} ordering => return ordering, } } Ordering::Equal } } /// Compatibility state between a local CLI version and a model id. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ModelCompatibility { /// The known minimum CLI version is satisfied. Compatible, /// The CLI version is absent, or the model is not covered by the matrix. Unknown, /// The model is covered by the matrix but appears newer than the local CLI. LikelyTooRecent, } /// Origin of a model-catalogue entry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ModelCatalogSource { /// Curated static seed maintained by IdeA. Catalogue, /// Best-effort provider API discovery. Provider, } /// Matrix mapping adapter/model ids to their minimum known CLI version. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CompatibilityMatrix { /// Matrix schema/data version. pub version: u32, /// Claude Code entries keyed by model id. #[serde(default)] pub claude: HashMap, /// Codex CLI entries keyed by model id. #[serde(default)] pub codex: HashMap, } impl CompatibilityMatrix { /// Looks up the minimum CLI version for an adapter/model pair. #[must_use] pub fn minimum_version(&self, adapter: StructuredAdapter, model_id: &str) -> Option<&str> { match adapter { StructuredAdapter::Claude => self.claude.get(model_id).map(String::as_str), StructuredAdapter::Codex => self.codex.get(model_id).map(String::as_str), StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => None, } } } /// Evaluates local compatibility using only pure matrix data. #[must_use] pub fn evaluate_compatibility( matrix: &CompatibilityMatrix, adapter: StructuredAdapter, model_id: &str, cli_version: Option<&CliVersion>, ) -> ModelCompatibility { let Some(cli_version) = cli_version else { return ModelCompatibility::Unknown; }; let Some(minimum) = matrix.minimum_version(adapter, model_id) else { return ModelCompatibility::Unknown; }; let Ok(minimum) = CliVersion::parse(minimum.to_owned()) else { return ModelCompatibility::Unknown; }; if &minimum <= cli_version { ModelCompatibility::Compatible } else { ModelCompatibility::LikelyTooRecent } } /// Errors from pure model-catalogue parsing. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ModelCatalogueError { /// Version strings must contain at least one numeric segment. #[error("invalid CLI version: {0}")] InvalidVersion(String), } #[cfg(test)] mod tests { use super::*; fn matrix() -> CompatibilityMatrix { CompatibilityMatrix { version: 1, claude: HashMap::from([("claude-sonnet-5".to_owned(), "1.2.0".to_owned())]), codex: HashMap::from([("gpt-5-codex".to_owned(), "0.44.0".to_owned())]), } } #[test] fn cli_versions_compare_by_numeric_parts() { assert!(CliVersion::parse("codex 0.10.0").unwrap() > CliVersion::parse("0.9.9").unwrap()); assert_eq!( CliVersion::parse("1.2") .unwrap() .cmp(&CliVersion::parse("1.2.0").unwrap()), Ordering::Equal ); } #[test] fn compatibility_is_unknown_without_version_or_matrix_entry() { let matrix = matrix(); assert_eq!( evaluate_compatibility(&matrix, StructuredAdapter::Codex, "gpt-5-codex", None), ModelCompatibility::Unknown ); assert_eq!( evaluate_compatibility( &matrix, StructuredAdapter::Codex, "future-model", Some(&CliVersion::parse("999.0.0").unwrap()) ), ModelCompatibility::Unknown ); } #[test] fn compatibility_detects_supported_and_too_recent_models() { let matrix = matrix(); assert_eq!( evaluate_compatibility( &matrix, StructuredAdapter::Codex, "gpt-5-codex", Some(&CliVersion::parse("0.44.0").unwrap()) ), ModelCompatibility::Compatible ); assert_eq!( evaluate_compatibility( &matrix, StructuredAdapter::Claude, "claude-sonnet-5", Some(&CliVersion::parse("1.1.9").unwrap()) ), ModelCompatibility::LikelyTooRecent ); } }