looprs 0.4.0

Concise coding assistant REPL — core library
Documentation
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;

#[derive(Debug, Deserialize, Clone)]
pub struct ProviderTier {
    pub provider: String,
    pub model: String,
}

#[derive(Debug, Deserialize, Default, Clone)]
struct MagiConfig {
    #[serde(default)]
    modelcard: String,
    #[serde(default)]
    db: String,
}

#[derive(Debug, Deserialize, Clone)]
pub struct ModelsConfig {
    pub default: ProviderTier,
    #[serde(default)]
    tiers: HashMap<String, ProviderTier>,
    #[serde(default)]
    magi: MagiConfig,
}

impl ModelsConfig {
    pub fn from_path(path: &Path) -> Result<Self> {
        let content =
            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
        toml::from_str(&content).context("parsing models.toml")
    }

    pub fn load() -> Result<Self> {
        let home = dirs::home_dir().context("could not determine home directory")?;
        Self::from_path(&home.join(".looprs").join("models.toml"))
    }

    pub fn tier(&self, name: &str) -> Option<&ProviderTier> {
        self.tiers.get(name)
    }

    pub fn magi_modelcard(&self) -> &str {
        &self.magi.modelcard
    }

    pub fn magi_db(&self) -> &str {
        &self.magi.db
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    #[test]
    fn test_from_path_minimal_valid_toml() {
        let mut file = NamedTempFile::new().expect("failed to create temp file");
        let content = r#"
[default]
provider = "openai"
model = "gpt-4"
"#;
        file.write_all(content.as_bytes())
            .expect("failed to write to temp file");
        file.flush().expect("failed to flush temp file");

        let config = ModelsConfig::from_path(file.path()).expect("failed to parse config");
        assert_eq!(config.default.provider, "openai");
        assert_eq!(config.default.model, "gpt-4");
        assert!(config.tiers.is_empty());
        assert_eq!(config.magi_modelcard(), "");
        assert_eq!(config.magi_db(), "");
    }

    #[test]
    fn test_from_path_with_tiers_and_magi() {
        let mut file = NamedTempFile::new().expect("failed to create temp file");
        let content = r#"
[default]
provider = "openai"
model = "gpt-4"

[tiers.fast]
provider = "anthropic"
model = "claude-opus"

[tiers.cheap]
provider = "openai"
model = "gpt-3.5-turbo"

[magi]
modelcard = "/path/to/modelcard"
db = "/path/to/db"
"#;
        file.write_all(content.as_bytes())
            .expect("failed to write to temp file");
        file.flush().expect("failed to flush temp file");

        let config = ModelsConfig::from_path(file.path()).expect("failed to parse config");
        assert_eq!(config.default.provider, "openai");
        assert_eq!(config.default.model, "gpt-4");

        let fast_tier = config.tier("fast").expect("fast tier not found");
        assert_eq!(fast_tier.provider, "anthropic");
        assert_eq!(fast_tier.model, "claude-opus");

        let cheap_tier = config.tier("cheap").expect("cheap tier not found");
        assert_eq!(cheap_tier.provider, "openai");
        assert_eq!(cheap_tier.model, "gpt-3.5-turbo");

        assert_eq!(config.magi_modelcard(), "/path/to/modelcard");
        assert_eq!(config.magi_db(), "/path/to/db");
    }

    #[test]
    fn test_from_path_nonexistent_file() {
        let path = Path::new("/nonexistent/path/to/models.toml");
        let result = ModelsConfig::from_path(path);
        assert!(result.is_err());
        let err_msg = format!("{:?}", result);
        assert!(err_msg.contains("reading") || err_msg.contains("No such file"));
    }

    #[test]
    fn test_from_path_invalid_toml() {
        let mut file = NamedTempFile::new().expect("failed to create temp file");
        let content = r#"
[default
provider = "openai"
"#;
        file.write_all(content.as_bytes())
            .expect("failed to write to temp file");
        file.flush().expect("failed to flush temp file");

        let result = ModelsConfig::from_path(file.path());
        assert!(result.is_err());
        let err_msg = format!("{:?}", result);
        assert!(err_msg.contains("parsing"));
    }

    #[test]
    fn test_tier_returns_some_for_existing() {
        let mut file = NamedTempFile::new().expect("failed to create temp file");
        let content = r#"
[default]
provider = "openai"
model = "gpt-4"

[tiers.fast]
provider = "anthropic"
model = "claude-opus"
"#;
        file.write_all(content.as_bytes())
            .expect("failed to write to temp file");
        file.flush().expect("failed to flush temp file");

        let config = ModelsConfig::from_path(file.path()).expect("failed to parse config");
        assert!(config.tier("fast").is_some());
    }

    #[test]
    fn test_tier_returns_none_for_missing() {
        let mut file = NamedTempFile::new().expect("failed to create temp file");
        let content = r#"
[default]
provider = "openai"
model = "gpt-4"
"#;
        file.write_all(content.as_bytes())
            .expect("failed to write to temp file");
        file.flush().expect("failed to flush temp file");

        let config = ModelsConfig::from_path(file.path()).expect("failed to parse config");
        assert!(config.tier("nonexistent").is_none());
    }

    #[test]
    fn test_magi_default_values_when_section_omitted() {
        let mut file = NamedTempFile::new().expect("failed to create temp file");
        let content = r#"
[default]
provider = "openai"
model = "gpt-4"
"#;
        file.write_all(content.as_bytes())
            .expect("failed to write to temp file");
        file.flush().expect("failed to flush temp file");

        let config = ModelsConfig::from_path(file.path()).expect("failed to parse config");
        assert_eq!(config.magi_modelcard(), "");
        assert_eq!(config.magi_db(), "");
    }

    #[test]
    fn test_magi_partial_values() {
        let mut file = NamedTempFile::new().expect("failed to create temp file");
        let content = r#"
[default]
provider = "openai"
model = "gpt-4"

[magi]
modelcard = "/path/to/modelcard"
"#;
        file.write_all(content.as_bytes())
            .expect("failed to write to temp file");
        file.flush().expect("failed to flush temp file");

        let config = ModelsConfig::from_path(file.path()).expect("failed to parse config");
        assert_eq!(config.magi_modelcard(), "/path/to/modelcard");
        assert_eq!(config.magi_db(), "");
    }
}