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
21#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct LlmConfig {
27 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 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub api_key: Option<String>,
38}
39
40impl LlmConfig {
41 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct EmbeddingConfig {
79 #[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 #[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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
133pub struct DaemonConfig {
134 #[serde(default)]
136 pub watch_dirs: Vec<String>,
137
138 #[serde(default = "default_true")]
140 pub auto_start: bool,
141}
142
143fn default_true() -> bool {
144 true
145}
146
147pub fn settings_path() -> PathBuf {
153 dirs_next::home_dir()
154 .unwrap_or_else(|| PathBuf::from("."))
155 .join(".innate")
156 .join("settings.json")
157}
158
159pub 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
172pub 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 #[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
193pub 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
205pub 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}