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    ///
69    /// Note: this is kept in-memory only. On disk we store `proxy_auth_encrypted`.
70    #[serde(skip_serializing)]
71    pub proxy_auth: Option<ProxyAuth>,
72    /// Encrypted proxy authentication credentials (nonce:ciphertext)
73    ///
74    /// This is the at-rest storage representation. When present, Bamboo will
75    /// decrypt it into `proxy_auth` at load time.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub proxy_auth_encrypted: Option<String>,
78    /// Default model to use (can be overridden per provider)
79    pub model: Option<String>,
80    /// Deprecated: Use `providers.copilot.headless_auth` instead
81    #[serde(default)]
82    pub headless_auth: bool,
83
84    /// Default LLM provider to use (e.g., "anthropic", "openai", "gemini", "copilot")
85    #[serde(default = "default_provider")]
86    pub provider: String,
87
88    /// Provider-specific configurations
89    #[serde(default)]
90    pub providers: ProviderConfigs,
91
92    /// HTTP server configuration
93    #[serde(default)]
94    pub server: ServerConfig,
95
96    /// Data directory path (defaults to ~/.bamboo)
97    #[serde(default = "default_data_dir")]
98    pub data_dir: PathBuf,
99}
100
101/// Container for provider-specific configurations
102///
103/// Each field is optional, allowing users to configure only the providers they need.
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct ProviderConfigs {
106    /// OpenAI provider configuration
107    pub openai: Option<OpenAIConfig>,
108    /// Anthropic provider configuration
109    pub anthropic: Option<AnthropicConfig>,
110    /// Google Gemini provider configuration
111    pub gemini: Option<GeminiConfig>,
112    /// GitHub Copilot provider configuration
113    pub copilot: Option<CopilotConfig>,
114}
115
116/// OpenAI provider configuration
117///
118/// # Example
119///
120/// ```json
121/// "openai": {
122///   "api_key": "sk-...",
123///   "base_url": "https://api.openai.com/v1",
124///   "model": "gpt-4"
125/// }
126/// ```
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct OpenAIConfig {
129    /// OpenAI API key
130    pub api_key: String,
131    /// Custom API base URL (for Azure or self-hosted deployments)
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub base_url: Option<String>,
134    /// Default model to use (e.g., "gpt-4", "gpt-3.5-turbo")
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub model: Option<String>,
137}
138
139/// Anthropic provider configuration
140///
141/// # Example
142///
143/// ```json
144/// "anthropic": {
145///   "api_key": "sk-ant-...",
146///   "model": "claude-3-5-sonnet-20241022",
147///   "max_tokens": 4096
148/// }
149/// ```
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct AnthropicConfig {
152    /// Anthropic API key
153    pub api_key: String,
154    /// Custom API base URL
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub base_url: Option<String>,
157    /// Default model to use (e.g., "claude-3-5-sonnet-20241022")
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub model: Option<String>,
160    /// Maximum tokens in model response
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub max_tokens: Option<u32>,
163}
164
165/// Google Gemini provider configuration
166///
167/// # Example
168///
169/// ```json
170/// "gemini": {
171///   "api_key": "AIza...",
172///   "model": "gemini-2.0-flash-exp"
173/// }
174/// ```
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct GeminiConfig {
177    /// Google AI API key
178    pub api_key: String,
179    /// Custom API base URL
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub base_url: Option<String>,
182    /// Default model to use (e.g., "gemini-2.0-flash-exp")
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub model: Option<String>,
185}
186
187/// GitHub Copilot provider configuration
188///
189/// # Example
190///
191/// ```json
192/// "copilot": {
193///   "enabled": true,
194///   "headless_auth": false,
195///   "model": "gpt-4o"
196/// }
197/// ```
198#[derive(Debug, Clone, Default, Serialize, Deserialize)]
199pub struct CopilotConfig {
200    /// Whether Copilot provider is enabled
201    #[serde(default)]
202    pub enabled: bool,
203    /// Print login URL to console instead of opening browser
204    #[serde(default)]
205    pub headless_auth: bool,
206    /// Default model to use for Copilot (used when clients request the "default" model)
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub model: Option<String>,
209}
210
211/// Returns the default provider name ("anthropic")
212fn default_provider() -> String {
213    "anthropic".to_string()
214}
215
216/// Returns the default server port (8080)
217fn default_port() -> u16 {
218    8080
219}
220
221/// Returns the default bind address (127.0.0.1)
222fn default_bind() -> String {
223    "127.0.0.1".to_string()
224}
225
226/// Returns the default worker count (10)
227fn default_workers() -> usize {
228    10
229}
230
231/// Returns the default data directory (~/.bamboo)
232fn default_data_dir() -> PathBuf {
233    super::paths::bamboo_dir()
234}
235
236/// HTTP server configuration
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ServerConfig {
239    /// Port to listen on
240    #[serde(default = "default_port")]
241    pub port: u16,
242
243    /// Bind address (127.0.0.1, 0.0.0.0, etc.)
244    #[serde(default = "default_bind")]
245    pub bind: String,
246
247    /// Static files directory (for Docker mode)
248    pub static_dir: Option<PathBuf>,
249
250    /// Worker count for Actix-web
251    #[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/// Proxy authentication credentials
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct ProxyAuth {
269    /// Proxy username
270    pub username: String,
271    /// Proxy password
272    pub password: String,
273}
274
275/// Configuration file name
276const CONFIG_FILE_PATH: &str = "config.toml";
277
278/// Parse a boolean value from environment variable strings
279///
280/// Accepts: "1", "true", "yes", "y", "on" (case-insensitive)
281fn 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    /// Load configuration from file with environment variable overrides
296    ///
297    /// Configuration loading order:
298    /// 1. Try loading from `config.json` (data_dir/config.json)
299    /// 2. Migrate old format if detected
300    /// 3. Fallback to `config.toml` in current directory
301    /// 4. Use defaults
302    /// 5. Apply environment variable overrides (highest priority)
303    ///
304    /// # Environment Variables
305    ///
306    /// - `BAMBOO_PORT`: Override server port
307    /// - `BAMBOO_BIND`: Override bind address
308    /// - `BAMBOO_DATA_DIR`: Override data directory
309    /// - `BAMBOO_PROVIDER`: Override default provider
310    /// - `MODEL`: Default model name
311    /// - `BAMBOO_HEADLESS`: Enable headless authentication mode
312    pub fn new() -> Self {
313        Self::from_data_dir(None)
314    }
315
316    /// Load configuration from a specific data directory
317    ///
318    /// # Arguments
319    ///
320    /// * `data_dir` - Optional data directory path. If None, uses default (~/.bamboo)
321    pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
322        // Determine data_dir early (needed to find config file)
323        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                // Try to parse as old format first (for migration)
332                if let Ok(old_config) = serde_json::from_str::<OldConfig>(&content) {
333                    // Check if it has old-only fields (indicating a true old config that needs migration)
334                    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                        // Save migrated config
343                        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                        // No old fields, so try to parse as new Config
349                        // OldConfig successfully parsed common fields like http_proxy, model, provider, etc.
350                        // Try Config, but if it fails (e.g., due to syntax errors), use OldConfig values
351                        match serde_json::from_str::<Config>(&content) {
352                            Ok(mut config) => {
353                                config.hydrate_proxy_auth();
354                                config
355                            }
356                            Err(_) => {
357                                // Config parse failed, but OldConfig worked, so preserve those values
358                                migrate_config(old_config)
359                            }
360                        }
361                    }
362                } else {
363                    // Couldn't parse as OldConfig, try as Config
364                    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            // Fallback to legacy config.toml
376            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        // Ensure data_dir is set correctly
392        config.data_dir = data_dir;
393        // Decrypt encrypted proxy auth into in-memory plaintext form.
394        config.hydrate_proxy_auth();
395
396        // Apply environment variable overrides (highest priority)
397        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        // Note: BAMBOO_DATA_DIR already handled above
408        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    /// Create a default configuration without loading from file
458    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    /// Get the full server address (bind:port)
474    pub fn server_addr(&self) -> String {
475        format!("{}:{}", self.server.bind, self.server.port)
476    }
477
478    /// Save configuration to disk
479    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/// Legacy configuration format for backward compatibility
500///
501/// This struct is used to migrate old configuration files to the new format.
502/// It supports the previous single-provider model.
503#[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    // Also capture new fields so we don't lose them during fallback
519    #[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
529/// Migrate old configuration format to new multi-provider format
530///
531/// Converts the legacy single-provider configuration to the new structure
532/// with explicit provider configurations.
533fn migrate_config(old: OldConfig) -> Config {
534    // Log warning about deprecated fields
535    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        // Use https_proxy_auth if available, otherwise fallback to http_proxy_auth
556        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            // Use .bamboo directory
624            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    /// Acquire the environment lock, recovering from poison if a previous test failed
643    fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
644        env_lock().lock().unwrap_or_else(|poisoned| {
645            // Lock was poisoned by a previous test failure - recover it
646            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        // Create config with old format
752        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        // Verify migration
777        assert_eq!(config.http_proxy, "http://proxy.example.com:8080");
778        assert_eq!(config.https_proxy, "http://proxy.example.com:8443");
779
780        // Should use https_proxy_auth (higher priority)
781        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        // Model and headless_auth should be preserved
787        assert_eq!(config.model.as_deref(), Some("gpt-4"));
788        assert!(config.headless_auth);
789
790        // api_key and api_base are no longer in Config
791    }
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        // Create config with only http_proxy_auth
799        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        // Should fallback to http_proxy_auth when https_proxy_auth is absent
815        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        // Set temp home BEFORE creating config
830        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        // Set temp home to avoid loading real config
854        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        // Set temp home BEFORE creating config
873        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        // Save
882        config.save().expect("Failed to save config");
883
884        // Load again
885        let loaded = Config::new();
886
887        // Verify
888        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        // Use a stable encryption key so this test doesn't depend on host identifiers.
899        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        // Use a stable encryption key so this test doesn't depend on host identifiers.
933        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}