Skip to main content

ix_config/
lib.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use thiserror::Error;
5
6/// Get the Ixchel home directory (`~/.ixchel` or `$IXCHEL_HOME`).
7///
8/// This is the root directory for all Ixchel global config/state/data.
9///
10/// # Environment Override
11/// Set `IXCHEL_HOME` to override the default location.
12#[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/// Get the config directory (`~/.ixchel/config`).
26///
27/// Contains user-editable TOML configuration files.
28#[must_use]
29pub fn ixchel_config_dir() -> PathBuf {
30    ixchel_home().join("config")
31}
32
33/// Get the data directory (`~/.ixchel/data`).
34///
35/// Contains caches and databases (auto-generated, safe to delete).
36#[must_use]
37pub fn ixchel_data_dir() -> PathBuf {
38    ixchel_home().join("data")
39}
40
41/// Get the state directory (`~/.ixchel/state`).
42///
43/// Contains runtime metadata (agents, locks, ephemeral caches).
44#[must_use]
45pub fn ixchel_state_dir() -> PathBuf {
46    ixchel_home().join("state")
47}
48
49/// Get the log directory (`~/.ixchel/log`).
50///
51/// Contains operation logs for debugging.
52#[must_use]
53pub fn ixchel_log_dir() -> PathBuf {
54    ixchel_home().join("log")
55}
56
57/// Shared configuration used by multiple Ixchel tools.
58///
59/// Loaded from `~/.ixchel/config/config.toml` and `.ixchel/config.toml`.
60#[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/// GitHub-related configuration.
73#[derive(Debug, Clone, Default, Deserialize, Serialize)]
74pub struct GitHubConfig {
75    /// GitHub personal access token. Can also be set via `GITHUB_TOKEN` or `GH_TOKEN`.
76    pub token: Option<String>,
77}
78
79/// Embedding model configuration.
80#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct EmbeddingConfig {
82    /// Provider implementation to use (e.g. "fastembed").
83    #[serde(default = "default_embedding_provider")]
84    pub provider: String,
85    /// The embedding model to use.
86    #[serde(default = "default_embedding_model")]
87    pub model: String,
88    /// Batch size for embedding operations.
89    #[serde(default = "default_batch_size")]
90    pub batch_size: usize,
91    /// Optional dimension override for providers that don't advertise dims.
92    #[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/// Storage configuration.
120#[derive(Debug, Clone, Deserialize, Serialize)]
121pub struct StorageConfig {
122    /// Storage backend to use (e.g. "helixdb", "surrealdb").
123    #[serde(default = "default_storage_backend")]
124    pub backend: String,
125
126    /// Path relative to `.ixchel/` for rebuildable storage.
127    #[serde(default = "default_storage_path")]
128    pub path: String,
129
130    /// Storage engine for backends that support multiple engines.
131    ///
132    /// For `SurrealDB`: "rocksdb" (default) or "surrealkv".
133    #[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
169/// Load the shared configuration from global and project config files.
170///
171/// # Errors
172/// Returns an error if config files exist but cannot be read or parsed.
173pub 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/// Get the global config directory (`~/.ixchel/config`).
321///
322/// This is a convenience alias for [`ixchel_config_dir`].
323#[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/// Find the project config directory (`.ixchel/`) by walking to git root.
330///
331/// Returns `None` if no `.ixchel/` directory exists at the repository root.
332#[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/// Detect GitHub token from multiple sources.
358///
359/// Detection order (highest priority first):
360/// 1. `GITHUB_TOKEN` environment variable
361/// 2. `GH_TOKEN` environment variable
362/// 3. `github.token` in config files
363/// 4. `gh auth token` command output
364#[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}