1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{LlmError, Result};
7
8#[derive(Debug, Clone)]
14pub struct Paths {
15 config_dir: PathBuf,
16 data_dir: PathBuf,
17}
18
19impl Paths {
20 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 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
81fn 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 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 pub fn default_model(&self) -> &str {
133 &self.default_model
136 }
137
138 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 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 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 pub fn model_options(&self, model: &str) -> HashMap<String, serde_json::Value> {
165 self.options.get(model).cloned().unwrap_or_default()
166 }
167
168 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 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 pub fn clear_model_options(&mut self, model: &str) -> bool {
192 self.options.remove(model).is_some()
193 }
194
195 pub fn set_alias(&mut self, alias: &str, model: &str) {
197 self.aliases.insert(alias.to_string(), model.to_string());
198 }
199
200 pub fn remove_alias(&mut self, alias: &str) -> bool {
202 self.aliases.remove(alias).is_some()
203 }
204}
205
206pub fn parse_option_value(s: &str) -> serde_json::Value {
214 if let Ok(n) = s.parse::<i64>() {
216 return serde_json::Value::Number(n.into());
217 }
218 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 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 serde_json::Value::String(s.to_string())
233}
234
235#[derive(Debug)]
241pub struct KeyStore {
242 keys: HashMap<String, String>,
243 path: PathBuf,
244}
245
246impl KeyStore {
247 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 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
301pub 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 #[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 #[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"); assert!(!config.logging); assert!(config.aliases.is_empty()); }
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 #[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"]); }
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 #[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 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")); }
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 #[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 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 #[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 assert_eq!(parse_option_value("True"), serde_json::json!("True"));
803 }
804
805 #[test]
806 fn parse_option_value_edge_cases() {
807 assert_eq!(parse_option_value("4096"), serde_json::json!(4096));
809 assert_eq!(parse_option_value("-0.5"), serde_json::json!(-0.5));
811 assert_eq!(parse_option_value(""), serde_json::json!(""));
813 }
814
815 #[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 #[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}