use crate::Role;
use crate::util::{UnwrapPoison, is_http_url};
use anyhow::{Context, Result};
use directories::UserDirs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{OnceLock, RwLock, RwLockReadGuard};
use tokio::fs;
pub(crate) const DEFAULT_PROVIDER_ENDPOINT: &str = "https://openrouter.ai/api/v1";
pub(crate) const DEFAULT_MANAGER_MODEL: &str = "deepseek/deepseek-v4-pro-0813";
pub(crate) const DEFAULT_WORKER_MODEL: &str = "deepseek/deepseek-v4-flash-0731";
pub(crate) const DEFAULT_MULTIMODAL_MODEL: &str = "qwen/qwen3.7-flash";
const DEFAULT_IMAGE_GEN_MODEL: &str = "google/gemini-3.1-flash-image";
const DEFAULT_VIDEO_MODEL: &str = "minimax/hailuo-3";
const FRESH_INSTALL_IMAGE_GEN_MODELS: &str =
"google/gemini-3.1-flash-image\nmicrosoft/mai-image-2.5\nqwen/qwen-image-3-pro";
const FRESH_INSTALL_VIDEO_MODELS: &str = "bytedance/seedance-2.0-mini\nminimax/hailuo-3";
pub(crate) const DEFAULT_TTS_LANGUAGE: &str = "na";
const DEFAULT_ADAPTIVE_K: &str = "2.5";
#[derive(Debug, Clone, PartialEq)]
pub struct ModelRouting {
pub model: String,
pub provider_order: Option<String>,
}
impl ModelRouting {
pub(crate) fn upsert(
routings: &mut Vec<ModelRouting>,
model: impl Into<String>,
set_field: impl FnOnce(&mut ModelRouting),
) {
let model = model.into();
if let Some(existing) = routings.iter_mut().find(|mr| mr.model == model) {
set_field(existing);
} else {
let mut new = ModelRouting {
model,
provider_order: None,
};
set_field(&mut new);
routings.push(new);
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigData {
pub provider_key: Option<String>,
pub provider_endpoint: Option<String>,
pub manager_model: Option<String>,
pub worker_model: Option<String>,
pub multimodal_model: Option<String>,
pub image_gen_model: Option<String>,
pub image_gen_models: Option<String>,
pub video_model: Option<String>,
pub video_models: Option<String>,
pub firecrawl_key: Option<String>,
pub exa_key: Option<String>,
pub web_search_provider: Option<String>,
pub telegram_bot_token: Option<String>,
pub audio_transcription_use_local: Option<String>,
pub voice_enabled: Option<String>,
pub tts_enabled: Option<String>,
pub tts_language: Option<String>,
pub wake_word_templates: Option<String>,
pub adaptive_k: Option<String>,
pub model_routings: Vec<ModelRouting>,
}
macro_rules! string_config_fields {
(
$(
$field:ident [ $($annotation:tt)* ]
),* $(,)?
) => {
::paste::paste! {
$(
pub const [<CONFIG_KEY_ $field:upper>]: &str = stringify!($field);
)*
}
impl ConfigData {
pub(crate) const STRUCT_FIELDS_DEFAULT: Self = Self {
$($field: None,)*
model_routings: Vec::new(),
};
#[must_use]
pub fn string_fields(&self) -> Vec<(&'static str, Option<&str>)> {
vec![$((stringify!($field), self.$field.as_deref())),*]
}
#[must_use]
pub fn set_string_field(&mut self, key: &str, value: &str) -> bool {
match key {
$(stringify!($field) => self.$field = Some(value.to_owned()),)*
_ => return false,
}
true
}
fn normalize_string_fields(&mut self) {
$(self.$field = non_empty(self.$field.take());)*
}
}
impl ConfigReload {
$(
string_config_fields!(@accessor $field $($annotation)*);
)*
}
};
(@accessor $field:ident non_empty) => {
#[doc = concat!(
"Returns the configured `", stringify!($field),
"`, with empty/whitespace values collapsed to `None`."
)]
#[must_use]
pub fn $field(&self) -> Option<String> {
non_empty(self.read().$field.clone())
}
};
(@accessor $field:ident or($default:expr)) => {
#[doc = concat!(
"Returns the configured `", stringify!($field),
"`, falling back to the default if unset."
)]
#[must_use]
pub fn $field(&self) -> String {
resolve_or(self.read().$field.clone(), $default)
}
};
(@accessor $field:ident fixed($default:expr)) => {
#[doc = concat!(
"Returns the hardcoded `", stringify!($field),
"` value `", stringify!($default),
"` — any persisted value is not honored while only this ",
"value is supported."
)]
#[must_use]
pub fn $field(&self) -> String {
$default.to_string()
}
};
(@accessor $field:ident list_or(fallback = $fallback:ident, default = $default:expr)) => {
#[doc = concat!(
"Returns the list of available `", stringify!($field), "`.",
"\n\nIf unset or the parsed newline-separated list is empty,",
" falls back to `", stringify!($fallback),
"`, then to a built-in default."
)]
#[must_use]
pub fn $field(&self) -> Vec<String> {
let guard = self.read();
resolve_list_or(
guard.$field.as_deref(),
guard.$fallback.clone(),
$default,
)
}
};
}
string_config_fields! {
provider_key [non_empty],
provider_endpoint [fixed(DEFAULT_PROVIDER_ENDPOINT)],
manager_model [or(DEFAULT_MANAGER_MODEL)],
worker_model [or(DEFAULT_WORKER_MODEL)],
multimodal_model [or(DEFAULT_MULTIMODAL_MODEL)],
image_gen_model [or(DEFAULT_IMAGE_GEN_MODEL)],
image_gen_models [list_or(fallback = image_gen_model, default = DEFAULT_IMAGE_GEN_MODEL)],
video_model [or(DEFAULT_VIDEO_MODEL)],
video_models [list_or(fallback = video_model, default = DEFAULT_VIDEO_MODEL)],
firecrawl_key [non_empty],
exa_key [non_empty],
web_search_provider [non_empty],
telegram_bot_token [non_empty],
audio_transcription_use_local [non_empty],
voice_enabled [non_empty],
tts_enabled [non_empty],
tts_language [or(DEFAULT_TTS_LANGUAGE)],
wake_word_templates [non_empty],
adaptive_k [fixed(DEFAULT_ADAPTIVE_K)],
}
impl ConfigData {
fn normalize_entries(&mut self) {
for mr in &mut self.model_routings {
mr.provider_order = non_empty(mr.provider_order.take());
}
}
pub(crate) fn normalize(&mut self) {
self.normalize_string_fields();
self.normalize_entries();
self.model_routings.sort_by(|a, b| a.model.cmp(&b.model));
}
}
#[must_use]
pub(crate) fn trimmed_or_none(s: &str) -> Option<String> {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
#[must_use]
pub(crate) fn parse_newline_list(s: &str) -> Vec<String> {
s.split('\n')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[must_use]
pub(crate) fn non_empty(val: Option<String>) -> Option<String> {
val.and_then(|s| trimmed_or_none(&s))
}
#[must_use]
pub(crate) fn resolve_or(val: Option<String>, fallback: &str) -> String {
non_empty(val).unwrap_or(fallback.to_string())
}
#[must_use]
pub(crate) fn resolve_list_or(
list_field: Option<&str>,
fallback_field: Option<String>,
default_value: &str,
) -> Vec<String> {
if let Some(raw) = list_field {
let parsed = parse_newline_list(raw);
if !parsed.is_empty() {
return parsed;
}
}
vec![resolve_or(fallback_field, default_value)]
}
pub static CONFIG: ConfigReload = ConfigReload::const_new();
static CONFIG_PERSIST_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
fn persist_lock() -> &'static tokio::sync::Mutex<()> {
CONFIG_PERSIST_LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}
static CONFIG_DB_FRESH_AT_BOOT: AtomicBool = AtomicBool::new(false);
pub struct ConfigReload {
storage_root: OnceLock<PathBuf>,
inner: RwLock<ConfigData>,
}
impl ConfigReload {
#[must_use]
pub const fn const_new() -> Self {
Self {
storage_root: OnceLock::new(),
inner: RwLock::new(ConfigData::STRUCT_FIELDS_DEFAULT),
}
}
pub fn global_storage_root(&self) -> PathBuf {
self.storage_root
.get()
.expect("CONFIG storage_root not initialized")
.clone()
}
#[must_use]
pub fn try_storage_root(&self) -> Option<PathBuf> {
self.storage_root.get().cloned()
}
pub(crate) fn set_storage_root(&self, root: PathBuf) {
self.storage_root
.set(root)
.expect("CONFIG storage_root already set");
}
#[cfg(test)]
pub(crate) fn try_set_storage_root(&self, root: PathBuf) -> std::result::Result<(), PathBuf> {
self.storage_root.set(root)
}
fn read(&self) -> RwLockReadGuard<'_, ConfigData> {
self.inner.read().unwrap_poison()
}
pub(crate) fn swap(&self, new_config: ConfigData) {
*self.inner.write().unwrap_poison() = new_config;
}
#[must_use]
pub fn snapshot(&self) -> ConfigData {
self.read().clone()
}
#[must_use]
pub fn set_string_field(&self, key: &str, value: &str) -> bool {
let mut guard = self.inner.write().unwrap_poison();
guard.set_string_field(key, value)
}
pub(crate) fn set_model_routing_row(&self, model: &str, provider_order: Option<String>) {
let mut guard = self.inner.write().unwrap_poison();
if provider_order.is_none() {
guard.model_routings.retain(|mr| mr.model != model);
} else {
ModelRouting::upsert(&mut guard.model_routings, model, |mr| {
mr.provider_order = provider_order;
});
}
}
pub(crate) fn model_routing_by_key(&self, model: &str) -> Option<ModelRouting> {
let guard = self.read();
guard
.model_routings
.iter()
.find(|mr| mr.model == model)
.cloned()
}
#[must_use]
pub fn model_routing(&self, model: &str) -> ModelRouting {
let guard = self.read();
if let Some(mr) = guard.model_routings.iter().find(|mr| mr.model == model) {
mr.clone()
} else {
ModelRouting {
model: model.to_string(),
provider_order: None,
}
}
}
#[must_use]
pub fn role_model(&self, role: Role) -> String {
match role {
Role::Manager => self.manager_model(),
Role::Artist | Role::Assistant => self.multimodal_model(),
_ => self.worker_model(),
}
}
}
pub fn default_config_dir() -> Result<PathBuf> {
if let Ok(home) = std::env::var("HOME")
&& !home.is_empty()
{
return Ok(PathBuf::from(home).join(".mahbot"));
}
let home = UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
Ok(home.join(".mahbot"))
}
pub async fn load_or_init() -> Result<()> {
let mahbot_dir = default_config_dir()?;
fs::create_dir_all(&mahbot_dir)
.await
.context("Failed to create config directory")?;
CONFIG.set_storage_root(mahbot_dir.clone());
CONFIG_DB_FRESH_AT_BOOT.store(config_db_is_fresh(&mahbot_dir), Ordering::Release);
CONFIG.swap(ConfigData::STRUCT_FIELDS_DEFAULT);
tracing::info!(
"Config system initialised (storage root: {}).",
mahbot_dir.display()
);
Ok(())
}
fn first_legacy_value<'a>(kvs: &'a [(String, String)], legacy: &[&str]) -> Option<&'a str> {
legacy.iter().find_map(|k| {
kvs.iter()
.find(|(kk, _)| kk.as_str() == *k)
.map(|(_, v)| v.as_str())
})
}
fn config_db_is_fresh(mahbot_dir: &std::path::Path) -> bool {
!crate::turso::store_db_path(mahbot_dir, "config").exists()
}
async fn seed_fresh_install_defaults(
fresh: bool,
store: &crate::config_db::ConfigStore,
) -> Result<()> {
if !fresh {
return Ok(());
}
store
.set_kv(CONFIG_KEY_AUDIO_TRANSCRIPTION_USE_LOCAL, "false")
.await?;
store
.set_kv(CONFIG_KEY_IMAGE_GEN_MODEL, DEFAULT_IMAGE_GEN_MODEL)
.await?;
store
.set_kv(CONFIG_KEY_IMAGE_GEN_MODELS, FRESH_INSTALL_IMAGE_GEN_MODELS)
.await?;
store
.set_kv(CONFIG_KEY_VIDEO_MODEL, DEFAULT_VIDEO_MODEL)
.await?;
store
.set_kv(CONFIG_KEY_VIDEO_MODELS, FRESH_INSTALL_VIDEO_MODELS)
.await?;
for default_model in [
DEFAULT_MANAGER_MODEL,
DEFAULT_WORKER_MODEL,
DEFAULT_MULTIMODAL_MODEL,
] {
if default_model.starts_with("deepseek/") {
store
.save_model_routing(default_model, Some("DeepSeek"))
.await?;
}
}
tracing::info!(
"Fresh config database: seeded fresh-install defaults (audio transcription off; image/video generation model sets; DeepSeek routing for deepseek/* default models)"
);
Ok(())
}
async fn seed_fresh_install_defaults_from_flag(
store: &crate::config_db::ConfigStore,
) -> Result<()> {
let fresh = CONFIG_DB_FRESH_AT_BOOT.swap(false, Ordering::AcqRel);
seed_fresh_install_defaults(fresh, store).await
}
pub async fn reload_from_db() -> Result<()> {
let store = crate::config_db::store();
let mut config = ConfigData::STRUCT_FIELDS_DEFAULT;
seed_fresh_install_defaults_from_flag(store).await?;
let kvs = store.get_all_kv().await?;
for (key, value) in &kvs {
if !config.set_string_field(key, value) {
tracing::debug!(key, "Unknown config key, ignoring");
}
}
if config.video_model.is_none() {
config.video_model =
first_legacy_value(&kvs, &["video_edit_model", "video_gen_model"]).map(String::from);
}
let routings = store.get_all_model_routings().await?;
config.model_routings = routings;
config.normalize();
CONFIG.swap(config);
tracing::info!("Config reloaded from DB");
Ok(())
}
pub async fn persist_settled_string_field(key: &str, value: &str) -> Result<String> {
let _guard = persist_lock().lock().await;
let trimmed = value.trim().to_string();
if key == CONFIG_KEY_WAKE_WORD_TEMPLATES {
return Ok(CONFIG.wake_word_templates().unwrap_or_default());
}
match key {
CONFIG_KEY_PROVIDER_ENDPOINT | CONFIG_KEY_PROVIDER_KEY => {
let mut probe = CONFIG.snapshot();
let _ = probe.set_string_field(key, &trimmed);
probe.normalize();
validate_config(&probe)?;
crate::providers::warmup_provider_from_config(&probe).await?;
write_kv_and_update_config(key, &trimmed).await?;
crate::providers::recreate_all(&CONFIG.snapshot()).await?;
}
CONFIG_KEY_TELEGRAM_BOT_TOKEN => {
let old_token = CONFIG.telegram_bot_token();
let new_token = trimmed_or_none(&trimmed);
if new_token != old_token
&& let Some(ref token) = new_token
{
crate::channels::telegram::TelegramChannel::validate_token(token).await?;
}
write_kv_and_update_config(CONFIG_KEY_TELEGRAM_BOT_TOKEN, &trimmed).await?;
let persisted = CONFIG.telegram_bot_token();
if persisted != old_token {
crate::channels::telegram::restart_telegram_listener(persisted.as_deref()).await?;
}
}
CONFIG_KEY_IMAGE_GEN_MODEL => {
let endpoint = CONFIG.provider_endpoint();
let model_opt = trimmed_or_none(&trimmed);
let model: &str = model_opt.as_deref().unwrap_or(DEFAULT_IMAGE_GEN_MODEL);
if model != CONFIG.image_gen_model() {
crate::tools::image_catalog::validate_image_model_for_endpoint(model, &endpoint)
.await?;
}
write_kv_and_update_config(CONFIG_KEY_IMAGE_GEN_MODEL, &trimmed).await?;
}
CONFIG_KEY_MULTIMODAL_MODEL => {
write_kv_and_update_config(key, &trimmed).await?;
crate::providers::recreate_media_transcriber();
}
_ => {
write_kv_and_update_config(key, &trimmed).await?;
}
}
Ok(trimmed_or_none(&trimmed).unwrap_or_default())
}
pub async fn persist_settled_routing_order(model: &str, order: &str) -> Result<String> {
let _guard = persist_lock().lock().await;
let order = trimmed_or_none(order);
let persisted = save_routing_row(model, order).await?;
if CONFIG.multimodal_model() == model {
crate::providers::recreate_media_transcriber();
}
Ok(persisted)
}
async fn save_routing_row(model: &str, order: Option<String>) -> Result<String> {
let store = crate::config_db::store();
store.save_model_routing(model, order.as_deref()).await?;
CONFIG.set_model_routing_row(model, order.clone());
Ok(order.unwrap_or_default())
}
async fn write_kv_and_update_config(key: &str, trimmed: &str) -> Result<()> {
if !ConfigData::STRUCT_FIELDS_DEFAULT
.string_fields()
.iter()
.any(|(known, _)| *known == key)
{
anyhow::bail!("unknown config field: {key}");
}
let store = crate::config_db::store();
if trimmed.is_empty() {
store.delete_kv(key).await?;
} else {
store.set_kv(key, trimmed).await?;
}
let _ = CONFIG.set_string_field(key, trimmed);
Ok(())
}
fn validate_config(config: &ConfigData) -> Result<()> {
if let Some(ref ep) = config.provider_endpoint
&& !is_http_url(ep)
{
anyhow::bail!("Provider endpoint must be a valid URL starting with https:// or http://");
}
if let Some(ref key) = config.provider_key
&& key.contains("...")
{
anyhow::bail!("Provider key is still the placeholder value — please set a real key");
}
Ok(())
}
#[cfg(test)]
pub(crate) fn model_routing(model: &str, provider_order: Option<&str>) -> ModelRouting {
ModelRouting {
model: model.into(),
provider_order: provider_order.map(String::from),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn string_fields_roundtrip() {
let mut config = ConfigData::STRUCT_FIELDS_DEFAULT;
for (_key, value) in config.string_fields() {
assert!(value.is_none(), "field should start as None");
}
let keys: Vec<&str> = config.string_fields().iter().map(|(k, _)| *k).collect();
for &key in &keys {
let test_value = format!("test-{key}");
let recognized = config.set_string_field(key, &test_value);
assert!(recognized, "key '{key}' should be recognized");
let found = config
.string_fields()
.iter()
.find(|(k, _)| *k == key)
.and_then(|(_, v)| *v);
assert_eq!(
found,
Some(test_value.as_str()),
"value for '{key}' should match after set"
);
}
let _ = config.set_string_field("provider_key", "");
let pk = config
.string_fields()
.iter()
.find(|(k, _)| *k == "provider_key")
.and_then(|(_, v)| *v);
assert_eq!(
pk,
Some(""),
"empty string stored as-is by set_string_field"
);
let _ = config.set_string_field("provider_key", " ");
let pk = config
.string_fields()
.iter()
.find(|(k, _)| *k == "provider_key")
.and_then(|(_, v)| *v);
assert_eq!(
pk,
Some(" "),
"whitespace-only string stored as-is by set_string_field"
);
config.normalize();
let pk = config
.string_fields()
.iter()
.find(|(k, _)| *k == "provider_key")
.and_then(|(_, v)| *v);
assert!(pk.is_none(), "normalize() collapses empty string to None");
assert!(!config.set_string_field("nonexistent_key", "value"));
}
#[test]
fn config_reload_accessors_roundtrip() {
let reload = ConfigReload::const_new();
assert_eq!(reload.provider_key(), None, "unset provider_key is None");
let mut config = ConfigData::STRUCT_FIELDS_DEFAULT;
assert!(config.set_string_field("provider_key", "sk-test"));
reload.swap(config);
assert_eq!(reload.provider_key(), Some("sk-test".to_string()));
reload.swap(ConfigData::STRUCT_FIELDS_DEFAULT);
assert_eq!(
reload.manager_model(),
DEFAULT_MANAGER_MODEL,
"unset manager_model falls back to default"
);
assert_eq!(
reload.worker_model(),
DEFAULT_WORKER_MODEL,
"unset worker_model falls back to default"
);
assert_eq!(
reload.multimodal_model(),
DEFAULT_MULTIMODAL_MODEL,
"unset multimodal_model falls back to default"
);
let mut fixed_cfg = ConfigData::STRUCT_FIELDS_DEFAULT;
assert!(fixed_cfg.set_string_field("provider_endpoint", "https://custom.example/v1"));
reload.swap(fixed_cfg);
assert_eq!(
reload.provider_endpoint(),
DEFAULT_PROVIDER_ENDPOINT,
"fixed field ignores a persisted custom value"
);
let mut empty = ConfigData::STRUCT_FIELDS_DEFAULT;
assert!(empty.set_string_field("provider_key", ""));
reload.swap(empty);
assert_eq!(
reload.provider_key(),
None,
"empty string is collapsed to None"
);
reload.swap(ConfigData::STRUCT_FIELDS_DEFAULT);
assert_eq!(
reload.image_gen_models(),
vec![DEFAULT_IMAGE_GEN_MODEL.to_string()],
"unset image_gen_models falls back to active model"
);
let mut list_config = ConfigData::STRUCT_FIELDS_DEFAULT;
assert!(list_config.set_string_field("image_gen_models", "model-a\nmodel-b\nmodel-c"));
reload.swap(list_config);
assert_eq!(
reload.image_gen_models(),
vec!["model-a", "model-b", "model-c"]
);
}
#[test]
fn trimmed_or_none_trims_whitespace() {
assert_eq!(trimmed_or_none(" value "), Some("value".to_string()));
assert_eq!(trimmed_or_none(" "), None);
assert_eq!(trimmed_or_none(""), None);
}
#[test]
fn normalize_entries_works() {
let mut config = ConfigData {
model_routings: vec![
ModelRouting {
model: "test-model".into(),
provider_order: Some(" ".into()),
},
ModelRouting {
model: "test-model-2".into(),
provider_order: Some(" OpenAi, Anthropic ".into()),
},
],
..ConfigData::STRUCT_FIELDS_DEFAULT
};
config.normalize_entries();
assert_eq!(config.model_routings[0].provider_order, None);
assert_eq!(
config.model_routings[1].provider_order,
Some("OpenAi, Anthropic".into())
);
}
#[test]
fn upsert_model_routing_fields() {
{
let mut items = vec![model_routing("test-model", Some("OpenAi"))];
ModelRouting::upsert(&mut items, "test-model", |item| {
item.provider_order = Some("Anthropic".into());
});
assert_eq!(items.len(), 1);
assert_eq!(
items[0].provider_order,
Some("Anthropic".into()),
"[provider_order] target field updated"
);
}
{
let mut items = vec![];
ModelRouting::upsert(&mut items, "test-model", |item| {
item.provider_order = Some("OpenAi".into());
});
assert_eq!(items.len(), 1);
assert_eq!(items[0].model, "test-model");
assert_eq!(
items[0].provider_order,
Some("OpenAi".into()),
"[provider_order] set on new entry"
);
}
{
let mut items = vec![model_routing("test-model", Some("OpenAi"))];
ModelRouting::upsert(&mut items, "test-model", |item| item.provider_order = None);
assert_eq!(
items[0].provider_order, None,
"[provider_order] cleared to None"
);
}
}
#[test]
fn upsert_multiple_entries_independent_keys() {
let mut routings = vec![
model_routing("test-router-a", Some("OpenAi")),
model_routing("test-router-b", Some("Anthropic")),
];
ModelRouting::upsert(&mut routings, "test-router-a", |mr| {
mr.provider_order = Some("Google".into());
});
assert_eq!(routings[0].provider_order, Some("Google".into()));
assert_eq!(routings[1].provider_order, Some("Anthropic".into()));
ModelRouting::upsert(&mut routings, "test-router-b", |mr| {
mr.provider_order = Some("OpenAi".into());
});
assert_eq!(routings[0].provider_order, Some("Google".into()));
assert_eq!(routings[1].provider_order, Some("OpenAi".into()));
assert_eq!(routings.len(), 2);
}
#[test]
fn validate_config_accepts_valid_url() {
let mut config = ConfigData {
provider_endpoint: Some("https://openrouter.ai/api/v1".into()),
..ConfigData::STRUCT_FIELDS_DEFAULT
};
config.normalize();
validate_config(&config).unwrap();
}
#[test]
fn validate_config_accepts_whitespace_padded_url_after_normalize() {
let mut config = ConfigData {
provider_endpoint: Some(" https://openrouter.ai/api/v1 ".into()),
..ConfigData::STRUCT_FIELDS_DEFAULT
};
config.normalize();
validate_config(&config).unwrap();
}
#[test]
fn validate_config_rejects_url_without_scheme() {
let mut config = ConfigData {
provider_endpoint: Some("not-a-url".into()),
..ConfigData::STRUCT_FIELDS_DEFAULT
};
config.normalize();
let err = validate_config(&config).unwrap_err();
assert!(
err.to_string()
.contains("Provider endpoint must be a valid URL"),
"expected URL scheme error, got: {err}",
);
}
#[test]
fn validate_config_rejects_placeholder_key() {
let mut config = ConfigData {
provider_key: Some("sk-or-v1-...".into()),
..ConfigData::STRUCT_FIELDS_DEFAULT
};
config.normalize();
let err = validate_config(&config).unwrap_err();
assert!(
err.to_string().contains("placeholder"),
"expected placeholder error, got: {err}",
);
}
#[tokio::test]
#[serial_test::serial(config_persist)]
async fn persist_settled_string_field_refuses_wake_word_templates() {
let template_json = r#"{"classifier":null}"#;
let original = CONFIG.snapshot();
let mut enrolled = ConfigData::STRUCT_FIELDS_DEFAULT;
assert!(enrolled.set_string_field("wake_word_templates", template_json));
CONFIG.swap(enrolled);
let result = persist_settled_string_field("wake_word_templates", "garbage").await;
assert_eq!(
result.unwrap(),
template_json,
"guard must return the current templates unchanged"
);
assert_eq!(
CONFIG.wake_word_templates(),
Some(template_json.to_string()),
"CONFIG wake_word_templates must be untouched"
);
CONFIG.swap(original);
}
#[test]
fn side_effect_config_keys_are_real_fields() {
let known: Vec<&'static str> = ConfigData::STRUCT_FIELDS_DEFAULT
.string_fields()
.iter()
.map(|(k, _)| *k)
.collect();
for key in [
CONFIG_KEY_PROVIDER_ENDPOINT,
CONFIG_KEY_PROVIDER_KEY,
CONFIG_KEY_TELEGRAM_BOT_TOKEN,
CONFIG_KEY_IMAGE_GEN_MODEL,
CONFIG_KEY_MULTIMODAL_MODEL,
CONFIG_KEY_WAKE_WORD_TEMPLATES,
] {
assert!(
known.contains(&key),
"side-effect key '{key}' must be a real config field"
);
}
}
#[tokio::test]
async fn fresh_config_db_seeds_defaults_only_when_new() {
let fresh_root = tempfile::TempDir::new().unwrap();
let fresh = config_db_is_fresh(fresh_root.path());
assert!(
fresh,
"a storage root with no config store file must be classified fresh"
);
CONFIG_DB_FRESH_AT_BOOT.store(fresh, Ordering::Release);
let fresh_store = crate::config_db::ConfigStore::open(fresh_root.path())
.await
.unwrap();
assert!(
!config_db_is_fresh(fresh_root.path()),
"an existing config store file must not be classified fresh"
);
seed_fresh_install_defaults_from_flag(&fresh_store)
.await
.unwrap();
assert!(
!CONFIG_DB_FRESH_AT_BOOT.load(Ordering::Acquire),
"the boot discriminator must be consumed by the seed"
);
assert_eq!(
fresh_store.get_all_kv().await.unwrap(),
vec![
(
"audio_transcription_use_local".to_string(),
"false".to_string()
),
(
"image_gen_model".to_string(),
"google/gemini-3.1-flash-image".to_string()
),
(
"image_gen_models".to_string(),
"google/gemini-3.1-flash-image\nmicrosoft/mai-image-2.5\nqwen/qwen-image-3-pro"
.to_string()
),
("video_model".to_string(), "minimax/hailuo-3".to_string()),
(
"video_models".to_string(),
"bytedance/seedance-2.0-mini\nminimax/hailuo-3".to_string()
),
],
"a fresh config database must be seeded with the fresh-install defaults"
);
assert_eq!(
fresh_store.get_all_model_routings().await.unwrap(),
vec![
model_routing("deepseek/deepseek-v4-flash-0731", Some("DeepSeek")),
model_routing("deepseek/deepseek-v4-pro-0813", Some("DeepSeek")),
],
"a fresh config database must seed DeepSeek routing rows for the \
deepseek/* default model slots (sorted by model; others get none)"
);
let (existing_store, existing_dir) =
crate::open_test_store!(crate::config_db::ConfigStore, "config");
let fresh_existing = config_db_is_fresh(existing_dir.path());
assert!(
!fresh_existing,
"an existing install must not be classified fresh"
);
CONFIG_DB_FRESH_AT_BOOT.store(fresh_existing, Ordering::Release);
seed_fresh_install_defaults_from_flag(&existing_store)
.await
.unwrap();
assert!(
existing_store.get_all_kv().await.unwrap().is_empty(),
"an existing config database must receive zero writes"
);
assert!(
existing_store
.get_all_model_routings()
.await
.unwrap()
.is_empty(),
"an existing config database must receive zero routing rows (no backfill)"
);
}
}