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