1use crate::config::agent::AgentProfile;
7use anyhow::{Context, Result};
8use directories::BaseDirs;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13const DEFAULT_CONFIG: &str =
15 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/spec-ai.config.toml"));
16
17const CONFIG_FILE_NAME: &str = "spec-ai.config.toml";
19
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct AppConfig {
23 #[serde(default)]
25 pub database: DatabaseConfig,
26 #[serde(default)]
28 pub model: ModelConfig,
29 #[serde(default)]
31 pub ui: UiConfig,
32 #[serde(default)]
34 pub logging: LoggingConfig,
35 #[serde(default)]
37 pub audio: AudioConfig,
38 #[serde(default)]
40 pub mesh: MeshConfig,
41 #[serde(default)]
43 pub plugins: PluginConfig,
44 #[serde(default)]
46 pub sync: SyncConfig,
47 #[serde(default)]
49 pub auth: AuthConfig,
50 #[serde(default)]
52 pub agents: HashMap<String, AgentProfile>,
53 #[serde(default)]
55 pub default_agent: Option<String>,
56}
57
58impl AppConfig {
59 pub fn load() -> Result<Self> {
61 if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_NAME) {
63 return toml::from_str(&content)
64 .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", CONFIG_FILE_NAME, e));
65 }
66
67 if let Ok(base_dirs) =
69 BaseDirs::new().ok_or(anyhow::anyhow!("Could not determine home directory"))
70 {
71 let home_config = base_dirs.home_dir().join(".spec-ai").join(CONFIG_FILE_NAME);
72 if let Ok(content) = std::fs::read_to_string(&home_config) {
73 return toml::from_str(&content).map_err(|e| {
74 anyhow::anyhow!("Failed to parse {}: {}", home_config.display(), e)
75 });
76 }
77 }
78
79 if let Ok(config_path) = std::env::var("CONFIG_PATH") {
81 if let Ok(content) = std::fs::read_to_string(&config_path) {
82 return toml::from_str(&content)
83 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e));
84 }
85 }
86
87 eprintln!(
89 "No configuration file found. Creating {} with default settings...",
90 CONFIG_FILE_NAME
91 );
92 if let Err(e) = std::fs::write(CONFIG_FILE_NAME, DEFAULT_CONFIG) {
93 eprintln!("Warning: Could not create {}: {}", CONFIG_FILE_NAME, e);
94 eprintln!("Continuing with default configuration in memory.");
95 } else {
96 eprintln!(
97 "Created {}. You can edit this file to customize your settings.",
98 CONFIG_FILE_NAME
99 );
100 }
101
102 toml::from_str(DEFAULT_CONFIG)
104 .map_err(|e| anyhow::anyhow!("Failed to parse embedded default config: {}", e))
105 }
106
107 pub fn load_from_file(path: &std::path::Path) -> Result<Self> {
110 match std::fs::read_to_string(path) {
112 Ok(content) => toml::from_str(&content).map_err(|e| {
113 anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e)
114 }),
115 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
116 eprintln!(
118 "Configuration file not found at {}. Creating with default settings...",
119 path.display()
120 );
121
122 if let Some(parent) = path.parent() {
124 std::fs::create_dir_all(parent)
125 .context(format!("Failed to create directory {}", parent.display()))?;
126 }
127
128 std::fs::write(path, DEFAULT_CONFIG).context(format!(
130 "Failed to create config file at {}",
131 path.display()
132 ))?;
133
134 eprintln!(
135 "Created {}. You can edit this file to customize your settings.",
136 path.display()
137 );
138
139 toml::from_str(DEFAULT_CONFIG)
141 .map_err(|e| anyhow::anyhow!("Failed to parse embedded default config: {}", e))
142 }
143 Err(e) => Err(anyhow::anyhow!(
144 "Failed to read config file {}: {}",
145 path.display(),
146 e
147 )),
148 }
149 }
150
151 pub fn validate(&self) -> Result<()> {
153 if self.model.provider.is_empty() {
155 return Err(anyhow::anyhow!("Model provider cannot be empty"));
156 }
157 {
159 let p = self.model.provider.to_lowercase();
160 let known = ["mock", "openai", "anthropic", "ollama", "mlx", "lmstudio"];
161 if !known.contains(&p.as_str()) {
162 return Err(anyhow::anyhow!(
163 "Invalid model provider: {}",
164 self.model.provider
165 ));
166 }
167 }
168
169 if self.model.temperature < 0.0 || self.model.temperature > 2.0 {
171 return Err(anyhow::anyhow!(
172 "Temperature must be between 0.0 and 2.0, got {}",
173 self.model.temperature
174 ));
175 }
176
177 match self.logging.level.as_str() {
179 "trace" | "debug" | "info" | "warn" | "error" => {}
180 _ => return Err(anyhow::anyhow!("Invalid log level: {}", self.logging.level)),
181 }
182
183 if let Some(default_agent) = &self.default_agent {
185 if !self.agents.contains_key(default_agent) {
186 return Err(anyhow::anyhow!(
187 "Default agent '{}' not found in agents map",
188 default_agent
189 ));
190 }
191 }
192
193 Ok(())
194 }
195
196 pub fn apply_env_overrides(&mut self) {
198 fn first(a: &str, b: &str) -> Option<String> {
200 std::env::var(a).ok().or_else(|| std::env::var(b).ok())
201 }
202
203 if let Some(provider) = first("AGENT_MODEL_PROVIDER", "SPEC_AI_PROVIDER") {
204 self.model.provider = provider;
205 }
206 if let Some(model_name) = first("AGENT_MODEL_NAME", "SPEC_AI_MODEL") {
207 self.model.model_name = Some(model_name);
208 }
209 if let Some(api_key_source) = first("AGENT_API_KEY_SOURCE", "SPEC_AI_API_KEY_SOURCE") {
210 self.model.api_key_source = Some(api_key_source);
211 }
212 if let Some(temp_str) = first("AGENT_MODEL_TEMPERATURE", "SPEC_AI_TEMPERATURE") {
213 if let Ok(temp) = temp_str.parse::<f32>() {
214 self.model.temperature = temp;
215 }
216 }
217 if let Some(level) = first("AGENT_LOG_LEVEL", "SPEC_AI_LOG_LEVEL") {
218 self.logging.level = level;
219 }
220 if let Some(db_path) = first("AGENT_DB_PATH", "SPEC_AI_DB_PATH") {
221 self.database.path = PathBuf::from(db_path);
222 }
223 if let Some(theme) = first("AGENT_UI_THEME", "SPEC_AI_UI_THEME") {
224 self.ui.theme = theme;
225 }
226 if let Some(default_agent) = first("AGENT_DEFAULT_AGENT", "SPEC_AI_DEFAULT_AGENT") {
227 self.default_agent = Some(default_agent);
228 }
229 }
230
231 pub fn summary(&self) -> String {
233 let mut summary = String::new();
234 summary.push_str("Configuration loaded:\n");
235 summary.push_str(&format!("Database: {}\n", self.database.path.display()));
236 summary.push_str(&format!("Model Provider: {}\n", self.model.provider));
237 if let Some(model) = &self.model.model_name {
238 summary.push_str(&format!("Model Name: {}\n", model));
239 }
240 summary.push_str(&format!("Temperature: {}\n", self.model.temperature));
241 summary.push_str(&format!("Logging Level: {}\n", self.logging.level));
242 summary.push_str(&format!("UI Theme: {}\n", self.ui.theme));
243 summary.push_str(&format!("Available Agents: {}\n", self.agents.len()));
244 if let Some(default) = &self.default_agent {
245 summary.push_str(&format!("Default Agent: {}\n", default));
246 }
247 summary
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct DatabaseConfig {
254 pub path: PathBuf,
256}
257
258impl Default for DatabaseConfig {
259 fn default() -> Self {
260 Self {
261 path: PathBuf::from("spec-ai.duckdb"),
262 }
263 }
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct ModelConfig {
269 pub provider: String,
271 #[serde(default)]
273 pub model_name: Option<String>,
274 #[serde(default)]
276 pub embeddings_model: Option<String>,
277 #[serde(default)]
279 pub api_key_source: Option<String>,
280 #[serde(default = "default_temperature")]
282 pub temperature: f32,
283}
284
285fn default_temperature() -> f32 {
286 0.7
287}
288
289impl Default for ModelConfig {
290 fn default() -> Self {
291 Self {
292 provider: "mock".to_string(),
293 model_name: None,
294 embeddings_model: None,
295 api_key_source: None,
296 temperature: default_temperature(),
297 }
298 }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct UiConfig {
304 pub prompt: String,
306 pub theme: String,
308}
309
310impl Default for UiConfig {
311 fn default() -> Self {
312 Self {
313 prompt: "> ".to_string(),
314 theme: "default".to_string(),
315 }
316 }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct LoggingConfig {
322 pub level: String,
324}
325
326impl Default for LoggingConfig {
327 fn default() -> Self {
328 Self {
329 level: "info".to_string(),
330 }
331 }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct MeshConfig {
337 #[serde(default)]
339 pub enabled: bool,
340 #[serde(default = "default_registry_port")]
342 pub registry_port: u16,
343 #[serde(default = "default_heartbeat_interval")]
345 pub heartbeat_interval_secs: u64,
346 #[serde(default = "default_leader_timeout")]
348 pub leader_timeout_secs: u64,
349 #[serde(default = "default_replication_factor")]
351 pub replication_factor: usize,
352 #[serde(default)]
354 pub auto_join: bool,
355}
356
357fn default_registry_port() -> u16 {
358 3000
359}
360
361fn default_heartbeat_interval() -> u64 {
362 5
363}
364
365fn default_leader_timeout() -> u64 {
366 15
367}
368
369fn default_replication_factor() -> usize {
370 2
371}
372
373impl Default for MeshConfig {
374 fn default() -> Self {
375 Self {
376 enabled: false,
377 registry_port: default_registry_port(),
378 heartbeat_interval_secs: default_heartbeat_interval(),
379 leader_timeout_secs: default_leader_timeout(),
380 replication_factor: default_replication_factor(),
381 auto_join: true,
382 }
383 }
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct AudioConfig {
389 #[serde(default)]
391 pub enabled: bool,
392 #[serde(default = "default_transcription_provider")]
394 pub provider: String,
395 #[serde(default)]
397 pub model: Option<String>,
398 #[serde(default)]
400 pub api_key_source: Option<String>,
401 #[serde(default)]
403 pub on_device: bool,
404 #[serde(default)]
406 pub endpoint: Option<String>,
407 #[serde(default = "default_chunk_duration")]
409 pub chunk_duration_secs: f64,
410 #[serde(default = "default_duration")]
412 pub default_duration_secs: u64,
413 #[serde(default = "default_duration")]
415 pub default_duration: u64,
416 #[serde(default)]
418 pub out_file: Option<String>,
419 #[serde(default)]
421 pub language: Option<String>,
422 #[serde(default)]
424 pub auto_respond: bool,
425 #[serde(default = "default_mock_scenario")]
427 pub mock_scenario: String,
428 #[serde(default = "default_event_delay_ms")]
430 pub event_delay_ms: u64,
431 #[serde(default)]
433 pub speak_responses: bool,
434}
435
436fn default_transcription_provider() -> String {
437 "vttrs".to_string()
438}
439
440fn default_chunk_duration() -> f64 {
441 5.0
442}
443
444fn default_duration() -> u64 {
445 30
446}
447
448fn default_mock_scenario() -> String {
449 "simple_conversation".to_string()
450}
451
452fn default_event_delay_ms() -> u64 {
453 500
454}
455
456impl Default for AudioConfig {
457 fn default() -> Self {
458 Self {
459 enabled: false,
460 provider: default_transcription_provider(),
461 model: Some("whisper-1".to_string()),
462 api_key_source: None,
463 on_device: false,
464 endpoint: None,
465 chunk_duration_secs: default_chunk_duration(),
466 default_duration_secs: default_duration(),
467 default_duration: default_duration(),
468 out_file: None,
469 language: None,
470 auto_respond: false,
471 mock_scenario: default_mock_scenario(),
472 event_delay_ms: default_event_delay_ms(),
473 speak_responses: false,
474 }
475 }
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct PluginConfig {
481 #[serde(default)]
483 pub enabled: bool,
484
485 #[serde(default = "default_plugins_dir")]
487 pub custom_tools_dir: PathBuf,
488
489 #[serde(default = "default_continue_on_error")]
491 pub continue_on_error: bool,
492
493 #[serde(default)]
495 pub allow_override_builtin: bool,
496}
497
498fn default_plugins_dir() -> PathBuf {
499 PathBuf::from("~/.spec-ai/tools")
500}
501
502fn default_continue_on_error() -> bool {
503 true
504}
505
506impl Default for PluginConfig {
507 fn default() -> Self {
508 Self {
509 enabled: false,
510 custom_tools_dir: default_plugins_dir(),
511 continue_on_error: true,
512 allow_override_builtin: false,
513 }
514 }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct AuthConfig {
520 #[serde(default)]
522 pub enabled: bool,
523
524 #[serde(default)]
528 pub credentials_file: Option<PathBuf>,
529
530 #[serde(default = "default_token_expiry")]
532 pub token_expiry_secs: u64,
533
534 #[serde(default)]
537 pub token_secret: Option<String>,
538}
539
540fn default_token_expiry() -> u64 {
541 86400 }
543
544impl Default for AuthConfig {
545 fn default() -> Self {
546 Self {
547 enabled: false,
548 credentials_file: None,
549 token_expiry_secs: default_token_expiry(),
550 token_secret: None,
551 }
552 }
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct SyncConfig {
558 #[serde(default)]
560 pub enabled: bool,
561
562 #[serde(default = "default_sync_interval")]
564 pub interval_secs: u64,
565
566 #[serde(default = "default_max_concurrent_syncs")]
568 pub max_concurrent_syncs: usize,
569
570 #[serde(default = "default_retry_interval")]
572 pub retry_interval_secs: u64,
573
574 #[serde(default = "default_max_retries")]
576 pub max_retries: usize,
577
578 #[serde(default)]
580 pub namespaces: Vec<SyncNamespace>,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct SyncNamespace {
586 pub session_id: String,
588 #[serde(default = "default_graph_name")]
590 pub graph_name: String,
591}
592
593fn default_sync_interval() -> u64 {
594 60
595}
596
597fn default_max_concurrent_syncs() -> usize {
598 3
599}
600
601fn default_retry_interval() -> u64 {
602 300
603}
604
605fn default_max_retries() -> usize {
606 3
607}
608
609fn default_graph_name() -> String {
610 "default".to_string()
611}
612
613impl Default for SyncConfig {
614 fn default() -> Self {
615 Self {
616 enabled: false,
617 interval_secs: default_sync_interval(),
618 max_concurrent_syncs: default_max_concurrent_syncs(),
619 retry_interval_secs: default_retry_interval(),
620 max_retries: default_max_retries(),
621 namespaces: Vec::new(),
622 }
623 }
624}