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 agents: HashMap<String, AgentProfile>,
50 #[serde(default)]
52 pub default_agent: Option<String>,
53}
54
55impl AppConfig {
56 pub fn load() -> Result<Self> {
58 if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_NAME) {
60 return toml::from_str(&content)
61 .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", CONFIG_FILE_NAME, e));
62 }
63
64 if let Ok(base_dirs) =
66 BaseDirs::new().ok_or(anyhow::anyhow!("Could not determine home directory"))
67 {
68 let home_config = base_dirs.home_dir().join(".spec-ai").join(CONFIG_FILE_NAME);
69 if let Ok(content) = std::fs::read_to_string(&home_config) {
70 return toml::from_str(&content).map_err(|e| {
71 anyhow::anyhow!("Failed to parse {}: {}", home_config.display(), e)
72 });
73 }
74 }
75
76 if let Ok(config_path) = std::env::var("CONFIG_PATH") {
78 if let Ok(content) = std::fs::read_to_string(&config_path) {
79 return toml::from_str(&content)
80 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e));
81 }
82 }
83
84 eprintln!(
86 "No configuration file found. Creating {} with default settings...",
87 CONFIG_FILE_NAME
88 );
89 if let Err(e) = std::fs::write(CONFIG_FILE_NAME, DEFAULT_CONFIG) {
90 eprintln!("Warning: Could not create {}: {}", CONFIG_FILE_NAME, e);
91 eprintln!("Continuing with default configuration in memory.");
92 } else {
93 eprintln!(
94 "Created {}. You can edit this file to customize your settings.",
95 CONFIG_FILE_NAME
96 );
97 }
98
99 toml::from_str(DEFAULT_CONFIG)
101 .map_err(|e| anyhow::anyhow!("Failed to parse embedded default config: {}", e))
102 }
103
104 pub fn load_from_file(path: &std::path::Path) -> Result<Self> {
107 match std::fs::read_to_string(path) {
109 Ok(content) => toml::from_str(&content).map_err(|e| {
110 anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e)
111 }),
112 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
113 eprintln!(
115 "Configuration file not found at {}. Creating with default settings...",
116 path.display()
117 );
118
119 if let Some(parent) = path.parent() {
121 std::fs::create_dir_all(parent)
122 .context(format!("Failed to create directory {}", parent.display()))?;
123 }
124
125 std::fs::write(path, DEFAULT_CONFIG).context(format!(
127 "Failed to create config file at {}",
128 path.display()
129 ))?;
130
131 eprintln!(
132 "Created {}. You can edit this file to customize your settings.",
133 path.display()
134 );
135
136 toml::from_str(DEFAULT_CONFIG)
138 .map_err(|e| anyhow::anyhow!("Failed to parse embedded default config: {}", e))
139 }
140 Err(e) => Err(anyhow::anyhow!(
141 "Failed to read config file {}: {}",
142 path.display(),
143 e
144 )),
145 }
146 }
147
148 pub fn validate(&self) -> Result<()> {
150 if self.model.provider.is_empty() {
152 return Err(anyhow::anyhow!("Model provider cannot be empty"));
153 }
154 {
156 let p = self.model.provider.to_lowercase();
157 let known = ["mock", "openai", "anthropic", "ollama", "mlx", "lmstudio"];
158 if !known.contains(&p.as_str()) {
159 return Err(anyhow::anyhow!(
160 "Invalid model provider: {}",
161 self.model.provider
162 ));
163 }
164 }
165
166 if self.model.temperature < 0.0 || self.model.temperature > 2.0 {
168 return Err(anyhow::anyhow!(
169 "Temperature must be between 0.0 and 2.0, got {}",
170 self.model.temperature
171 ));
172 }
173
174 match self.logging.level.as_str() {
176 "trace" | "debug" | "info" | "warn" | "error" => {}
177 _ => return Err(anyhow::anyhow!("Invalid log level: {}", self.logging.level)),
178 }
179
180 if let Some(default_agent) = &self.default_agent {
182 if !self.agents.contains_key(default_agent) {
183 return Err(anyhow::anyhow!(
184 "Default agent '{}' not found in agents map",
185 default_agent
186 ));
187 }
188 }
189
190 Ok(())
191 }
192
193 pub fn apply_env_overrides(&mut self) {
195 fn first(a: &str, b: &str) -> Option<String> {
197 std::env::var(a).ok().or_else(|| std::env::var(b).ok())
198 }
199
200 if let Some(provider) = first("AGENT_MODEL_PROVIDER", "SPEC_AI_PROVIDER") {
201 self.model.provider = provider;
202 }
203 if let Some(model_name) = first("AGENT_MODEL_NAME", "SPEC_AI_MODEL") {
204 self.model.model_name = Some(model_name);
205 }
206 if let Some(api_key_source) = first("AGENT_API_KEY_SOURCE", "SPEC_AI_API_KEY_SOURCE") {
207 self.model.api_key_source = Some(api_key_source);
208 }
209 if let Some(temp_str) = first("AGENT_MODEL_TEMPERATURE", "SPEC_AI_TEMPERATURE") {
210 if let Ok(temp) = temp_str.parse::<f32>() {
211 self.model.temperature = temp;
212 }
213 }
214 if let Some(level) = first("AGENT_LOG_LEVEL", "SPEC_AI_LOG_LEVEL") {
215 self.logging.level = level;
216 }
217 if let Some(db_path) = first("AGENT_DB_PATH", "SPEC_AI_DB_PATH") {
218 self.database.path = PathBuf::from(db_path);
219 }
220 if let Some(theme) = first("AGENT_UI_THEME", "SPEC_AI_UI_THEME") {
221 self.ui.theme = theme;
222 }
223 if let Some(default_agent) = first("AGENT_DEFAULT_AGENT", "SPEC_AI_DEFAULT_AGENT") {
224 self.default_agent = Some(default_agent);
225 }
226 }
227
228 pub fn summary(&self) -> String {
230 let mut summary = String::new();
231 summary.push_str("Configuration loaded:\n");
232 summary.push_str(&format!("Database: {}\n", self.database.path.display()));
233 summary.push_str(&format!("Model Provider: {}\n", self.model.provider));
234 if let Some(model) = &self.model.model_name {
235 summary.push_str(&format!("Model Name: {}\n", model));
236 }
237 summary.push_str(&format!("Temperature: {}\n", self.model.temperature));
238 summary.push_str(&format!("Logging Level: {}\n", self.logging.level));
239 summary.push_str(&format!("UI Theme: {}\n", self.ui.theme));
240 summary.push_str(&format!("Available Agents: {}\n", self.agents.len()));
241 if let Some(default) = &self.default_agent {
242 summary.push_str(&format!("Default Agent: {}\n", default));
243 }
244 summary
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct DatabaseConfig {
251 pub path: PathBuf,
253}
254
255impl Default for DatabaseConfig {
256 fn default() -> Self {
257 Self {
258 path: PathBuf::from("spec-ai.duckdb"),
259 }
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ModelConfig {
266 pub provider: String,
268 #[serde(default)]
270 pub model_name: Option<String>,
271 #[serde(default)]
273 pub embeddings_model: Option<String>,
274 #[serde(default)]
276 pub api_key_source: Option<String>,
277 #[serde(default = "default_temperature")]
279 pub temperature: f32,
280}
281
282fn default_temperature() -> f32 {
283 0.7
284}
285
286impl Default for ModelConfig {
287 fn default() -> Self {
288 Self {
289 provider: "mock".to_string(),
290 model_name: None,
291 embeddings_model: None,
292 api_key_source: None,
293 temperature: default_temperature(),
294 }
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct UiConfig {
301 pub prompt: String,
303 pub theme: String,
305}
306
307impl Default for UiConfig {
308 fn default() -> Self {
309 Self {
310 prompt: "> ".to_string(),
311 theme: "default".to_string(),
312 }
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct LoggingConfig {
319 pub level: String,
321}
322
323impl Default for LoggingConfig {
324 fn default() -> Self {
325 Self {
326 level: "info".to_string(),
327 }
328 }
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct MeshConfig {
334 #[serde(default)]
336 pub enabled: bool,
337 #[serde(default = "default_registry_port")]
339 pub registry_port: u16,
340 #[serde(default = "default_heartbeat_interval")]
342 pub heartbeat_interval_secs: u64,
343 #[serde(default = "default_leader_timeout")]
345 pub leader_timeout_secs: u64,
346 #[serde(default = "default_replication_factor")]
348 pub replication_factor: usize,
349 #[serde(default)]
351 pub auto_join: bool,
352}
353
354fn default_registry_port() -> u16 {
355 3000
356}
357
358fn default_heartbeat_interval() -> u64 {
359 5
360}
361
362fn default_leader_timeout() -> u64 {
363 15
364}
365
366fn default_replication_factor() -> usize {
367 2
368}
369
370impl Default for MeshConfig {
371 fn default() -> Self {
372 Self {
373 enabled: false,
374 registry_port: default_registry_port(),
375 heartbeat_interval_secs: default_heartbeat_interval(),
376 leader_timeout_secs: default_leader_timeout(),
377 replication_factor: default_replication_factor(),
378 auto_join: true,
379 }
380 }
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct AudioConfig {
386 #[serde(default)]
388 pub enabled: bool,
389 #[serde(default = "default_transcription_provider")]
391 pub provider: String,
392 #[serde(default)]
394 pub model: Option<String>,
395 #[serde(default)]
397 pub api_key_source: Option<String>,
398 #[serde(default)]
400 pub on_device: bool,
401 #[serde(default)]
403 pub endpoint: Option<String>,
404 #[serde(default = "default_chunk_duration")]
406 pub chunk_duration_secs: f64,
407 #[serde(default = "default_duration")]
409 pub default_duration_secs: u64,
410 #[serde(default = "default_duration")]
412 pub default_duration: u64,
413 #[serde(default)]
415 pub out_file: Option<String>,
416 #[serde(default)]
418 pub language: Option<String>,
419 #[serde(default)]
421 pub auto_respond: bool,
422 #[serde(default = "default_mock_scenario")]
424 pub mock_scenario: String,
425 #[serde(default = "default_event_delay_ms")]
427 pub event_delay_ms: u64,
428 #[serde(default)]
430 pub speak_responses: bool,
431}
432
433fn default_transcription_provider() -> String {
434 "vttrs".to_string()
435}
436
437fn default_chunk_duration() -> f64 {
438 5.0
439}
440
441fn default_duration() -> u64 {
442 30
443}
444
445fn default_mock_scenario() -> String {
446 "simple_conversation".to_string()
447}
448
449fn default_event_delay_ms() -> u64 {
450 500
451}
452
453impl Default for AudioConfig {
454 fn default() -> Self {
455 Self {
456 enabled: false,
457 provider: default_transcription_provider(),
458 model: Some("whisper-1".to_string()),
459 api_key_source: None,
460 on_device: false,
461 endpoint: None,
462 chunk_duration_secs: default_chunk_duration(),
463 default_duration_secs: default_duration(),
464 default_duration: default_duration(),
465 out_file: None,
466 language: None,
467 auto_respond: false,
468 mock_scenario: default_mock_scenario(),
469 event_delay_ms: default_event_delay_ms(),
470 speak_responses: false,
471 }
472 }
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct PluginConfig {
478 #[serde(default)]
480 pub enabled: bool,
481
482 #[serde(default = "default_plugins_dir")]
484 pub custom_tools_dir: PathBuf,
485
486 #[serde(default = "default_continue_on_error")]
488 pub continue_on_error: bool,
489
490 #[serde(default)]
492 pub allow_override_builtin: bool,
493}
494
495fn default_plugins_dir() -> PathBuf {
496 PathBuf::from("~/.spec-ai/tools")
497}
498
499fn default_continue_on_error() -> bool {
500 true
501}
502
503impl Default for PluginConfig {
504 fn default() -> Self {
505 Self {
506 enabled: false,
507 custom_tools_dir: default_plugins_dir(),
508 continue_on_error: true,
509 allow_override_builtin: false,
510 }
511 }
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct SyncConfig {
517 #[serde(default)]
519 pub enabled: bool,
520
521 #[serde(default = "default_sync_interval")]
523 pub interval_secs: u64,
524
525 #[serde(default = "default_max_concurrent_syncs")]
527 pub max_concurrent_syncs: usize,
528
529 #[serde(default = "default_retry_interval")]
531 pub retry_interval_secs: u64,
532
533 #[serde(default = "default_max_retries")]
535 pub max_retries: usize,
536
537 #[serde(default)]
539 pub namespaces: Vec<SyncNamespace>,
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct SyncNamespace {
545 pub session_id: String,
547 #[serde(default = "default_graph_name")]
549 pub graph_name: String,
550}
551
552fn default_sync_interval() -> u64 {
553 60
554}
555
556fn default_max_concurrent_syncs() -> usize {
557 3
558}
559
560fn default_retry_interval() -> u64 {
561 300
562}
563
564fn default_max_retries() -> usize {
565 3
566}
567
568fn default_graph_name() -> String {
569 "default".to_string()
570}
571
572impl Default for SyncConfig {
573 fn default() -> Self {
574 Self {
575 enabled: false,
576 interval_secs: default_sync_interval(),
577 max_concurrent_syncs: default_max_concurrent_syncs(),
578 retry_interval_secs: default_retry_interval(),
579 max_retries: default_max_retries(),
580 namespaces: Vec::new(),
581 }
582 }
583}