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 pub proxy_auth: Option<ProxyAuth>,
69 pub model: Option<String>,
71 #[serde(default)]
73 pub headless_auth: bool,
74
75 #[serde(default = "default_provider")]
77 pub provider: String,
78
79 #[serde(default)]
81 pub providers: ProviderConfigs,
82
83 #[serde(default)]
85 pub server: ServerConfig,
86
87 #[serde(default = "default_data_dir")]
89 pub data_dir: PathBuf,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct ProviderConfigs {
97 pub openai: Option<OpenAIConfig>,
99 pub anthropic: Option<AnthropicConfig>,
101 pub gemini: Option<GeminiConfig>,
103 pub copilot: Option<CopilotConfig>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct OpenAIConfig {
120 pub api_key: String,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub base_url: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub model: Option<String>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct AnthropicConfig {
143 pub api_key: String,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub base_url: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub model: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub max_tokens: Option<u32>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct GeminiConfig {
168 pub api_key: String,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub base_url: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub model: Option<String>,
176}
177
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct CopilotConfig {
190 #[serde(default)]
192 pub enabled: bool,
193 #[serde(default)]
195 pub headless_auth: bool,
196}
197
198fn default_provider() -> String {
200 "anthropic".to_string()
201}
202
203fn default_port() -> u16 {
205 8080
206}
207
208fn default_bind() -> String {
210 "127.0.0.1".to_string()
211}
212
213fn default_workers() -> usize {
215 10
216}
217
218fn default_data_dir() -> PathBuf {
220 super::paths::bamboo_dir()
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ServerConfig {
226 #[serde(default = "default_port")]
228 pub port: u16,
229
230 #[serde(default = "default_bind")]
232 pub bind: String,
233
234 pub static_dir: Option<PathBuf>,
236
237 #[serde(default = "default_workers")]
239 pub workers: usize,
240}
241
242impl Default for ServerConfig {
243 fn default() -> Self {
244 Self {
245 port: default_port(),
246 bind: default_bind(),
247 static_dir: None,
248 workers: default_workers(),
249 }
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct ProxyAuth {
256 pub username: String,
258 pub password: String,
260}
261
262const CONFIG_FILE_PATH: &str = "config.toml";
264
265fn parse_bool_env(value: &str) -> bool {
269 matches!(
270 value.trim().to_ascii_lowercase().as_str(),
271 "1" | "true" | "yes" | "y" | "on"
272 )
273}
274
275impl Default for Config {
276 fn default() -> Self {
277 Self::new()
278 }
279}
280
281impl Config {
282 pub fn new() -> Self {
300 Self::from_data_dir(None)
301 }
302
303 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
309 let data_dir = data_dir
311 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
312 .unwrap_or_else(default_data_dir);
313
314 let config_path = data_dir.join("config.json");
315
316 let mut config = if config_path.exists() {
317 if let Ok(content) = std::fs::read_to_string(&config_path) {
318 if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
320 let has_old_fields = old_config.http_proxy_auth.is_some()
322 || old_config.https_proxy_auth.is_some()
323 || old_config.api_key.is_some()
324 || old_config.api_base.is_some();
325
326 if has_old_fields {
327 log::info!("Migrating old config format to new format");
328 let migrated = migrate_config(old_config);
329 if let Ok(new_content) = serde_json::to_string_pretty(&migrated) {
331 let _ = std::fs::write(&config_path, new_content);
332 }
333 migrated
334 } else {
335 match serde_json::from_str::<Config>(&content) {
339 Ok(config) => config,
340 Err(_) => {
341 migrate_config(old_config)
343 }
344 }
345 }
346 } else {
347 serde_json::from_str::<Config>(&content)
349 .unwrap_or_else(|_| Self::create_default())
350 }
351 } else {
352 Self::create_default()
353 }
354 } else {
355 if std::path::Path::new(CONFIG_FILE_PATH).exists() {
357 if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_PATH) {
358 if let Ok(old_config) = toml::from_str::<OldConfig>(&content) {
359 migrate_config(old_config)
360 } else {
361 Self::create_default()
362 }
363 } else {
364 Self::create_default()
365 }
366 } else {
367 Self::create_default()
368 }
369 };
370
371 config.data_dir = data_dir;
373
374 if let Ok(port) = std::env::var("BAMBOO_PORT") {
376 if let Ok(port) = port.parse() {
377 config.server.port = port;
378 }
379 }
380
381 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
382 config.server.bind = bind;
383 }
384
385 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
387 config.provider = provider;
388 }
389
390 if let Ok(model) = std::env::var("MODEL") {
391 config.model = Some(model);
392 }
393
394 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
395 config.headless_auth = parse_bool_env(&headless);
396 }
397
398 config
399 }
400
401 fn create_default() -> Self {
403 Config {
404 http_proxy: String::new(),
405 https_proxy: String::new(),
406 proxy_auth: None,
407 model: None,
408 headless_auth: false,
409 provider: default_provider(),
410 providers: ProviderConfigs::default(),
411 server: ServerConfig::default(),
412 data_dir: default_data_dir(),
413 }
414 }
415
416 pub fn server_addr(&self) -> String {
418 format!("{}:{}", self.server.bind, self.server.port)
419 }
420
421 pub fn save(&self) -> Result<()> {
423 let path = self.data_dir.join("config.json");
424
425 if let Some(parent) = path.parent() {
426 std::fs::create_dir_all(parent)
427 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
428 }
429
430 let content =
431 serde_json::to_string_pretty(self).context("Failed to serialize config to JSON")?;
432
433 std::fs::write(&path, content)
434 .with_context(|| format!("Failed to write config file: {:?}", path))?;
435
436 Ok(())
437 }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
445struct OldConfig {
446 #[serde(default)]
447 http_proxy: String,
448 #[serde(default)]
449 https_proxy: String,
450 #[serde(default)]
451 http_proxy_auth: Option<ProxyAuth>,
452 #[serde(default)]
453 https_proxy_auth: Option<ProxyAuth>,
454 api_key: Option<String>,
455 api_base: Option<String>,
456 model: Option<String>,
457 #[serde(default)]
458 headless_auth: bool,
459 #[serde(default = "default_provider")]
461 provider: String,
462 #[serde(default)]
463 server: ServerConfig,
464 #[serde(default)]
465 providers: ProviderConfigs,
466 #[serde(default)]
467 data_dir: Option<PathBuf>,
468}
469
470fn migrate_config(old: OldConfig) -> Config {
475 if old.api_key.is_some() {
477 log::warn!(
478 "api_key is no longer used. CopilotClient automatically manages authentication."
479 );
480 }
481 if old.api_base.is_some() {
482 log::warn!(
483 "api_base is no longer used. CopilotClient automatically manages API endpoints."
484 );
485 }
486
487 Config {
488 http_proxy: old.http_proxy,
489 https_proxy: old.https_proxy,
490 proxy_auth: old.https_proxy_auth.or(old.http_proxy_auth),
492 model: old.model,
493 headless_auth: old.headless_auth,
494 provider: old.provider,
495 providers: old.providers,
496 server: old.server,
497 data_dir: old.data_dir.unwrap_or_else(default_data_dir),
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use std::ffi::OsString;
505 use std::path::PathBuf;
506 use std::sync::{Mutex, OnceLock};
507 use std::time::{SystemTime, UNIX_EPOCH};
508
509 struct EnvVarGuard {
510 key: &'static str,
511 previous: Option<OsString>,
512 }
513
514 impl EnvVarGuard {
515 fn set(key: &'static str, value: &str) -> Self {
516 let previous = std::env::var_os(key);
517 std::env::set_var(key, value);
518 Self { key, previous }
519 }
520
521 fn unset(key: &'static str) -> Self {
522 let previous = std::env::var_os(key);
523 std::env::remove_var(key);
524 Self { key, previous }
525 }
526 }
527
528 impl Drop for EnvVarGuard {
529 fn drop(&mut self) {
530 match &self.previous {
531 Some(value) => std::env::set_var(self.key, value),
532 None => std::env::remove_var(self.key),
533 }
534 }
535 }
536
537 struct TempHome {
538 path: PathBuf,
539 }
540
541 impl TempHome {
542 fn new() -> Self {
543 let nanos = SystemTime::now()
544 .duration_since(UNIX_EPOCH)
545 .expect("clock should be after unix epoch")
546 .as_nanos();
547 let path = std::env::temp_dir().join(format!(
548 "chat-core-config-test-{}-{}",
549 std::process::id(),
550 nanos
551 ));
552 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
553 Self { path }
554 }
555
556 fn set_config_json(&self, content: &str) {
557 let config_dir = self.path.join(".bamboo");
559 std::fs::create_dir_all(&config_dir).expect("failed to create config dir");
560 std::fs::write(config_dir.join("config.json"), content)
561 .expect("failed to write config.json");
562 }
563 }
564
565 impl Drop for TempHome {
566 fn drop(&mut self) {
567 let _ = std::fs::remove_dir_all(&self.path);
568 }
569 }
570
571 fn env_lock() -> &'static Mutex<()> {
572 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
573 LOCK.get_or_init(|| Mutex::new(()))
574 }
575
576 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
578 env_lock().lock().unwrap_or_else(|poisoned| {
579 poisoned.into_inner()
581 })
582 }
583
584 #[test]
585 fn parse_bool_env_true_values() {
586 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
587 assert!(parse_bool_env(value), "value {value:?} should be true");
588 }
589 }
590
591 #[test]
592 fn parse_bool_env_false_values() {
593 for value in ["0", "false", "no", "off", "", " "] {
594 assert!(!parse_bool_env(value), "value {value:?} should be false");
595 }
596 }
597
598 #[test]
599 fn config_new_ignores_http_proxy_env_vars() {
600 let _lock = env_lock_acquire();
601 let temp_home = TempHome::new();
602 temp_home.set_config_json(
603 r#"{
604 "http_proxy": "",
605 "https_proxy": ""
606}"#,
607 );
608
609 let home = temp_home.path.to_string_lossy().to_string();
610 let _home = EnvVarGuard::set("HOME", &home);
611 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
612 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
613
614 let config = Config::new();
615
616 assert!(
617 config.http_proxy.is_empty(),
618 "config should ignore HTTP_PROXY env var"
619 );
620 assert!(
621 config.https_proxy.is_empty(),
622 "config should ignore HTTPS_PROXY env var"
623 );
624 }
625
626 #[test]
627 fn config_new_loads_config_when_proxy_fields_omitted() {
628 let _lock = env_lock_acquire();
629 let temp_home = TempHome::new();
630 temp_home.set_config_json(
631 r#"{
632 "model": "gpt-4"
633}"#,
634 );
635
636 let home = temp_home.path.to_string_lossy().to_string();
637 let _home = EnvVarGuard::set("HOME", &home);
638 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
639 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
640
641 let config = Config::new();
642
643 assert_eq!(
644 config.model.as_deref(),
645 Some("gpt-4"),
646 "config should load model from config file even when proxy fields are omitted"
647 );
648 assert!(config.http_proxy.is_empty());
649 assert!(config.https_proxy.is_empty());
650 }
651
652 #[test]
653 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
654 let _lock = env_lock_acquire();
655 let temp_home = TempHome::new();
656 temp_home.set_config_json(
657 r#"{
658 "model": "gpt-4"
659}"#,
660 );
661
662 let home = temp_home.path.to_string_lossy().to_string();
663 let _home = EnvVarGuard::set("HOME", &home);
664 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
665 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
666
667 let config = Config::new();
668
669 assert_eq!(config.model.as_deref(), Some("gpt-4"));
670 assert!(
671 config.http_proxy.is_empty(),
672 "config should keep http_proxy empty when field is omitted"
673 );
674 assert!(
675 config.https_proxy.is_empty(),
676 "config should keep https_proxy empty when field is omitted"
677 );
678 }
679
680 #[test]
681 fn config_migrates_old_format_to_new() {
682 let _lock = env_lock_acquire();
683 let temp_home = TempHome::new();
684
685 temp_home.set_config_json(
687 r#"{
688 "http_proxy": "http://proxy.example.com:8080",
689 "https_proxy": "http://proxy.example.com:8443",
690 "http_proxy_auth": {
691 "username": "http_user",
692 "password": "http_pass"
693 },
694 "https_proxy_auth": {
695 "username": "https_user",
696 "password": "https_pass"
697 },
698 "api_key": "old_key",
699 "api_base": "https://old.api.com",
700 "model": "gpt-4",
701 "headless_auth": true
702}"#,
703 );
704
705 let home = temp_home.path.to_string_lossy().to_string();
706 let _home = EnvVarGuard::set("HOME", &home);
707
708 let config = Config::new();
709
710 assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
712 assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
713
714 assert!(config.proxy_auth.is_some());
716 let auth = config.proxy_auth.unwrap();
717 assert_eq!(auth.username, "https_user");
718 assert_eq!(auth.password, "https_pass");
719
720 assert_eq!(config.model.as_deref(), Some("gpt-4"));
722 assert!(config.headless_auth);
723
724 }
726
727 #[test]
728 fn config_migrates_only_http_proxy_auth() {
729 let _lock = env_lock_acquire();
730 let temp_home = TempHome::new();
731
732 temp_home.set_config_json(
734 r#"{
735 "http_proxy": "http://proxy.example.com:8080",
736 "http_proxy_auth": {
737 "username": "http_user",
738 "password": "http_pass"
739 }
740}"#,
741 );
742
743 let home = temp_home.path.to_string_lossy().to_string();
744 let _home = EnvVarGuard::set("HOME", &home);
745
746 let config = Config::new();
747
748 assert!(
750 config.proxy_auth.is_some(),
751 "proxy_auth should be migrated from http_proxy_auth"
752 );
753 let auth = config.proxy_auth.unwrap();
754 assert_eq!(auth.username, "http_user");
755 assert_eq!(auth.password, "http_pass");
756 }
757
758 #[test]
759 fn test_server_config_defaults() {
760 let _lock = env_lock_acquire();
761 let temp_home = TempHome::new();
762
763 let home = temp_home.path.to_string_lossy().to_string();
765 let _home = EnvVarGuard::set("HOME", &home);
766
767 let config = Config::default();
768 assert_eq!(config.server.port, 8080);
769 assert_eq!(config.server.bind, "127.0.0.1");
770 assert_eq!(config.server.workers, 10);
771 assert!(config.server.static_dir.is_none());
772 }
773
774 #[test]
775 fn test_server_addr() {
776 let mut config = Config::default();
777 config.server.port = 9000;
778 config.server.bind = "0.0.0.0".to_string();
779 assert_eq!(config.server_addr(), "0.0.0.0:9000");
780 }
781
782 #[test]
783 fn test_env_var_overrides() {
784 let _lock = env_lock_acquire();
785 let temp_home = TempHome::new();
786
787 let home = temp_home.path.to_string_lossy().to_string();
789 let _home = EnvVarGuard::set("HOME", &home);
790
791 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
792 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
793 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
794
795 let config = Config::new();
796 assert_eq!(config.server.port, 9999);
797 assert_eq!(config.server.bind, "192.168.1.1");
798 assert_eq!(config.provider, "openai");
799 }
800
801 #[test]
802 fn test_config_save_and_load() {
803 let _lock = env_lock_acquire();
804 let temp_home = TempHome::new();
805
806 let home = temp_home.path.to_string_lossy().to_string();
808 let _home = EnvVarGuard::set("HOME", &home);
809
810 let mut config = Config::default();
811 config.server.port = 9000;
812 config.server.bind = "0.0.0.0".to_string();
813 config.provider = "anthropic".to_string();
814
815 config.save().expect("Failed to save config");
817
818 let loaded = Config::new();
820
821 assert_eq!(loaded.server.port, 9000);
823 assert_eq!(loaded.server.bind, "0.0.0.0");
824 assert_eq!(loaded.provider, "anthropic");
825 }
826}