magi-rs 0.8.0

Magi Agent: a terminal AI assistant in Rust with sandboxed tool execution, OAuth login, and encrypted local memory (authenticated encryption with error-correcting FEC via the cryptovault crate).
// Author: Julian Bolivar
// Version: 1.0.0
// Date: 2026-06-08

//! Pure decision logic that maps per-agent MAGI model overrides to adapter specs
//! and the static-path notice. Isolates the `magi_core::schema::AgentName`
//! coupling and the `"{backend}-{agent}"` naming so `main.rs` stays thin glue.

use crate::config::{resolve_magi_override, MagiModelsConfig};
use magi_core::schema::AgentName;

/// Env-var model overrides for the three MAGI agents. Named fields (no positional
/// same-type swap risk — mirrors the `OpenAiSettings` precedent).
#[derive(Debug, Default, Clone)]
pub struct MagiEnvModels {
    pub melchior: Option<String>,
    pub balthasar: Option<String>,
    pub caspar: Option<String>,
}

/// A resolved per-agent provider override: which agent, which model, and the
/// adapter display name (`"{backend}-{agent}"`) for magi-core's report/logs.
#[derive(Debug, Clone, PartialEq)]
pub struct MagiAdapterSpec {
    pub agent: AgentName,
    pub model: String,
    pub adapter_name: String,
}

/// Lowercase label for an agent (matches magi-core's lowercase serialization).
pub fn agent_label(agent: AgentName) -> &'static str {
    match agent {
        AgentName::Melchior => "melchior",
        AgentName::Balthasar => "balthasar",
        AgentName::Caspar => "caspar",
    }
}

/// The built-in default model for an agent on the openai (Ollama) path (RF-4).
fn default_magi_model(agent: AgentName) -> &'static str {
    match agent {
        AgentName::Melchior => crate::defaults::DEFAULT_MAGI_MELCHIOR,
        AgentName::Balthasar => crate::defaults::DEFAULT_MAGI_BALTHASAR,
        AgentName::Caspar => crate::defaults::DEFAULT_MAGI_CASPAR,
    }
}

/// Computes the per-agent adapter specs for agents with an effective override
/// (env > TOML > none). An empty result means "no overrides — use `Magi::new`".
///
/// # Arguments
/// * `backend_label` - `"openai"` or `"anthropic"` (the resolved principal backend).
/// * `cfg`           - The `[magi]` TOML config.
/// * `env`           - Env-var overrides per agent.
pub fn resolve_magi_adapter_specs(
    backend_label: &str,
    cfg: &MagiModelsConfig,
    env: &MagiEnvModels,
) -> Vec<MagiAdapterSpec> {
    [
        (
            AgentName::Melchior,
            cfg.melchior_model.as_deref(),
            env.melchior.as_deref(),
        ),
        (
            AgentName::Balthasar,
            cfg.balthasar_model.as_deref(),
            env.balthasar.as_deref(),
        ),
        (
            AgentName::Caspar,
            cfg.caspar_model.as_deref(),
            env.caspar.as_deref(),
        ),
    ]
    .into_iter()
    .filter_map(|(agent, toml, env_model)| {
        let model = resolve_magi_override(toml, env_model).or_else(|| {
            if backend_label == "openai" {
                Some(default_magi_model(agent).to_string())
            } else {
                None
            }
        })?;
        Some(MagiAdapterSpec {
            agent,
            model,
            adapter_name: format!("{backend_label}-{}", agent_label(agent)),
        })
    })
    .collect()
}

/// Returns a non-silent startup notice when `[magi]` overrides are present but
/// the principal provider is static (no API backend to build adapters). `None`
/// otherwise (RF-10, S-13).
pub fn static_override_notice(is_static: bool, has_overrides: bool) -> Option<String> {
    if is_static && has_overrides {
        Some("[magi] per-agent models ignored: no API backend configured".to_string())
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cfg(m: Option<&str>, b: Option<&str>, c: Option<&str>) -> MagiModelsConfig {
        MagiModelsConfig {
            melchior_model: m.map(str::to_string),
            balthasar_model: b.map(str::to_string),
            caspar_model: c.map(str::to_string),
            auto_approve: false,
        }
    }

    #[test]
    fn test_no_overrides_yields_empty_specs() {
        // S-5: trigger false
        let specs = resolve_magi_adapter_specs(
            "anthropic",
            &MagiModelsConfig::default(),
            &MagiEnvModels::default(),
        );
        assert!(specs.is_empty());
    }

    #[test]
    fn test_empty_string_override_yields_empty_specs() {
        // S-5: empty string is not an override
        let specs = resolve_magi_adapter_specs(
            "anthropic",
            &cfg(Some(""), None, None),
            &MagiEnvModels {
                melchior: Some("  ".into()),
                ..Default::default()
            },
        );
        assert!(specs.is_empty());
    }

    #[test]
    fn test_single_override_anthropic_naming() {
        // S-7
        let specs = resolve_magi_adapter_specs(
            "anthropic",
            &cfg(Some("qwen3:8b"), None, None),
            &MagiEnvModels::default(),
        );
        assert_eq!(specs.len(), 1);
        assert_eq!(specs[0].agent, AgentName::Melchior);
        assert_eq!(specs[0].model, "qwen3:8b");
        assert_eq!(specs[0].adapter_name, "anthropic-melchior");
    }

    #[test]
    fn test_three_overrides() {
        // S-8
        let specs = resolve_magi_adapter_specs(
            "anthropic",
            &cfg(Some("a"), Some("b"), Some("c")),
            &MagiEnvModels::default(),
        );
        assert_eq!(specs.len(), 3);
        let names: Vec<&str> = specs.iter().map(|s| s.adapter_name.as_str()).collect();
        assert_eq!(
            names,
            [
                "anthropic-melchior",
                "anthropic-balthasar",
                "anthropic-caspar"
            ]
        );
    }

    #[test]
    fn test_openai_default_trio_when_no_overrides() {
        // S-4: openai + no overrides → the 3 built-in defaults
        use crate::defaults::{DEFAULT_MAGI_BALTHASAR, DEFAULT_MAGI_CASPAR, DEFAULT_MAGI_MELCHIOR};
        let specs = resolve_magi_adapter_specs(
            "openai",
            &MagiModelsConfig::default(),
            &MagiEnvModels::default(),
        );
        assert_eq!(specs.len(), 3);
        assert_eq!(specs[0].model, DEFAULT_MAGI_MELCHIOR);
        assert_eq!(specs[0].adapter_name, "openai-melchior");
        assert_eq!(specs[1].model, DEFAULT_MAGI_BALTHASAR);
        assert_eq!(specs[1].adapter_name, "openai-balthasar");
        assert_eq!(specs[2].model, DEFAULT_MAGI_CASPAR);
        assert_eq!(specs[2].adapter_name, "openai-caspar");
    }

    #[test]
    fn test_anthropic_no_default_trio() {
        // S-5: anthropic + no overrides → 0 specs (share principal; no Ollama defaults)
        let specs = resolve_magi_adapter_specs(
            "anthropic",
            &MagiModelsConfig::default(),
            &MagiEnvModels::default(),
        );
        assert!(specs.is_empty());
    }

    #[test]
    fn test_openai_override_wins_over_default_trio() {
        // S-6: openai + melchior override → melchior=custom, others=defaults
        use crate::defaults::{DEFAULT_MAGI_BALTHASAR, DEFAULT_MAGI_CASPAR};
        let specs = resolve_magi_adapter_specs(
            "openai",
            &cfg(Some("custom-melchior"), None, None),
            &MagiEnvModels::default(),
        );
        assert_eq!(specs.len(), 3);
        assert_eq!(specs[0].model, "custom-melchior");
        assert_eq!(specs[1].model, DEFAULT_MAGI_BALTHASAR);
        assert_eq!(specs[2].model, DEFAULT_MAGI_CASPAR);
    }

    #[test]
    fn test_static_with_overrides_emits_notice() {
        // S-13
        let n = static_override_notice(true, true);
        assert!(n.as_deref().unwrap().contains("per-agent models ignored"));
    }

    #[test]
    fn test_no_notice_when_not_static_or_no_overrides() {
        assert_eq!(static_override_notice(false, true), None);
        assert_eq!(static_override_notice(true, false), None);
        assert_eq!(static_override_notice(false, false), None);
    }
}