Skip to main content

innate_core/
settings.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5pub const SCHEMA_JSONC: &str = include_str!("settings.schema.jsonc");
6
7fn default_schema_path() -> String {
8    "https://raw.githubusercontent.com/vima-tech/Innate/main/settings.schema.jsonc".to_string()
9}
10
11// ---------------------------------------------------------------------------
12// Top-level Settings
13// ---------------------------------------------------------------------------
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Settings {
17    /// JSON Schema reference — always present; written alongside settings.json.
18    #[serde(rename = "$schema", default = "default_schema_path")]
19    pub schema: String,
20
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub llm: Option<LlmConfig>,
23
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub embedding: Option<EmbeddingConfig>,
26
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub daemon: Option<DaemonConfig>,
29
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub backup: Option<BackupConfig>,
32}
33
34impl Default for Settings {
35    fn default() -> Self {
36        Self {
37            schema: default_schema_path(),
38            llm: None,
39            embedding: None,
40            daemon: None,
41            backup: None,
42        }
43    }
44}
45
46// ---------------------------------------------------------------------------
47// LLM (generative) config — used by LlmDistiller
48// ---------------------------------------------------------------------------
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct LlmConfig {
52    /// "openai" | "anthropic"
53    pub provider: String,
54
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub base_url: Option<String>,
57
58    pub model_id: String,
59
60    /// API key (env var override: INNATE_LLM_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY)
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub api_key: Option<String>,
63}
64
65impl LlmConfig {
66    /// Resolved API key: settings file → env var fallback.
67    pub fn resolved_api_key(&self) -> Option<String> {
68        if let Some(ref k) = self.api_key {
69            if !k.is_empty() {
70                return Some(k.clone());
71            }
72        }
73        // Generic override
74        if let Ok(k) = std::env::var("INNATE_LLM_API_KEY") {
75            if !k.is_empty() {
76                return Some(k);
77            }
78        }
79        match self.provider.as_str() {
80            "anthropic" => std::env::var("ANTHROPIC_API_KEY")
81                .ok()
82                .filter(|k| !k.is_empty()),
83            _ => std::env::var("OPENAI_API_KEY")
84                .ok()
85                .filter(|k| !k.is_empty()),
86        }
87    }
88
89    pub fn resolved_base_url(&self) -> String {
90        if let Some(ref u) = self.base_url {
91            if !u.is_empty() {
92                return u.trim_end_matches('/').to_string();
93            }
94        }
95        match self.provider.as_str() {
96            "anthropic" => "https://api.anthropic.com".to_string(),
97            _ => "https://api.openai.com/v1".to_string(),
98        }
99    }
100}
101
102// ---------------------------------------------------------------------------
103// Embedding config — used by LlmEmbeddingProvider
104// ---------------------------------------------------------------------------
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct EmbeddingConfig {
108    /// Only "openai" format is supported (Anthropic has no embedding API).
109    #[serde(default = "default_openai")]
110    pub provider: String,
111
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub base_url: Option<String>,
114
115    pub model_id: String,
116
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub api_key: Option<String>,
119
120    /// Embedding output dimension (model-specific; defaults to 1536 for text-embedding-3-small).
121    #[serde(default = "default_embed_dim")]
122    pub dim: usize,
123}
124
125fn default_openai() -> String {
126    "openai".to_string()
127}
128
129fn default_embed_dim() -> usize {
130    1536
131}
132
133impl EmbeddingConfig {
134    pub fn resolved_api_key(&self) -> Option<String> {
135        if let Some(ref k) = self.api_key {
136            if !k.is_empty() {
137                return Some(k.clone());
138            }
139        }
140        if let Ok(k) = std::env::var("INNATE_LLM_API_KEY") {
141            if !k.is_empty() {
142                return Some(k);
143            }
144        }
145        std::env::var("OPENAI_API_KEY")
146            .ok()
147            .filter(|k| !k.is_empty())
148    }
149
150    pub fn resolved_base_url(&self) -> String {
151        self.base_url
152            .as_deref()
153            .filter(|u| !u.is_empty())
154            .map(|u| u.trim_end_matches('/').to_string())
155            .unwrap_or_else(|| "https://api.openai.com/v1".to_string())
156    }
157}
158
159// ---------------------------------------------------------------------------
160// Daemon config
161// ---------------------------------------------------------------------------
162
163#[derive(Debug, Default, Clone, Serialize, Deserialize)]
164pub struct DaemonConfig {
165    /// Directories the daemon watches for .log files.
166    #[serde(default)]
167    pub watch_dirs: Vec<String>,
168
169    /// Automatically spawn the daemon when the MCP server starts (default: true).
170    #[serde(default = "default_true")]
171    pub auto_start: bool,
172}
173
174fn default_true() -> bool {
175    true
176}
177
178// ---------------------------------------------------------------------------
179// Backup config
180// ---------------------------------------------------------------------------
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct BackupConfig {
184    /// Master switch — backup is disabled by default. Set to true to enable.
185    #[serde(default)]
186    pub enable: bool,
187
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub r2: Option<R2Config>,
190
191    /// Auto-backup interval in hours (default: 24).
192    #[serde(default = "default_backup_interval_hours")]
193    pub auto_backup_interval_hours: u64,
194
195    /// Delete backups older than this many days (default: 60).
196    #[serde(default = "default_retention_days")]
197    pub retention_days: u64,
198
199    /// Always keep at least this many backup files regardless of age (default: 5).
200    #[serde(default = "default_min_backups")]
201    pub min_backups: usize,
202}
203
204impl Default for BackupConfig {
205    fn default() -> Self {
206        Self {
207            enable: false,
208            r2: None,
209            auto_backup_interval_hours: default_backup_interval_hours(),
210            retention_days: default_retention_days(),
211            min_backups: default_min_backups(),
212        }
213    }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct R2Config {
218    /// Cloudflare account ID (found in the R2 dashboard URL).
219    pub account_id: String,
220
221    /// R2 bucket name.
222    pub bucket: String,
223
224    /// R2 API token access key ID. Env override: INNATE_R2_ACCESS_KEY_ID.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub access_key_id: Option<String>,
227
228    /// R2 API token secret access key. Env override: INNATE_R2_SECRET_ACCESS_KEY.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub secret_access_key: Option<String>,
231
232    /// Optional key prefix (e.g. "innate/"). Default: "".
233    #[serde(default)]
234    pub prefix: String,
235}
236
237impl R2Config {
238    pub fn resolved_access_key_id(&self) -> Option<String> {
239        if let Some(ref k) = self.access_key_id {
240            if !k.is_empty() {
241                return Some(k.clone());
242            }
243        }
244        std::env::var("INNATE_R2_ACCESS_KEY_ID")
245            .ok()
246            .filter(|k| !k.is_empty())
247    }
248
249    pub fn resolved_secret_access_key(&self) -> Option<String> {
250        if let Some(ref k) = self.secret_access_key {
251            if !k.is_empty() {
252                return Some(k.clone());
253            }
254        }
255        std::env::var("INNATE_R2_SECRET_ACCESS_KEY")
256            .ok()
257            .filter(|k| !k.is_empty())
258    }
259}
260
261fn default_backup_interval_hours() -> u64 {
262    24
263}
264
265fn default_retention_days() -> u64 {
266    60
267}
268
269fn default_min_backups() -> usize {
270    5
271}
272
273// ---------------------------------------------------------------------------
274// Load / save
275// ---------------------------------------------------------------------------
276
277/// Returns `~/.innate/settings.json`.
278pub fn settings_path() -> PathBuf {
279    crate::paths::settings_path()
280}
281
282/// Load settings from `~/.innate/settings.json`. Returns `Settings::default()` if absent.
283pub fn load() -> Settings {
284    let path = settings_path();
285    load_from(&path)
286}
287
288pub fn load_from(path: &Path) -> Settings {
289    let Ok(text) = std::fs::read_to_string(path) else {
290        return Settings::default();
291    };
292    serde_json::from_str(&text).unwrap_or_default()
293}
294
295/// Write settings to `~/.innate/settings.json` with mode 0600.
296pub fn save(settings: &Settings) -> anyhow::Result<()> {
297    let path = settings_path();
298    save_to(settings, &path)
299}
300
301pub fn save_to(settings: &Settings, path: &Path) -> anyhow::Result<()> {
302    if let Some(parent) = path.parent() {
303        std::fs::create_dir_all(parent)?;
304        // Write the schema file alongside settings.json so $schema resolves locally.
305        let schema_path = parent.join("settings.schema.jsonc");
306        let _ = std::fs::write(&schema_path, SCHEMA_JSONC);
307    }
308    let json = serde_json::to_string_pretty(settings)?;
309    std::fs::write(path, &json)?;
310    // 0600 on Unix
311    #[cfg(unix)]
312    {
313        use std::os::unix::fs::PermissionsExt;
314        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
315    }
316    Ok(())
317}
318
319/// Expand `~` at the start of a path string to the home directory.
320pub fn expand_tilde(path: &str) -> String {
321    if path.starts_with("~/") || path == "~" {
322        let home = dirs_next::home_dir()
323            .map(|h| h.display().to_string())
324            .unwrap_or_default();
325        path.replacen('~', &home, 1)
326    } else {
327        path.to_string()
328    }
329}
330
331/// Return expanded watch directories from daemon config.
332pub fn resolved_watch_dirs(settings: &Settings) -> Vec<String> {
333    settings
334        .daemon
335        .as_ref()
336        .map(|d| d.watch_dirs.iter().map(|p| expand_tilde(p)).collect())
337        .unwrap_or_default()
338}