1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5#[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#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct LlmConfig {
30 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 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub api_key: Option<String>,
41}
42
43impl LlmConfig {
44 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct EmbeddingConfig {
82 #[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 #[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
136pub struct DaemonConfig {
137 #[serde(default)]
139 pub watch_dirs: Vec<String>,
140
141 #[serde(default = "default_true")]
143 pub auto_start: bool,
144}
145
146fn default_true() -> bool {
147 true
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct BackupConfig {
156 #[serde(default)]
158 pub enable: bool,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub r2: Option<R2Config>,
162
163 #[serde(default = "default_backup_interval_hours")]
165 pub auto_backup_interval_hours: u64,
166
167 #[serde(default = "default_retention_days")]
169 pub retention_days: u64,
170
171 #[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 pub account_id: String,
192
193 pub bucket: String,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub access_key_id: Option<String>,
199
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub secret_access_key: Option<String>,
203
204 #[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
241pub fn settings_path() -> PathBuf {
247 dirs_next::home_dir()
248 .unwrap_or_else(|| PathBuf::from("."))
249 .join(".innate")
250 .join("settings.json")
251}
252
253pub 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
266pub 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 #[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
287pub 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
299pub 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}