Skip to main content

innate_core/
settings.rs

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