use std::path::Path;
use serde::Deserialize;
use crate::budget::TokenPool;
use crate::cache::CacheConfig;
use crate::provider::ProviderType;
use crate::router::{ProviderRoute, RoutingStrategy};
use crate::server::ServerConfig;
#[derive(Debug, Deserialize)]
pub struct HooshConfig {
#[serde(default)]
pub server: ServerSection,
#[serde(default)]
pub cache: CacheSection,
#[serde(default)]
pub providers: Vec<ProviderSection>,
#[serde(default)]
pub budgets: Vec<BudgetPoolSection>,
#[serde(default)]
pub whisper: WhisperSection,
#[serde(default)]
pub tts: TtsSection,
#[serde(default)]
pub audit: AuditSection,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub telemetry: TelemetrySection,
#[serde(default)]
pub context: ContextSection,
#[serde(default)]
pub retry: crate::provider::retry::RetryConfig,
#[serde(default)]
pub hardware: HardwareSection,
}
#[derive(Debug, Default, Deserialize)]
pub struct WhisperSection {
pub model: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct TtsSection {
pub url: Option<String>,
}
#[derive(Default, Deserialize)]
pub struct AuditSection {
#[serde(default)]
pub enabled: bool,
pub signing_key: Option<String>,
#[serde(default = "default_audit_max")]
pub max_entries: usize,
}
fn default_audit_max() -> usize {
10_000
}
impl std::fmt::Debug for AuditSection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditSection")
.field("enabled", &self.enabled)
.field(
"signing_key",
&self.signing_key.as_ref().map(|_| "[REDACTED]"),
)
.field("max_entries", &self.max_entries)
.finish()
}
}
#[derive(Debug, Deserialize)]
pub struct ContextSection {
#[serde(default = "default_compaction_threshold")]
pub compaction_threshold: f64,
#[serde(default = "default_keep_last")]
pub keep_last_messages: usize,
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for ContextSection {
fn default() -> Self {
Self {
compaction_threshold: default_compaction_threshold(),
keep_last_messages: default_keep_last(),
enabled: true,
}
}
}
fn default_compaction_threshold() -> f64 {
0.8
}
fn default_keep_last() -> usize {
10
}
#[derive(Debug, Deserialize)]
pub struct HardwareSection {
#[serde(default = "default_hw_cache_ttl")]
pub cache_ttl_secs: u64,
#[serde(default)]
pub disabled_backends: Vec<String>,
#[serde(default)]
pub vram_reserve_bytes: u64,
#[serde(default = "default_hw_refresh")]
pub refresh_interval_secs: u64,
}
impl Default for HardwareSection {
fn default() -> Self {
Self {
cache_ttl_secs: default_hw_cache_ttl(),
disabled_backends: Vec::new(),
vram_reserve_bytes: 0,
refresh_interval_secs: default_hw_refresh(),
}
}
}
fn default_hw_cache_ttl() -> u64 {
300
}
fn default_hw_refresh() -> u64 {
30
}
#[derive(Debug, Default, Deserialize)]
pub struct TelemetrySection {
pub otlp_endpoint: Option<String>,
#[serde(default = "default_service_name")]
pub service_name: String,
}
fn default_service_name() -> String {
"hoosh".into()
}
#[derive(Default, Deserialize)]
pub struct AuthConfig {
#[serde(default)]
pub tokens: Vec<String>,
}
impl std::fmt::Debug for AuthConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthConfig")
.field("tokens", &format!("[{} configured]", self.tokens.len()))
.finish()
}
}
impl std::fmt::Debug for ProviderSection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderSection")
.field("provider_type", &self.provider_type)
.field("base_url", &self.base_url)
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("priority", &self.priority)
.field("models", &self.models)
.field("enabled", &self.enabled)
.finish()
}
}
#[derive(Debug, Deserialize)]
pub struct BudgetPoolSection {
pub name: String,
pub capacity: u64,
}
#[derive(Debug, Deserialize)]
pub struct ServerSection {
#[serde(default = "default_bind")]
pub bind: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub strategy: StrategyValue,
#[serde(default = "default_health_interval")]
pub health_check_interval_secs: u64,
}
impl Default for ServerSection {
fn default() -> Self {
Self {
bind: default_bind(),
port: default_port(),
strategy: StrategyValue::default(),
health_check_interval_secs: default_health_interval(),
}
}
}
impl From<StrategyValue> for RoutingStrategy {
fn from(v: StrategyValue) -> Self {
match v {
StrategyValue::Priority => RoutingStrategy::Priority,
StrategyValue::RoundRobin => RoutingStrategy::RoundRobin,
StrategyValue::LowestLatency => RoutingStrategy::LowestLatency,
StrategyValue::Direct => RoutingStrategy::Direct,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StrategyValue {
#[default]
Priority,
RoundRobin,
LowestLatency,
Direct,
}
#[derive(Debug, Deserialize)]
pub struct CacheSection {
#[serde(default = "default_cache_max")]
pub max_entries: usize,
#[serde(default = "default_cache_ttl")]
pub ttl_secs: u64,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Deserialize)]
pub struct ProviderSection {
#[serde(rename = "type")]
pub provider_type: ProviderType,
pub base_url: Option<String>,
pub api_key: Option<String>,
#[serde(default = "default_priority")]
pub priority: u32,
#[serde(default)]
pub models: Vec<String>,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub max_tokens_limit: Option<u32>,
#[serde(default)]
pub rate_limit_rpm: Option<u32>,
#[serde(default)]
pub tls_pinned_certs: Vec<String>,
pub client_cert: Option<String>,
pub client_key: Option<String>,
}
fn default_bind() -> String {
"127.0.0.1".into()
}
fn default_port() -> u16 {
8088
}
fn default_cache_max() -> usize {
1000
}
fn default_cache_ttl() -> u64 {
300
}
fn default_true() -> bool {
true
}
fn default_priority() -> u32 {
10
}
fn default_health_interval() -> u64 {
30
}
impl Default for CacheSection {
fn default() -> Self {
Self {
max_entries: default_cache_max(),
ttl_secs: default_cache_ttl(),
enabled: true,
}
}
}
fn resolve_api_key(raw: &Option<String>) -> Option<String> {
let raw = raw.as_ref()?;
if let Some(var_name) = raw.strip_prefix('$') {
match std::env::var(var_name) {
Ok(val) => Some(val),
Err(_) => {
tracing::warn!(
"API key env var ${var_name} is not set — provider will have no API key"
);
None
}
}
} else {
Some(raw.clone())
}
}
fn default_base_url(provider_type: ProviderType) -> &'static str {
match provider_type {
ProviderType::Ollama => "http://localhost:11434",
ProviderType::LlamaCpp => "http://localhost:8080",
ProviderType::Synapse => "http://localhost:5000",
ProviderType::LmStudio => "http://localhost:1234",
ProviderType::LocalAi => "http://localhost:8080",
ProviderType::OpenAi => "https://api.openai.com",
ProviderType::Anthropic => "https://api.anthropic.com",
ProviderType::DeepSeek => "https://api.deepseek.com",
ProviderType::Mistral => "https://api.mistral.ai",
ProviderType::Groq => "https://api.groq.com/openai",
ProviderType::OpenRouter => "https://openrouter.ai/api",
ProviderType::Google => "https://generativelanguage.googleapis.com",
ProviderType::Grok => "https://api.x.ai",
ProviderType::Whisper => "http://localhost:8080",
}
}
impl HooshConfig {
pub fn load(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let contents = std::fs::read_to_string(path.as_ref())?;
let config: HooshConfig = toml::from_str(&contents).map_err(|e| {
let msg = e.to_string();
if msg.contains("api_key") {
anyhow::anyhow!("failed to parse config: TOML syntax error near api_key field")
} else {
anyhow::anyhow!("failed to parse config: {e}")
}
})?;
Ok(config)
}
pub fn load_or_default() -> Self {
if Path::new("hoosh.toml").exists() {
match Self::load("hoosh.toml") {
Ok(config) => {
tracing::info!("loaded config from hoosh.toml");
config
}
Err(e) => {
tracing::error!("failed to load hoosh.toml: {e}");
std::process::exit(1);
}
}
} else {
Self {
server: ServerSection::default(),
cache: CacheSection::default(),
providers: Vec::new(),
budgets: Vec::new(),
whisper: WhisperSection::default(),
tts: TtsSection::default(),
audit: AuditSection::default(),
auth: AuthConfig::default(),
telemetry: TelemetrySection::default(),
context: ContextSection::default(),
retry: crate::provider::retry::RetryConfig::default(),
hardware: HardwareSection::default(),
}
}
}
pub fn routes(&self) -> Vec<ProviderRoute> {
self.providers
.iter()
.map(|p| {
let base_url = p
.base_url
.clone()
.unwrap_or_else(|| default_base_url(p.provider_type).into());
let tls_config = if !p.tls_pinned_certs.is_empty()
|| p.client_cert.is_some()
|| p.client_key.is_some()
{
Some(crate::provider::TlsConfig {
pinned_certs: p.tls_pinned_certs.clone(),
client_cert: p.client_cert.clone(),
client_key: p.client_key.clone(),
})
} else {
None
};
ProviderRoute {
provider: p.provider_type,
priority: p.priority,
model_patterns: p.models.clone(),
enabled: p.enabled,
base_url,
api_key: resolve_api_key(&p.api_key),
max_tokens_limit: p.max_tokens_limit,
rate_limit_rpm: p.rate_limit_rpm,
tls_config,
}
})
.collect()
}
pub fn into_server_config(
self,
bind_override: Option<&str>,
port_override: Option<u16>,
config_path: Option<String>,
) -> ServerConfig {
let routes = self.routes();
let strategy: RoutingStrategy = self.server.strategy.into();
let budget_pools = self
.budgets
.iter()
.map(|b| TokenPool::new(&b.name, b.capacity))
.collect();
ServerConfig {
bind: bind_override.map(String::from).unwrap_or(self.server.bind),
port: port_override.unwrap_or(self.server.port),
routes,
strategy,
cache_config: CacheConfig {
max_entries: self.cache.max_entries,
ttl_secs: self.cache.ttl_secs,
enabled: self.cache.enabled,
},
budget_pools,
whisper_model: self.whisper.model,
tts_model: self.tts.url,
audit_enabled: self.audit.enabled,
audit_signing_key: resolve_api_key(&self.audit.signing_key),
audit_max_entries: self.audit.max_entries,
auth_tokens: self.auth.tokens,
otlp_endpoint: self.telemetry.otlp_endpoint,
telemetry_service_name: self.telemetry.service_name,
health_check_interval_secs: self.server.health_check_interval_secs,
config_path,
context_config: self.context,
retry_config: self.retry,
#[cfg(feature = "hwaccel")]
hardware_config: self.hardware,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_config() {
let toml = "";
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.server.port, 8088);
assert_eq!(config.server.bind, "127.0.0.1");
assert!(config.providers.is_empty());
}
#[test]
fn parse_full_config() {
let toml = r#"
[server]
bind = "0.0.0.0"
port = 9000
strategy = "round_robin"
[cache]
max_entries = 500
ttl_secs = 600
enabled = false
[[providers]]
type = "Ollama"
base_url = "http://gpu-box:11434"
priority = 1
models = ["llama*", "mistral*"]
[[providers]]
type = "OpenAi"
api_key = "$OPENAI_API_KEY"
priority = 10
models = ["gpt-*"]
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.server.port, 9000);
assert_eq!(config.server.bind, "0.0.0.0");
assert_eq!(config.cache.max_entries, 500);
assert!(!config.cache.enabled);
assert_eq!(config.providers.len(), 2);
assert_eq!(config.providers[0].provider_type, ProviderType::Ollama);
assert_eq!(config.providers[1].provider_type, ProviderType::OpenAi);
assert_eq!(
config.providers[1].api_key.as_deref(),
Some("$OPENAI_API_KEY")
);
}
#[test]
fn routes_from_config() {
let toml = r#"
[[providers]]
type = "Ollama"
priority = 1
models = ["llama*"]
[[providers]]
type = "OpenAi"
api_key = "sk-test-key"
priority = 5
models = ["gpt-*"]
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let routes = config.routes();
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].base_url, "http://localhost:11434");
assert!(routes[0].api_key.is_none());
assert_eq!(routes[1].base_url, "https://api.openai.com");
assert_eq!(routes[1].api_key.as_deref(), Some("sk-test-key"));
}
#[test]
fn resolve_api_key_literal() {
let key = Some("sk-literal".into());
assert_eq!(resolve_api_key(&key).as_deref(), Some("sk-literal"));
}
#[test]
fn resolve_api_key_env_var() {
unsafe { std::env::set_var("HOOSH_TEST_KEY_1234", "sk-from-env") };
let key = Some("$HOOSH_TEST_KEY_1234".into());
assert_eq!(resolve_api_key(&key).as_deref(), Some("sk-from-env"));
unsafe { std::env::remove_var("HOOSH_TEST_KEY_1234") };
}
#[test]
fn resolve_api_key_missing_env() {
let key = Some("$HOOSH_NONEXISTENT_KEY_999".into());
assert!(resolve_api_key(&key).is_none());
}
#[test]
fn resolve_api_key_none() {
assert!(resolve_api_key(&None).is_none());
}
#[test]
fn default_base_urls() {
assert_eq!(
default_base_url(ProviderType::Ollama),
"http://localhost:11434"
);
assert_eq!(
default_base_url(ProviderType::OpenAi),
"https://api.openai.com"
);
assert_eq!(
default_base_url(ProviderType::Anthropic),
"https://api.anthropic.com"
);
assert_eq!(
default_base_url(ProviderType::Groq),
"https://api.groq.com/openai"
);
}
#[test]
fn into_server_config_with_overrides() {
let toml = r#"
[server]
port = 9000
bind = "0.0.0.0"
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let sc = config.into_server_config(Some("127.0.0.1"), Some(8080), None);
assert_eq!(sc.bind, "127.0.0.1");
assert_eq!(sc.port, 8080);
}
#[test]
fn into_server_config_no_overrides() {
let toml = r#"
[server]
port = 9000
bind = "0.0.0.0"
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let sc = config.into_server_config(None, None, None);
assert_eq!(sc.bind, "0.0.0.0");
assert_eq!(sc.port, 9000);
}
#[test]
fn parse_with_budgets() {
let toml = r#"
[[budgets]]
name = "default"
capacity = 100000
[[budgets]]
name = "agents"
capacity = 50000
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.budgets.len(), 2);
assert_eq!(config.budgets[0].name, "default");
assert_eq!(config.budgets[0].capacity, 100000);
}
#[test]
fn parse_with_whisper_and_tts() {
let toml = r#"
[whisper]
model = "models/ggml-base.en.bin"
[tts]
url = "http://localhost:5500"
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.whisper.model.as_deref(),
Some("models/ggml-base.en.bin")
);
assert_eq!(config.tts.url.as_deref(), Some("http://localhost:5500"));
}
#[test]
fn into_server_config_with_budgets() {
let toml = r#"
[[budgets]]
name = "pool1"
capacity = 5000
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let sc = config.into_server_config(None, None, None);
assert_eq!(sc.budget_pools.len(), 1);
assert_eq!(sc.budget_pools[0].name, "pool1");
assert_eq!(sc.budget_pools[0].capacity, 5000);
}
#[test]
fn into_server_config_with_whisper_tts() {
let toml = r#"
[whisper]
model = "model.bin"
[tts]
url = "http://tts:5500"
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let sc = config.into_server_config(None, None, None);
assert_eq!(sc.whisper_model.as_deref(), Some("model.bin"));
assert_eq!(sc.tts_model.as_deref(), Some("http://tts:5500"));
}
#[test]
fn into_server_config_all_strategies() {
for (strategy_str, _) in [
("priority", "Priority"),
("round_robin", "RoundRobin"),
("lowest_latency", "LowestLatency"),
("direct", "Direct"),
] {
let toml = format!("[server]\nstrategy = \"{strategy_str}\"");
let config: HooshConfig = toml::from_str(&toml).unwrap();
let sc = config.into_server_config(None, None, None);
let _ = sc.strategy;
}
}
#[test]
fn all_default_base_urls_covered() {
let types = [
(ProviderType::Ollama, "http://localhost:11434"),
(ProviderType::LlamaCpp, "http://localhost:8080"),
(ProviderType::Synapse, "http://localhost:5000"),
(ProviderType::LmStudio, "http://localhost:1234"),
(ProviderType::LocalAi, "http://localhost:8080"),
(ProviderType::OpenAi, "https://api.openai.com"),
(ProviderType::Anthropic, "https://api.anthropic.com"),
(ProviderType::DeepSeek, "https://api.deepseek.com"),
(ProviderType::Mistral, "https://api.mistral.ai"),
(ProviderType::Groq, "https://api.groq.com/openai"),
(ProviderType::OpenRouter, "https://openrouter.ai/api"),
(
ProviderType::Google,
"https://generativelanguage.googleapis.com",
),
(ProviderType::Grok, "https://api.x.ai"),
(ProviderType::Whisper, "http://localhost:8080"),
];
for (pt, expected) in types {
assert_eq!(default_base_url(pt), expected, "mismatch for {pt}");
}
}
#[test]
fn provider_with_max_tokens_limit() {
let toml = r#"
[[providers]]
type = "OpenAi"
api_key = "sk-test"
max_tokens_limit = 4096
models = ["gpt-*"]
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.providers[0].max_tokens_limit, Some(4096));
let routes = config.routes();
assert_eq!(routes[0].max_tokens_limit, Some(4096));
}
#[test]
fn provider_defaults() {
let toml = r#"
[[providers]]
type = "Ollama"
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let p = &config.providers[0];
assert_eq!(p.priority, 10);
assert!(p.enabled);
assert!(p.models.is_empty());
assert!(p.base_url.is_none());
}
#[test]
fn routes_with_tls_config() {
let toml = r#"
[[providers]]
type = "OpenAi"
api_key = "sk-test"
models = ["gpt-*"]
tls_pinned_certs = ["/path/to/cert.pem"]
client_cert = "/path/to/client.pem"
client_key = "/path/to/client-key.pem"
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let routes = config.routes();
assert_eq!(routes.len(), 1);
let tls = routes[0].tls_config.as_ref().unwrap();
assert_eq!(tls.pinned_certs.len(), 1);
assert_eq!(tls.client_cert.as_deref(), Some("/path/to/client.pem"));
assert_eq!(tls.client_key.as_deref(), Some("/path/to/client-key.pem"));
}
#[test]
fn routes_without_tls_config() {
let toml = r#"
[[providers]]
type = "Ollama"
models = ["llama*"]
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let routes = config.routes();
assert!(routes[0].tls_config.is_none());
}
#[test]
fn routes_with_rate_limit() {
let toml = r#"
[[providers]]
type = "OpenAi"
api_key = "sk-test"
models = ["gpt-*"]
rate_limit_rpm = 60
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let routes = config.routes();
assert_eq!(routes[0].rate_limit_rpm, Some(60));
}
#[test]
fn routes_disabled_provider() {
let toml = r#"
[[providers]]
type = "Ollama"
enabled = false
models = ["llama*"]
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let routes = config.routes();
assert!(!routes[0].enabled);
}
#[test]
fn parse_audit_section() {
let toml = r#"
[audit]
enabled = true
signing_key = "my-secret-key"
max_entries = 5000
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert!(config.audit.enabled);
assert_eq!(config.audit.signing_key.as_deref(), Some("my-secret-key"));
assert_eq!(config.audit.max_entries, 5000);
}
#[test]
fn parse_audit_defaults() {
let config: HooshConfig = toml::from_str("").unwrap();
assert!(!config.audit.enabled);
assert!(config.audit.signing_key.is_none());
}
#[test]
fn parse_context_section() {
let toml = r#"
[context]
compaction_threshold = 0.6
keep_last_messages = 5
enabled = false
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert!((config.context.compaction_threshold - 0.6).abs() < f64::EPSILON);
assert_eq!(config.context.keep_last_messages, 5);
assert!(!config.context.enabled);
}
#[test]
fn parse_context_defaults() {
let config: HooshConfig = toml::from_str("").unwrap();
assert!((config.context.compaction_threshold - 0.8).abs() < f64::EPSILON);
assert_eq!(config.context.keep_last_messages, 10);
assert!(config.context.enabled);
}
#[test]
fn parse_telemetry_section() {
let toml = r#"
[telemetry]
otlp_endpoint = "http://localhost:4317"
service_name = "my-hoosh"
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(
config.telemetry.otlp_endpoint.as_deref(),
Some("http://localhost:4317")
);
assert_eq!(config.telemetry.service_name, "my-hoosh");
}
#[test]
fn parse_telemetry_defaults() {
let config: HooshConfig = toml::from_str("").unwrap();
assert!(config.telemetry.otlp_endpoint.is_none());
}
#[test]
fn parse_telemetry_with_service_name() {
let toml = r#"
[telemetry]
service_name = "hoosh"
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.telemetry.service_name, "hoosh");
}
#[test]
fn parse_auth_section() {
let toml = r#"
[auth]
tokens = ["token1", "token2"]
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.auth.tokens.len(), 2);
}
#[test]
fn parse_retry_section() {
let toml = r#"
[retry]
max_retries = 5
base_delay_ms = 1000
max_delay_ms = 60000
jitter_factor = 0.3
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.retry.max_retries, 5);
assert_eq!(config.retry.base_delay_ms, 1000);
assert_eq!(config.retry.max_delay_ms, 60_000);
}
#[test]
fn into_server_config_full() {
let toml = r#"
[server]
port = 9000
bind = "0.0.0.0"
strategy = "lowest_latency"
health_check_interval_secs = 60
[cache]
max_entries = 500
ttl_secs = 600
enabled = false
[audit]
enabled = true
max_entries = 5000
[telemetry]
otlp_endpoint = "http://otel:4317"
service_name = "test-hoosh"
[auth]
tokens = ["tok1"]
[context]
compaction_threshold = 0.9
keep_last_messages = 20
[retry]
max_retries = 5
base_delay_ms = 500
max_delay_ms = 30000
jitter_factor = 0.5
[[budgets]]
name = "default"
capacity = 100000
[[providers]]
type = "Ollama"
priority = 1
models = ["llama*"]
rate_limit_rpm = 120
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
let sc = config.into_server_config(None, None, Some("/path/to/config.toml".into()));
assert_eq!(sc.port, 9000);
assert_eq!(sc.bind, "0.0.0.0");
assert_eq!(sc.cache_config.max_entries, 500);
assert!(!sc.cache_config.enabled);
assert!(sc.audit_enabled);
assert_eq!(sc.audit_max_entries, 5000);
assert_eq!(sc.otlp_endpoint.as_deref(), Some("http://otel:4317"));
assert_eq!(sc.telemetry_service_name, "test-hoosh");
assert_eq!(sc.auth_tokens.len(), 1);
assert_eq!(sc.health_check_interval_secs, 60);
assert_eq!(sc.config_path.as_deref(), Some("/path/to/config.toml"));
assert!((sc.context_config.compaction_threshold - 0.9).abs() < f64::EPSILON);
assert_eq!(sc.context_config.keep_last_messages, 20);
assert_eq!(sc.retry_config.max_retries, 5);
assert_eq!(sc.budget_pools.len(), 1);
assert_eq!(sc.routes.len(), 1);
}
#[test]
fn audit_section_debug_redacts_key() {
let section = AuditSection {
enabled: true,
signing_key: Some("super-secret".into()),
max_entries: 1000,
};
let debug = format!("{section:?}");
assert!(!debug.contains("super-secret"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn auth_config_debug_shows_count() {
let auth = AuthConfig {
tokens: vec!["tok1".into(), "tok2".into()],
};
let debug = format!("{auth:?}");
assert!(debug.contains("2 configured"));
assert!(!debug.contains("tok1"));
}
#[test]
fn provider_section_debug_redacts_key() {
let section: ProviderSection = toml::from_str(
r#"
type = "OpenAi"
api_key = "sk-secret-key"
models = ["gpt-*"]
"#,
)
.unwrap();
let debug = format!("{section:?}");
assert!(!debug.contains("sk-secret-key"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn load_nonexistent_config_file() {
let result = HooshConfig::load("/nonexistent/path/hoosh.toml");
assert!(result.is_err());
}
#[test]
fn load_invalid_toml() {
let dir = std::env::temp_dir();
let path = dir.join("hoosh_test_invalid.toml");
std::fs::write(&path, "invalid {{{{ toml content").unwrap();
let result = HooshConfig::load(&path);
assert!(result.is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn context_section_default() {
let ctx = ContextSection::default();
assert!((ctx.compaction_threshold - 0.8).abs() < f64::EPSILON);
assert_eq!(ctx.keep_last_messages, 10);
assert!(ctx.enabled);
}
#[test]
fn server_section_default() {
let s = ServerSection::default();
assert_eq!(s.bind, "127.0.0.1");
assert_eq!(s.port, 8088);
assert_eq!(s.health_check_interval_secs, 30);
}
#[test]
fn cache_section_default() {
let c = CacheSection::default();
assert_eq!(c.max_entries, 1000);
assert_eq!(c.ttl_secs, 300);
assert!(c.enabled);
}
#[test]
fn hardware_section_default() {
let h = HardwareSection::default();
assert_eq!(h.cache_ttl_secs, 300);
assert!(h.disabled_backends.is_empty());
assert_eq!(h.vram_reserve_bytes, 0);
assert_eq!(h.refresh_interval_secs, 30);
}
#[test]
fn parse_hardware_section() {
let toml = r#"
[hardware]
cache_ttl_secs = 600
disabled_backends = ["vulkan", "tpu"]
vram_reserve_bytes = 2147483648
refresh_interval_secs = 60
"#;
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.hardware.cache_ttl_secs, 600);
assert_eq!(config.hardware.disabled_backends, vec!["vulkan", "tpu"]);
assert_eq!(config.hardware.vram_reserve_bytes, 2_147_483_648);
assert_eq!(config.hardware.refresh_interval_secs, 60);
}
#[test]
fn parse_hardware_section_empty_defaults() {
let toml = "[hardware]\n";
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.hardware.cache_ttl_secs, 300);
assert!(config.hardware.disabled_backends.is_empty());
assert_eq!(config.hardware.vram_reserve_bytes, 0);
assert_eq!(config.hardware.refresh_interval_secs, 30);
}
#[test]
fn parse_no_hardware_section() {
let toml = "[server]\nport = 9000\n";
let config: HooshConfig = toml::from_str(toml).unwrap();
assert_eq!(config.hardware.cache_ttl_secs, 300); }
}