use super::crabrace::CrabraceConfig;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
static CONFIG_RECOVERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static CONFIG_TYPO_WARNINGS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
pub static CONFIG_FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub crabrace: CrabraceConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub debug: DebugConfig,
#[serde(default)]
pub providers: ProviderConfigs,
#[serde(default)]
pub channels: ChannelsConfig,
#[serde(default)]
pub agent: AgentConfig,
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default, alias = "gateway")]
pub a2a: A2aConfig,
#[serde(default)]
pub image: ImageConfig,
#[serde(default)]
pub cron: CronConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub brain: BrainConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainConfig {
#[serde(default = "default_strip_empty_sections")]
pub strip_empty_sections: bool,
#[serde(default)]
pub caps: std::collections::BTreeMap<String, usize>,
#[serde(default = "default_brain_file_cap")]
pub default_cap: usize,
}
fn default_strip_empty_sections() -> bool {
true
}
fn default_brain_file_cap() -> usize {
500
}
impl Default for BrainConfig {
fn default() -> Self {
Self {
strip_empty_sections: default_strip_empty_sections(),
caps: std::collections::BTreeMap::new(),
default_cap: default_brain_file_cap(),
}
}
}
impl BrainConfig {
pub fn cap_for(&self, filename: &str) -> usize {
self.caps.get(filename).copied().unwrap_or(self.default_cap)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DaemonConfig {
#[serde(default)]
pub health_port: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2aConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_a2a_bind")]
pub bind: String,
#[serde(default = "default_a2a_port")]
pub port: u16,
#[serde(default)]
pub allowed_origins: Vec<String>,
#[serde(default)]
pub api_key: Option<String>,
}
fn default_a2a_bind() -> String {
"127.0.0.1".to_string()
}
fn default_a2a_port() -> u16 {
18790
}
impl Default for A2aConfig {
fn default() -> Self {
Self {
enabled: false,
bind: default_a2a_bind(),
port: default_a2a_port(),
allowed_origins: vec![],
api_key: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChannelsConfig {
#[serde(default)]
pub telegram: TelegramConfig,
#[serde(default)]
pub discord: DiscordConfig,
#[serde(default)]
pub whatsapp: WhatsAppConfig,
#[serde(default)]
pub slack: SlackConfig,
#[serde(default)]
pub trello: TrelloConfig,
#[serde(default)]
pub signal: SignalConfig,
#[serde(default)]
pub google_chat: GoogleChatConfig,
#[serde(default)]
pub imessage: IMessageConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RespondTo {
All,
DmOnly,
#[default]
Mention,
}
fn deser_users_compat<'de, D>(d: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum NumOrStr {
Int(i64),
Str(String),
}
Vec::<NumOrStr>::deserialize(d).map(|v| {
v.into_iter()
.map(|x| match x {
NumOrStr::Int(n) => n.to_string(),
NumOrStr::Str(s) => s,
})
.collect()
})
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TelegramConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default)]
pub allowed_channels: Vec<String>,
#[serde(default)]
pub respond_to: RespondTo,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DiscordConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default)]
pub allowed_channels: Vec<String>,
#[serde(default)]
pub respond_to: RespondTo,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SlackConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub app_token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default)]
pub allowed_channels: Vec<String>,
#[serde(default)]
pub respond_to: RespondTo,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WhatsAppConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_phones: Vec<String>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TrelloConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub app_token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default, alias = "allowed_channels")]
pub board_ids: Vec<String>,
#[serde(default)]
pub poll_interval_secs: Option<u64>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SignalConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_phones: Vec<String>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GoogleChatConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token: Option<String>,
#[serde(default, deserialize_with = "deser_users_compat")]
pub allowed_users: Vec<String>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IMessageConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_phones: Vec<String>,
#[serde(default)]
pub session_idle_hours: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SttMode {
#[default]
Api,
Local,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TtsMode {
#[default]
Api,
Local,
}
#[derive(Debug, Clone)]
pub struct VoiceConfig {
pub stt_enabled: bool,
pub stt_mode: SttMode,
pub local_stt_model: String,
pub stt_base_url: Option<String>,
pub stt_model: Option<String>,
pub stt_api_key: Option<String>,
pub tts_enabled: bool,
pub tts_mode: TtsMode,
pub tts_voice: String,
pub tts_model: String,
pub tts_base_url: Option<String>,
pub tts_api_key: Option<String>,
pub local_tts_voice: String,
pub stt_provider: Option<ProviderConfig>,
pub tts_provider: Option<ProviderConfig>,
pub voicebox_stt_enabled: bool,
pub voicebox_stt_base_url: String,
pub voicebox_tts_enabled: bool,
pub voicebox_tts_base_url: String,
pub voicebox_tts_profile_id: String,
pub voicebox_tts_engine: String,
pub stt_fallback_chain: Vec<String>,
pub tts_fallback_chain: Vec<String>,
}
fn default_local_stt_model() -> String {
"local-tiny".to_string()
}
fn default_tts_voice() -> String {
"echo".to_string()
}
fn default_tts_model() -> String {
"gpt-4o-mini-tts".to_string()
}
fn default_local_tts_voice() -> String {
"ryan".to_string()
}
impl Default for VoiceConfig {
fn default() -> Self {
Self {
stt_enabled: false,
stt_mode: SttMode::default(),
local_stt_model: default_local_stt_model(),
stt_base_url: None,
stt_model: None,
stt_api_key: None,
tts_enabled: false,
tts_mode: TtsMode::default(),
tts_voice: default_tts_voice(),
tts_model: default_tts_model(),
tts_base_url: None,
tts_api_key: None,
local_tts_voice: default_local_tts_voice(),
stt_provider: None,
tts_provider: None,
voicebox_stt_enabled: false,
voicebox_stt_base_url: default_voicebox_url(),
voicebox_tts_enabled: false,
voicebox_tts_base_url: default_voicebox_url(),
voicebox_tts_profile_id: String::new(),
voicebox_tts_engine: String::new(),
stt_fallback_chain: Vec::new(),
tts_fallback_chain: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImageConfig {
#[serde(default)]
pub generation: ImageGenerationConfig,
#[serde(default)]
pub vision: ImageVisionConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageGenerationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_image_model")]
pub model: String,
#[serde(skip, default)]
pub api_key: Option<String>,
}
impl Default for ImageGenerationConfig {
fn default() -> Self {
Self {
enabled: false,
model: default_image_model(),
api_key: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageVisionConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_image_model")]
pub model: String,
#[serde(skip, default)]
pub api_key: Option<String>,
}
impl Default for ImageVisionConfig {
fn default() -> Self {
Self {
enabled: false,
model: default_image_model(),
api_key: None,
}
}
}
pub fn default_image_model() -> String {
"gemini-3.1-flash-image-preview".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
#[serde(default = "default_approval_policy")]
pub approval_policy: String,
#[serde(default = "default_max_concurrent")]
pub max_concurrent: u32,
#[serde(default = "default_context_limit")]
pub context_limit: u32,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
#[serde(default)]
pub subagent_provider: Option<String>,
#[serde(default)]
pub subagent_model: Option<String>,
#[serde(default = "default_auto_update")]
pub auto_update: bool,
#[serde(default)]
pub self_improvement_provider: Option<String>,
#[serde(default)]
pub self_improvement_model: Option<String>,
#[serde(default)]
pub silent_compaction: bool,
}
fn default_approval_policy() -> String {
"auto-always".to_string()
}
fn default_max_concurrent() -> u32 {
4
}
fn default_context_limit() -> u32 {
200_000
}
fn default_max_tokens() -> u32 {
65536
}
fn default_auto_update() -> bool {
true
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
approval_policy: default_approval_policy(),
max_concurrent: default_max_concurrent(),
context_limit: default_context_limit(),
max_tokens: default_max_tokens(),
subagent_provider: None,
subagent_model: None,
auto_update: default_auto_update(),
self_improvement_provider: None,
self_improvement_model: None,
silent_compaction: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CronConfig {
#[serde(default)]
pub default_provider: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmbeddingConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub dimensions: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
#[serde(default = "default_vector_enabled")]
pub vector_enabled: bool,
#[serde(default)]
pub embedding: Option<EmbeddingConfig>,
}
const fn default_vector_enabled() -> bool {
true
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
vector_enabled: default_vector_enabled(),
embedding: None,
}
}
}
impl MemoryConfig {
fn is_vps() -> bool {
#[cfg(target_os = "linux")]
{
if let Ok(product) = std::fs::read_to_string("/sys/class/dmi/id/product_name") {
let product = product.to_lowercase();
let cloud_vendors = [
"droplet",
"digitalocean",
"ec2",
"amazon",
"gce",
"google compute",
"kvm",
"vultr",
"linode",
"akamai",
"azure",
"hyper-v",
"oracle",
"oci",
];
for vendor in &cloud_vendors {
if product.contains(vendor) {
return true;
}
}
}
if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
&& (cgroup.contains("docker")
|| cgroup.contains("containerd")
|| cgroup.contains("kubepods"))
{
return true;
}
if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") {
for line in meminfo.lines() {
if line.starts_with("MemTotal:") {
if let Some(kb_str) = line.split_whitespace().nth(1)
&& let Ok(kb) = kb_str.parse::<u64>()
&& {
let gb = kb / 1_048_576; gb < 2
}
{
return true;
}
break;
}
}
}
let has_display =
std::env::var("DISPLAY").is_ok() || std::env::var("WAYLAND_DISPLAY").is_ok();
if !has_display {
return true;
}
}
#[cfg(not(target_os = "linux"))]
{
}
false
}
pub fn auto_apply_vps_defaults() -> bool {
if !Self::is_vps() {
return false;
}
let config_path = opencrabs_home().join("config.toml");
if let Ok(content) = std::fs::read_to_string(&config_path) {
if content.contains("[memory]") {
return false;
}
}
tracing::info!(
"VPS/cloud detected — disabling vector embeddings for memory search (FTS-only mode)"
);
let append = "\n# Auto-configured: VPS/cloud detected\n\
# Local vector embeddings disabled to save RAM (~2.9GB).\n\
# FTS5 keyword search still works. WIP: OpenAI-compatible\n\
# embedding through API coming soon.\n\
[memory]\n\
vector_enabled = false\n";
let _ = std::fs::OpenOptions::new()
.append(true)
.open(&config_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, append.as_bytes()));
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DebugConfig {
#[serde(default)]
pub debug_lsp: bool,
#[serde(default)]
pub profiling: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfigs {
#[serde(default)]
pub anthropic: Option<ProviderConfig>,
#[serde(default)]
pub openai: Option<ProviderConfig>,
#[serde(default)]
pub openrouter: Option<ProviderConfig>,
#[serde(default)]
pub minimax: Option<ProviderConfig>,
#[serde(default)]
pub zhipu: Option<ProviderConfig>,
#[serde(default, deserialize_with = "deserialize_custom_providers")]
pub custom: Option<BTreeMap<String, ProviderConfig>>,
#[serde(default)]
pub github: Option<ProviderConfig>,
#[serde(default)]
pub gemini: Option<ProviderConfig>,
#[serde(default)]
pub claude_cli: Option<ProviderConfig>,
#[serde(default)]
pub opencode_cli: Option<ProviderConfig>,
#[serde(default)]
pub codex_cli: Option<ProviderConfig>,
#[serde(default)]
pub codex: Option<ProviderConfig>,
#[serde(default)]
pub opencode: Option<ProviderConfig>,
#[serde(default)]
pub qwen: Option<ProviderConfig>,
#[serde(default)]
pub ollama: Option<ProviderConfig>,
#[serde(default)]
pub bedrock: Option<ProviderConfig>,
#[serde(default)]
pub vertex: Option<ProviderConfig>,
#[serde(default)]
pub stt: Option<SttProviders>,
#[serde(default)]
pub tts: Option<TtsProviders>,
#[serde(default)]
pub web_search: Option<WebSearchProviders>,
#[serde(default)]
pub image: Option<ImageProviders>,
#[serde(default)]
pub fallback: Option<FallbackProviderConfig>,
}
impl ProviderConfigs {
pub fn active_custom(&self) -> Option<(&str, &ProviderConfig)> {
self.custom
.as_ref()?
.iter()
.find(|(_, cfg)| cfg.enabled)
.map(|(name, cfg)| (name.as_str(), cfg))
}
pub fn custom_by_name(&self, name: &str) -> Option<&ProviderConfig> {
let normalized = normalize_toml_key(name);
self.custom.as_ref()?.get(&normalized)
}
fn provider_registry(
&self,
) -> [(&'static str, &'static str, bool, Option<&ProviderConfig>); 16] {
[
("claude-cli", "Claude CLI", false, self.claude_cli.as_ref()),
(
"opencode-cli",
"OpenCode CLI",
false,
self.opencode_cli.as_ref(),
),
("codex-cli", "Codex CLI", false, self.codex_cli.as_ref()),
("codex", "Codex OAuth", false, self.codex.as_ref()),
("opencode", "OpenCode", false, self.opencode.as_ref()),
("qwen", "Qwen", true, self.qwen.as_ref()),
("minimax", "Minimax", true, self.minimax.as_ref()),
("zhipu", "z.ai GLM", true, self.zhipu.as_ref()),
("openrouter", "OpenRouter", true, self.openrouter.as_ref()),
("anthropic", "Anthropic", true, self.anthropic.as_ref()),
("openai", "OpenAI", true, self.openai.as_ref()),
("github", "GitHub Copilot", true, self.github.as_ref()),
("gemini", "Google Gemini", true, self.gemini.as_ref()),
("ollama", "Ollama", false, self.ollama.as_ref()),
("bedrock", "AWS Bedrock", true, self.bedrock.as_ref()),
("vertex", "Google Vertex", true, self.vertex.as_ref()),
]
}
pub fn active_provider_and_model(&self) -> (String, String) {
for (id, _display, requires_api_key, cfg) in self.provider_registry() {
if let Some(c) = cfg
&& c.enabled
&& (!requires_api_key || c.api_key.is_some())
{
let model = c
.default_model
.clone()
.unwrap_or_else(|| "(default)".to_string());
return (id.to_string(), model);
}
}
if let Some((name, cfg)) = self.active_custom() {
let model = cfg
.default_model
.clone()
.unwrap_or_else(|| "(default)".to_string());
return (format!("custom:{}", name), model);
}
("none".to_string(), "none".to_string())
}
}
fn deserialize_custom_providers<'de, D>(
deserializer: D,
) -> std::result::Result<Option<BTreeMap<String, ProviderConfig>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
let value: Option<toml::Value> = Option::deserialize(deserializer)?;
let Some(value) = value else {
return Ok(None);
};
let table = match value.as_table() {
Some(t) => t,
None => return Ok(None),
};
let flat_keys = ["enabled", "api_key", "base_url", "default_model", "models"];
let has_flat = flat_keys.iter().any(|k| table.contains_key(*k));
let has_named = table.values().any(|v| v.is_table());
if has_flat && has_named {
let mut map = BTreeMap::new();
let mut flat_table = toml::map::Map::new();
for key in &flat_keys {
if let Some(v) = table.get(*key) {
flat_table.insert(key.to_string(), v.clone());
}
}
let default_cfg: ProviderConfig = toml::Value::Table(flat_table)
.try_into()
.map_err(de::Error::custom)?;
map.insert("default".to_string(), default_cfg);
for (name, val) in table {
if flat_keys.contains(&name.as_str()) {
continue;
}
if val.is_table() {
let cfg: ProviderConfig = val.clone().try_into().map_err(de::Error::custom)?;
map.insert(normalize_toml_key(name), cfg);
}
}
Ok(Some(map))
} else if has_flat {
let config: ProviderConfig = toml::Value::Table(table.clone())
.try_into()
.map_err(de::Error::custom)?;
let mut map = BTreeMap::new();
map.insert("default".to_string(), config);
Ok(Some(map))
} else {
let raw: BTreeMap<String, ProviderConfig> = toml::Value::Table(table.clone())
.try_into()
.map_err(de::Error::custom)?;
let map: BTreeMap<String, ProviderConfig> = raw
.into_iter()
.map(|(k, v)| (normalize_toml_key(&k), v))
.collect();
Ok(if map.is_empty() { None } else { Some(map) })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FallbackProviderConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SttProviders {
#[serde(default)]
pub groq: Option<ProviderConfig>,
#[serde(default)]
pub local: Option<LocalSttConfig>,
#[serde(default)]
pub openai_compatible: Option<OpenaiCompatibleSttConfig>,
#[serde(default)]
pub voicebox: Option<VoiceboxSttConfig>,
#[serde(default)]
pub fallback_chain: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenaiCompatibleSttConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceboxSttConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_voicebox_url")]
pub base_url: String,
}
impl Default for VoiceboxSttConfig {
fn default() -> Self {
Self {
enabled: false,
base_url: default_voicebox_url(),
}
}
}
fn default_voicebox_url() -> String {
"http://localhost:8000".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalSttConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_local_stt_model")]
pub model: String,
}
impl Default for LocalSttConfig {
fn default() -> Self {
Self {
enabled: false,
model: default_local_stt_model(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TtsProviders {
#[serde(default)]
pub openai: Option<ProviderConfig>,
#[serde(default)]
pub local: Option<LocalTtsConfig>,
#[serde(default)]
pub openai_compatible: Option<OpenaiCompatibleTtsConfig>,
#[serde(default)]
pub voicebox: Option<VoiceboxTtsConfig>,
#[serde(default)]
pub fallback_chain: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OpenaiCompatibleTtsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub voice: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceboxTtsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_voicebox_url")]
pub base_url: String,
#[serde(default)]
pub profile_id: String,
#[serde(default)]
pub engine: String,
}
impl Default for VoiceboxTtsConfig {
fn default() -> Self {
Self {
enabled: false,
base_url: default_voicebox_url(),
profile_id: String::new(),
engine: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalTtsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_local_tts_voice")]
pub voice: String,
}
impl Default for LocalTtsConfig {
fn default() -> Self {
Self {
enabled: false,
voice: default_local_tts_voice(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WebSearchProviders {
#[serde(default)]
pub exa: Option<ProviderConfig>,
#[serde(default)]
pub brave: Option<ProviderConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImageProviders {
#[serde(default)]
pub gemini: Option<ProviderConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default)]
pub models: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vision_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_thinking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<u32>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
#[serde(default = "default_db_path")]
pub path: PathBuf,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
path: default_db_path(),
}
}
}
fn default_db_path() -> PathBuf {
opencrabs_home().join("opencrabs.db")
}
fn expand_tilde(p: &Path) -> PathBuf {
if let Ok(rest) = p.strip_prefix("~") {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(rest)
} else {
p.to_path_buf()
}
}
pub fn opencrabs_home() -> PathBuf {
let p = super::profile::resolve_profile_home();
if !p.exists()
&& let Err(e) = std::fs::create_dir_all(&p)
{
tracing::error!("Failed to create opencrabs home directory {p:?}: {e}");
}
p
}
pub fn daily_backup(path: &Path, max_days: usize) {
if !path.exists() {
return;
}
let parent = match path.parent() {
Some(p) => p,
None => return,
};
let stem = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let today_backup = parent.join(format!("{stem}.{today}.bak"));
if today_backup.exists() {
return;
}
if let Err(e) = fs::copy(path, &today_backup) {
tracing::warn!("Failed to back up {} before write: {e}", path.display());
return;
}
tracing::debug!("Daily backup: {}", today_backup.display());
let prefix = format!("{stem}.");
let suffix = ".bak";
if let Ok(entries) = fs::read_dir(parent) {
let mut backups: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with(&prefix) && name.ends_with(suffix) && name != stem {
Some(name)
} else {
None
}
})
.collect();
backups.sort();
backups.reverse(); for old in backups.iter().skip(max_days) {
let _ = fs::remove_file(parent.join(old));
tracing::debug!("Pruned old backup: {old}");
}
}
}
pub fn save_last_good_config() {
let home = opencrabs_home();
let config_path = home.join("config.toml");
let keys_path_src = home.join("keys.toml");
let config_good = home.join("config.last_good.toml");
let keys_good = home.join("keys.last_good.toml");
if config_path.exists()
&& let Err(e) = fs::copy(&config_path, &config_good)
{
tracing::debug!("Failed to save last-good config: {e}");
}
if keys_path_src.exists()
&& let Err(e) = fs::copy(&keys_path_src, &keys_good)
{
tracing::debug!("Failed to save last-good keys: {e}");
}
}
pub fn load_last_good_config() -> Option<Config> {
let home = opencrabs_home();
let config_good = home.join("config.last_good.toml");
if !config_good.exists() {
return None;
}
tracing::warn!("Attempting recovery from last-known-good config");
let mut config = match Config::load_from_path(&config_good) {
Ok(c) => c,
Err(e) => {
tracing::error!("Last-good config also failed: {e}");
return None;
}
};
let keys_good = home.join("keys.last_good.toml");
if keys_good.exists()
&& let Ok(content) = fs::read_to_string(&keys_good)
&& let Ok(keys) = toml::from_str::<KeysFile>(&content)
{
config.providers = merge_provider_keys(config.providers, keys.providers);
config.channels = merge_channel_keys(config.channels, keys.channels);
}
tracing::warn!("Recovered config from last-known-good snapshot");
Some(config)
}
pub fn keys_path() -> PathBuf {
opencrabs_home().join("keys.toml")
}
pub(crate) fn raw_config_custom_provider_names() -> std::collections::HashSet<String> {
use toml_edit::DocumentMut;
let path = Config::system_config_path().unwrap_or_else(|| opencrabs_home().join("config.toml"));
let Ok(content) = std::fs::read_to_string(&path) else {
return std::collections::HashSet::new();
};
let Ok(doc) = content.parse::<DocumentMut>() else {
return std::collections::HashSet::new();
};
doc.as_table()
.get("providers")
.and_then(|t| t.as_table())
.and_then(|t| t.get("custom"))
.and_then(|t| t.as_table())
.map(|t| t.iter().map(|(k, _)| k.to_string()).collect())
.unwrap_or_default()
}
pub fn save_keys(keys: &ProviderConfigs) -> Result<()> {
let providers: &[(&str, Option<&ProviderConfig>)] = &[
("providers.anthropic", keys.anthropic.as_ref()),
("providers.openai", keys.openai.as_ref()),
("providers.openrouter", keys.openrouter.as_ref()),
("providers.minimax", keys.minimax.as_ref()),
("providers.gemini", keys.gemini.as_ref()),
];
for (section, provider) in providers {
if let Some(p) = provider
&& let Some(key) = &p.api_key
&& !key.is_empty()
{
write_secret_key(section, "api_key", key)?;
}
}
if let Some(customs) = &keys.custom {
for (name, p) in customs {
if let Some(key) = &p.api_key
&& !key.is_empty()
{
let section = if name == "default" {
"providers.custom".to_string()
} else {
format!("providers.custom.{}", name)
};
write_secret_key(§ion, "api_key", key)?;
}
}
}
tracing::info!("Saved API keys to: {:?}", keys_path());
Ok(())
}
pub fn normalize_toml_key(key: &str) -> String {
key.trim()
.to_lowercase()
.replace(['.', '_', ' '], "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>()
.trim_matches('-')
.to_string()
}
pub fn write_secret_key(section: &str, key: &str, value: &str) -> Result<()> {
use toml_edit::DocumentMut;
let value = value.split(['\r', '\n']).next().unwrap_or("").trim();
if value.is_empty() {
return Ok(()); }
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = keys_path();
let mut doc: DocumentMut = if path.exists() {
fs::read_to_string(&path)?.parse()?
} else {
DocumentMut::new()
};
let parts: Vec<String> = section
.split('.')
.enumerate()
.map(|(i, p)| {
if i >= 2 && section.starts_with("providers.custom") {
normalize_toml_key(p)
} else {
p.to_string()
}
})
.collect();
let mut current = doc.as_table_mut();
for part in &parts {
if current.get(part.as_str()).is_none() {
current.insert(part, toml_edit::Item::Table(toml_edit::Table::new()));
}
current = current
.get_mut(part.as_str())
.context("section not found after insert")?
.as_table_mut()
.with_context(|| format!("'{}' is not a table", part))?;
}
current.insert(key, toml_edit::value(value));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
daily_backup(&path, 7);
fs::write(&path, doc.to_string())?;
tracing::info!("Wrote secret key [{section}].{key}");
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeysFile {
#[serde(default)]
pub providers: ProviderConfigs,
#[serde(default)]
pub channels: ChannelsConfig,
#[serde(default)]
pub a2a: Option<KeysA2a>,
#[serde(default)]
pub image: Option<ImageKeys>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImageKeys {
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KeysA2a {
pub api_key: Option<String>,
}
fn load_keys_from_file() -> Result<KeysFile> {
let keys_path = keys_path();
if !keys_path.exists() {
return Ok(KeysFile::default());
}
tracing::debug!("Loading keys from: {:?}", keys_path);
let content = std::fs::read_to_string(&keys_path)?;
let keys: KeysFile = toml::from_str(&content)?;
Ok(keys)
}
pub(crate) fn merge_provider_keys(
mut base: ProviderConfigs,
keys: ProviderConfigs,
) -> ProviderConfigs {
let is_real_key = |k: &str| !k.is_empty() && k != "__EXISTING_KEY__";
if let Some(k) = keys.anthropic
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.anthropic.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.openai
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.openai.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.openrouter
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.openrouter.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
tracing::debug!(
"merge_provider_keys: minimax keys present={}, base present={}",
keys.minimax.is_some(),
base.minimax.is_some()
);
if let Some(k) = keys.minimax
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.minimax.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.gemini
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.gemini.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.github
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.github.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.zhipu
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.zhipu.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(k) = keys.qwen
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.qwen.get_or_insert_with(|| ProviderConfig {
enabled: true,
..Default::default()
});
entry.api_key = Some(key);
if entry.default_model.is_none() && k.default_model.is_some() {
entry.default_model = k.default_model;
}
if entry.base_url.is_none() && k.base_url.is_some() {
entry.base_url = k.base_url;
}
}
if let Some(k) = keys.opencode
&& let Some(key) = k.api_key
&& is_real_key(&key)
{
let entry = base.opencode.get_or_insert_with(|| ProviderConfig {
enabled: true,
..Default::default()
});
entry.api_key = Some(key);
if entry.default_model.is_none() && k.default_model.is_some() {
entry.default_model = k.default_model;
}
if entry.base_url.is_none() && k.base_url.is_some() {
entry.base_url = k.base_url;
}
}
if let Some(custom_keys) = keys.custom {
let base_customs = base.custom.get_or_insert_with(BTreeMap::default);
for (name, key_cfg) in custom_keys {
if let Some(key) = key_cfg.api_key
&& is_real_key(&key)
{
use std::collections::btree_map::Entry;
match base_customs.entry(name.clone()) {
Entry::Occupied(mut occupied) => {
tracing::info!(
"merge_provider_keys: merging api_key for custom '{}'",
name
);
occupied.get_mut().api_key = Some(key);
}
Entry::Vacant(vacant) => {
tracing::info!(
"merge_provider_keys: custom '{}' has key in keys.toml but no config.toml entry — creating minimal entry",
name
);
vacant.insert(ProviderConfig {
api_key: Some(key),
base_url: key_cfg.base_url,
default_model: key_cfg.default_model,
..Default::default()
});
}
}
}
}
}
if let Some(stt) = keys.stt
&& let Some(groq) = stt.groq
&& let Some(key) = groq.api_key
{
let base_stt = base.stt.get_or_insert_with(SttProviders::default);
let entry = base_stt.groq.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(tts) = keys.tts
&& let Some(openai) = tts.openai
&& let Some(key) = openai.api_key
{
let base_tts = base.tts.get_or_insert_with(TtsProviders::default);
let entry = base_tts.openai.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(ws) = keys.web_search {
let base_ws = base
.web_search
.get_or_insert_with(WebSearchProviders::default);
if let Some(exa) = ws.exa
&& let Some(key) = exa.api_key
&& !key.is_empty()
{
let entry = base_ws.exa.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
if let Some(brave) = ws.brave
&& let Some(key) = brave.api_key
&& !key.is_empty()
{
let entry = base_ws.brave.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
}
if let Some(img) = keys.image {
let base_img = base.image.get_or_insert_with(ImageProviders::default);
if let Some(gemini) = img.gemini
&& let Some(key) = gemini.api_key
&& !key.is_empty()
{
let entry = base_img.gemini.get_or_insert_with(ProviderConfig::default);
entry.api_key = Some(key);
}
}
if let Some(ref customs) = base.custom {
let total = customs.len();
let with_key = customs
.values()
.filter(|c| {
c.api_key
.as_ref()
.is_some_and(|k| !k.is_empty() && k != "__EXISTING_KEY__")
})
.count();
let missing: Vec<&str> = customs
.iter()
.filter(|(_, c)| {
!c.api_key
.as_ref()
.is_some_and(|k| !k.is_empty() && k != "__EXISTING_KEY__")
})
.map(|(n, _)| n.as_str())
.collect();
tracing::info!(
"merge_provider_keys: custom providers loaded = {} ({} with real api_key); \
providers missing a real key: {:?}",
total,
with_key,
missing,
);
}
base
}
fn merge_channel_keys(mut base: ChannelsConfig, keys: ChannelsConfig) -> ChannelsConfig {
if let Some(ref token) = keys.telegram.token
&& !token.is_empty()
{
base.telegram.token = Some(token.clone());
}
if let Some(ref token) = keys.discord.token
&& !token.is_empty()
{
base.discord.token = Some(token.clone());
}
if let Some(ref token) = keys.slack.token
&& !token.is_empty()
{
base.slack.token = Some(token.clone());
}
if let Some(ref app_token) = keys.slack.app_token
&& !app_token.is_empty()
{
base.slack.app_token = Some(app_token.clone());
}
if let Some(ref app_token) = keys.trello.app_token
&& !app_token.is_empty()
{
base.trello.app_token = Some(app_token.clone());
}
if let Some(ref token) = keys.trello.token
&& !token.is_empty()
{
base.trello.token = Some(token.clone());
}
base
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub file: Option<PathBuf>,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: default_log_level(),
file: None,
}
}
}
fn default_log_level() -> String {
"info".to_string()
}
impl Default for Config {
fn default() -> Self {
Self {
crabrace: CrabraceConfig::default(),
database: DatabaseConfig {
path: default_db_path(),
},
logging: LoggingConfig {
level: default_log_level(),
file: None,
},
debug: DebugConfig::default(),
providers: ProviderConfigs::default(),
channels: ChannelsConfig::default(),
agent: AgentConfig::default(),
daemon: DaemonConfig::default(),
a2a: A2aConfig::default(),
image: ImageConfig::default(),
cron: CronConfig::default(),
memory: MemoryConfig::default(),
brain: BrainConfig::default(),
}
}
}
impl Config {
pub fn voice_config(&self) -> VoiceConfig {
let stt = self.providers.stt.as_ref();
let tts = self.providers.tts.as_ref();
let groq_enabled = stt.and_then(|s| s.groq.as_ref()).is_some_and(|g| g.enabled);
let local_stt_enabled = stt
.and_then(|s| s.local.as_ref())
.is_some_and(|l| l.enabled);
let openai_compatible_stt_enabled = stt
.and_then(|s| s.openai_compatible.as_ref())
.is_some_and(|c| c.enabled);
let voicebox_stt_enabled = stt
.and_then(|s| s.voicebox.as_ref())
.is_some_and(|v| v.enabled);
let stt_enabled = groq_enabled
|| local_stt_enabled
|| openai_compatible_stt_enabled
|| voicebox_stt_enabled;
let stt_mode = if local_stt_enabled {
SttMode::Local
} else {
SttMode::Api
};
let local_stt_model = stt
.and_then(|s| s.local.as_ref())
.map(|l| l.model.clone())
.unwrap_or_else(default_local_stt_model);
let stt_base_url = stt
.and_then(|s| s.openai_compatible.as_ref())
.and_then(|c| c.base_url.clone())
.or_else(|| groq_enabled.then(|| "https://api.groq.com/openai/v1".to_string()));
let stt_model = stt
.and_then(|s| s.openai_compatible.as_ref())
.and_then(|c| c.model.clone())
.or_else(|| Some("whisper-large-v3-turbo".to_string()));
let stt_api_key = stt
.and_then(|s| s.openai_compatible.as_ref())
.and_then(|c| c.api_key.clone())
.or_else(|| {
stt.and_then(|s| s.groq.as_ref())
.and_then(|g| g.api_key.clone())
});
let openai_tts_enabled = tts
.and_then(|t| t.openai.as_ref())
.is_some_and(|o| o.enabled);
let local_tts_enabled = tts
.and_then(|t| t.local.as_ref())
.is_some_and(|l| l.enabled);
let openai_compatible_tts_enabled = tts
.and_then(|t| t.openai_compatible.as_ref())
.is_some_and(|c| c.enabled);
let voicebox_tts_enabled = tts
.and_then(|t| t.voicebox.as_ref())
.is_some_and(|v| v.enabled);
let tts_enabled = openai_tts_enabled
|| local_tts_enabled
|| openai_compatible_tts_enabled
|| voicebox_tts_enabled;
let tts_mode = if local_tts_enabled {
TtsMode::Local
} else {
TtsMode::Api
};
let tts_voice = tts
.and_then(|t| t.openai.as_ref())
.and_then(|o| o.voice.clone())
.or_else(|| {
tts.and_then(|t| t.openai_compatible.as_ref())
.and_then(|c| c.voice.clone())
})
.unwrap_or_else(default_tts_voice);
let tts_model = tts
.and_then(|t| t.openai.as_ref())
.and_then(|o| o.model.clone().or_else(|| o.default_model.clone()))
.or_else(|| {
tts.and_then(|t| t.openai_compatible.as_ref())
.and_then(|c| c.model.clone())
})
.unwrap_or_else(default_tts_model);
let tts_base_url = tts
.and_then(|t| t.openai_compatible.as_ref())
.and_then(|c| c.base_url.clone())
.or_else(|| openai_tts_enabled.then(|| "https://api.openai.com".to_string()));
let tts_api_key = tts
.and_then(|t| t.openai_compatible.as_ref())
.and_then(|c| c.api_key.clone())
.or_else(|| {
tts.and_then(|t| t.openai.as_ref())
.and_then(|o| o.api_key.clone())
});
let local_tts_voice = tts
.and_then(|t| t.local.as_ref())
.map(|l| l.voice.clone())
.unwrap_or_else(default_local_tts_voice);
let voicebox_stt_base_url = stt
.and_then(|s| s.voicebox.as_ref())
.map(|v| v.base_url.clone())
.unwrap_or_else(default_voicebox_url);
let voicebox_tts_base_url = tts
.and_then(|t| t.voicebox.as_ref())
.map(|v| v.base_url.clone())
.unwrap_or_else(default_voicebox_url);
let voicebox_tts_profile_id = tts
.and_then(|t| t.voicebox.as_ref())
.map(|v| v.profile_id.clone())
.unwrap_or_default();
let voicebox_tts_engine = tts
.and_then(|t| t.voicebox.as_ref())
.map(|v| v.engine.clone())
.unwrap_or_default();
let stt_provider = stt.and_then(|s| s.groq.clone());
let tts_provider = tts.and_then(|t| t.openai.clone());
let stt_fallback_chain = stt
.and_then(|s| s.fallback_chain.clone())
.unwrap_or_default();
let tts_fallback_chain = tts
.and_then(|t| t.fallback_chain.clone())
.unwrap_or_default();
VoiceConfig {
stt_enabled,
stt_mode,
local_stt_model,
stt_base_url,
stt_model,
stt_api_key,
tts_enabled,
tts_mode,
tts_voice,
tts_model,
tts_base_url,
tts_api_key,
local_tts_voice,
stt_provider,
tts_provider,
voicebox_stt_enabled,
voicebox_stt_base_url,
voicebox_tts_enabled,
voicebox_tts_base_url,
voicebox_tts_profile_id,
voicebox_tts_engine,
stt_fallback_chain,
tts_fallback_chain,
}
}
pub fn load() -> Result<Self> {
match Self::load_inner() {
Ok(config) => Ok(config),
Err(e) => {
tracing::error!("Config load failed: {e} — trying last-known-good");
if let Some(good) = load_last_good_config() {
CONFIG_RECOVERED.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(good)
} else {
Err(e)
}
}
}
}
pub fn was_recovered() -> bool {
CONFIG_RECOVERED.swap(false, std::sync::atomic::Ordering::Relaxed)
}
fn load_inner() -> Result<Self> {
tracing::debug!("Loading configuration...");
let mut config = Self::default();
if let Some(system_config_path) = Self::system_config_path()
&& system_config_path.exists()
{
tracing::debug!("Loading system config from: {:?}", system_config_path);
config = Self::merge_from_file(config, &system_config_path)?;
}
let local_config_path = Self::local_config_path();
if local_config_path.exists() {
tracing::debug!("Loading local config from: {:?}", local_config_path);
config = Self::merge_from_file(config, &local_config_path)?;
}
if let Some(ref path) = Self::system_config_path() {
Self::migrate_if_needed(path);
}
match load_keys_from_file() {
Err(e) => {
tracing::error!("Failed to load keys.toml: {:#}", e);
let keys_good = opencrabs_home().join("keys.last_good.toml");
if keys_good.exists() {
match fs::read_to_string(&keys_good)
.context("reading keys.last_good.toml")
.and_then(|content| {
toml::from_str::<KeysFile>(&content)
.context("parsing keys.last_good.toml")
}) {
Ok(keys) => {
tracing::warn!(
"Recovered API keys from keys.last_good.toml — \
fix or delete keys.toml to clear this warning"
);
config.providers =
merge_provider_keys(config.providers, keys.providers);
config.channels = merge_channel_keys(config.channels, keys.channels);
if let Some(a2a_keys) = keys.a2a
&& let Some(key) = a2a_keys.api_key
&& !key.is_empty()
{
config.a2a.api_key = Some(key);
}
}
Err(e2) => {
tracing::error!(
"keys.last_good.toml also failed: {:#} — no API keys loaded",
e2
);
}
}
} else {
tracing::error!("No keys.last_good.toml backup — no API keys loaded");
}
}
Ok(keys) => {
config.providers = merge_provider_keys(config.providers, keys.providers);
config.channels = merge_channel_keys(config.channels, keys.channels);
if let Some(a2a_keys) = keys.a2a
&& let Some(key) = a2a_keys.api_key
&& !key.is_empty()
{
config.a2a.api_key = Some(key);
}
let image_key = config
.providers
.image
.as_ref()
.and_then(|img| img.gemini.as_ref())
.and_then(|g| g.api_key.as_ref())
.filter(|k| !k.is_empty())
.cloned()
.or_else(|| {
keys.image
.and_then(|img| img.api_key)
.filter(|k| !k.is_empty())
});
if let Some(key) = image_key {
config.image.generation.api_key = Some(key.clone());
config.image.vision.api_key = Some(key);
}
}
}
config = Self::apply_env_overrides(config)?;
config.database.path = expand_tilde(&config.database.path);
if let Some(path) = Self::system_config_path()
&& path.exists()
{
Self::warn_unknown_keys(&path);
}
tracing::debug!("Configuration loaded successfully");
Ok(config)
}
const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
"crabrace",
"database",
"logging",
"debug",
"providers",
"channels",
"agent",
"daemon",
"a2a",
"gateway",
"image",
"cron",
"memory",
];
fn warn_unknown_keys(path: &Path) {
use std::sync::atomic::{AtomicBool, Ordering};
static CHECKED: AtomicBool = AtomicBool::new(false);
if CHECKED.swap(true, Ordering::Relaxed) {
return;
}
let Ok(raw) = std::fs::read_to_string(path) else {
return;
};
let Ok(table) = raw.parse::<toml::Table>() else {
return;
};
let mut unknown: Vec<String> = Vec::new();
for key in table.keys() {
if !Self::KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
unknown.push(key.clone());
}
}
if !unknown.is_empty() {
tracing::warn!(
"Unknown top-level keys in config.toml (possible typos): {}",
unknown.join(", ")
);
CONFIG_TYPO_WARNINGS
.lock()
.unwrap_or_else(|e| e.into_inner())
.extend(unknown);
}
}
pub fn take_typo_warnings() -> Vec<String> {
CONFIG_TYPO_WARNINGS
.lock()
.unwrap_or_else(|e| e.into_inner())
.drain(..)
.collect()
}
pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
tracing::debug!("Loading configuration from custom path: {:?}", path);
let mut config = Self::default();
if path.exists() {
config = Self::merge_from_file(config, path)?;
} else {
anyhow::bail!("Config file not found: {:?}", path);
}
config = Self::apply_env_overrides(config)?;
config.database.path = expand_tilde(&config.database.path);
tracing::debug!("Configuration loaded successfully from custom path");
Ok(config)
}
fn migrate_if_needed(path: &Path) {
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Ok(content) = fs::read_to_string(path) else {
return;
};
let mut doc: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(_) => return,
};
let mut changed = false;
if let Some(trello) = doc
.get_mut("channels")
.and_then(|c| c.get_mut("trello"))
.and_then(|t| t.as_table_mut())
&& let Some(val) = trello.remove("allowed_channels")
&& !trello.contains_key("board_ids")
{
trello.insert("board_ids".to_string(), val);
changed = true;
}
if let Some(voice) = doc.get("voice").and_then(|v| v.as_table()).cloned() {
let root = doc.as_table_mut().unwrap();
if !root.contains_key("providers") {
root.insert(
"providers".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
}
let providers = root.get_mut("providers").unwrap().as_table_mut().unwrap();
if !providers.contains_key("stt") {
providers.insert("stt".to_string(), toml::Value::Table(toml::map::Map::new()));
}
if !providers.contains_key("tts") {
providers.insert("tts".to_string(), toml::Value::Table(toml::map::Map::new()));
}
let stt_enabled = voice
.get("stt_enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let stt_mode = voice
.get("stt_mode")
.and_then(|v| v.as_str())
.unwrap_or("api")
.to_string();
if stt_enabled {
let stt = providers.get_mut("stt").unwrap().as_table_mut().unwrap();
if stt_mode == "local" {
if !stt.contains_key("local") {
stt.insert(
"local".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
}
let local = stt.get_mut("local").unwrap().as_table_mut().unwrap();
local.entry("enabled").or_insert(toml::Value::Boolean(true));
if let Some(model) = voice.get("local_stt_model") {
local.entry("model").or_insert(model.clone());
}
} else if let Some(groq) = stt.get_mut("groq").and_then(|g| g.as_table_mut()) {
groq.entry("enabled").or_insert(toml::Value::Boolean(true));
}
}
let tts_enabled = voice
.get("tts_enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let tts_mode = voice
.get("tts_mode")
.and_then(|v| v.as_str())
.unwrap_or("api")
.to_string();
if tts_enabled {
let tts = providers.get_mut("tts").unwrap().as_table_mut().unwrap();
if tts_mode == "local" {
if !tts.contains_key("local") {
tts.insert(
"local".to_string(),
toml::Value::Table(toml::map::Map::new()),
);
}
let local = tts.get_mut("local").unwrap().as_table_mut().unwrap();
local.entry("enabled").or_insert(toml::Value::Boolean(true));
if let Some(voice_name) = voice.get("local_tts_voice") {
local.entry("voice").or_insert(voice_name.clone());
}
} else if let Some(openai) = tts.get_mut("openai").and_then(|o| o.as_table_mut()) {
openai
.entry("enabled")
.or_insert(toml::Value::Boolean(true));
if let Some(v) = voice.get("tts_voice") {
openai.entry("voice").or_insert(v.clone());
}
if let Some(m) = voice.get("tts_model") {
openai.entry("model").or_insert(m.clone());
}
}
}
root.remove("voice");
changed = true;
}
if !changed {
let content = fs::read_to_string(path).unwrap_or_default();
let has_subagent =
content.contains("subagent_provider") || content.contains("subagent_model");
if !has_subagent && let Ok(injected) = inject_subagent_defaults(&content) {
match fs::write(path, &injected) {
Ok(()) => {
tracing::info!("Config migrated: injected subagent defaults into [agent]")
}
Err(e) => tracing::warn!(
"Config migration: failed to write injected defaults to {}: {e}",
path.display()
),
}
}
return;
}
let Ok(mut edit_doc) = content.parse::<toml_edit::DocumentMut>() else {
tracing::warn!(
"Config migration: failed to parse config.toml for format-preserving write"
);
return;
};
if let Some(trello) = edit_doc
.get_mut("channels")
.and_then(|c| c.as_table_mut())
.and_then(|c| c.get_mut("trello"))
.and_then(|t| t.as_table_mut())
&& let Some(val) = trello.remove("allowed_channels")
&& trello.get("board_ids").is_none()
{
trello.insert("board_ids", val);
}
edit_doc.as_table_mut().remove("voice");
Self::backup_config(path, 7);
if fs::write(path, edit_doc.to_string()).is_ok() {
tracing::info!("Config migrated: [voice] → providers.stt/tts");
}
let updated_content = fs::read_to_string(path).unwrap_or_default();
let has_subagent = updated_content.contains("subagent_provider")
|| updated_content.contains("subagent_model");
if !has_subagent
&& let Ok(injected) = inject_subagent_defaults(&updated_content)
&& let Err(e) = fs::write(path, &injected)
{
tracing::warn!("Config migration: failed to inject subagent defaults: {e}");
}
}
pub fn system_config_path() -> Option<PathBuf> {
Some(opencrabs_home().join("config.toml"))
}
fn local_config_path() -> PathBuf {
PathBuf::from("./opencrabs.toml")
}
fn merge_from_file(base: Self, path: &Path) -> Result<Self> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
let file_config: Self = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
Ok(Self::merge(base, file_config))
}
fn merge(_base: Self, overlay: Self) -> Self {
Self {
crabrace: overlay.crabrace,
database: overlay.database,
logging: overlay.logging,
debug: overlay.debug,
providers: overlay.providers,
channels: overlay.channels,
agent: overlay.agent,
daemon: overlay.daemon,
a2a: overlay.a2a,
image: overlay.image,
cron: overlay.cron,
memory: overlay.memory,
brain: overlay.brain,
}
}
fn apply_env_overrides(mut config: Self) -> Result<Self> {
if let Ok(db_path) = std::env::var("OPENCRABS_DB_PATH") {
config.database.path = PathBuf::from(db_path);
}
if let Ok(log_level) = std::env::var("OPENCRABS_LOG_LEVEL") {
config.logging.level = log_level;
}
if let Ok(log_file) = std::env::var("OPENCRABS_LOG_FILE") {
config.logging.file = Some(PathBuf::from(log_file));
}
if let Ok(debug_lsp) = std::env::var("OPENCRABS_DEBUG_LSP") {
config.debug.debug_lsp = debug_lsp.parse().unwrap_or(false);
}
if let Ok(profiling) = std::env::var("OPENCRABS_PROFILING") {
config.debug.profiling = profiling.parse().unwrap_or(false);
}
if let Ok(enabled) = std::env::var("OPENCRABS_CRABRACE_ENABLED") {
config.crabrace.enabled = enabled.parse().unwrap_or(true);
}
if let Ok(base_url) = std::env::var("OPENCRABS_CRABRACE_URL") {
config.crabrace.base_url = base_url;
}
if let Ok(auto_update) = std::env::var("OPENCRABS_CRABRACE_AUTO_UPDATE") {
config.crabrace.auto_update = auto_update.parse().unwrap_or(true);
}
Ok(config)
}
pub fn reload() -> Result<Self> {
tracing::info!("Reloading configuration from disk");
Self::load()
}
pub fn write_key(section: &str, key: &str, value: &str) -> Result<()> {
use toml_edit::DocumentMut;
let value = value.trim();
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path =
Self::system_config_path().unwrap_or_else(|| opencrabs_home().join("config.toml"));
let mut doc: DocumentMut = if path.exists() {
fs::read_to_string(&path)?.parse()?
} else {
DocumentMut::new()
};
let parts: Vec<String> = section
.split('.')
.enumerate()
.map(|(i, p)| {
if i >= 2 && section.starts_with("providers.custom") {
normalize_toml_key(p)
} else {
p.to_string()
}
})
.collect();
let mut current = doc.as_table_mut();
for part in &parts {
if current.get(part.as_str()).is_none() {
current.insert(part, toml_edit::Item::Table(toml_edit::Table::new()));
}
current = current
.get_mut(part.as_str())
.context("section not found after insert")?
.as_table_mut()
.with_context(|| format!("'{}' is not a table", part))?;
}
let parsed: toml_edit::Item = if value.starts_with('[') && value.ends_with(']') {
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(value) {
let mut toml_arr = toml_edit::Array::new();
for v in arr {
match v {
serde_json::Value::String(s) => {
toml_arr.push(s);
}
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
toml_arr.push(i);
} else if let Some(f) = n.as_f64() {
toml_arr.push(f);
}
}
serde_json::Value::Bool(b) => {
toml_arr.push(b);
}
_ => {}
}
}
toml_edit::value(toml_arr)
} else {
toml_edit::value(value)
}
} else if let Ok(v) = value.parse::<i64>() {
toml_edit::value(v)
} else if let Ok(v) = value.parse::<f64>() {
toml_edit::value(v)
} else if let Ok(v) = value.parse::<bool>() {
toml_edit::value(v)
} else {
toml_edit::value(value)
};
current.insert(key, parsed);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
tracing::info!("Wrote config key [{section}].{key}");
Ok(())
}
pub fn write_keys_key(section: &str, key: &str, value: &str) -> Result<()> {
use toml_edit::DocumentMut;
let value = value.trim();
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = opencrabs_home().join("keys.toml");
let mut doc: DocumentMut = if path.exists() {
fs::read_to_string(&path)?.parse()?
} else {
DocumentMut::new()
};
let parts: Vec<String> = section.split('.').map(|p| p.to_string()).collect();
let mut current = doc.as_table_mut();
for part in &parts {
if current.get(part.as_str()).is_none() {
current.insert(part, toml_edit::Item::Table(toml_edit::Table::new()));
}
current = current
.get_mut(part.as_str())
.context("section not found after insert")?
.as_table_mut()
.with_context(|| format!("'{}' is not a table", part))?;
}
let parsed: toml_edit::Item = toml_edit::value(value);
current.insert(key, parsed);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
tracing::info!("Wrote keys.toml key [{section}].{key}");
Ok(())
}
pub fn remove_secret_section(section: &str) -> Result<()> {
use toml_edit::DocumentMut;
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = opencrabs_home().join("keys.toml");
if !path.exists() {
return Ok(());
}
let mut doc: DocumentMut = fs::read_to_string(&path)?.parse()?;
let parts: Vec<&str> = section.split('.').collect();
if parts.is_empty() {
return Ok(());
}
let parent_parts = &parts[..parts.len() - 1];
let leaf = parts[parts.len() - 1];
let mut current = doc.as_table_mut();
for part in parent_parts {
match current.get_mut(part) {
Some(v) if v.is_table() => {
current = v.as_table_mut().unwrap();
}
_ => return Ok(()),
}
}
if current.remove(leaf).is_some() {
tracing::info!("Removed keys.toml section [{section}]");
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
}
Ok(())
}
pub fn remove_section(section: &str) -> Result<()> {
use toml_edit::DocumentMut;
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path =
Self::system_config_path().unwrap_or_else(|| opencrabs_home().join("config.toml"));
if !path.exists() {
return Ok(());
}
let mut doc: DocumentMut = fs::read_to_string(&path)?.parse()?;
let parts: Vec<&str> = section.split('.').collect();
if parts.is_empty() {
return Ok(());
}
let parent_parts = &parts[..parts.len() - 1];
let leaf = parts[parts.len() - 1];
let mut current = doc.as_table_mut();
for part in parent_parts {
match current.get_mut(part) {
Some(v) if v.is_table() => {
current = v.as_table_mut().unwrap();
}
_ => return Ok(()), }
}
current.remove(leaf);
tracing::info!("Removed config section [{section}]");
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
Ok(())
}
pub fn cleanup_empty_custom_providers() {
if let Ok(config) = Self::load()
&& let Some(customs) = &config.providers.custom
{
for (name, cfg) in customs {
let is_empty_name = name.is_empty();
let has_url = cfg.base_url.as_ref().is_some_and(|u| !u.is_empty());
let has_model = cfg.default_model.as_ref().is_some_and(|m| !m.is_empty());
if is_empty_name || (!has_url && !has_model) {
let section = format!("providers.custom.{}", name);
if let Err(e) = Self::remove_section(§ion) {
tracing::warn!("Failed to remove empty custom provider '{}': {}", name, e);
}
}
}
}
Self::cleanup_keys_custom_providers();
}
pub(crate) fn cleanup_keys_custom_providers() {
use toml_edit::DocumentMut;
let keys_file = keys_path();
if !keys_file.exists() {
return;
}
let Ok(content) = std::fs::read_to_string(&keys_file) else {
return;
};
let Ok(mut doc) = content.parse::<DocumentMut>() else {
return;
};
let custom_table = match doc
.as_table_mut()
.get_mut("providers")
.and_then(|t| t.as_table_mut())
.and_then(|t| t.get_mut("custom"))
.and_then(|t| t.as_table_mut())
{
Some(t) => t,
None => return,
};
let config_names: std::collections::HashSet<String> = raw_config_custom_provider_names();
let remove: Vec<String> = custom_table
.iter()
.map(|(k, _)| k.to_string())
.filter(|k| k.is_empty() || !config_names.contains(k))
.collect();
if remove.is_empty() {
return;
}
for key in &remove {
custom_table.remove(key);
tracing::info!("Removed ghost key from keys.toml: providers.custom.{}", key);
}
daily_backup(&keys_file, 7);
if let Err(e) = std::fs::write(&keys_file, doc.to_string()) {
tracing::warn!("Failed to clean ghost keys from keys.toml: {e}");
}
}
pub fn write_array(section: &str, key: &str, values: &[String]) -> Result<()> {
use toml_edit::DocumentMut;
let path =
Self::system_config_path().unwrap_or_else(|| opencrabs_home().join("config.toml"));
let mut doc: DocumentMut = if path.exists() {
fs::read_to_string(&path)?.parse()?
} else {
DocumentMut::new()
};
let parts: Vec<&str> = section.split('.').collect();
let mut current = doc.as_table_mut();
for part in &parts {
if current.get(part).is_none() {
current.insert(part, toml_edit::Item::Table(toml_edit::Table::new()));
}
current = current
.get_mut(part)
.context("section not found after insert")?
.as_table_mut()
.with_context(|| format!("'{}' is not a table", part))?;
}
let mut arr = toml_edit::Array::new();
for v in values {
arr.push(v.as_str());
}
current.insert(key, toml_edit::value(arr));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Self::backup_config(&path, 7);
fs::write(&path, doc.to_string())?;
tracing::info!(
"Wrote config array [{section}].{key} ({} items)",
values.len()
);
Ok(())
}
pub fn has_any_api_key(&self) -> bool {
let has_anthropic = self
.providers
.anthropic
.as_ref()
.is_some_and(|p| p.api_key.is_some());
let has_openai = self
.providers
.openai
.as_ref()
.is_some_and(|p| p.api_key.is_some());
let has_gemini = self
.providers
.gemini
.as_ref()
.is_some_and(|p| p.api_key.is_some());
has_anthropic || has_openai || has_gemini
}
pub fn validate(&self) -> Result<()> {
tracing::debug!("Validating configuration...");
if let Some(parent) = self.database.path.parent()
&& !parent.exists()
{
tracing::warn!(
"Database parent directory does not exist, will be created: {:?}",
parent
);
}
let valid_levels = ["trace", "debug", "info", "warn", "error"];
if !valid_levels.contains(&self.logging.level.as_str()) {
anyhow::bail!(
"Invalid log level: {}. Must be one of: {:?}",
self.logging.level,
valid_levels
);
}
if self.crabrace.enabled && self.crabrace.base_url.is_empty() {
anyhow::bail!("Crabrace is enabled but base_url is empty");
}
tracing::debug!("Configuration validation passed");
Ok(())
}
fn backup_config(path: &Path, max_days: usize) {
daily_backup(path, max_days);
}
pub fn save(&self, path: &Path) -> Result<()> {
let _guard = CONFIG_FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let toml_string =
toml::to_string_pretty(self).context("Failed to serialize config to TOML")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
Self::backup_config(path, 7);
fs::write(path, toml_string)
.with_context(|| format!("Failed to write config file: {:?}", path))?;
tracing::info!("Configuration saved to: {:?}", path);
Ok(())
}
}
fn inject_subagent_defaults(content: &str) -> Result<String> {
let comment_block = "\n# Sub-agent routing — override the parent session's provider for\n# spawned agents, team members, and background workers.\n# subagent_provider = \"anthropic\" # e.g. openrouter, minimax, custom:ollama\n# subagent_model = \"claude-sonnet-4-6\" # only used when subagent_provider is set\n";
let marker = "[agent]";
let agent_pos = content
.find(marker)
.ok_or_else(|| anyhow::anyhow!("No [agent] section found"))?;
let section_start = agent_pos + marker.len();
let rest = &content[section_start..];
let next_section = rest.find("\n[");
let section_end = if let Some(pos) = next_section {
section_start + pos
} else {
content.len()
};
let mut out = String::with_capacity(content.len() + comment_block.len());
out.push_str(&content[..section_end]);
let before = &content[..section_end];
let ends_with_blank = before.ends_with("\n\n");
if !ends_with_blank {
out.push('\n');
}
out.push_str(comment_block);
out.push_str(&content[section_end..]);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.crabrace.enabled);
assert_eq!(config.logging.level, "info");
assert!(!config.debug.debug_lsp);
assert!(!config.debug.profiling);
}
#[test]
fn test_config_validation() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_config_validation_invalid_log_level() {
let mut config = Config::default();
config.logging.level = "invalid".to_string();
assert!(config.validate().is_err());
}
#[test]
fn test_config_validation_empty_crabrace_url() {
let mut config = Config::default();
config.crabrace.base_url = String::new();
assert!(config.validate().is_err());
}
#[test]
fn test_config_from_toml() {
let toml_content = r#"
[database]
path = "/custom/path/db.sqlite"
[logging]
level = "debug"
[debug]
debug_lsp = true
profiling = true
[crabrace]
enabled = false
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(
config.database.path,
PathBuf::from("/custom/path/db.sqlite")
);
assert_eq!(config.logging.level, "debug");
assert!(config.debug.debug_lsp);
assert!(config.debug.profiling);
assert!(!config.crabrace.enabled);
}
#[test]
fn test_config_save_and_load() {
let temp_file = NamedTempFile::new().unwrap();
let config = Config::default();
config.save(temp_file.path()).unwrap();
let contents = std::fs::read_to_string(temp_file.path()).unwrap();
let loaded_config: Config = toml::from_str(&contents).unwrap();
assert_eq!(loaded_config.logging.level, config.logging.level);
assert_eq!(loaded_config.crabrace.enabled, config.crabrace.enabled);
}
#[test]
fn test_config_from_toml_overrides() {
let toml_content = r#"
[logging]
level = "trace"
[debug]
debug_lsp = true
profiling = true
[database]
path = "/tmp/test.db"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.logging.level, "trace");
assert!(config.debug.debug_lsp);
assert!(config.debug.profiling);
assert_eq!(config.database.path, PathBuf::from("/tmp/test.db"));
}
#[test]
fn test_provider_config_from_toml() {
let toml_content = r#"
[providers.anthropic]
enabled = true
api_key = "test-anthropic-key"
default_model = "claude-opus-4-6"
[providers.openai]
enabled = true
api_key = "test-openai-key"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert!(config.providers.anthropic.is_some());
let anthropic = config.providers.anthropic.as_ref().unwrap();
assert_eq!(anthropic.api_key, Some("test-anthropic-key".to_string()));
assert_eq!(anthropic.default_model, Some("claude-opus-4-6".to_string()));
assert!(config.providers.openai.is_some());
assert_eq!(
config.providers.openai.as_ref().unwrap().api_key,
Some("test-openai-key".to_string())
);
}
#[test]
fn test_system_config_path() {
let path = Config::system_config_path();
assert!(path.is_some());
let path = path.unwrap();
assert!(path.to_string_lossy().contains("opencrabs"));
assert!(path.to_string_lossy().ends_with("config.toml"));
}
#[test]
fn test_local_config_path() {
let path = Config::local_config_path();
assert_eq!(path, PathBuf::from("./opencrabs.toml"));
}
#[test]
fn test_debug_config_default() {
let debug = DebugConfig::default();
assert!(!debug.debug_lsp);
assert!(!debug.profiling);
}
#[test]
fn test_provider_configs_default() {
let providers = ProviderConfigs::default();
assert!(providers.anthropic.is_none());
assert!(providers.openai.is_none());
assert!(providers.gemini.is_none());
assert!(providers.bedrock.is_none());
assert!(providers.vertex.is_none());
}
#[test]
fn test_database_config_default() {
let db_config = DatabaseConfig::default();
assert!(!db_config.path.as_os_str().is_empty());
}
#[test]
fn test_logging_config_default() {
let logging = LoggingConfig::default();
assert_eq!(logging.level, "info");
assert!(logging.file.is_none());
}
#[test]
fn test_agent_config_default() {
let agent = AgentConfig::default();
assert_eq!(agent.approval_policy, "auto-always");
assert_eq!(agent.max_concurrent, 4);
}
#[test]
fn test_agent_config_from_toml() {
let toml_content = r#"
[agent]
approval_policy = "auto-always"
max_concurrent = 8
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.agent.approval_policy, "auto-always");
assert_eq!(config.agent.max_concurrent, 8);
}
#[test]
fn test_agent_config_defaults_when_absent() {
let toml_content = r#"
[logging]
level = "info"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.agent.approval_policy, "auto-always");
assert_eq!(config.agent.max_concurrent, 4);
}
#[test]
fn test_write_key_creates_and_updates() {
let dir = tempfile::TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
fs::write(&config_path, "[logging]\nlevel = \"info\"\n").unwrap();
let content = fs::read_to_string(&config_path).unwrap();
let mut doc: toml::Value = toml::from_str(&content).unwrap();
let table = doc.as_table_mut().unwrap();
table.insert(
"agent".to_string(),
toml::Value::Table({
let mut m = toml::map::Map::new();
m.insert(
"approval_policy".to_string(),
toml::Value::String("auto-session".to_string()),
);
m
}),
);
let output = toml::to_string_pretty(&doc).unwrap();
fs::write(&config_path, &output).unwrap();
let content = fs::read_to_string(&config_path).unwrap();
let loaded: Config = toml::from_str(&content).unwrap();
assert_eq!(loaded.agent.approval_policy, "auto-session");
assert_eq!(loaded.logging.level, "info");
}
#[test]
fn test_config_save_with_agent_section() {
let temp_file = NamedTempFile::new().unwrap();
let mut config = Config::default();
config.agent.approval_policy = "auto-always".to_string();
config.agent.max_concurrent = 2;
config.save(temp_file.path()).unwrap();
let contents = fs::read_to_string(temp_file.path()).unwrap();
let loaded: Config = toml::from_str(&contents).unwrap();
assert_eq!(loaded.agent.approval_policy, "auto-always");
assert_eq!(loaded.agent.max_concurrent, 2);
}
}
#[allow(clippy::items_after_test_module)]
pub fn resolve_provider_from_config(config: &Config) -> (&str, &str) {
for (_id, display, requires_api_key, cfg) in config.providers.provider_registry() {
if let Some(c) = cfg
&& c.enabled
&& (!requires_api_key || c.api_key.is_some())
{
let model = c.default_model.as_deref().unwrap_or("(default)");
return (display, model);
}
}
if let Some((name, cfg)) = config.providers.active_custom() {
let model = cfg.default_model.as_deref().unwrap_or("default");
return (name, model);
}
("Not configured", "N/A")
}