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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Settings {
17 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct LlmConfig {
52 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 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub api_key: Option<String>,
63}
64
65impl LlmConfig {
66 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct EmbeddingConfig {
108 #[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 #[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
164pub struct DaemonConfig {
165 #[serde(default)]
167 pub watch_dirs: Vec<String>,
168
169 #[serde(default = "default_true")]
171 pub auto_start: bool,
172}
173
174fn default_true() -> bool {
175 true
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct BackupConfig {
184 #[serde(default)]
186 pub enable: bool,
187
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub r2: Option<R2Config>,
190
191 #[serde(default = "default_backup_interval_hours")]
193 pub auto_backup_interval_hours: u64,
194
195 #[serde(default = "default_retention_days")]
197 pub retention_days: u64,
198
199 #[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 pub account_id: String,
220
221 pub bucket: String,
223
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub access_key_id: Option<String>,
227
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub secret_access_key: Option<String>,
231
232 #[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
273pub fn settings_path() -> PathBuf {
279 crate::paths::settings_path()
280}
281
282pub 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
295pub 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 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 #[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
319pub 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
331pub 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}