weft-core 0.1.1

OpenAI-compatible AI agent runtime with WASM capability plugin system
Documentation
use super::{validate_config, AppConfig};
use anyhow::{Context, Result};
use std::path::Path;

pub fn load_config(path: &Path) -> Result<AppConfig> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config: {}", path.display()))?;
    let config: AppConfig =
        toml::from_str(&content).with_context(|| "Failed to parse config TOML")?;
    validate_config(&config).with_context(|| "Invalid config")?;
    Ok(config)
}

pub fn load_config_or_default(path: &Path) -> AppConfig {
    match load_config(path) {
        Ok(cfg) => cfg,
        Err(e) => {
            tracing::warn!("Config load failed ({:#}), using defaults", e);
            default_config()
        }
    }
}

pub fn save_config(path: &Path, config: &AppConfig) -> Result<()> {
    let content = toml::to_string_pretty(config).context("Failed to serialize config")?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, content)
        .with_context(|| format!("Failed to write config: {}", path.display()))?;
    Ok(())
}

fn default_config() -> AppConfig {
    AppConfig {
        core: Default::default(),
        providers: vec![],
        routing: Default::default(),
        key_strategy: Default::default(),
        fallback: Default::default(),
        virtual_keys: vec![],
        services: vec![],
        packages: vec![],
        registry: Default::default(),
        package_aliases: Default::default(),
        web_search: Default::default(),
        team: Default::default(),
    }
}

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

    #[test]
    fn test_load_minimal_config() {
        let mut f = NamedTempFile::new().unwrap();
        write!(
            f,
            r#"
[core]
host = "0.0.0.0"
port = 8080
"#
        )
        .unwrap();
        let cfg = load_config(f.path()).unwrap();
        assert_eq!(cfg.core.host, "0.0.0.0");
        assert_eq!(cfg.core.port, 8080);
        assert_eq!(cfg.core.log_level, "info");
    }

    #[test]
    fn test_load_with_providers() {
        let mut f = NamedTempFile::new().unwrap();
        write!(
            f,
            r#"
[core]
host = "127.0.0.1"
port = 3000

[[providers]]
name = "openrouter"
base_url = "https://openrouter.ai/api/v1"
models = ["claude-sonnet-4"]

[[providers.keys]]
value = "sk-test-123"
label = "test key"
"#
        )
        .unwrap();
        let cfg = load_config(f.path()).unwrap();
        assert_eq!(cfg.providers.len(), 1);
        assert_eq!(cfg.providers[0].name, "openrouter");
        assert_eq!(cfg.providers[0].keys.len(), 1);
        assert_eq!(cfg.providers[0].keys[0].value, "sk-test-123");
    }

    #[test]
    fn test_roundtrip() {
        let mut f = NamedTempFile::new().unwrap();
        write!(
            f,
            r#"
[core]
host = "127.0.0.1"
port = 3000
log_level = "debug"
data_dir = "./data"

[routing]
default_provider = "openrouter"

[key_strategy]
mode = "round_robin"
"#
        )
        .unwrap();
        let cfg = load_config(f.path()).unwrap();
        let tmp = NamedTempFile::new().unwrap();
        save_config(tmp.path(), &cfg).unwrap();
        let cfg2 = load_config(tmp.path()).unwrap();
        assert_eq!(cfg2.core.port, 3000);
        assert_eq!(cfg2.key_strategy.mode, "round_robin");
    }

    #[test]
    fn test_load_registry_source_urls() {
        let mut f = NamedTempFile::new().unwrap();
        write!(
            f,
            r#"
[core]
host = "127.0.0.1"
port = 3000

[registry]
gitea_url = "https://gitea.alhz.org"
package_source_url = "http://127.0.0.1:4011/packages"
app_source_url = "http://127.0.0.1:4012/apps"
"#
        )
        .unwrap();
        let cfg = load_config(f.path()).unwrap();
        assert_eq!(
            cfg.registry.package_source_url.as_deref(),
            Some("http://127.0.0.1:4011/packages")
        );
        assert_eq!(
            cfg.registry.app_source_url.as_deref(),
            Some("http://127.0.0.1:4012/apps")
        );
    }

    #[test]
    fn test_legacy_provider_config_defaults_to_chat_completions_api() {
        let mut f = NamedTempFile::new().unwrap();
        write!(
            f,
            r#"
[core]
host = "127.0.0.1"
port = 3000

[[providers]]
name = "legacy-openai"
base_url = "https://api.openai.com/v1"
format = "openai"
models = ["gpt-3.5-turbo"]
"#
        )
        .unwrap();

        let cfg = load_config(f.path()).unwrap();
        assert_eq!(
            cfg.providers[0].api,
            crate::config::ProviderApi::ChatCompletions
        );
    }

    #[test]
    fn test_gpt_5_with_default_api_fails_config_load_with_responses_message() {
        let mut f = NamedTempFile::new().unwrap();
        write!(
            f,
            r#"
[core]
host = "127.0.0.1"
port = 3000

[[providers]]
name = "openai"
base_url = "https://api.openai.com/v1"
format = "openai"
models = ["gpt-5"]
"#
        )
        .unwrap();

        let err = load_config(f.path()).unwrap_err();
        let message = format!("{err:#}");
        assert!(message.contains("Invalid config"));
        assert!(message.contains("requires OpenAI Responses API"));
        assert!(message.contains("api = 'responses'"));
    }

    #[test]
    fn test_gpt_5_with_responses_api_loads_successfully() {
        let mut f = NamedTempFile::new().unwrap();
        write!(
            f,
            r#"
[core]
host = "127.0.0.1"
port = 3000

[[providers]]
name = "openai"
base_url = "https://api.openai.com/v1"
format = "openai"
api = "responses"
models = ["gpt-5"]
"#
        )
        .unwrap();

        let cfg = load_config(f.path()).unwrap();
        assert_eq!(cfg.providers[0].api, crate::config::ProviderApi::Responses);
    }
}