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
21// ---------------------------------------------------------------------------
22// LLM (generative) config — used by LlmDistiller
23// ---------------------------------------------------------------------------
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct LlmConfig {
27    /// "openai" | "anthropic"
28    pub provider: String,
29
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub base_url: Option<String>,
32
33    pub model_id: String,
34
35    /// API key (env var override: INNATE_LLM_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY)
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub api_key: Option<String>,
38}
39
40impl LlmConfig {
41    /// Resolved API key: settings file → env var fallback.
42    pub fn resolved_api_key(&self) -> Option<String> {
43        if let Some(ref k) = self.api_key {
44            if !k.is_empty() {
45                return Some(k.clone());
46            }
47        }
48        // Generic override
49        if let Ok(k) = std::env::var("INNATE_LLM_API_KEY") {
50            if !k.is_empty() {
51                return Some(k);
52            }
53        }
54        match self.provider.as_str() {
55            "anthropic" => std::env::var("ANTHROPIC_API_KEY").ok().filter(|k| !k.is_empty()),
56            _ => std::env::var("OPENAI_API_KEY").ok().filter(|k| !k.is_empty()),
57        }
58    }
59
60    pub fn resolved_base_url(&self) -> String {
61        if let Some(ref u) = self.base_url {
62            if !u.is_empty() {
63                return u.trim_end_matches('/').to_string();
64            }
65        }
66        match self.provider.as_str() {
67            "anthropic" => "https://api.anthropic.com".to_string(),
68            _ => "https://api.openai.com/v1".to_string(),
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// Embedding config — used by LlmEmbeddingProvider
75// ---------------------------------------------------------------------------
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct EmbeddingConfig {
79    /// Only "openai" format is supported (Anthropic has no embedding API).
80    #[serde(default = "default_openai")]
81    pub provider: String,
82
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub base_url: Option<String>,
85
86    pub model_id: String,
87
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub api_key: Option<String>,
90
91    /// Embedding output dimension (model-specific; defaults to 1536 for text-embedding-3-small).
92    #[serde(default = "default_embed_dim")]
93    pub dim: usize,
94}
95
96fn default_openai() -> String {
97    "openai".to_string()
98}
99
100fn default_embed_dim() -> usize {
101    1536
102}
103
104impl EmbeddingConfig {
105    pub fn resolved_api_key(&self) -> Option<String> {
106        if let Some(ref k) = self.api_key {
107            if !k.is_empty() {
108                return Some(k.clone());
109            }
110        }
111        if let Ok(k) = std::env::var("INNATE_LLM_API_KEY") {
112            if !k.is_empty() {
113                return Some(k);
114            }
115        }
116        std::env::var("OPENAI_API_KEY").ok().filter(|k| !k.is_empty())
117    }
118
119    pub fn resolved_base_url(&self) -> String {
120        self.base_url
121            .as_deref()
122            .filter(|u| !u.is_empty())
123            .map(|u| u.trim_end_matches('/').to_string())
124            .unwrap_or_else(|| "https://api.openai.com/v1".to_string())
125    }
126}
127
128// ---------------------------------------------------------------------------
129// Daemon config
130// ---------------------------------------------------------------------------
131
132#[derive(Debug, Default, Clone, Serialize, Deserialize)]
133pub struct DaemonConfig {
134    /// Directories the daemon watches for .log files.
135    #[serde(default)]
136    pub watch_dirs: Vec<String>,
137
138    /// Automatically spawn the daemon when the MCP server starts (default: true).
139    #[serde(default = "default_true")]
140    pub auto_start: bool,
141}
142
143fn default_true() -> bool {
144    true
145}
146
147// ---------------------------------------------------------------------------
148// Load / save
149// ---------------------------------------------------------------------------
150
151/// Returns `~/.innate/settings.json`.
152pub fn settings_path() -> PathBuf {
153    dirs_next::home_dir()
154        .unwrap_or_else(|| PathBuf::from("."))
155        .join(".innate")
156        .join("settings.json")
157}
158
159/// Load settings from `~/.innate/settings.json`. Returns `Settings::default()` if absent.
160pub fn load() -> Settings {
161    let path = settings_path();
162    load_from(&path)
163}
164
165pub fn load_from(path: &Path) -> Settings {
166    let Ok(text) = std::fs::read_to_string(path) else {
167        return Settings::default();
168    };
169    serde_json::from_str(&text).unwrap_or_default()
170}
171
172/// Write settings to `~/.innate/settings.json` with mode 0600.
173pub fn save(settings: &Settings) -> anyhow::Result<()> {
174    let path = settings_path();
175    save_to(settings, &path)
176}
177
178pub fn save_to(settings: &Settings, path: &Path) -> anyhow::Result<()> {
179    if let Some(parent) = path.parent() {
180        std::fs::create_dir_all(parent)?;
181    }
182    let json = serde_json::to_string_pretty(settings)?;
183    std::fs::write(path, &json)?;
184    // 0600 on Unix
185    #[cfg(unix)]
186    {
187        use std::os::unix::fs::PermissionsExt;
188        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
189    }
190    Ok(())
191}
192
193/// Expand `~` at the start of a path string to the home directory.
194pub fn expand_tilde(path: &str) -> String {
195    if path.starts_with("~/") || path == "~" {
196        let home = dirs_next::home_dir()
197            .map(|h| h.display().to_string())
198            .unwrap_or_default();
199        path.replacen('~', &home, 1)
200    } else {
201        path.to_string()
202    }
203}
204
205/// Return expanded watch directories from daemon config.
206pub fn resolved_watch_dirs(settings: &Settings) -> Vec<String> {
207    settings
208        .daemon
209        .as_ref()
210        .map(|d| d.watch_dirs.iter().map(|p| expand_tilde(p)).collect())
211        .unwrap_or_default()
212}