1use serde::{Deserialize, Serialize};
52use std::path::PathBuf;
53use anyhow::{Context, Result};
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 "copilot".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 let migrated = migrate_config(old_config);
328 if let Ok(new_content) = serde_json::to_string_pretty(&migrated) {
330 let _ = std::fs::write(&config_path, new_content);
331 }
332 migrated
333 } else {
334 serde_json::from_str::<Config>(&content).unwrap_or_else(|_| Self::create_default())
336 }
337 } else {
338 serde_json::from_str::<Config>(&content).unwrap_or_else(|_| Self::create_default())
340 }
341 } else {
342 Self::create_default()
343 }
344 } else {
345 if std::path::Path::new(CONFIG_FILE_PATH).exists() {
347 if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_PATH) {
348 if let Ok(old_config) = toml::from_str::<OldConfig>(&content) {
349 migrate_config(old_config)
350 } else {
351 Self::create_default()
352 }
353 } else {
354 Self::create_default()
355 }
356 } else {
357 Self::create_default()
358 }
359 };
360
361 config.data_dir = data_dir;
363
364 if let Ok(port) = std::env::var("BAMBOO_PORT") {
366 if let Ok(port) = port.parse() {
367 config.server.port = port;
368 }
369 }
370
371 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
372 config.server.bind = bind;
373 }
374
375 if let Ok(provider) = std::env::var("BAMBOO_PROVIDER") {
377 config.provider = provider;
378 }
379
380 if let Ok(model) = std::env::var("MODEL") {
381 config.model = Some(model);
382 }
383
384 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
385 config.headless_auth = parse_bool_env(&headless);
386 }
387
388 config
389 }
390
391 fn create_default() -> Self {
393 Config {
394 http_proxy: String::new(),
395 https_proxy: String::new(),
396 proxy_auth: None,
397 model: None,
398 headless_auth: false,
399 provider: default_provider(),
400 providers: ProviderConfigs::default(),
401 server: ServerConfig::default(),
402 data_dir: default_data_dir(),
403 }
404 }
405
406 pub fn server_addr(&self) -> String {
408 format!("{}:{}", self.server.bind, self.server.port)
409 }
410
411 pub fn save(&self) -> Result<()> {
413 let path = self.data_dir.join("config.json");
414
415 if let Some(parent) = path.parent() {
416 std::fs::create_dir_all(parent)
417 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
418 }
419
420 let content = serde_json::to_string_pretty(self)
421 .context("Failed to serialize config to JSON")?;
422
423 std::fs::write(&path, content)
424 .with_context(|| format!("Failed to write config file: {:?}", path))?;
425
426 Ok(())
427 }
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
435struct OldConfig {
436 #[serde(default)]
437 http_proxy: String,
438 #[serde(default)]
439 https_proxy: String,
440 #[serde(default)]
441 http_proxy_auth: Option<ProxyAuth>,
442 #[serde(default)]
443 https_proxy_auth: Option<ProxyAuth>,
444 api_key: Option<String>,
445 api_base: Option<String>,
446 model: Option<String>,
447 #[serde(default)]
448 headless_auth: bool,
449}
450
451fn migrate_config(old: OldConfig) -> Config {
456 if old.api_key.is_some() {
458 log::warn!(
459 "api_key is no longer used. CopilotClient automatically manages authentication."
460 );
461 }
462 if old.api_base.is_some() {
463 log::warn!(
464 "api_base is no longer used. CopilotClient automatically manages API endpoints."
465 );
466 }
467
468 Config {
469 http_proxy: old.http_proxy,
470 https_proxy: old.https_proxy,
471 proxy_auth: old.https_proxy_auth.or(old.http_proxy_auth),
473 model: old.model,
474 headless_auth: old.headless_auth,
475 provider: default_provider(),
476 providers: ProviderConfigs::default(),
477 server: ServerConfig::default(),
478 data_dir: default_data_dir(),
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use std::ffi::OsString;
486 use std::path::PathBuf;
487 use std::sync::{Mutex, OnceLock};
488 use std::time::{SystemTime, UNIX_EPOCH};
489
490 struct EnvVarGuard {
491 key: &'static str,
492 previous: Option<OsString>,
493 }
494
495 impl EnvVarGuard {
496 fn set(key: &'static str, value: &str) -> Self {
497 let previous = std::env::var_os(key);
498 std::env::set_var(key, value);
499 Self { key, previous }
500 }
501
502 fn unset(key: &'static str) -> Self {
503 let previous = std::env::var_os(key);
504 std::env::remove_var(key);
505 Self { key, previous }
506 }
507 }
508
509 impl Drop for EnvVarGuard {
510 fn drop(&mut self) {
511 match &self.previous {
512 Some(value) => std::env::set_var(self.key, value),
513 None => std::env::remove_var(self.key),
514 }
515 }
516 }
517
518 struct TempHome {
519 path: PathBuf,
520 }
521
522 impl TempHome {
523 fn new() -> Self {
524 let nanos = SystemTime::now()
525 .duration_since(UNIX_EPOCH)
526 .expect("clock should be after unix epoch")
527 .as_nanos();
528 let path = std::env::temp_dir().join(format!(
529 "chat-core-config-test-{}-{}",
530 std::process::id(),
531 nanos
532 ));
533 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
534 Self { path }
535 }
536
537 fn set_config_json(&self, content: &str) {
538 let config_dir = self.path.join(".bamboo");
540 std::fs::create_dir_all(&config_dir).expect("failed to create config dir");
541 std::fs::write(config_dir.join("config.json"), content)
542 .expect("failed to write config.json");
543 }
544 }
545
546 impl Drop for TempHome {
547 fn drop(&mut self) {
548 let _ = std::fs::remove_dir_all(&self.path);
549 }
550 }
551
552 fn env_lock() -> &'static Mutex<()> {
553 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
554 LOCK.get_or_init(|| Mutex::new(()))
555 }
556
557 #[test]
558 fn parse_bool_env_true_values() {
559 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
560 assert!(parse_bool_env(value), "value {value:?} should be true");
561 }
562 }
563
564 #[test]
565 fn parse_bool_env_false_values() {
566 for value in ["0", "false", "no", "off", "", " "] {
567 assert!(!parse_bool_env(value), "value {value:?} should be false");
568 }
569 }
570
571 #[test]
572 fn config_new_ignores_http_proxy_env_vars() {
573 let _lock = env_lock().lock().expect("env lock poisoned");
574 let temp_home = TempHome::new();
575 temp_home.set_config_json(
576 r#"{
577 "http_proxy": "",
578 "https_proxy": ""
579}"#,
580 );
581
582 let home = temp_home.path.to_string_lossy().to_string();
583 let _home = EnvVarGuard::set("HOME", &home);
584 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
585 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
586
587 let config = Config::new();
588
589 assert!(
590 config.http_proxy.is_empty(),
591 "config should ignore HTTP_PROXY env var"
592 );
593 assert!(
594 config.https_proxy.is_empty(),
595 "config should ignore HTTPS_PROXY env var"
596 );
597 }
598
599 #[test]
600 fn config_new_loads_config_when_proxy_fields_omitted() {
601 let _lock = env_lock().lock().expect("env lock poisoned");
602 let temp_home = TempHome::new();
603 temp_home.set_config_json(
604 r#"{
605 "model": "gpt-4"
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::unset("HTTP_PROXY");
612 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
613
614 let config = Config::new();
615
616 assert_eq!(
617 config.model.as_deref(),
618 Some("gpt-4"),
619 "config should load model from config file even when proxy fields are omitted"
620 );
621 assert!(config.http_proxy.is_empty());
622 assert!(config.https_proxy.is_empty());
623 }
624
625 #[test]
626 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
627 let _lock = env_lock().lock().expect("env lock poisoned");
628 let temp_home = TempHome::new();
629 temp_home.set_config_json(
630 r#"{
631 "model": "gpt-4"
632}"#,
633 );
634
635 let home = temp_home.path.to_string_lossy().to_string();
636 let _home = EnvVarGuard::set("HOME", &home);
637 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
638 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
639
640 let config = Config::new();
641
642 assert_eq!(config.model.as_deref(), Some("gpt-4"));
643 assert!(
644 config.http_proxy.is_empty(),
645 "config should keep http_proxy empty when field is omitted"
646 );
647 assert!(
648 config.https_proxy.is_empty(),
649 "config should keep https_proxy empty when field is omitted"
650 );
651 }
652
653 #[test]
654 fn config_migrates_old_format_to_new() {
655 let _lock = env_lock().lock().expect("env lock poisoned");
656 let temp_home = TempHome::new();
657
658 temp_home.set_config_json(
660 r#"{
661 "http_proxy": "http://proxy.example.com:8080",
662 "https_proxy": "http://proxy.example.com:8443",
663 "http_proxy_auth": {
664 "username": "http_user",
665 "password": "http_pass"
666 },
667 "https_proxy_auth": {
668 "username": "https_user",
669 "password": "https_pass"
670 },
671 "api_key": "old_key",
672 "api_base": "https://old.api.com",
673 "model": "gpt-4",
674 "headless_auth": true
675}"#,
676 );
677
678 let home = temp_home.path.to_string_lossy().to_string();
679 let _home = EnvVarGuard::set("HOME", &home);
680
681 let config = Config::new();
682
683 assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
685 assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
686
687 assert!(config.proxy_auth.is_some());
689 let auth = config.proxy_auth.unwrap();
690 assert_eq!(auth.username, "https_user");
691 assert_eq!(auth.password, "https_pass");
692
693 assert_eq!(config.model.as_deref(), Some("gpt-4"));
695 assert!(config.headless_auth);
696
697 }
699
700 #[test]
701 fn config_migrates_only_http_proxy_auth() {
702 let _lock = env_lock().lock().expect("env lock poisoned");
703 let temp_home = TempHome::new();
704
705 temp_home.set_config_json(
707 r#"{
708 "http_proxy": "http://proxy.example.com:8080",
709 "http_proxy_auth": {
710 "username": "http_user",
711 "password": "http_pass"
712 }
713}"#,
714 );
715
716 let home = temp_home.path.to_string_lossy().to_string();
717 let _home = EnvVarGuard::set("HOME", &home);
718
719 let config = Config::new();
720
721 assert!(
723 config.proxy_auth.is_some(),
724 "proxy_auth should be migrated from http_proxy_auth"
725 );
726 let auth = config.proxy_auth.unwrap();
727 assert_eq!(auth.username, "http_user");
728 assert_eq!(auth.password, "http_pass");
729 }
730
731 #[test]
732 fn test_server_config_defaults() {
733 let _lock = env_lock().lock().expect("env lock poisoned");
734 let temp_home = TempHome::new();
735
736 let home = temp_home.path.to_string_lossy().to_string();
738 let _home = EnvVarGuard::set("HOME", &home);
739
740 let config = Config::default();
741 assert_eq!(config.server.port, 8080);
742 assert_eq!(config.server.bind, "127.0.0.1");
743 assert_eq!(config.server.workers, 10);
744 assert!(config.server.static_dir.is_none());
745 }
746
747 #[test]
748 fn test_server_addr() {
749 let mut config = Config::default();
750 config.server.port = 9000;
751 config.server.bind = "0.0.0.0".to_string();
752 assert_eq!(config.server_addr(), "0.0.0.0:9000");
753 }
754
755 #[test]
756 fn test_env_var_overrides() {
757 let _lock = env_lock().lock().expect("env lock poisoned");
758 let temp_home = TempHome::new();
759
760 let home = temp_home.path.to_string_lossy().to_string();
762 let _home = EnvVarGuard::set("HOME", &home);
763
764 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
765 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
766 let _provider = EnvVarGuard::set("BAMBOO_PROVIDER", "openai");
767
768 let config = Config::new();
769 assert_eq!(config.server.port, 9999);
770 assert_eq!(config.server.bind, "192.168.1.1");
771 assert_eq!(config.provider, "openai");
772 }
773
774 #[test]
775 fn test_config_save_and_load() {
776 let _lock = env_lock().lock().expect("env lock poisoned");
777 let temp_home = TempHome::new();
778
779 let home = temp_home.path.to_string_lossy().to_string();
781 let _home = EnvVarGuard::set("HOME", &home);
782
783 let mut config = Config::default();
784 config.server.port = 9000;
785 config.server.bind = "0.0.0.0".to_string();
786 config.provider = "anthropic".to_string();
787
788 config.save().expect("Failed to save config");
790
791 let loaded = Config::new();
793
794 assert_eq!(loaded.server.port, 9000);
796 assert_eq!(loaded.server.bind, "0.0.0.0");
797 assert_eq!(loaded.provider, "anthropic");
798 }
799}