Skip to main content

brainos_core/config/
loader.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4};
5
6use figment::{
7    providers::{Env, Format, Yaml},
8    Figment,
9};
10
11use super::*;
12
13impl BrainConfig {
14    /// Load configuration from all sources.
15    ///
16    /// Priority (highest wins):
17    /// 1. Environment variables (`BRAIN_LLM__MODEL=...`)
18    /// 2. User config (`~/.brain/config.yaml`)
19    /// 3. Embedded defaults (compiled into binary)
20    #[allow(clippy::result_large_err)]
21    pub fn load() -> Result<Self, figment::Error> {
22        Self::load_from(None)
23    }
24
25    /// Load configuration with an optional explicit config path.
26    #[allow(clippy::result_large_err)]
27    pub fn load_from(config_path: Option<&Path>) -> Result<Self, figment::Error> {
28        // Layer 1: Embedded defaults (always available, no file needed)
29        let mut figment = Figment::new().merge(Yaml::string(super::DEFAULT_CONFIG));
30
31        // Layer 2: User config (~/.brain/config.yaml)
32        let user_config = Self::user_config_path();
33        if user_config.exists() {
34            figment = figment.merge(Yaml::file(&user_config));
35        }
36
37        // Layer 3: Explicit config path (if provided)
38        if let Some(path) = config_path {
39            figment = figment.merge(Yaml::file(path));
40        }
41
42        // Layer 4: Environment variables (BRAIN_LLM__MODEL=...)
43        figment = figment.merge(Env::prefixed("BRAIN_").split("__"));
44
45        let mut cfg: Self = figment.extract()?;
46
47        // Post-load: if the user set legacy llm.{base_url,model,api_key} via
48        // env vars but `providers[]` is non-empty, the multi-provider path
49        // would silently ignore the env override. Forward those overrides
50        // onto providers[0] so `BRAIN_LLM__BASE_URL=...` does what users
51        // expect regardless of how the YAML is structured.
52        if !cfg.llm.providers.is_empty() {
53            if std::env::var("BRAIN_LLM__BASE_URL").is_ok() {
54                cfg.llm.providers[0].base_url = cfg.llm.base_url.clone();
55            }
56            if std::env::var("BRAIN_LLM__MODEL").is_ok() {
57                cfg.llm.providers[0].model = cfg.llm.model.clone();
58            }
59            if std::env::var("BRAIN_LLM__API_KEY").is_ok() {
60                cfg.llm.providers[0].api_key = cfg.llm.api_key.clone();
61            }
62        }
63
64        Ok(cfg)
65    }
66
67    /// Resolve the data directory path, expanding `~` to the home directory.
68    pub fn data_dir(&self) -> PathBuf {
69        expand_tilde(&self.brain.data_dir)
70    }
71
72    /// Ensure the data directory and subdirectories exist.
73    pub fn ensure_data_dirs(&self) -> std::io::Result<()> {
74        let data_dir = self.data_dir();
75        let dirs = [
76            data_dir.clone(),
77            data_dir.join("db"),
78            data_dir.join("ruvector"),
79            data_dir.join("models"),
80            data_dir.join("logs"),
81            data_dir.join("exports"),
82        ];
83
84        for dir in &dirs {
85            std::fs::create_dir_all(dir)?;
86        }
87
88        Ok(())
89    }
90
91    /// Path to the SQLite database file.
92    pub fn sqlite_path(&self) -> PathBuf {
93        self.data_dir().join("db").join("brain.db")
94    }
95
96    /// Path to the RuVector directory.
97    pub fn ruvector_path(&self) -> PathBuf {
98        self.data_dir().join("ruvector")
99    }
100
101    /// Path to the models directory.
102    pub fn models_path(&self) -> PathBuf {
103        self.data_dir().join("models")
104    }
105
106    /// Check whether Brain has been initialized (data dir exists).
107    pub fn is_initialized() -> bool {
108        expand_tilde("~/.brain").exists()
109    }
110
111    /// Write the default config to `~/.brain/config.yaml`.
112    ///
113    /// Returns `(config_path, generated_api_key)`, or `None` if the file already
114    /// exists and `force` is false.
115    pub fn write_default_config(force: bool) -> std::io::Result<Option<(PathBuf, String)>> {
116        let config_path = Self::user_config_path();
117
118        if config_path.exists() && !force {
119            return Ok(None);
120        }
121
122        if let Some(parent) = config_path.parent() {
123            std::fs::create_dir_all(parent)?;
124        }
125
126        let api_key = Self::generate_api_key();
127        let config = super::DEFAULT_CONFIG.replace(
128            "api_keys: []",
129            &format!(
130                "api_keys:\n    - key: \"{}\"\n      name: \"Default Key\"\n      permissions: [read, write]",
131                api_key
132            ),
133        );
134
135        std::fs::write(&config_path, config)?;
136        Ok(Some((config_path, api_key)))
137    }
138
139    /// Generate a random 36-char API key with `brk_` prefix.
140    fn generate_api_key() -> String {
141        let mut buf = [0u8; 16];
142        getrandom::getrandom(&mut buf).expect("failed to obtain random bytes from OS");
143        let hex: String = buf.iter().map(|b| format!("{:02x}", b)).collect();
144        format!("brk_{}", hex)
145    }
146
147    /// Path to user config file.
148    ///
149    /// `BRAIN_CONFIG` env var overrides the default `~/.brain/config.yaml`,
150    /// useful for sandboxes, CI, and multi-config workflows.
151    pub fn user_config_path() -> PathBuf {
152        if let Ok(p) = std::env::var("BRAIN_CONFIG") {
153            if !p.trim().is_empty() {
154                return PathBuf::from(p);
155            }
156        }
157        expand_tilde("~/.brain/config.yaml")
158    }
159
160    /// Get the embedded default config content.
161    pub fn default_config_content() -> &'static str {
162        super::DEFAULT_CONFIG
163    }
164
165    /// Validate configuration and return a list of warnings.
166    pub fn validate(&self) -> Result<Vec<String>, String> {
167        let mut warnings: Vec<String> = Vec::new();
168
169        let mut ports: HashMap<u16, &str> = HashMap::new();
170        let adapter_ports = [
171            (self.adapters.http.port, "http"),
172            (self.adapters.ws.port, "ws"),
173            (self.adapters.mcp.port, "mcp"),
174            (self.adapters.grpc.port, "grpc"),
175        ];
176        for (port, name) in &adapter_ports {
177            if let Some(existing) = ports.insert(*port, name) {
178                return Err(format!(
179                    "Port conflict: adapters '{}' and '{}' both use port {}",
180                    existing, name, port
181                ));
182            }
183        }
184
185        let url = &self.llm.base_url;
186        if !url.starts_with("http://") && !url.starts_with("https://") {
187            return Err(format!(
188                "Invalid LLM base_url '{}': must start with http:// or https://",
189                url
190            ));
191        }
192
193        let data_dir = self.data_dir();
194        if data_dir.exists() {
195            let probe = data_dir.join(".brain_write_probe");
196            if std::fs::write(&probe, b"").is_err() {
197                return Err(format!(
198                    "Data directory '{}' is not writable",
199                    data_dir.display()
200                ));
201            }
202            let _ = std::fs::remove_file(&probe);
203        }
204
205        if self.access.api_keys.is_empty() {
206            warnings.push("No API keys configured — all adapters will reject authenticated requests. Run `brain init` or add a key under 'access.api_keys'.".to_string());
207        }
208
209        if self.llm.temperature > 1.5 {
210            warnings.push(format!(
211                "LLM temperature {:.1} is very high — responses may be unpredictable.",
212                self.llm.temperature
213            ));
214        }
215
216        if self.memory.consolidation.enabled && self.memory.consolidation.interval_hours == 0 {
217            warnings.push("Consolidation interval_hours is 0 — consolidation will run immediately on every daemon wake-up, which may impact performance.".to_string());
218        }
219
220        if self.actions.web_search.enabled {
221            match self.actions.web_search.provider {
222                WebSearchProvider::Custom if self.actions.web_search.endpoint.trim().is_empty() => {
223                    warnings.push("Actions web_search provider is 'custom' but endpoint is empty; dispatches will fail with backend-not-configured.".to_string());
224                }
225                WebSearchProvider::Tavily if self.actions.web_search.api_key.trim().is_empty() => {
226                    warnings.push("Actions web_search provider is 'tavily' but api_key is empty; dispatches will fail.".to_string());
227                }
228                _ => {}
229            }
230        }
231
232        if self.actions.messaging.enabled {
233            if self.actions.messaging.channels.is_empty() {
234                if self.channel.transports.is_empty() && self.channel.relays.is_empty() {
235                    warnings.push("Actions messaging is enabled but neither actions.messaging.channels, channel.transports, nor channel.relays are configured; dispatches will fail.".to_string());
236                }
237            } else {
238                for (name, channel_cfg) in &self.actions.messaging.channels {
239                    if channel_cfg.url.trim().is_empty() {
240                        warnings.push(format!(
241                            "actions.messaging.channels.{name}: url is empty; dispatches to this channel will fail."
242                        ));
243                    }
244                }
245            }
246        }
247
248        for (name, ms) in [
249            ("web_search.timeout_ms", self.actions.web_search.timeout_ms),
250            ("messaging.timeout_ms", self.actions.messaging.timeout_ms),
251        ] {
252            if ms == 0 {
253                warnings.push(format!(
254                    "actions.{name} is 0; will be clamped to 1ms at runtime."
255                ));
256            } else if ms > 30_000 {
257                warnings.push(format!(
258                    "actions.{name} is {}ms (>30s) — requests may block for a long time.",
259                    ms
260                ));
261            }
262        }
263
264        let res = &self.actions.resilience;
265        if res.max_retries > 10 {
266            warnings.push(format!("actions.resilience.max_retries is {} (>10) — excessive retries may amplify failures.", res.max_retries));
267        }
268        if res.circuit_breaker_threshold == 0 {
269            warnings.push("actions.resilience.circuit_breaker_threshold is 0; circuit breaker will never trip.".to_string());
270        }
271
272        Ok(warnings)
273    }
274}
275
276impl Default for BrainConfig {
277    fn default() -> Self {
278        Self {
279            brain: GeneralConfig {
280                version: env!("CARGO_PKG_VERSION").to_string(),
281                data_dir: "~/.brain".to_string(),
282            },
283            storage: StorageConfig {
284                ruvector_path: "~/.brain/ruvector/".to_string(),
285                sqlite_path: "~/.brain/db/brain.db".to_string(),
286                hnsw: HnswConfig {
287                    ef_construction: 200,
288                    m: 16,
289                    ef_search: 50,
290                },
291            },
292            llm: LlmConfig {
293                provider: "ollama".to_string(),
294                model: "qwen2.5-coder:7b".to_string(),
295                base_url: "http://localhost:11434".to_string(),
296                temperature: 0.7,
297                max_tokens: 4096,
298                api_key: String::new(),
299                providers: Vec::new(),
300            },
301            embedding: EmbeddingConfig {
302                model: "nomic-embed-text".to_string(),
303                dimensions: 768,
304            },
305            memory: MemoryConfig {
306                episodic: EpisodicConfig {},
307                semantic: SemanticConfig {
308                    similarity_threshold: 0.65,
309                    max_results: 20,
310                },
311                search: SearchConfig {
312                    rrf_k: 60,
313                    pre_fusion_limit: 50,
314                    importance_weight: 0.3,
315                    recency_weight: 0.2,
316                    decay_rate: 0.01,
317                },
318                consolidation: ConsolidationConfig {
319                    enabled: true,
320                    interval_hours: 24,
321                    forgetting_threshold: 0.05,
322                },
323            },
324            encryption: EncryptionConfig { enabled: false },
325            security: SecurityConfig {
326                exec_allowlist: vec![
327                    // Read-only inspection
328                    "ls".into(),
329                    "cat".into(),
330                    "head".into(),
331                    "tail".into(),
332                    "wc".into(),
333                    "file".into(),
334                    "stat".into(),
335                    // Text processing
336                    "grep".into(),
337                    "find".into(),
338                    "sort".into(),
339                    "uniq".into(),
340                    "cut".into(),
341                    "awk".into(),
342                    "sed".into(),
343                    // Shell discovery / pathing
344                    "which".into(),
345                    "command".into(),
346                    "type".into(),
347                    "test".into(),
348                    "basename".into(),
349                    "dirname".into(),
350                    "realpath".into(),
351                    // Output
352                    "echo".into(),
353                    "printf".into(),
354                    "true".into(),
355                    "false".into(),
356                    // Toolchain
357                    "git".into(),
358                    "cargo".into(),
359                    "rustc".into(),
360                    "rustup".into(),
361                    // Shell wrapper for the shell-mode execution tier
362                    // (see SandboxCommand::shell). The per-binary
363                    // allowlist is bypassed for commands wrapped by
364                    // `sh -c` — rlimits + Seatbelt + timeout +
365                    // forbidden_commands still apply.
366                    "sh".into(),
367                ],
368                exec_timeout_seconds: 30,
369            },
370            actions: ActionsConfig {
371                web_search: WebSearchActionConfig {
372                    // On by default. DuckDuckGo HTML scraping is the
373                    // zero-config built-in so first-run has working web
374                    // search without Docker or an API key.
375                    enabled: true,
376                    provider: WebSearchProvider::DuckDuckGo,
377                    endpoint: "http://localhost:8888".to_string(),
378                    api_key: String::new(),
379                    timeout_ms: 3_000,
380                    default_top_k: 5,
381                },
382                scheduling: SchedulingActionConfig {
383                    enabled: false,
384                    mode: SchedulingMode::PersistOnly,
385                },
386                messaging: MessagingActionConfig {
387                    enabled: false,
388                    timeout_ms: 3_000,
389                    channels: HashMap::new(),
390                },
391                resilience: ResilienceConfig::default(),
392            },
393            proactivity: ProactivityConfig {
394                enabled: false,
395                max_per_day: 5,
396                min_interval_minutes: 60,
397                quiet_hours: QuietHoursConfig {
398                    start: "22:00".to_string(),
399                    end: "08:00".to_string(),
400                    timezone: "UTC".to_string(),
401                },
402                delivery: DeliveryConfig::default(),
403                open_loop: OpenLoopDetectionConfig::default(),
404            },
405            adapters: AdaptersConfig {
406                http: HttpAdapterConfig {
407                    enabled: true,
408                    host: "127.0.0.1".to_string(),
409                    port: 19789,
410                    cors: true,
411                },
412                ws: WebSocketAdapterConfig {
413                    enabled: true,
414                    port: 19790,
415                },
416                mcp: McpAdapterConfig {
417                    enabled: true,
418                    stdio: true,
419                    http: true,
420                    port: 19791,
421                },
422                grpc: GrpcAdapterConfig {
423                    enabled: true,
424                    port: 19792,
425                },
426            },
427            access: AccessConfig {
428                api_keys: vec![ApiKeyConfig {
429                    key: Self::generate_api_key(),
430                    name: "Default Key".to_string(),
431                    permissions: vec!["read".to_string(), "write".to_string()],
432                }],
433            },
434            channel: ChannelIntelligenceConfig::default(),
435            agents: AgentsConfig::default(),
436        }
437    }
438}
439
440pub(crate) fn expand_tilde(path: &str) -> PathBuf {
441    if let Some(rest) = path.strip_prefix("~/") {
442        if let Some(home) = dirs_home() {
443            return home.join(rest);
444        }
445    }
446    PathBuf::from(path)
447}
448
449fn dirs_home() -> Option<PathBuf> {
450    std::env::var_os("HOME").map(PathBuf::from)
451}