opencode-provider-manager 0.1.7-beta.4

TUI/CLI binary crate for managing OpenCode provider configs
Documentation
//! Validation for oh-my-openagent configurations.
//!
//! Performs structural and semantic validation against the schema.

use crate::omo_config::error::{AgentConfigError, Result};
use crate::omo_config::types::{FallbackModelEntry, FallbackModels, OhMyOpencodeConfig};
use std::collections::HashSet;

/// Validate an agent configuration.
///
/// Checks:
/// - `agent_order` length <= 64, each item length <= 128
/// - `color` fields match hex pattern
/// - `temperature` is within 0–2
/// - `top_p` is within 0–1
/// - Required fields are present where applicable
pub fn validate_agent_config(config: &OhMyOpencodeConfig) -> Result<()> {
    validate_agent_config_with_models(config, None)
}

/// Validate an agent configuration with optional available model checking.
///
/// If `available_models` is provided, validates that agent `model` and
/// `fallback_models` reference existing models in the `provider/model` format.
///
/// `available_models` should contain model IDs in `provider/model` format
/// (e.g., "anthropic/claude-sonnet-4-5").
pub fn validate_agent_config_with_models(
    config: &OhMyOpencodeConfig,
    available_models: Option<&HashSet<String>>,
) -> Result<()> {
    let mut errors = Vec::new();

    // Validate agent_order constraints
    if let Some(ref order) = config.agent_order {
        if order.len() > 64 {
            errors.push(format!(
                "agent_order exceeds maximum length of 64 (got {})",
                order.len()
            ));
        }
        for (i, item) in order.iter().enumerate() {
            if item.len() > 128 {
                errors.push(format!(
                    "agent_order[{}] exceeds maximum length of 128 (got {})",
                    i,
                    item.len()
                ));
            }
        }
    }

    // Validate agent definitions
    if let Some(ref agents) = config.agents {
        if let Some(ref build) = agents.build {
            validate_agent_definition(build, "agents.build", &mut errors, available_models);
        }
        if let Some(ref plan) = agents.plan {
            validate_agent_definition(plan, "agents.plan", &mut errors, available_models);
        }
        if let Some(ref sisyphus) = agents.sisyphus {
            validate_agent_definition(sisyphus, "agents.sisyphus", &mut errors, available_models);
        }
        if let Some(ref hephaestus) = agents.hephaestus {
            validate_agent_definition(
                hephaestus,
                "agents.hephaestus",
                &mut errors,
                available_models,
            );
        }
        if let Some(ref prometheus) = agents.prometheus {
            validate_agent_definition(
                prometheus,
                "agents.prometheus",
                &mut errors,
                available_models,
            );
        }
        if let Some(ref oracle) = agents.oracle {
            validate_agent_definition(oracle, "agents.oracle", &mut errors, available_models);
        }
        if let Some(ref librarian) = agents.librarian {
            validate_agent_definition(librarian, "agents.librarian", &mut errors, available_models);
        }
        if let Some(ref explore) = agents.explore {
            validate_agent_definition(explore, "agents.explore", &mut errors, available_models);
        }
        if let Some(ref multimodal_looker) = agents.multimodal_looker {
            validate_agent_definition(
                multimodal_looker,
                "agents.multimodal-looker",
                &mut errors,
                available_models,
            );
        }
        if let Some(ref metis) = agents.metis {
            validate_agent_definition(metis, "agents.metis", &mut errors, available_models);
        }
        if let Some(ref momus) = agents.momus {
            validate_agent_definition(momus, "agents.momus", &mut errors, available_models);
        }
        if let Some(ref atlas) = agents.atlas {
            validate_agent_definition(atlas, "agents.atlas", &mut errors, available_models);
        }
        for (name, agent) in &agents.custom {
            validate_agent_definition(
                agent,
                &format!("agents.{}", name),
                &mut errors,
                available_models,
            );
        }
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(AgentConfigError::ValidationError(errors.join("; ")))
    }
}

/// Check if a model ID is in the available models set.
/// Supports `provider/model` and `provider/model:variant` formats.
fn check_model_available(
    model_id: &str,
    available: &HashSet<String>,
    path: &str,
    errors: &mut Vec<String>,
) {
    // Strip variant suffix if present (e.g., "ollama/model:variant" -> "ollama/model")
    let base_model = model_id.split(':').next().unwrap_or(model_id);

    if !available.contains(base_model) && !available.contains(model_id) {
        errors.push(format!(
            "{}: model '{}' not found in available models (run 'opencode models' to see available models)",
            path, model_id
        ));
    }
}

/// Validate fallback models against available models.
fn validate_fallback_models(
    fallback: &FallbackModels,
    available: &HashSet<String>,
    path: &str,
    errors: &mut Vec<String>,
) {
    match fallback {
        FallbackModels::Single(id) => {
            check_model_available(id, available, &format!("{}.fallback_models", path), errors);
        }
        FallbackModels::StringList(ids) => {
            for (i, id) in ids.iter().enumerate() {
                check_model_available(
                    id,
                    available,
                    &format!("{}.fallback_models[{}]", path, i),
                    errors,
                );
            }
        }
        FallbackModels::DetailedList(specs) => {
            for (i, spec) in specs.iter().enumerate() {
                check_model_available(
                    &spec.model,
                    available,
                    &format!("{}.fallback_models[{}]", path, i),
                    errors,
                );
            }
        }
        FallbackModels::MixedList(entries) => {
            for (i, entry) in entries.iter().enumerate() {
                match entry {
                    FallbackModelEntry::String(id) => {
                        check_model_available(
                            id,
                            available,
                            &format!("{}.fallback_models[{}]", path, i),
                            errors,
                        );
                    }
                    FallbackModelEntry::Detailed(spec) => {
                        check_model_available(
                            &spec.model,
                            available,
                            &format!("{}.fallback_models[{}]", path, i),
                            errors,
                        );
                    }
                }
            }
        }
    }
}

fn validate_agent_definition(
    agent: &crate::omo_config::types::AgentDefinition,
    path: &str,
    errors: &mut Vec<String>,
    available_models: Option<&HashSet<String>>,
) {
    // Validate temperature range
    if let Some(temp) = agent.temperature {
        if !(0.0..=2.0).contains(&temp) {
            errors.push(format!(
                "{}: temperature must be between 0 and 2 (got {})",
                path, temp
            ));
        }
    }

    // Validate top_p range
    if let Some(top_p) = agent.top_p {
        if !(0.0..=1.0).contains(&top_p) {
            errors.push(format!(
                "{}: top_p must be between 0 and 1 (got {})",
                path, top_p
            ));
        }
    }

    // Validate color format (hex)
    if let Some(ref color) = agent.color {
        if !is_valid_hex_color(color) {
            errors.push(format!(
                "{}: color must be a valid hex color like #RRGGBB (got {})",
                path, color
            ));
        }
    }

    // Validate max_tokens is positive
    if let Some(max) = agent.max_tokens {
        if max == 0 {
            errors.push(format!("{}: maxTokens must be greater than 0", path));
        }
    }

    // Validate thinking budget
    if let Some(ref thinking) = agent.thinking {
        if let Some(budget) = thinking.budget_tokens {
            if budget == 0 {
                errors.push(format!(
                    "{}: thinking.budgetTokens must be greater than 0",
                    path
                ));
            }
        }
    }

    // Validate model availability if available_models is provided
    if let Some(available) = available_models {
        if let Some(ref model_id) = agent.model {
            check_model_available(model_id, available, path, errors);
        }
        if let Some(ref fallback) = agent.fallback_models {
            validate_fallback_models(fallback, available, path, errors);
        }
    }
}

fn is_valid_hex_color(color: &str) -> bool {
    if color.len() != 7 {
        return false;
    }
    if !color.starts_with('#') {
        return false;
    }
    color[1..].chars().all(|c| c.is_ascii_hexdigit())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::omo_config::types::{
        AgentDefinition, AgentMode, AgentThinking, AgentsConfig, OhMyOpencodeConfig, ThinkingType,
    };

    #[test]
    fn test_valid_config() {
        let config = OhMyOpencodeConfig {
            new_task_system_enabled: Some(true),
            agents: Some(AgentsConfig {
                build: Some(AgentDefinition {
                    model: Some("anthropic/claude-sonnet".to_string()),
                    temperature: Some(0.7),
                    mode: Some(AgentMode::Subagent),
                    color: Some("#FF5733".to_string()),
                    thinking: Some(AgentThinking {
                        r#type: ThinkingType::Enabled,
                        budget_tokens: Some(1024),
                    }),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };

        assert!(validate_agent_config(&config).is_ok());
    }

    #[test]
    fn test_invalid_temperature() {
        let config = OhMyOpencodeConfig {
            agents: Some(AgentsConfig {
                build: Some(AgentDefinition {
                    temperature: Some(3.0),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };

        let err = validate_agent_config(&config).unwrap_err();
        assert!(err.to_string().contains("temperature"));
    }

    #[test]
    fn test_invalid_color() {
        let config = OhMyOpencodeConfig {
            agents: Some(AgentsConfig {
                build: Some(AgentDefinition {
                    color: Some("red".to_string()),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            ..Default::default()
        };

        let err = validate_agent_config(&config).unwrap_err();
        assert!(err.to_string().contains("color"));
    }

    #[test]
    fn test_agent_order_too_long() {
        let config = OhMyOpencodeConfig {
            agent_order: Some(vec!["agent".to_string(); 65]),
            ..Default::default()
        };

        let err = validate_agent_config(&config).unwrap_err();
        assert!(err.to_string().contains("agent_order"));
    }
}