Skip to main content

llm_core/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{LlmError, Result};
7
8// ---------------------------------------------------------------------------
9// Paths
10// ---------------------------------------------------------------------------
11
12/// Resolved filesystem paths for config and data directories.
13#[derive(Debug, Clone)]
14pub struct Paths {
15    config_dir: PathBuf,
16    data_dir: PathBuf,
17}
18
19impl Paths {
20    /// Resolve paths from environment variables (pure XDG).
21    ///
22    /// Priority:
23    /// 1. `LLM_USER_PATH` → flat layout (both dirs point there)
24    /// 2. `$XDG_CONFIG_HOME/llm` / `$XDG_DATA_HOME/llm`
25    /// 3. `$HOME/.config/llm` / `$HOME/.local/share/llm`
26    pub fn resolve() -> Result<Self> {
27        if let Ok(user_path) = std::env::var("LLM_USER_PATH") {
28            return Ok(Self::from_dir(Path::new(&user_path)));
29        }
30
31        let home = std::env::var("HOME")
32            .map_err(|_| LlmError::Config("$HOME is not set".into()))?;
33        let home = PathBuf::from(home);
34
35        let config_dir = match std::env::var("XDG_CONFIG_HOME") {
36            Ok(val) if !val.is_empty() => PathBuf::from(val).join("llm"),
37            _ => home.join(".config").join("llm"),
38        };
39
40        let data_dir = match std::env::var("XDG_DATA_HOME") {
41            Ok(val) if !val.is_empty() => PathBuf::from(val).join("llm"),
42            _ => home.join(".local").join("share").join("llm"),
43        };
44
45        Ok(Self { config_dir, data_dir })
46    }
47
48    /// Both dirs point to `dir`. Used for testing and `LLM_USER_PATH` override.
49    pub fn from_dir(dir: &Path) -> Self {
50        Self {
51            config_dir: dir.to_path_buf(),
52            data_dir: dir.to_path_buf(),
53        }
54    }
55
56    pub fn config_dir(&self) -> &Path {
57        &self.config_dir
58    }
59
60    pub fn data_dir(&self) -> &Path {
61        &self.data_dir
62    }
63
64    pub fn config_file(&self) -> PathBuf {
65        self.config_dir.join("config.toml")
66    }
67
68    pub fn keys_file(&self) -> PathBuf {
69        self.config_dir.join("keys.toml")
70    }
71
72    pub fn logs_dir(&self) -> PathBuf {
73        self.data_dir.join("logs")
74    }
75
76    pub fn agents_dir(&self) -> PathBuf {
77        self.config_dir.join("agents")
78    }
79}
80
81// ---------------------------------------------------------------------------
82// Config
83// ---------------------------------------------------------------------------
84
85fn default_model() -> String {
86    "gpt-4o-mini".into()
87}
88
89fn default_true() -> bool {
90    true
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Config {
95    #[serde(default = "default_model")]
96    pub default_model: String,
97    #[serde(default = "default_true")]
98    pub logging: bool,
99    #[serde(default)]
100    pub aliases: HashMap<String, String>,
101    #[serde(default)]
102    pub options: HashMap<String, HashMap<String, serde_json::Value>>,
103    #[serde(default)]
104    pub providers: HashMap<String, serde_json::Value>,
105}
106
107impl Default for Config {
108    fn default() -> Self {
109        Self {
110            default_model: default_model(),
111            logging: true,
112            aliases: HashMap::new(),
113            options: HashMap::new(),
114            providers: HashMap::new(),
115        }
116    }
117}
118
119impl Config {
120    /// Load config from a TOML file. Returns defaults if file doesn't exist.
121    pub fn load(path: &Path) -> Result<Self> {
122        match std::fs::read_to_string(path) {
123            Ok(contents) => {
124                toml::from_str(&contents).map_err(|e| LlmError::Config(e.to_string()))
125            }
126            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
127            Err(e) => Err(LlmError::Io(e)),
128        }
129    }
130
131    /// Returns the effective default model, checking `LLM_DEFAULT_MODEL` env var first.
132    pub fn default_model(&self) -> &str {
133        // Can't return a reference to an env var, so this method checks at call time.
134        // The caller should use `effective_default_model()` for the owned version.
135        &self.default_model
136    }
137
138    /// Returns the effective default model (env var `LLM_DEFAULT_MODEL` takes priority).
139    pub fn effective_default_model(&self) -> String {
140        match std::env::var("LLM_DEFAULT_MODEL") {
141            Ok(val) if !val.is_empty() => val,
142            _ => self.default_model.clone(),
143        }
144    }
145
146    /// Resolve a model name through aliases. Returns the alias target if found,
147    /// otherwise returns the input unchanged.
148    pub fn resolve_model<'a>(&'a self, input: &'a str) -> &'a str {
149        self.aliases.get(input).map(|s| s.as_str()).unwrap_or(input)
150    }
151
152    /// Save config to a TOML file, creating parent directories if needed.
153    pub fn save(&self, path: &Path) -> Result<()> {
154        if let Some(parent) = path.parent() {
155            std::fs::create_dir_all(parent)?;
156        }
157        let content = toml::to_string_pretty(self)
158            .map_err(|e| LlmError::Config(e.to_string()))?;
159        std::fs::write(path, content)?;
160        Ok(())
161    }
162
163    /// Returns a clone of all options for a given model (empty if none set).
164    pub fn model_options(&self, model: &str) -> HashMap<String, serde_json::Value> {
165        self.options.get(model).cloned().unwrap_or_default()
166    }
167
168    /// Set a single option for a model.
169    pub fn set_option(&mut self, model: &str, key: &str, value: serde_json::Value) {
170        self.options
171            .entry(model.to_string())
172            .or_default()
173            .insert(key.to_string(), value);
174    }
175
176    /// Clear a single option for a model. Returns `true` if the key existed.
177    /// Removes the model entry entirely if no options remain.
178    pub fn clear_option(&mut self, model: &str, key: &str) -> bool {
179        if let Some(model_opts) = self.options.get_mut(model) {
180            let removed = model_opts.remove(key).is_some();
181            if model_opts.is_empty() {
182                self.options.remove(model);
183            }
184            removed
185        } else {
186            false
187        }
188    }
189
190    /// Clear all options for a model. Returns `true` if the model had options.
191    pub fn clear_model_options(&mut self, model: &str) -> bool {
192        self.options.remove(model).is_some()
193    }
194
195    /// Set an alias mapping `alias` to `model`.
196    pub fn set_alias(&mut self, alias: &str, model: &str) {
197        self.aliases.insert(alias.to_string(), model.to_string());
198    }
199
200    /// Remove an alias. Returns `true` if the alias existed.
201    pub fn remove_alias(&mut self, alias: &str) -> bool {
202        self.aliases.remove(alias).is_some()
203    }
204}
205
206// ---------------------------------------------------------------------------
207// parse_option_value
208// ---------------------------------------------------------------------------
209
210/// Smart-coerce a string into a JSON value.
211///
212/// Tries, in order: integer, float, bool (`true`/`false`), `null`, fallback to string.
213pub fn parse_option_value(s: &str) -> serde_json::Value {
214    // Integer (no decimal point)
215    if let Ok(n) = s.parse::<i64>() {
216        return serde_json::Value::Number(n.into());
217    }
218    // Float
219    if let Ok(f) = s.parse::<f64>() {
220        if let Some(n) = serde_json::Number::from_f64(f) {
221            return serde_json::Value::Number(n);
222        }
223    }
224    // Bool
225    match s {
226        "true" => return serde_json::Value::Bool(true),
227        "false" => return serde_json::Value::Bool(false),
228        "null" => return serde_json::Value::Null,
229        _ => {}
230    }
231    // Fallback: string
232    serde_json::Value::String(s.to_string())
233}
234
235// ---------------------------------------------------------------------------
236// KeyStore
237// ---------------------------------------------------------------------------
238
239/// API key storage backed by a TOML file.
240#[derive(Debug)]
241pub struct KeyStore {
242    keys: HashMap<String, String>,
243    path: PathBuf,
244}
245
246impl KeyStore {
247    /// Load keys from a TOML file. Returns an empty store if file doesn't exist.
248    pub fn load(path: &Path) -> Result<Self> {
249        let keys = match std::fs::read_to_string(path) {
250            Ok(contents) => {
251                toml::from_str::<HashMap<String, String>>(&contents)
252                    .map_err(|e| LlmError::Config(format!("invalid keys.toml: {e}")))?
253            }
254            Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
255            Err(e) => return Err(LlmError::Io(e)),
256        };
257        Ok(Self {
258            keys,
259            path: path.to_path_buf(),
260        })
261    }
262
263    pub fn get(&self, name: &str) -> Option<&str> {
264        self.keys.get(name).map(|s| s.as_str())
265    }
266
267    pub fn list(&self) -> Vec<&str> {
268        let mut names: Vec<&str> = self.keys.keys().map(|s| s.as_str()).collect();
269        names.sort();
270        names
271    }
272
273    pub fn path(&self) -> &Path {
274        &self.path
275    }
276
277    /// Set a key, writing the updated store to disk.
278    /// Creates parent directories and sets 0o600 permissions on Unix.
279    pub fn set(&mut self, name: &str, value: &str) -> Result<()> {
280        self.keys.insert(name.to_string(), value.to_string());
281
282        if let Some(parent) = self.path.parent() {
283            std::fs::create_dir_all(parent)?;
284        }
285
286        let contents = toml::to_string(&self.keys)
287            .map_err(|e| LlmError::Config(format!("failed to serialize keys: {e}")))?;
288        std::fs::write(&self.path, &contents)?;
289
290        #[cfg(unix)]
291        {
292            use std::os::unix::fs::PermissionsExt;
293            let perms = std::fs::Permissions::from_mode(0o600);
294            std::fs::set_permissions(&self.path, perms)?;
295        }
296
297        Ok(())
298    }
299}
300
301// ---------------------------------------------------------------------------
302// resolve_key
303// ---------------------------------------------------------------------------
304
305/// Resolve an API key through the 4-level fallback chain.
306///
307/// 1. `explicit_key` (from `--key` CLI flag)
308/// 2. `key_store.get(key_alias)` (from `keys.toml`)
309/// 3. Environment variable (e.g. `OPENAI_API_KEY`)
310/// 4. Error with actionable message
311pub fn resolve_key(
312    explicit_key: Option<&str>,
313    key_store: &KeyStore,
314    key_alias: &str,
315    env_var: Option<&str>,
316) -> Result<String> {
317    if let Some(key) = explicit_key {
318        return Ok(key.to_string());
319    }
320
321    if let Some(key) = key_store.get(key_alias) {
322        return Ok(key.to_string());
323    }
324
325    if let Some(var_name) = env_var
326        && let Ok(val) = std::env::var(var_name)
327        && !val.is_empty()
328    {
329        return Ok(val);
330    }
331
332    let mut msg = format!("No key found - set one with 'llm keys set {key_alias}'");
333    if let Some(var_name) = env_var {
334        msg.push_str(&format!(" or set the {var_name} environment variable"));
335    }
336    Err(LlmError::NeedsKey(msg))
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    // --- Cycle 1: Paths ---
344
345    #[test]
346    fn paths_from_dir() {
347        let paths = Paths::from_dir(Path::new("/tmp/llm-test"));
348        assert_eq!(paths.config_dir(), Path::new("/tmp/llm-test"));
349        assert_eq!(paths.data_dir(), Path::new("/tmp/llm-test"));
350    }
351
352    #[test]
353    fn paths_derived_methods() {
354        let paths = Paths::from_dir(Path::new("/base"));
355        assert_eq!(paths.config_file(), PathBuf::from("/base/config.toml"));
356        assert_eq!(paths.keys_file(), PathBuf::from("/base/keys.toml"));
357        assert_eq!(paths.logs_dir(), PathBuf::from("/base/logs"));
358        assert_eq!(paths.agents_dir(), PathBuf::from("/base/agents"));
359    }
360
361    #[test]
362    fn paths_agents_dir() {
363        let paths = Paths {
364            config_dir: PathBuf::from("/etc/llm"),
365            data_dir: PathBuf::from("/var/llm"),
366        };
367        assert_eq!(paths.agents_dir(), PathBuf::from("/etc/llm/agents"));
368    }
369
370    #[test]
371    fn paths_agents_dir_from_dir() {
372        let paths = Paths::from_dir(Path::new("/tmp/llm-test"));
373        assert_eq!(paths.agents_dir(), PathBuf::from("/tmp/llm-test/agents"));
374    }
375
376    #[test]
377    fn paths_separate_dirs() {
378        let paths = Paths {
379            config_dir: PathBuf::from("/etc/llm"),
380            data_dir: PathBuf::from("/var/llm"),
381        };
382        assert_eq!(paths.config_file(), PathBuf::from("/etc/llm/config.toml"));
383        assert_eq!(paths.keys_file(), PathBuf::from("/etc/llm/keys.toml"));
384        assert_eq!(paths.logs_dir(), PathBuf::from("/var/llm/logs"));
385    }
386
387    #[test]
388    fn paths_resolve_xdg_defaults() {
389        let tmp = tempfile::tempdir().unwrap();
390        let home = tmp.path().to_str().unwrap();
391
392        temp_env::with_vars(
393            [
394                ("HOME", Some(home)),
395                ("LLM_USER_PATH", None::<&str>),
396                ("XDG_CONFIG_HOME", None::<&str>),
397                ("XDG_DATA_HOME", None::<&str>),
398            ],
399            || {
400                let paths = Paths::resolve().unwrap();
401                assert_eq!(paths.config_dir(), tmp.path().join(".config/llm"));
402                assert_eq!(paths.data_dir(), tmp.path().join(".local/share/llm"));
403            },
404        );
405    }
406
407    #[test]
408    fn paths_resolve_xdg_custom() {
409        let tmp = tempfile::tempdir().unwrap();
410        let xdg_config = tmp.path().join("myconfig");
411        let xdg_data = tmp.path().join("mydata");
412
413        temp_env::with_vars(
414            [
415                ("HOME", Some(tmp.path().to_str().unwrap())),
416                ("LLM_USER_PATH", None::<&str>),
417                ("XDG_CONFIG_HOME", Some(xdg_config.to_str().unwrap())),
418                ("XDG_DATA_HOME", Some(xdg_data.to_str().unwrap())),
419            ],
420            || {
421                let paths = Paths::resolve().unwrap();
422                assert_eq!(paths.config_dir(), xdg_config.join("llm"));
423                assert_eq!(paths.data_dir(), xdg_data.join("llm"));
424            },
425        );
426    }
427
428    // --- Cycle 2: Config ---
429
430    #[test]
431    fn config_default() {
432        let config = Config::default();
433        assert_eq!(config.default_model, "gpt-4o-mini");
434        assert!(config.logging);
435        assert!(config.aliases.is_empty());
436        assert!(config.options.is_empty());
437        assert!(config.providers.is_empty());
438    }
439
440    #[test]
441    fn config_load_missing_file() {
442        let config = Config::load(Path::new("/nonexistent/config.toml")).unwrap();
443        assert_eq!(config.default_model, "gpt-4o-mini");
444        assert!(config.logging);
445    }
446
447    #[test]
448    fn config_load_valid_toml() {
449        let tmp = tempfile::tempdir().unwrap();
450        let path = tmp.path().join("config.toml");
451        std::fs::write(
452            &path,
453            r#"
454default_model = "claude-sonnet-4-20250514"
455logging = false
456
457[aliases]
458claude = "claude-sonnet-4-20250514"
459fast = "gpt-4o-mini"
460
461[options.gpt-4o]
462temperature = 0.7
463"#,
464        )
465        .unwrap();
466
467        let config = Config::load(&path).unwrap();
468        assert_eq!(config.default_model, "claude-sonnet-4-20250514");
469        assert!(!config.logging);
470        assert_eq!(config.aliases.len(), 2);
471        assert_eq!(config.aliases["claude"], "claude-sonnet-4-20250514");
472        assert_eq!(config.options["gpt-4o"]["temperature"], 0.7);
473    }
474
475    #[test]
476    fn config_load_partial_toml() {
477        let tmp = tempfile::tempdir().unwrap();
478        let path = tmp.path().join("config.toml");
479        std::fs::write(&path, "logging = false\n").unwrap();
480
481        let config = Config::load(&path).unwrap();
482        assert_eq!(config.default_model, "gpt-4o-mini"); // default
483        assert!(!config.logging); // overridden
484        assert!(config.aliases.is_empty()); // default
485    }
486
487    #[test]
488    fn config_load_empty_file() {
489        let tmp = tempfile::tempdir().unwrap();
490        let path = tmp.path().join("config.toml");
491        std::fs::write(&path, "").unwrap();
492
493        let config = Config::load(&path).unwrap();
494        assert_eq!(config.default_model, "gpt-4o-mini");
495        assert!(config.logging);
496    }
497
498    #[test]
499    fn config_load_invalid_toml() {
500        let tmp = tempfile::tempdir().unwrap();
501        let path = tmp.path().join("config.toml");
502        std::fs::write(&path, "not valid {{{{ toml").unwrap();
503
504        let result = Config::load(&path);
505        assert!(result.is_err());
506        assert!(matches!(result.unwrap_err(), LlmError::Config(_)));
507    }
508
509    #[test]
510    fn config_resolve_model_alias() {
511        let mut config = Config::default();
512        config
513            .aliases
514            .insert("claude".into(), "claude-sonnet-4-20250514".into());
515
516        assert_eq!(config.resolve_model("claude"), "claude-sonnet-4-20250514");
517    }
518
519    #[test]
520    fn config_resolve_model_passthrough() {
521        let config = Config::default();
522        assert_eq!(config.resolve_model("gpt-4o"), "gpt-4o");
523    }
524
525    #[test]
526    fn config_effective_default_model_env_override() {
527        let config = Config::default();
528        temp_env::with_vars(
529            [("LLM_DEFAULT_MODEL", Some("o3"))],
530            || {
531                assert_eq!(config.effective_default_model(), "o3");
532            },
533        );
534    }
535
536    #[test]
537    fn config_effective_default_model_fallback() {
538        let config = Config::default();
539        temp_env::with_vars(
540            [("LLM_DEFAULT_MODEL", None::<&str>)],
541            || {
542                assert_eq!(config.effective_default_model(), "gpt-4o-mini");
543            },
544        );
545    }
546
547    #[test]
548    fn paths_resolve_llm_user_path() {
549        temp_env::with_vars(
550            [
551                ("LLM_USER_PATH", Some("/custom/llm")),
552                ("HOME", Some("/should-not-matter")),
553            ],
554            || {
555                let paths = Paths::resolve().unwrap();
556                assert_eq!(paths.config_dir(), Path::new("/custom/llm"));
557                assert_eq!(paths.data_dir(), Path::new("/custom/llm"));
558            },
559        );
560    }
561
562    // --- Cycle 3: KeyStore read ---
563
564    #[test]
565    fn keystore_load_missing_file() {
566        let store = KeyStore::load(Path::new("/nonexistent/keys.toml")).unwrap();
567        assert!(store.list().is_empty());
568    }
569
570    #[test]
571    fn keystore_load_valid_toml() {
572        let tmp = tempfile::tempdir().unwrap();
573        let path = tmp.path().join("keys.toml");
574        std::fs::write(&path, "openai = \"sk-abc\"\nanthropic = \"sk-ant-xyz\"\n").unwrap();
575
576        let store = KeyStore::load(&path).unwrap();
577        assert_eq!(store.get("openai"), Some("sk-abc"));
578        assert_eq!(store.get("anthropic"), Some("sk-ant-xyz"));
579    }
580
581    #[test]
582    fn keystore_load_invalid_toml() {
583        let tmp = tempfile::tempdir().unwrap();
584        let path = tmp.path().join("keys.toml");
585        std::fs::write(&path, "not {{ valid").unwrap();
586
587        let result = KeyStore::load(&path);
588        assert!(result.is_err());
589        assert!(matches!(result.unwrap_err(), LlmError::Config(_)));
590    }
591
592    #[test]
593    fn keystore_get_existing() {
594        let tmp = tempfile::tempdir().unwrap();
595        let path = tmp.path().join("keys.toml");
596        std::fs::write(&path, "openai = \"sk-test123\"\n").unwrap();
597
598        let store = KeyStore::load(&path).unwrap();
599        assert_eq!(store.get("openai"), Some("sk-test123"));
600    }
601
602    #[test]
603    fn keystore_get_missing() {
604        let tmp = tempfile::tempdir().unwrap();
605        let path = tmp.path().join("keys.toml");
606        std::fs::write(&path, "openai = \"sk-test\"\n").unwrap();
607
608        let store = KeyStore::load(&path).unwrap();
609        assert_eq!(store.get("anthropic"), None);
610    }
611
612    #[test]
613    fn keystore_list() {
614        let tmp = tempfile::tempdir().unwrap();
615        let path = tmp.path().join("keys.toml");
616        std::fs::write(&path, "openai = \"sk-1\"\nanthropic = \"sk-2\"\nollama = \"\"\n").unwrap();
617
618        let store = KeyStore::load(&path).unwrap();
619        assert_eq!(store.list(), vec!["anthropic", "ollama", "openai"]); // sorted
620    }
621
622    #[test]
623    fn keystore_path() {
624        let store = KeyStore::load(Path::new("/some/keys.toml")).unwrap();
625        assert_eq!(store.path(), Path::new("/some/keys.toml"));
626    }
627
628    // --- Cycle 4: KeyStore write ---
629
630    #[test]
631    fn keystore_set_new_key() {
632        let tmp = tempfile::tempdir().unwrap();
633        let path = tmp.path().join("keys.toml");
634
635        let mut store = KeyStore::load(&path).unwrap();
636        store.set("openai", "sk-new").unwrap();
637
638        // Verify by re-loading
639        let store2 = KeyStore::load(&path).unwrap();
640        assert_eq!(store2.get("openai"), Some("sk-new"));
641    }
642
643    #[test]
644    fn keystore_set_overwrite() {
645        let tmp = tempfile::tempdir().unwrap();
646        let path = tmp.path().join("keys.toml");
647        std::fs::write(&path, "openai = \"sk-old\"\nanthropic = \"sk-ant\"\n").unwrap();
648
649        let mut store = KeyStore::load(&path).unwrap();
650        store.set("openai", "sk-new").unwrap();
651
652        let store2 = KeyStore::load(&path).unwrap();
653        assert_eq!(store2.get("openai"), Some("sk-new"));
654        assert_eq!(store2.get("anthropic"), Some("sk-ant")); // preserved
655    }
656
657    #[test]
658    fn keystore_set_creates_parent_dirs() {
659        let tmp = tempfile::tempdir().unwrap();
660        let path = tmp.path().join("sub").join("dir").join("keys.toml");
661
662        let mut store = KeyStore::load(&path).unwrap();
663        store.set("openai", "sk-test").unwrap();
664        assert!(path.exists());
665    }
666
667    #[cfg(unix)]
668    #[test]
669    fn keystore_set_file_permissions() {
670        use std::os::unix::fs::PermissionsExt;
671
672        let tmp = tempfile::tempdir().unwrap();
673        let path = tmp.path().join("keys.toml");
674
675        let mut store = KeyStore::load(&path).unwrap();
676        store.set("openai", "sk-secret").unwrap();
677
678        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
679        assert_eq!(mode & 0o777, 0o600);
680    }
681
682    // --- Cycle 5: resolve_key ---
683
684    #[test]
685    fn resolve_key_explicit() {
686        let tmp = tempfile::tempdir().unwrap();
687        let path = tmp.path().join("keys.toml");
688        std::fs::write(&path, "openai = \"sk-stored\"\n").unwrap();
689        let store = KeyStore::load(&path).unwrap();
690
691        let key = resolve_key(Some("sk-explicit"), &store, "openai", Some("OPENAI_API_KEY")).unwrap();
692        assert_eq!(key, "sk-explicit");
693    }
694
695    #[test]
696    fn resolve_key_from_store() {
697        let tmp = tempfile::tempdir().unwrap();
698        let path = tmp.path().join("keys.toml");
699        std::fs::write(&path, "openai = \"sk-stored\"\n").unwrap();
700        let store = KeyStore::load(&path).unwrap();
701
702        temp_env::with_vars(
703            [("OPENAI_API_KEY", None::<&str>)],
704            || {
705                let key = resolve_key(None, &store, "openai", Some("OPENAI_API_KEY")).unwrap();
706                assert_eq!(key, "sk-stored");
707            },
708        );
709    }
710
711    #[test]
712    fn resolve_key_from_env() {
713        let tmp = tempfile::tempdir().unwrap();
714        let path = tmp.path().join("keys.toml");
715        // empty store
716        let store = KeyStore::load(&path).unwrap();
717
718        temp_env::with_vars(
719            [("OPENAI_API_KEY", Some("sk-from-env"))],
720            || {
721                let key = resolve_key(None, &store, "openai", Some("OPENAI_API_KEY")).unwrap();
722                assert_eq!(key, "sk-from-env");
723            },
724        );
725    }
726
727    #[test]
728    fn resolve_key_error() {
729        let tmp = tempfile::tempdir().unwrap();
730        let path = tmp.path().join("keys.toml");
731        let store = KeyStore::load(&path).unwrap();
732
733        temp_env::with_vars(
734            [("OPENAI_API_KEY", None::<&str>)],
735            || {
736                let err = resolve_key(None, &store, "openai", Some("OPENAI_API_KEY")).unwrap_err();
737                let msg = err.to_string();
738                assert!(msg.contains("llm keys set openai"), "msg: {msg}");
739                assert!(msg.contains("OPENAI_API_KEY"), "msg: {msg}");
740            },
741        );
742    }
743
744    #[test]
745    fn resolve_key_env_empty_string_skipped() {
746        let tmp = tempfile::tempdir().unwrap();
747        let path = tmp.path().join("keys.toml");
748        let store = KeyStore::load(&path).unwrap();
749
750        temp_env::with_vars(
751            [("OPENAI_API_KEY", Some(""))],
752            || {
753                let result = resolve_key(None, &store, "openai", Some("OPENAI_API_KEY"));
754                assert!(result.is_err());
755            },
756        );
757    }
758
759    #[test]
760    fn resolve_key_no_env_var() {
761        let tmp = tempfile::tempdir().unwrap();
762        let path = tmp.path().join("keys.toml");
763        let store = KeyStore::load(&path).unwrap();
764
765        let err = resolve_key(None, &store, "openai", None).unwrap_err();
766        let msg = err.to_string();
767        assert!(msg.contains("llm keys set openai"), "msg: {msg}");
768        assert!(!msg.contains("environment variable"), "msg: {msg}");
769    }
770
771    // --- parse_option_value ---
772
773    #[test]
774    fn parse_option_value_int() {
775        assert_eq!(parse_option_value("42"), serde_json::json!(42));
776        assert_eq!(parse_option_value("-1"), serde_json::json!(-1));
777        assert_eq!(parse_option_value("0"), serde_json::json!(0));
778    }
779
780    #[test]
781    fn parse_option_value_float() {
782        assert_eq!(parse_option_value("0.7"), serde_json::json!(0.7));
783        assert_eq!(parse_option_value("1.5"), serde_json::json!(1.5));
784    }
785
786    #[test]
787    fn parse_option_value_bool() {
788        assert_eq!(parse_option_value("true"), serde_json::json!(true));
789        assert_eq!(parse_option_value("false"), serde_json::json!(false));
790    }
791
792    #[test]
793    fn parse_option_value_null() {
794        assert_eq!(parse_option_value("null"), serde_json::Value::Null);
795    }
796
797    #[test]
798    fn parse_option_value_string_fallback() {
799        assert_eq!(parse_option_value("hello"), serde_json::json!("hello"));
800        assert_eq!(parse_option_value("gpt-4o"), serde_json::json!("gpt-4o"));
801        // "True" (capitalized) is not bool
802        assert_eq!(parse_option_value("True"), serde_json::json!("True"));
803    }
804
805    #[test]
806    fn parse_option_value_edge_cases() {
807        // Large integer
808        assert_eq!(parse_option_value("4096"), serde_json::json!(4096));
809        // Negative float
810        assert_eq!(parse_option_value("-0.5"), serde_json::json!(-0.5));
811        // Empty string
812        assert_eq!(parse_option_value(""), serde_json::json!(""));
813    }
814
815    // --- Config model_options / set_option / clear ---
816
817    #[test]
818    fn config_model_options_empty() {
819        let config = Config::default();
820        assert!(config.model_options("gpt-4o").is_empty());
821    }
822
823    #[test]
824    fn config_set_and_get_option() {
825        let mut config = Config::default();
826        config.set_option("gpt-4o", "temperature", serde_json::json!(0.7));
827        config.set_option("gpt-4o", "max_tokens", serde_json::json!(200));
828
829        let opts = config.model_options("gpt-4o");
830        assert_eq!(opts.len(), 2);
831        assert_eq!(opts["temperature"], serde_json::json!(0.7));
832        assert_eq!(opts["max_tokens"], serde_json::json!(200));
833    }
834
835    #[test]
836    fn config_set_option_overwrite() {
837        let mut config = Config::default();
838        config.set_option("gpt-4o", "temperature", serde_json::json!(0.5));
839        config.set_option("gpt-4o", "temperature", serde_json::json!(0.9));
840        assert_eq!(config.model_options("gpt-4o")["temperature"], serde_json::json!(0.9));
841    }
842
843    #[test]
844    fn config_clear_option_single() {
845        let mut config = Config::default();
846        config.set_option("gpt-4o", "temperature", serde_json::json!(0.7));
847        config.set_option("gpt-4o", "max_tokens", serde_json::json!(200));
848
849        assert!(config.clear_option("gpt-4o", "temperature"));
850        let opts = config.model_options("gpt-4o");
851        assert_eq!(opts.len(), 1);
852        assert!(!opts.contains_key("temperature"));
853    }
854
855    #[test]
856    fn config_clear_option_removes_empty_model() {
857        let mut config = Config::default();
858        config.set_option("gpt-4o", "temperature", serde_json::json!(0.7));
859
860        assert!(config.clear_option("gpt-4o", "temperature"));
861        assert!(!config.options.contains_key("gpt-4o"));
862    }
863
864    #[test]
865    fn config_clear_option_missing() {
866        let mut config = Config::default();
867        assert!(!config.clear_option("gpt-4o", "temperature"));
868    }
869
870    #[test]
871    fn config_clear_model_options() {
872        let mut config = Config::default();
873        config.set_option("gpt-4o", "temperature", serde_json::json!(0.7));
874        config.set_option("gpt-4o", "max_tokens", serde_json::json!(200));
875
876        assert!(config.clear_model_options("gpt-4o"));
877        assert!(config.model_options("gpt-4o").is_empty());
878        assert!(!config.options.contains_key("gpt-4o"));
879    }
880
881    #[test]
882    fn config_clear_model_options_missing() {
883        let mut config = Config::default();
884        assert!(!config.clear_model_options("gpt-4o"));
885    }
886
887    #[test]
888    fn config_options_save_and_load_roundtrip() {
889        let tmp = tempfile::tempdir().unwrap();
890        let path = tmp.path().join("config.toml");
891
892        let mut config = Config::default();
893        config.set_option("gpt-4o", "temperature", serde_json::json!(0.7));
894        config.set_option("gpt-4o", "max_tokens", serde_json::json!(200));
895        config.save(&path).unwrap();
896
897        let loaded = Config::load(&path).unwrap();
898        assert_eq!(loaded.model_options("gpt-4o")["temperature"], serde_json::json!(0.7));
899        assert_eq!(loaded.model_options("gpt-4o")["max_tokens"], serde_json::json!(200));
900    }
901
902    // --- Aliases: set_alias / remove_alias ---
903
904    #[test]
905    fn config_set_alias() {
906        let mut config = Config::default();
907        config.set_alias("claude", "claude-sonnet-4-20250514");
908        assert_eq!(config.aliases["claude"], "claude-sonnet-4-20250514");
909    }
910
911    #[test]
912    fn config_set_alias_overwrite() {
913        let mut config = Config::default();
914        config.set_alias("claude", "claude-sonnet-4-20250514");
915        config.set_alias("claude", "claude-opus-4-20250514");
916        assert_eq!(config.aliases["claude"], "claude-opus-4-20250514");
917    }
918
919    #[test]
920    fn config_remove_alias() {
921        let mut config = Config::default();
922        config.set_alias("claude", "claude-sonnet-4-20250514");
923        assert!(config.remove_alias("claude"));
924        assert!(!config.aliases.contains_key("claude"));
925    }
926
927    #[test]
928    fn config_remove_alias_missing() {
929        let mut config = Config::default();
930        assert!(!config.remove_alias("nonexistent"));
931    }
932
933    #[test]
934    fn config_alias_roundtrip() {
935        let tmp = tempfile::tempdir().unwrap();
936        let path = tmp.path().join("config.toml");
937
938        let mut config = Config::default();
939        config.set_alias("claude", "claude-sonnet-4-20250514");
940        config.set_alias("fast", "gpt-4o-mini");
941        config.save(&path).unwrap();
942
943        let loaded = Config::load(&path).unwrap();
944        assert_eq!(loaded.aliases["claude"], "claude-sonnet-4-20250514");
945        assert_eq!(loaded.aliases["fast"], "gpt-4o-mini");
946    }
947}