1use anyhow::{Context, Result};
52use serde::{Deserialize, Serialize};
53use std::path::PathBuf;
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Config {
61 #[serde(default)]
63 pub http_proxy: String,
64 #[serde(default)]
66 pub https_proxy: String,
67 #[serde(skip_serializing)]
71 pub proxy_auth: Option<ProxyAuth>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub proxy_auth_encrypted: Option<String>,
78 pub model: Option<String>,
80 #[serde(default)]
82 pub headless_auth: bool,
83
84 #[serde(default = "default_provider")]
86 pub provider: String,
87
88 #[serde(default)]
90 pub providers: ProviderConfigs,
91
92 #[serde(default)]
94 pub server: ServerConfig,
95
96 #[serde(default = "default_data_dir")]
98 pub data_dir: PathBuf,
99}
100
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct ProviderConfigs {
106 pub openai: Option<OpenAIConfig>,
108 pub anthropic: Option<AnthropicConfig>,
110 pub gemini: Option<GeminiConfig>,
112 pub copilot: Option<CopilotConfig>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct OpenAIConfig {
129 pub api_key: String,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub base_url: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub model: Option<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct AnthropicConfig {
152 pub api_key: String,
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub base_url: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub model: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub max_tokens: Option<u32>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct GeminiConfig {
177 pub api_key: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub base_url: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub model: Option<String>,
185}
186
187#[derive(Debug, Clone, Default, Serialize, Deserialize)]
199pub struct CopilotConfig {
200 #[serde(default)]
202 pub enabled: bool,
203 #[serde(default)]
205 pub headless_auth: bool,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub model: Option<String>,
209}
210
211fn default_provider() -> String {
213 "anthropic".to_string()
214}
215
216fn default_port() -> u16 {
218 8080
219}
220
221fn default_bind() -> String {
223 "127.0.0.1".to_string()
224}
225
226fn default_workers() -> usize {
228 10
229}
230
231fn default_data_dir() -> PathBuf {
233 super::paths::bamboo_dir()
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ServerConfig {
239 #[serde(default = "default_port")]
241 pub port: u16,
242
243 #[serde(default = "default_bind")]
245 pub bind: String,
246
247 pub static_dir: Option<PathBuf>,
249
250 #[serde(default = "default_workers")]
252 pub workers: usize,
253}
254
255impl Default for ServerConfig {
256 fn default() -> Self {
257 Self {
258 port: default_port(),
259 bind: default_bind(),
260 static_dir: None,
261 workers: default_workers(),
262 }
263 }
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct ProxyAuth {
269 pub username: String,
271 pub password: String,
273}
274
275const CONFIG_FILE_PATH: &str = "config.toml";
277
278fn parse_bool_env(value: &str) -> bool {
282 matches!(
283 value.trim().to_ascii_lowercase().as_str(),
284 "1" | "true" | "yes" | "y" | "on"
285 )
286}
287
288impl Default for Config {
289 fn default() -> Self {
290 Self::new()
291 }
292}
293
294impl Config {
295 pub fn new() -> Self {
313 Self::from_data_dir(None)
314 }
315
316 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
322 let data_dir = data_dir
324 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
325 .unwrap_or_else(default_data_dir);
326
327 let config_path = data_dir.join("config.json");
328
329 let mut config = if config_path.exists() {
330 if let Ok(content) = std::fs::read_to_string(&config_path) {
331 if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
333 let has_old_fields = old_config.http_proxy_auth.is_some()
335 || old_config.https_proxy_auth.is_some()
336 || old_config.api_key.is_some()
337 || old_config.api_base.is_some();
338
339 if has_old_fields {
340 log::info!("Migrating old config format to new format");
341 let migrated = migrate_config(old_config);
342 if let Ok(new_content) = serde_json::to_string_pretty(&migrated) {
344 let _ = std::fs::write(&config_path, new_content);
345 }
346 migrated
347 } else {
348 match serde_json::from_str::<Config>(&content) {
352 Ok(mut config) => {
353 config.hydrate_proxy_auth();
354 config
355 }
356 Err(_) => {
357 migrate_config(old_config)
359 }
360 }
361 }
362 } else {
363 serde_json::from_str::<Config>(&content)
365 .map(|mut config| {
366 config.hydrate_proxy_auth();
367 config
368 })
369 .unwrap_or_else(|_| Self::create_default())
370 }
371 } else {
372 Self::create_default()
373 }
374 } else {
375 if std::path::Path::new(CONFIG_FILE_PATH).exists() {
377 if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_PATH) {
378 if let Ok(old_config) = toml::from_str::<OldConfig>(&content) {
379 migrate_config(old_config)
380 } else {
381 Self::create_default()
382 }
383 } else {
384 Self::create_default()
385 }
386 } else {
387 Self::create_default()
388 }
389 };
390
391 config.data_dir = data_dir;
393 config.hydrate_proxy_auth();
395
396 if let Ok(port) = std::env::var("BAMBOO_PORT") {
398 if let Ok(port) = port.parse() {
399 config.server.port = port;
400 }
401 }
402
403 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
404 config.server.bind = bind;
405 }
406
407 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
409 config.provider = provider;
410 }
411
412 if let Ok(model) = std::env::var("MODEL") {
413 config.model = Some(model);
414 }
415
416 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
417 config.headless_auth = parse_bool_env(&headless);
418 }
419
420 config
421 }
422
423 fn hydrate_proxy_auth(&mut self) {
424 if self.proxy_auth.is_some() {
425 return;
426 }
427
428 let Some(encrypted) = self.proxy_auth_encrypted.as_deref() else {
429 return;
430 };
431
432 match crate::core::encryption::decrypt(encrypted) {
433 Ok(decrypted) => match serde_json::from_str::<ProxyAuth>(&decrypted) {
434 Ok(auth) => self.proxy_auth = Some(auth),
435 Err(e) => log::warn!("Failed to parse decrypted proxy auth JSON: {}", e),
436 },
437 Err(e) => log::warn!("Failed to decrypt proxy auth: {}", e),
438 }
439 }
440
441 fn encrypt_proxy_auth_for_storage(&mut self) -> Result<()> {
442 if self.proxy_auth_encrypted.is_some() || self.proxy_auth.is_none() {
443 return Ok(());
444 }
445
446 let auth = self
447 .proxy_auth
448 .as_ref()
449 .context("proxy_auth missing when trying to encrypt")?;
450 let auth_str = serde_json::to_string(auth).context("Failed to serialize proxy auth")?;
451 let encrypted =
452 crate::core::encryption::encrypt(&auth_str).context("Failed to encrypt proxy auth")?;
453 self.proxy_auth_encrypted = Some(encrypted);
454 Ok(())
455 }
456
457 fn create_default() -> Self {
459 Config {
460 http_proxy: String::new(),
461 https_proxy: String::new(),
462 proxy_auth: None,
463 proxy_auth_encrypted: None,
464 model: None,
465 headless_auth: false,
466 provider: default_provider(),
467 providers: ProviderConfigs::default(),
468 server: ServerConfig::default(),
469 data_dir: default_data_dir(),
470 }
471 }
472
473 pub fn server_addr(&self) -> String {
475 format!("{}:{}", self.server.bind, self.server.port)
476 }
477
478 pub fn save(&self) -> Result<()> {
480 let path = self.data_dir.join("config.json");
481
482 if let Some(parent) = path.parent() {
483 std::fs::create_dir_all(parent)
484 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
485 }
486
487 let mut to_save = self.clone();
488 to_save.encrypt_proxy_auth_for_storage()?;
489 let content =
490 serde_json::to_string_pretty(&to_save).context("Failed to serialize config to JSON")?;
491
492 std::fs::write(&path, content)
493 .with_context(|| format!("Failed to write config file: {:?}", path))?;
494
495 Ok(())
496 }
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
504struct OldConfig {
505 #[serde(default)]
506 http_proxy: String,
507 #[serde(default)]
508 https_proxy: String,
509 #[serde(default)]
510 http_proxy_auth: Option<ProxyAuth>,
511 #[serde(default)]
512 https_proxy_auth: Option<ProxyAuth>,
513 api_key: Option<String>,
514 api_base: Option<String>,
515 model: Option<String>,
516 #[serde(default)]
517 headless_auth: bool,
518 #[serde(default = "default_provider")]
520 provider: String,
521 #[serde(default)]
522 server: ServerConfig,
523 #[serde(default)]
524 providers: ProviderConfigs,
525 #[serde(default)]
526 data_dir: Option<PathBuf>,
527}
528
529fn migrate_config(old: OldConfig) -> Config {
534 if old.api_key.is_some() {
536 log::warn!(
537 "api_key is no longer used. CopilotClient automatically manages authentication."
538 );
539 }
540 if old.api_base.is_some() {
541 log::warn!(
542 "api_base is no longer used. CopilotClient automatically manages API endpoints."
543 );
544 }
545
546 let proxy_auth = old.https_proxy_auth.or(old.http_proxy_auth);
547 let proxy_auth_encrypted = proxy_auth
548 .as_ref()
549 .and_then(|auth| serde_json::to_string(auth).ok())
550 .and_then(|auth_str| crate::core::encryption::encrypt(&auth_str).ok());
551
552 Config {
553 http_proxy: old.http_proxy,
554 https_proxy: old.https_proxy,
555 proxy_auth,
557 proxy_auth_encrypted,
558 model: old.model,
559 headless_auth: old.headless_auth,
560 provider: old.provider,
561 providers: old.providers,
562 server: old.server,
563 data_dir: old.data_dir.unwrap_or_else(default_data_dir),
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use std::ffi::OsString;
571 use std::path::PathBuf;
572 use std::sync::{Mutex, OnceLock};
573 use std::time::{SystemTime, UNIX_EPOCH};
574
575 struct EnvVarGuard {
576 key: &'static str,
577 previous: Option<OsString>,
578 }
579
580 impl EnvVarGuard {
581 fn set(key: &'static str, value: &str) -> Self {
582 let previous = std::env::var_os(key);
583 std::env::set_var(key, value);
584 Self { key, previous }
585 }
586
587 fn unset(key: &'static str) -> Self {
588 let previous = std::env::var_os(key);
589 std::env::remove_var(key);
590 Self { key, previous }
591 }
592 }
593
594 impl Drop for EnvVarGuard {
595 fn drop(&mut self) {
596 match &self.previous {
597 Some(value) => std::env::set_var(self.key, value),
598 None => std::env::remove_var(self.key),
599 }
600 }
601 }
602
603 struct TempHome {
604 path: PathBuf,
605 }
606
607 impl TempHome {
608 fn new() -> Self {
609 let nanos = SystemTime::now()
610 .duration_since(UNIX_EPOCH)
611 .expect("clock should be after unix epoch")
612 .as_nanos();
613 let path = std::env::temp_dir().join(format!(
614 "chat-core-config-test-{}-{}",
615 std::process::id(),
616 nanos
617 ));
618 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
619 Self { path }
620 }
621
622 fn set_config_json(&self, content: &str) {
623 let config_dir = self.path.join(".bamboo");
625 std::fs::create_dir_all(&config_dir).expect("failed to create config dir");
626 std::fs::write(config_dir.join("config.json"), content)
627 .expect("failed to write config.json");
628 }
629 }
630
631 impl Drop for TempHome {
632 fn drop(&mut self) {
633 let _ = std::fs::remove_dir_all(&self.path);
634 }
635 }
636
637 fn env_lock() -> &'static Mutex<()> {
638 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
639 LOCK.get_or_init(|| Mutex::new(()))
640 }
641
642 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
644 env_lock().lock().unwrap_or_else(|poisoned| {
645 poisoned.into_inner()
647 })
648 }
649
650 #[test]
651 fn parse_bool_env_true_values() {
652 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
653 assert!(parse_bool_env(value), "value {value:?} should be true");
654 }
655 }
656
657 #[test]
658 fn parse_bool_env_false_values() {
659 for value in ["0", "false", "no", "off", "", " "] {
660 assert!(!parse_bool_env(value), "value {value:?} should be false");
661 }
662 }
663
664 #[test]
665 fn config_new_ignores_http_proxy_env_vars() {
666 let _lock = env_lock_acquire();
667 let temp_home = TempHome::new();
668 temp_home.set_config_json(
669 r#"{
670 "http_proxy": "",
671 "https_proxy": ""
672}"#,
673 );
674
675 let home = temp_home.path.to_string_lossy().to_string();
676 let _home = EnvVarGuard::set("HOME", &home);
677 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
678 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
679
680 let config = Config::new();
681
682 assert!(
683 config.http_proxy.is_empty(),
684 "config should ignore HTTP_PROXY env var"
685 );
686 assert!(
687 config.https_proxy.is_empty(),
688 "config should ignore HTTPS_PROXY env var"
689 );
690 }
691
692 #[test]
693 fn config_new_loads_config_when_proxy_fields_omitted() {
694 let _lock = env_lock_acquire();
695 let temp_home = TempHome::new();
696 temp_home.set_config_json(
697 r#"{
698 "model": "gpt-4"
699}"#,
700 );
701
702 let home = temp_home.path.to_string_lossy().to_string();
703 let _home = EnvVarGuard::set("HOME", &home);
704 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
705 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
706
707 let config = Config::new();
708
709 assert_eq!(
710 config.model.as_deref(),
711 Some("gpt-4"),
712 "config should load model from config file even when proxy fields are omitted"
713 );
714 assert!(config.http_proxy.is_empty());
715 assert!(config.https_proxy.is_empty());
716 }
717
718 #[test]
719 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
720 let _lock = env_lock_acquire();
721 let temp_home = TempHome::new();
722 temp_home.set_config_json(
723 r#"{
724 "model": "gpt-4"
725}"#,
726 );
727
728 let home = temp_home.path.to_string_lossy().to_string();
729 let _home = EnvVarGuard::set("HOME", &home);
730 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
731 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
732
733 let config = Config::new();
734
735 assert_eq!(config.model.as_deref(), Some("gpt-4"));
736 assert!(
737 config.http_proxy.is_empty(),
738 "config should keep http_proxy empty when field is omitted"
739 );
740 assert!(
741 config.https_proxy.is_empty(),
742 "config should keep https_proxy empty when field is omitted"
743 );
744 }
745
746 #[test]
747 fn config_migrates_old_format_to_new() {
748 let _lock = env_lock_acquire();
749 let temp_home = TempHome::new();
750
751 temp_home.set_config_json(
753 r#"{
754 "http_proxy": "http://proxy.example.com:8080",
755 "https_proxy": "http://proxy.example.com:8443",
756 "http_proxy_auth": {
757 "username": "http_user",
758 "password": "http_pass"
759 },
760 "https_proxy_auth": {
761 "username": "https_user",
762 "password": "https_pass"
763 },
764 "api_key": "old_key",
765 "api_base": "https://old.api.com",
766 "model": "gpt-4",
767 "headless_auth": true
768}"#,
769 );
770
771 let home = temp_home.path.to_string_lossy().to_string();
772 let _home = EnvVarGuard::set("HOME", &home);
773
774 let config = Config::new();
775
776 assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
778 assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
779
780 assert!(config.proxy_auth.is_some());
782 let auth = config.proxy_auth.unwrap();
783 assert_eq!(auth.username, "https_user");
784 assert_eq!(auth.password, "https_pass");
785
786 assert_eq!(config.model.as_deref(), Some("gpt-4"));
788 assert!(config.headless_auth);
789
790 }
792
793 #[test]
794 fn config_migrates_only_http_proxy_auth() {
795 let _lock = env_lock_acquire();
796 let temp_home = TempHome::new();
797
798 temp_home.set_config_json(
800 r#"{
801 "http_proxy": "http://proxy.example.com:8080",
802 "http_proxy_auth": {
803 "username": "http_user",
804 "password": "http_pass"
805 }
806}"#,
807 );
808
809 let home = temp_home.path.to_string_lossy().to_string();
810 let _home = EnvVarGuard::set("HOME", &home);
811
812 let config = Config::new();
813
814 assert!(
816 config.proxy_auth.is_some(),
817 "proxy_auth should be migrated from http_proxy_auth"
818 );
819 let auth = config.proxy_auth.unwrap();
820 assert_eq!(auth.username, "http_user");
821 assert_eq!(auth.password, "http_pass");
822 }
823
824 #[test]
825 fn test_server_config_defaults() {
826 let _lock = env_lock_acquire();
827 let temp_home = TempHome::new();
828
829 let home = temp_home.path.to_string_lossy().to_string();
831 let _home = EnvVarGuard::set("HOME", &home);
832
833 let config = Config::default();
834 assert_eq!(config.server.port, 8080);
835 assert_eq!(config.server.bind, "127.0.0.1");
836 assert_eq!(config.server.workers, 10);
837 assert!(config.server.static_dir.is_none());
838 }
839
840 #[test]
841 fn test_server_addr() {
842 let mut config = Config::default();
843 config.server.port = 9000;
844 config.server.bind = "0.0.0.0".to_string();
845 assert_eq!(config.server_addr(), "0.0.0.0:9000");
846 }
847
848 #[test]
849 fn test_env_var_overrides() {
850 let _lock = env_lock_acquire();
851 let temp_home = TempHome::new();
852
853 let home = temp_home.path.to_string_lossy().to_string();
855 let _home = EnvVarGuard::set("HOME", &home);
856
857 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
858 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
859 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
860
861 let config = Config::new();
862 assert_eq!(config.server.port, 9999);
863 assert_eq!(config.server.bind, "192.168.1.1");
864 assert_eq!(config.provider, "openai");
865 }
866
867 #[test]
868 fn test_config_save_and_load() {
869 let _lock = env_lock_acquire();
870 let temp_home = TempHome::new();
871
872 let home = temp_home.path.to_string_lossy().to_string();
874 let _home = EnvVarGuard::set("HOME", &home);
875
876 let mut config = Config::default();
877 config.server.port = 9000;
878 config.server.bind = "0.0.0.0".to_string();
879 config.provider = "anthropic".to_string();
880
881 config.save().expect("Failed to save config");
883
884 let loaded = Config::new();
886
887 assert_eq!(loaded.server.port, 9000);
889 assert_eq!(loaded.server.bind, "0.0.0.0");
890 assert_eq!(loaded.provider, "anthropic");
891 }
892
893 #[test]
894 fn config_decrypts_proxy_auth_from_encrypted_field() {
895 let _lock = env_lock_acquire();
896 let temp_home = TempHome::new();
897
898 let _key = EnvVarGuard::set(
900 "BAMBOO_CONFIG_ENCRYPTION_KEY",
901 "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
902 );
903
904 let auth = ProxyAuth {
905 username: "user".to_string(),
906 password: "pass".to_string(),
907 };
908 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
909 let encrypted = crate::core::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
910
911 temp_home.set_config_json(&format!(
912 r#"{{
913 "http_proxy": "http://proxy.example.com:8080",
914 "proxy_auth_encrypted": "{encrypted}"
915}}"#
916 ));
917
918 let home = temp_home.path.to_string_lossy().to_string();
919 let _home = EnvVarGuard::set("HOME", &home);
920
921 let config = Config::new();
922 let loaded_auth = config.proxy_auth.expect("proxy auth should be hydrated");
923 assert_eq!(loaded_auth.username, "user");
924 assert_eq!(loaded_auth.password, "pass");
925 }
926
927 #[test]
928 fn config_save_encrypts_proxy_auth_and_load_hydrates_plaintext() {
929 let _lock = env_lock_acquire();
930 let temp_home = TempHome::new();
931
932 let _key = EnvVarGuard::set(
934 "BAMBOO_CONFIG_ENCRYPTION_KEY",
935 "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
936 );
937
938 let home = temp_home.path.to_string_lossy().to_string();
939 let _home = EnvVarGuard::set("HOME", &home);
940
941 let mut config = Config::default();
942 config.proxy_auth = Some(ProxyAuth {
943 username: "user".to_string(),
944 password: "pass".to_string(),
945 });
946 config.save().expect("save should encrypt proxy auth");
947
948 let content = std::fs::read_to_string(temp_home.path.join(".bamboo").join("config.json"))
949 .expect("read config.json");
950 assert!(
951 content.contains("proxy_auth_encrypted"),
952 "config.json should store encrypted proxy auth"
953 );
954 assert!(
955 !content.contains("\"proxy_auth\""),
956 "config.json should not store plaintext proxy_auth"
957 );
958
959 let loaded = Config::new();
960 let loaded_auth = loaded.proxy_auth.expect("proxy auth should be hydrated");
961 assert_eq!(loaded_auth.username, "user");
962 assert_eq!(loaded_auth.password, "pass");
963 }
964}