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 anyhow::{Context, Result};
52use serde::{Deserialize, Serialize};
53use std::path::PathBuf;
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 ("anthropic")
199fn default_provider() -> String {
200    "anthropic".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 (indicating a true old config that needs migration)
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                        log::info!("Migrating old config format to new format");
328                        let migrated = migrate_config(old_config);
329                        // Save migrated config
330                        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                        // No old fields, so try to parse as new Config
336                        // OldConfig successfully parsed common fields like http_proxy, model, provider, etc.
337                        // Try Config, but if it fails (e.g., due to syntax errors), use OldConfig values
338                        match serde_json::from_str::<Config>(&content) {
339                            Ok(config) => config,
340                            Err(_) => {
341                                // Config parse failed, but OldConfig worked, so preserve those values
342                                migrate_config(old_config)
343                            }
344                        }
345                    }
346                } else {
347                    // Couldn't parse as OldConfig, try as Config
348                    serde_json::from_str::<Config>(&content)
349                        .unwrap_or_else(|_| Self::create_default())
350                }
351            } else {
352                Self::create_default()
353            }
354        } else {
355            // Fallback to legacy config.toml
356            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        // Ensure data_dir is set correctly
372        config.data_dir = data_dir;
373
374        // Apply environment variable overrides (highest priority)
375        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        // Note: BAMBOO_DATA_DIR already handled above
386        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    /// Create a default configuration without loading from file
402    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    /// Get the full server address (bind:port)
417    pub fn server_addr(&self) -> String {
418        format!("{}:{}", self.server.bind, self.server.port)
419    }
420
421    /// Save configuration to disk
422    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/// Legacy configuration format for backward compatibility
441///
442/// This struct is used to migrate old configuration files to the new format.
443/// It supports the previous single-provider model.
444#[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    // Also capture new fields so we don't lose them during fallback
460    #[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
470/// Migrate old configuration format to new multi-provider format
471///
472/// Converts the legacy single-provider configuration to the new structure
473/// with explicit provider configurations.
474fn migrate_config(old: OldConfig) -> Config {
475    // Log warning about deprecated fields
476    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        // Use https_proxy_auth if available, otherwise fallback to http_proxy_auth
491        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            // Use .bamboo directory
558            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    /// Acquire the environment lock, recovering from poison if a previous test failed
577    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
578        env_lock().lock().unwrap_or_else(|poisoned| {
579            // Lock was poisoned by a previous test failure - recover it
580            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        // Create config with old format
686        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        // Verify migration
711        assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
712        assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
713
714        // Should use https_proxy_auth (higher priority)
715        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        // Model and headless_auth should be preserved
721        assert_eq!(config.model.as_deref(), Some("gpt-4"));
722        assert!(config.headless_auth);
723
724        // api_key and api_base are no longer in Config
725    }
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        // Create config with only http_proxy_auth
733        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        // Should fallback to http_proxy_auth when https_proxy_auth is absent
749        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        // Set temp home BEFORE creating config
764        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        // Set temp home to avoid loading real config
788        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        // Set temp home BEFORE creating config
807        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        // Save
816        config.save().expect("Failed to save config");
817
818        // Load again
819        let loaded = Config::new();
820
821        // Verify
822        assert_eq!(loaded.server.port, 9000);
823        assert_eq!(loaded.server.bind, "0.0.0.0");
824        assert_eq!(loaded.provider, "anthropic");
825    }
826}