use crate::error::{Result, ResultExt, SunbeamError}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::OnceLock; // --------------------------------------------------------------------------- // Config data model // --------------------------------------------------------------------------- /// Sunbeam configuration stored at ~/.sunbeam.json. /// /// Supports kubectl-style named contexts. Each context bundles a domain, /// kube context, SSH host, and infrastructure directory. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SunbeamConfig { /// The active context name. If empty, uses "default". #[serde(default, rename = "current-context")] pub current_context: String, /// Named contexts. #[serde(default)] pub contexts: HashMap, // --- Legacy fields (migrated on load) --- #[serde(default, skip_serializing_if = "String::is_empty")] pub production_host: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub infra_directory: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub acme_email: String, } /// A named context — everything needed to target a specific environment. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Context { /// The domain suffix (e.g. "sunbeam.pt", "192.168.105.3.sslip.io"). #[serde(default)] pub domain: String, /// Kubernetes context name (e.g. "production", "sunbeam"). #[serde(default, rename = "kube-context")] pub kube_context: String, /// SSH host for production tunnel (e.g. "sienna@62.210.145.138"). #[serde(default, rename = "ssh-host")] pub ssh_host: String, /// Infrastructure directory root. #[serde(default, rename = "infra-dir")] pub infra_dir: String, /// ACME email for cert-manager. #[serde(default, rename = "acme-email")] pub acme_email: String, } // --------------------------------------------------------------------------- // Active context (set once at startup, read everywhere) // --------------------------------------------------------------------------- static ACTIVE_CONTEXT: OnceLock = OnceLock::new(); /// Initialize the active context. Called once from cli::dispatch(). pub fn set_active_context(ctx: Context) { let _ = ACTIVE_CONTEXT.set(ctx); } /// Get the active context. Panics if not initialized (should never happen /// after dispatch starts). pub fn active_context() -> &'static Context { ACTIVE_CONTEXT.get().expect("active context not initialized") } /// Get the domain from the active context. Returns empty string if not set. pub fn domain() -> &'static str { ACTIVE_CONTEXT .get() .map(|c| c.domain.as_str()) .unwrap_or("") } // --------------------------------------------------------------------------- // Config file I/O // --------------------------------------------------------------------------- fn config_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".sunbeam.json") } /// Load configuration from ~/.sunbeam.json, return default if not found. /// Migrates legacy flat config to context-based format. pub fn load_config() -> SunbeamConfig { let path = config_path(); if !path.exists() { return SunbeamConfig::default(); } let mut config: SunbeamConfig = match std::fs::read_to_string(&path) { Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { crate::output::warn(&format!( "Failed to parse config from {}: {e}", path.display() )); SunbeamConfig::default() }), Err(e) => { crate::output::warn(&format!( "Failed to read config from {}: {e}", path.display() )); SunbeamConfig::default() } }; // Migrate legacy flat fields into a "production" context if !config.production_host.is_empty() && !config.contexts.contains_key("production") { let domain = derive_domain_from_host(&config.production_host); config.contexts.insert( "production".to_string(), Context { domain, kube_context: "production".to_string(), ssh_host: config.production_host.clone(), infra_dir: config.infra_directory.clone(), acme_email: config.acme_email.clone(), }, ); if config.current_context.is_empty() { config.current_context = "production".to_string(); } } config } /// Save configuration to ~/.sunbeam.json. pub fn save_config(config: &SunbeamConfig) -> Result<()> { let path = config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_ctx(|| { format!("Failed to create config directory: {}", parent.display()) })?; } let content = serde_json::to_string_pretty(config)?; std::fs::write(&path, content) .with_ctx(|| format!("Failed to save config to {}", path.display()))?; crate::output::ok(&format!("Configuration saved to {}", path.display())); Ok(()) } /// Resolve the context to use, given CLI flags and config. pub fn resolve_context( config: &SunbeamConfig, env_flag: &str, context_override: Option<&str>, domain_override: &str, ) -> Context { // Start from the named context (CLI --env or current-context) let context_name = context_override .map(|s| s.to_string()) .unwrap_or_else(|| { if env_flag == "production" { "production".to_string() } else if env_flag == "local" { "local".to_string() } else if !config.current_context.is_empty() { config.current_context.clone() } else { "local".to_string() } }); let mut ctx = config .contexts .get(&context_name) .cloned() .unwrap_or_else(|| { // Synthesize defaults for well-known names match context_name.as_str() { "local" => Context { kube_context: "sunbeam".to_string(), ..Default::default() }, "production" => Context { kube_context: "production".to_string(), ssh_host: config.production_host.clone(), infra_dir: config.infra_directory.clone(), acme_email: config.acme_email.clone(), domain: derive_domain_from_host(&config.production_host), ..Default::default() }, _ => Default::default(), } }); // CLI flags override context values if !domain_override.is_empty() { ctx.domain = domain_override.to_string(); } ctx } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Derive a domain from an SSH host (e.g. "user@admin.sunbeam.pt" → "sunbeam.pt"). fn derive_domain_from_host(host: &str) -> String { let raw = host.split('@').last().unwrap_or(host); let raw = raw.split(':').next().unwrap_or(raw); let parts: Vec<&str> = raw.split('.').collect(); if parts.len() >= 2 { format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]) } else { String::new() } } /// Get production host from config or SUNBEAM_SSH_HOST environment variable. pub fn get_production_host() -> String { let config = load_config(); // Check active context first if let Some(ctx) = ACTIVE_CONTEXT.get() { if !ctx.ssh_host.is_empty() { return ctx.ssh_host.clone(); } } if !config.production_host.is_empty() { return config.production_host; } std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default() } /// Infrastructure manifests directory as a Path. pub fn get_infra_dir() -> PathBuf { // Check active context if let Some(ctx) = ACTIVE_CONTEXT.get() { if !ctx.infra_dir.is_empty() { return PathBuf::from(&ctx.infra_dir); } } let configured = load_config().infra_directory; if !configured.is_empty() { return PathBuf::from(configured); } // Dev fallback std::env::current_exe() .ok() .and_then(|p| p.canonicalize().ok()) .and_then(|p| { let mut dir = p.as_path(); for _ in 0..10 { dir = dir.parent()?; if dir.join("infrastructure").is_dir() { return Some(dir.join("infrastructure")); } } None }) .unwrap_or_else(|| PathBuf::from("infrastructure")) } /// Monorepo root directory (parent of the infrastructure directory). pub fn get_repo_root() -> PathBuf { get_infra_dir() .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")) } /// Clear configuration file. pub fn clear_config() -> Result<()> { let path = config_path(); if path.exists() { std::fs::remove_file(&path) .with_ctx(|| format!("Failed to remove {}", path.display()))?; crate::output::ok(&format!("Configuration cleared from {}", path.display())); } else { crate::output::warn("No configuration file found to clear"); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_config() { let config = SunbeamConfig::default(); assert!(config.current_context.is_empty()); assert!(config.contexts.is_empty()); } #[test] fn test_derive_domain_from_host() { assert_eq!(derive_domain_from_host("sienna@admin.sunbeam.pt"), "sunbeam.pt"); assert_eq!(derive_domain_from_host("user@62.210.145.138"), "145.138"); assert_eq!(derive_domain_from_host("sunbeam.pt"), "sunbeam.pt"); assert_eq!(derive_domain_from_host("localhost"), ""); } #[test] fn test_legacy_migration() { let json = r#"{ "production_host": "sienna@62.210.145.138", "infra_directory": "/path/to/infra", "acme_email": "ops@sunbeam.pt" }"#; let config: SunbeamConfig = serde_json::from_str(json).unwrap(); // After load_config migration, contexts would be populated. // Here we just test the struct deserializes legacy fields. assert_eq!(config.production_host, "sienna@62.210.145.138"); assert!(config.contexts.is_empty()); // migration happens in load_config() } #[test] fn test_context_roundtrip() { let mut config = SunbeamConfig::default(); config.current_context = "production".to_string(); config.contexts.insert( "production".to_string(), Context { domain: "sunbeam.pt".to_string(), kube_context: "production".to_string(), ssh_host: "sienna@server.sunbeam.pt".to_string(), infra_dir: "/home/infra".to_string(), acme_email: "ops@sunbeam.pt".to_string(), }, ); let json = serde_json::to_string(&config).unwrap(); let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap(); assert_eq!(loaded.current_context, "production"); let ctx = loaded.contexts.get("production").unwrap(); assert_eq!(ctx.domain, "sunbeam.pt"); assert_eq!(ctx.ssh_host, "sienna@server.sunbeam.pt"); } #[test] fn test_resolve_context_from_env_flag() { let mut config = SunbeamConfig::default(); config.contexts.insert( "production".to_string(), Context { domain: "sunbeam.pt".to_string(), kube_context: "production".to_string(), ..Default::default() }, ); let ctx = resolve_context(&config, "production", None, ""); assert_eq!(ctx.domain, "sunbeam.pt"); assert_eq!(ctx.kube_context, "production"); } #[test] fn test_resolve_context_domain_override() { let config = SunbeamConfig::default(); let ctx = resolve_context(&config, "local", None, "custom.example.com"); assert_eq!(ctx.domain, "custom.example.com"); } #[test] fn test_resolve_context_defaults_local() { let config = SunbeamConfig::default(); let ctx = resolve_context(&config, "local", None, ""); assert_eq!(ctx.kube_context, "sunbeam"); } }