Skip to main content

bamboo_agent/core/
config.rs

1//! Configuration management for Bamboo agent
2//!
3//! This module provides unified configuration types and loading logic for the entire
4//! Bamboo agent system. It supports multiple LLM providers, proxy settings,
5//! and JSON configuration format.
6//!
7//! # Configuration File
8//!
9//! Configuration is stored in `config.json` under the unified data directory
10//! (defaults to `~/.bamboo/`). Environment variables can override file values.
11//!
12//! # Example (JSON)
13//!
14//! ```json
15//! {
16//!   "provider": "anthropic",
17//!   "server": {
18//!     "port": 8080,
19//!     "bind": "127.0.0.1"
20//!   },
21//!   "providers": {
22//!     "anthropic": {
23//!       "api_key": "sk-ant-...",
24//!       "model": "claude-3-5-sonnet-20241022"
25//!     },
26//!     "openai": {
27//!       "api_key": "sk-...",
28//!       "base_url": "https://api.openai.com/v1"
29//!     }
30//!   }
31//! }
32//! ```
33//!
34//! # Priority Order
35//!
36//! Configuration values are loaded in this order (later overrides earlier):
37//! 1. Code defaults (hardcoded default values)
38//! 2. Config file values (from `~/.bamboo/config.json`)
39//! 3. Environment variables (e.g., `BAMBOO_PORT`)
40//! 4. CLI arguments (e.g., `--port 9000`)
41//!
42//! # Environment Variables
43//!
44//! - `BAMBOO_DATA_DIR`: Override data directory location
45//! - `BAMBOO_PORT`: Override server port
46//! - `BAMBOO_BIND`: Override server bind address
47//! - `BAMBOO_PROVIDER`: Override default provider
48//! - `BAMBOO_HEADLESS`: Enable headless authentication mode
49//! - `MODEL`: Override default model
50
51use serde::{Deserialize, Serialize};
52use std::path::PathBuf;
53use anyhow::{Context, Result};
54
55/// Main configuration structure for Bamboo agent
56///
57/// Contains all settings needed to run the agent, including provider credentials,
58/// proxy settings, model selection, and server configuration.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Config {
61    /// HTTP proxy URL (e.g., `http://proxy.example.com:8080`)
62    #[serde(default)]
63    pub http_proxy: String,
64    /// HTTPS proxy URL (e.g., `https://proxy.example.com:8080`)
65    #[serde(default)]
66    pub https_proxy: String,
67    /// Proxy authentication credentials
68    pub proxy_auth: Option<ProxyAuth>,
69    /// Default model to use (can be overridden per provider)
70    pub model: Option<String>,
71    /// Deprecated: Use `providers.copilot.headless_auth` instead
72    #[serde(default)]
73    pub headless_auth: bool,
74
75    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
76    #[serde(default = "default_provider")]
77    pub provider: String,
78
79    /// Provider-specific configurations
80    #[serde(default)]
81    pub providers: ProviderConfigs,
82
83    /// HTTP server configuration
84    #[serde(default)]
85    pub server: ServerConfig,
86
87    /// Data directory path (defaults to ~/.bamboo)
88    #[serde(default = "default_data_dir")]
89    pub data_dir: PathBuf,
90}
91
92/// Container for provider-specific configurations
93///
94/// Each field is optional, allowing users to configure only the providers they need.
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct ProviderConfigs {
97    /// OpenAI provider configuration
98    pub openai: Option<OpenAIConfig>,
99    /// Anthropic provider configuration
100    pub anthropic: Option<AnthropicConfig>,
101    /// Google Gemini provider configuration
102    pub gemini: Option<GeminiConfig>,
103    /// GitHub Copilot provider configuration
104    pub copilot: Option<CopilotConfig>,
105}
106
107/// OpenAI provider configuration
108///
109/// # Example
110///
111/// ```json
112/// "openai": {
113///   "api_key": "sk-...",
114///   "base_url": "https://api.openai.com/v1",
115///   "model": "gpt-4"
116/// }
117/// ```
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct OpenAIConfig {
120    /// OpenAI API key
121    pub api_key: String,
122    /// Custom API base URL (for Azure or self-hosted deployments)
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub base_url: Option<String>,
125    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub model: Option<String>,
128}
129
130/// Anthropic provider configuration
131///
132/// # Example
133///
134/// ```json
135/// "anthropic": {
136///   "api_key": "sk-ant-...",
137///   "model": "claude-3-5-sonnet-20241022",
138///   "max_tokens": 4096
139/// }
140/// ```
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct AnthropicConfig {
143    /// Anthropic API key
144    pub api_key: String,
145    /// Custom API base URL
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub base_url: Option<String>,
148    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub model: Option<String>,
151    /// Maximum tokens in model response
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub max_tokens: Option<u32>,
154}
155
156/// Google Gemini provider configuration
157///
158/// # Example
159///
160/// ```json
161/// "gemini": {
162///   "api_key": "AIza...",
163///   "model": "gemini-2.0-flash-exp"
164/// }
165/// ```
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct GeminiConfig {
168    /// Google AI API key
169    pub api_key: String,
170    /// Custom API base URL
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub base_url: Option<String>,
173    /// Default model to use (e.g., "gemini-2.0-flash-exp")
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub model: Option<String>,
176}
177
178/// GitHub Copilot provider configuration
179///
180/// # Example
181///
182/// ```json
183/// "copilot": {
184///   "enabled": true,
185///   "headless_auth": false
186/// }
187/// ```
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct CopilotConfig {
190    /// Whether Copilot provider is enabled
191    #[serde(default)]
192    pub enabled: bool,
193    /// Print login URL to console instead of opening browser
194    #[serde(default)]
195    pub headless_auth: bool,
196}
197
198/// Returns the default provider name ("copilot")
199fn default_provider() -> String {
200    "copilot".to_string()
201}
202
203/// Returns the default server port (8080)
204fn default_port() -> u16 {
205    8080
206}
207
208/// Returns the default bind address (127.0.0.1)
209fn default_bind() -> String {
210    "127.0.0.1".to_string()
211}
212
213/// Returns the default worker count (10)
214fn default_workers() -> usize {
215    10
216}
217
218/// Returns the default data directory (~/.bamboo)
219fn default_data_dir() -> PathBuf {
220    super::paths::bamboo_dir()
221}
222
223/// HTTP server configuration
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ServerConfig {
226    /// Port to listen on
227    #[serde(default = "default_port")]
228    pub port: u16,
229
230    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
231    #[serde(default = "default_bind")]
232    pub bind: String,
233
234    /// Static files directory (for Docker mode)
235    pub static_dir: Option<PathBuf>,
236
237    /// Worker count for Actix-web
238    #[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/// Proxy authentication credentials
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct ProxyAuth {
256    /// Proxy username
257    pub username: String,
258    /// Proxy password
259    pub password: String,
260}
261
262/// Configuration file name
263const CONFIG_FILE_PATH: &str = "config.toml";
264
265/// Parse a boolean value from environment variable strings
266///
267/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
268fn 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    /// Load configuration from file with environment variable overrides
283    ///
284    /// Configuration loading order:
285    /// 1. Try loading from `config.json` (data_dir/config.json)
286    /// 2. Migrate old format if detected
287    /// 3. Fallback to `config.toml` in current directory
288    /// 4. Use defaults
289    /// 5. Apply environment variable overrides (highest priority)
290    ///
291    /// # Environment Variables
292    ///
293    /// - `BAMBOO_PORT`: Override server port
294    /// - `BAMBOO_BIND`: Override bind address
295    /// - `BAMBOO_DATA_DIR`: Override data directory
296    /// - `BAMBOO_PROVIDER`: Override default provider
297    /// - `MODEL`: Default model name
298    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
299    pub fn new() -> Self {
300        Self::from_data_dir(None)
301    }
302
303    /// Load configuration from a specific data directory
304    ///
305    /// # Arguments
306    ///
307    /// * `data_dir` - Optional data directory path. If None, uses default (~/.bamboo)
308    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
309        // Determine data_dir early (needed to find config file)
310        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                // Try to parse as old format first (for migration)
319                if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
320                    // Check if it has old-only fields
321                    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                        // Save migrated config
329                        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                        // Try to parse as new Config format
335                        serde_json::from_str::<Config>(&content).unwrap_or_else(|_| Self::create_default())
336                    }
337                } else {
338                    // Try to parse as new Config format
339                    serde_json::from_str::<Config>(&content).unwrap_or_else(|_| Self::create_default())
340                }
341            } else {
342                Self::create_default()
343            }
344        } else {
345            // Fallback to legacy config.toml
346            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        // Ensure data_dir is set correctly
362        config.data_dir = data_dir;
363
364        // Apply environment variable overrides (highest priority)
365        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        // Note: BAMBOO_DATA_DIR already handled above
376        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    /// Create a default configuration without loading from file
392    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    /// Get the full server address (bind:port)
407    pub fn server_addr(&self) -> String {
408        format!("{}:{}", self.server.bind, self.server.port)
409    }
410
411    /// Save configuration to disk
412    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/// Legacy configuration format for backward compatibility
431///
432/// This struct is used to migrate old configuration files to the new format.
433/// It supports the previous single-provider model.
434#[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
451/// Migrate old configuration format to new multi-provider format
452///
453/// Converts the legacy single-provider configuration to the new structure
454/// with explicit provider configurations.
455fn migrate_config(old: OldConfig) -> Config {
456    // Log warning about deprecated fields
457    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        // Use https_proxy_auth if available, otherwise fallback to http_proxy_auth
472        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            // Use .bamboo directory
539            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        // Create config with old format
659        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        // Verify migration
684        assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
685        assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
686
687        // Should use https_proxy_auth (higher priority)
688        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        // Model and headless_auth should be preserved
694        assert_eq!(config.model.as_deref(), Some("gpt-4"));
695        assert!(config.headless_auth);
696
697        // api_key and api_base are no longer in Config
698    }
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        // Create config with only http_proxy_auth
706        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        // Should fallback to http_proxy_auth when https_proxy_auth is absent
722        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        // Set temp home BEFORE creating config
737        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        // Set temp home to avoid loading real config
761        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        // Set temp home BEFORE creating config
780        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        // Save
789        config.save().expect("Failed to save config");
790
791        // Load again
792        let loaded = Config::new();
793
794        // Verify
795        assert_eq!(loaded.server.port, 9000);
796        assert_eq!(loaded.server.bind, "0.0.0.0");
797        assert_eq!(loaded.provider, "anthropic");
798    }
799}