Skip to main content

codei_config/
load.rs

1use std::env;
2use std::path::PathBuf;
3
4use figment::{
5    providers::{Env, Format, Serialized, Toml},
6    Figment,
7};
8
9use crate::error::ConfigError;
10use crate::model::{Config, ResolvedConfig};
11use crate::paths::{discover_project_root, project_config_path, user_config_path};
12
13/// Options that override layered configuration (CLI flags, etc.).
14#[derive(Debug, Clone, Default)]
15pub struct LoadOptions {
16    pub cwd: Option<PathBuf>,
17    pub model: Option<String>,
18    pub provider: Option<String>,
19    pub language: Option<String>,
20}
21
22/// Loads configuration from defaults, user file, project file, env, and CLI overrides.
23pub fn load(opts: &LoadOptions) -> Result<ResolvedConfig, ConfigError> {
24    let cwd = opts
25        .cwd
26        .clone()
27        .or_else(|| env::current_dir().ok())
28        .unwrap_or_else(|| PathBuf::from("."));
29
30    let user_config_path = user_config_path();
31    let project_root = discover_project_root(&cwd);
32    let project_config_path = project_root.as_ref().map(|root| project_config_path(root));
33
34    let mut figment = Figment::new().merge(Serialized::defaults(Config::default()));
35
36    if user_config_path.is_file() {
37        figment = figment.merge(Toml::file(&user_config_path));
38    }
39
40    if let Some(path) = &project_config_path {
41        if path.is_file() {
42            figment = figment.merge(Toml::file(path));
43        }
44    }
45
46    // e.g. CODEI_DEFAULTS__MODEL=gpt-4o-mini
47    figment = figment.merge(Env::prefixed("CODEI_").split("__"));
48
49    let mut config: Config = figment
50        .extract()
51        .map_err(|e| ConfigError::Load(Box::new(e)))?;
52    apply_cli_overrides(&mut config, opts);
53
54    let resolved = ResolvedConfig {
55        config,
56        cwd,
57        project_root,
58        user_config_path,
59        project_config_path,
60    };
61    resolved.validate()?;
62    Ok(resolved)
63}
64
65fn apply_cli_overrides(config: &mut Config, opts: &LoadOptions) {
66    if let Some(model) = &opts.model {
67        config.defaults.model = model.clone();
68    }
69    if let Some(provider) = &opts.provider {
70        config.defaults.provider = provider.clone();
71    }
72    if let Some(language) = &opts.language {
73        config.defaults.language = language.clone();
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use std::fs;
81    use std::io::Write;
82    use std::path::Path;
83
84    use tempfile::TempDir;
85
86    fn write_config(dir: &Path, rel: &str, content: &str) {
87        let path = dir.join(rel);
88        if let Some(parent) = path.parent() {
89            fs::create_dir_all(parent).unwrap();
90        }
91        fs::File::create(path)
92            .unwrap()
93            .write_all(content.as_bytes())
94            .unwrap();
95    }
96
97    #[test]
98    fn loads_defaults_without_files() {
99        let dir = TempDir::new().unwrap();
100        let opts = LoadOptions {
101            cwd: Some(dir.path().to_path_buf()),
102            ..Default::default()
103        };
104        let resolved = load(&opts).unwrap();
105        assert!(!resolved.config.defaults.provider.is_empty());
106        assert!(resolved.config.defaults.max_tokens > 0);
107    }
108
109    #[test]
110    fn merges_project_config() {
111        let dir = TempDir::new().unwrap();
112        write_config(
113            dir.path(),
114            ".codei/config.toml",
115            r#"
116[defaults]
117model = "project-model"
118"#,
119        );
120
121        let opts = LoadOptions {
122            cwd: Some(dir.path().to_path_buf()),
123            ..Default::default()
124        };
125        let resolved = load(&opts).unwrap();
126        assert_eq!(resolved.config.defaults.model, "project-model");
127        assert_eq!(
128            resolved.project_root.as_deref(),
129            Some(dir.path().canonicalize().unwrap().as_path())
130        );
131    }
132
133    #[test]
134    fn cli_overrides_take_precedence() {
135        let dir = TempDir::new().unwrap();
136        write_config(
137            dir.path(),
138            ".codei/config.toml",
139            r#"
140[defaults]
141model = "project-model"
142"#,
143        );
144
145        let opts = LoadOptions {
146            cwd: Some(dir.path().to_path_buf()),
147            model: Some("cli-model".to_string()),
148            ..Default::default()
149        };
150        let resolved = load(&opts).unwrap();
151        assert_eq!(resolved.config.defaults.model, "cli-model");
152    }
153}