1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use thiserror::Error;
5
6#[must_use]
13pub fn ixchel_home() -> PathBuf {
14 if let Ok(home) = std::env::var("IXCHEL_HOME") {
15 return PathBuf::from(home);
16 }
17 if let Ok(home) = std::env::var("HELIX_HOME") {
18 return PathBuf::from(home);
19 }
20 dirs::home_dir()
21 .unwrap_or_else(|| PathBuf::from("."))
22 .join(".ixchel")
23}
24
25#[must_use]
29pub fn ixchel_config_dir() -> PathBuf {
30 ixchel_home().join("config")
31}
32
33#[must_use]
37pub fn ixchel_data_dir() -> PathBuf {
38 ixchel_home().join("data")
39}
40
41#[must_use]
45pub fn ixchel_state_dir() -> PathBuf {
46 ixchel_home().join("state")
47}
48
49#[must_use]
53pub fn ixchel_log_dir() -> PathBuf {
54 ixchel_home().join("log")
55}
56
57#[derive(Debug, Clone, Default, Deserialize, Serialize)]
61pub struct IxchelConfig {
62 #[serde(default)]
63 pub github: GitHubConfig,
64 #[serde(default)]
65 pub embedding: EmbeddingConfig,
66 #[serde(default)]
67 pub storage: StorageConfig,
68}
69
70pub type SharedConfig = IxchelConfig;
71
72#[derive(Debug, Clone, Default, Deserialize, Serialize)]
74pub struct GitHubConfig {
75 pub token: Option<String>,
77}
78
79#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct EmbeddingConfig {
82 #[serde(default = "default_embedding_provider")]
84 pub provider: String,
85 #[serde(default = "default_embedding_model")]
87 pub model: String,
88 #[serde(default = "default_batch_size")]
90 pub batch_size: usize,
91 #[serde(default)]
93 pub dimension: Option<usize>,
94}
95
96impl Default for EmbeddingConfig {
97 fn default() -> Self {
98 Self {
99 provider: default_embedding_provider(),
100 model: default_embedding_model(),
101 batch_size: default_batch_size(),
102 dimension: None,
103 }
104 }
105}
106
107fn default_embedding_provider() -> String {
108 "fastembed".to_string()
109}
110
111fn default_embedding_model() -> String {
112 "BAAI/bge-small-en-v1.5".to_string()
113}
114
115const fn default_batch_size() -> usize {
116 32
117}
118
119#[derive(Debug, Clone, Deserialize, Serialize)]
121pub struct StorageConfig {
122 #[serde(default = "default_storage_backend")]
124 pub backend: String,
125
126 #[serde(default = "default_storage_path")]
128 pub path: String,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub engine: Option<String>,
135}
136
137impl Default for StorageConfig {
138 fn default() -> Self {
139 Self {
140 backend: default_storage_backend(),
141 path: default_storage_path(),
142 engine: None,
143 }
144 }
145}
146
147fn default_storage_backend() -> String {
148 "surrealdb".to_string()
149}
150
151fn default_storage_path() -> String {
152 "data/ixchel".to_string()
153}
154
155impl IxchelConfig {
156 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
157 let raw = toml::to_string_pretty(self).map_err(|source| ConfigError::SerializeError {
158 path: path.to_path_buf(),
159 source,
160 })?;
161 std::fs::write(path, raw).map_err(|source| ConfigError::WriteError {
162 path: path.to_path_buf(),
163 source,
164 })?;
165 Ok(())
166 }
167}
168
169pub fn load_shared_config() -> Result<SharedConfig, ConfigError> {
174 ConfigLoader::new("").load()
175}
176
177#[derive(Debug, Error)]
178pub enum ConfigError {
179 #[error("Failed to read config file {}: {source}", path.display())]
180 ReadError {
181 path: PathBuf,
182 #[source]
183 source: std::io::Error,
184 },
185
186 #[error("Failed to parse config file {}: {source}", path.display())]
187 ParseError {
188 path: PathBuf,
189 #[source]
190 source: toml::de::Error,
191 },
192
193 #[error("Failed to write config file {}: {source}", path.display())]
194 WriteError {
195 path: PathBuf,
196 #[source]
197 source: std::io::Error,
198 },
199
200 #[error("Failed to serialize config file {}: {source}", path.display())]
201 SerializeError {
202 path: PathBuf,
203 #[source]
204 source: toml::ser::Error,
205 },
206}
207
208pub fn load_config<T: DeserializeOwned + Default>(tool_name: &str) -> Result<T, ConfigError> {
209 ConfigLoader::new(tool_name).load()
210}
211
212pub struct ConfigLoader {
213 tool_name: String,
214 env_prefix: Option<String>,
215 project_dir: Option<PathBuf>,
216 global_dir: Option<PathBuf>,
217}
218
219impl ConfigLoader {
220 pub fn new(tool_name: impl Into<String>) -> Self {
221 Self {
222 tool_name: tool_name.into(),
223 env_prefix: None,
224 project_dir: None,
225 global_dir: None,
226 }
227 }
228
229 #[must_use]
230 pub fn with_env_prefix(mut self, prefix: impl Into<String>) -> Self {
231 self.env_prefix = Some(prefix.into());
232 self
233 }
234
235 #[must_use]
236 pub fn with_project_dir(mut self, path: impl Into<PathBuf>) -> Self {
237 self.project_dir = Some(path.into());
238 self
239 }
240
241 #[must_use]
242 pub fn with_global_dir(mut self, path: impl Into<PathBuf>) -> Self {
243 self.global_dir = Some(path.into());
244 self
245 }
246
247 pub fn load<T: DeserializeOwned + Default>(self) -> Result<T, ConfigError> {
248 let mut merged = toml::Table::new();
249
250 let global_dir = self.global_dir.unwrap_or_else(ixchel_config_dir);
251
252 let project_dir = self.project_dir.or_else(find_project_config_dir);
253 if let Some(dir) = project_dir {
254 if let Some(table) = load_toml_file(&dir.join("config.toml"))? {
255 merge_tables(&mut merged, table);
256 }
257
258 if !self.tool_name.is_empty() {
259 let tool_config = dir.join(format!("{}.toml", self.tool_name));
260 if let Some(table) = load_toml_file(&tool_config)? {
261 merge_tables(&mut merged, table);
262 }
263 }
264 }
265
266 if let Some(table) = load_toml_file(&global_dir.join("config.toml"))? {
267 merge_tables(&mut merged, table);
268 }
269
270 if !self.tool_name.is_empty() {
271 let tool_config = global_dir.join(format!("{}.toml", self.tool_name));
272 if let Some(table) = load_toml_file(&tool_config)? {
273 merge_tables(&mut merged, table);
274 }
275 }
276
277 if merged.is_empty() {
278 return Ok(T::default());
279 }
280
281 let value = toml::Value::Table(merged);
282 value.try_into().map_err(|e| ConfigError::ParseError {
283 path: PathBuf::from("<merged>"),
284 source: e,
285 })
286 }
287}
288
289fn load_toml_file(path: &Path) -> Result<Option<toml::Table>, ConfigError> {
290 if !path.exists() {
291 return Ok(None);
292 }
293
294 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadError {
295 path: path.to_path_buf(),
296 source: e,
297 })?;
298
299 let table: toml::Table = toml::from_str(&content).map_err(|e| ConfigError::ParseError {
300 path: path.to_path_buf(),
301 source: e,
302 })?;
303
304 Ok(Some(table))
305}
306
307fn merge_tables(base: &mut toml::Table, overlay: toml::Table) {
308 for (key, value) in overlay {
309 match (base.get_mut(&key), value) {
310 (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
311 merge_tables(base_table, overlay_table);
312 }
313 (_, value) => {
314 base.insert(key, value);
315 }
316 }
317 }
318}
319
320#[must_use]
324#[deprecated(since = "0.2.0", note = "use ixchel_config_dir() instead")]
325pub fn global_config_dir() -> Option<PathBuf> {
326 Some(ixchel_config_dir())
327}
328
329#[must_use]
333pub fn find_project_config_dir() -> Option<PathBuf> {
334 let cwd = std::env::current_dir().ok()?;
335 let root = find_git_root(&cwd)?;
336 let ixchel_dir = root.join(".ixchel");
337 ixchel_dir.exists().then_some(ixchel_dir)
338}
339
340#[deprecated(since = "0.2.0", note = "use find_project_config_dir() instead")]
341pub fn project_config_dir() -> Option<PathBuf> {
342 find_project_config_dir()
343}
344
345#[must_use]
346fn find_git_root(start: &Path) -> Option<PathBuf> {
347 let mut current = Some(start);
348 while let Some(dir) = current {
349 if dir.join(".git").exists() {
350 return Some(dir.to_path_buf());
351 }
352 current = dir.parent();
353 }
354 None
355}
356
357#[must_use]
365pub fn detect_github_token() -> Option<String> {
366 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
367 return Some(token);
368 }
369
370 if let Ok(token) = std::env::var("GH_TOKEN") {
371 return Some(token);
372 }
373
374 if let Ok(config) = load_shared_config()
375 && let Some(token) = config.github.token
376 {
377 return Some(token);
378 }
379
380 std::process::Command::new("gh")
381 .args(["auth", "token"])
382 .output()
383 .ok()
384 .filter(|o| o.status.success())
385 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use serde::Deserialize;
392
393 #[derive(Debug, Default, Deserialize, PartialEq)]
394 struct TestConfig {
395 #[serde(default)]
396 value: i32,
397 #[serde(default)]
398 nested: NestedConfig,
399 }
400
401 #[derive(Debug, Default, Deserialize, PartialEq)]
402 struct NestedConfig {
403 #[serde(default)]
404 inner: String,
405 }
406
407 #[test]
408 fn test_merge_tables_simple() {
409 let mut base: toml::Table = toml::toml! {
410 value = 1
411 other = "kept"
412 };
413
414 let overlay: toml::Table = toml::toml! {
415 value = 2
416 };
417
418 merge_tables(&mut base, overlay);
419
420 assert_eq!(base.get("value").unwrap().as_integer(), Some(2));
421 assert_eq!(base.get("other").unwrap().as_str(), Some("kept"));
422 }
423
424 #[test]
425 fn test_merge_tables_nested() {
426 let mut base: toml::Table = toml::toml! {
427 [nested]
428 inner = "base"
429 other = "kept"
430 };
431
432 let overlay: toml::Table = toml::toml! {
433 [nested]
434 inner = "overlay"
435 };
436
437 merge_tables(&mut base, overlay);
438
439 let nested = base.get("nested").unwrap().as_table().unwrap();
440 assert_eq!(nested.get("inner").unwrap().as_str(), Some("overlay"));
441 assert_eq!(nested.get("other").unwrap().as_str(), Some("kept"));
442 }
443
444 #[test]
445 fn test_load_missing_returns_default() {
446 let config: TestConfig = ConfigLoader::new("nonexistent")
447 .with_global_dir("/nonexistent/path")
448 .with_project_dir("/nonexistent/path")
449 .load()
450 .unwrap();
451
452 assert_eq!(config, TestConfig::default());
453 }
454}