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").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#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct EmbeddingConfig {
104 #[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 #[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
158pub struct DaemonConfig {
159 #[serde(default)]
161 pub watch_dirs: Vec<String>,
162
163 #[serde(default = "default_true")]
165 pub auto_start: bool,
166}
167
168fn default_true() -> bool {
169 true
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct BackupConfig {
178 #[serde(default)]
180 pub enable: bool,
181
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub r2: Option<R2Config>,
184
185 #[serde(default = "default_backup_interval_hours")]
187 pub auto_backup_interval_hours: u64,
188
189 #[serde(default = "default_retention_days")]
191 pub retention_days: u64,
192
193 #[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 pub account_id: String,
214
215 pub bucket: String,
217
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub access_key_id: Option<String>,
221
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub secret_access_key: Option<String>,
225
226 #[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
263pub fn settings_path() -> PathBuf {
269 dirs_next::home_dir()
270 .unwrap_or_else(|| PathBuf::from("."))
271 .join(".innate")
272 .join("settings.json")
273}
274
275pub 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
288pub 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 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 #[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
312pub 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
324pub 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}