//! Configuration structs loaded once at startup from environment variables. //! //! Each struct provides a `from_env()` constructor that reads `std::env::var` //! values. Required variables cause an `Error::ConfigError` on failure; //! optional ones default to an empty string. use secrecy::SecretString; use super::Error; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Read a required environment variable or return `Error::ConfigError`. fn required_env(name: &str) -> Result { std::env::var(name).map_err(|_| Error::ConfigError(format!("{name} is required but not set"))) } /// Read an optional environment variable, defaulting to an empty string. fn optional_env(name: &str) -> String { std::env::var(name).unwrap_or_default() } // --------------------------------------------------------------------------- // KeycloakConfig // --------------------------------------------------------------------------- /// Keycloak OpenID Connect settings for the public (frontend) client. /// /// Also carries the admin service-account credentials used for /// server-to-server calls (e.g. user management APIs). #[derive(Debug)] pub struct KeycloakConfig { /// Base URL of the Keycloak instance (e.g. `http://localhost:8080`). pub url: String, /// Keycloak realm name. pub realm: String, /// Public client ID used by the dashboard frontend. pub client_id: String, /// OAuth redirect URI registered in Keycloak. pub redirect_uri: String, /// Root URL of this application (used for post-logout redirect). pub app_url: String, /// Confidential client ID for admin/server-to-server calls. pub admin_client_id: String, /// Confidential client secret (wrapped for debug safety). pub admin_client_secret: SecretString, } impl KeycloakConfig { /// Load Keycloak configuration from environment variables. /// /// # Errors /// /// Returns `Error::ConfigError` if a required variable is missing. pub fn from_env() -> Result { Ok(Self { url: required_env("KEYCLOAK_URL")?, realm: required_env("KEYCLOAK_REALM")?, client_id: required_env("KEYCLOAK_CLIENT_ID")?, redirect_uri: required_env("REDIRECT_URI")?, app_url: required_env("APP_URL")?, admin_client_id: optional_env("KEYCLOAK_ADMIN_CLIENT_ID"), admin_client_secret: SecretString::from(optional_env("KEYCLOAK_ADMIN_CLIENT_SECRET")), }) } /// OpenID Connect authorization endpoint URL. pub fn auth_endpoint(&self) -> String { format!( "{}/realms/{}/protocol/openid-connect/auth", self.url, self.realm ) } /// OpenID Connect token endpoint URL. pub fn token_endpoint(&self) -> String { format!( "{}/realms/{}/protocol/openid-connect/token", self.url, self.realm ) } /// OpenID Connect userinfo endpoint URL. pub fn userinfo_endpoint(&self) -> String { format!( "{}/realms/{}/protocol/openid-connect/userinfo", self.url, self.realm ) } /// OpenID Connect end-session (logout) endpoint URL. pub fn logout_endpoint(&self) -> String { format!( "{}/realms/{}/protocol/openid-connect/logout", self.url, self.realm ) } } // --------------------------------------------------------------------------- // SmtpConfig // --------------------------------------------------------------------------- /// SMTP mail settings for transactional emails (invites, alerts, etc.). #[derive(Debug)] pub struct SmtpConfig { /// SMTP server hostname. pub host: String, /// SMTP server port (as string for flexibility, e.g. "587"). pub port: String, /// SMTP username. pub username: String, /// SMTP password (wrapped for debug safety). pub password: SecretString, /// Sender address shown in the `From:` header. pub from_address: String, } impl SmtpConfig { /// Load SMTP configuration from environment variables. /// /// All fields are optional; defaults to empty strings when absent. /// /// # Errors /// /// Currently infallible but returns `Result` for consistency. pub fn from_env() -> Result { Ok(Self { host: optional_env("SMTP_HOST"), port: optional_env("SMTP_PORT"), username: optional_env("SMTP_USERNAME"), password: SecretString::from(optional_env("SMTP_PASSWORD")), from_address: optional_env("SMTP_FROM_ADDRESS"), }) } } // --------------------------------------------------------------------------- // ServiceUrls // --------------------------------------------------------------------------- /// URLs and credentials for external services (Ollama, SearXNG, S3, etc.). #[derive(Debug)] pub struct ServiceUrls { /// Ollama LLM instance base URL. pub ollama_url: String, /// Default Ollama model to use. pub ollama_model: String, /// SearXNG meta-search engine base URL. pub searxng_url: String, /// LangChain service URL. pub langchain_url: String, /// LangGraph service URL. pub langgraph_url: String, /// Langfuse observability URL. pub langfuse_url: String, /// Vector database URL. pub vectordb_url: String, /// S3-compatible object storage URL. pub s3_url: String, /// S3 access key. pub s3_access_key: String, /// S3 secret key (wrapped for debug safety). pub s3_secret_key: SecretString, } impl ServiceUrls { /// Load service URLs from environment variables. /// /// All fields are optional with sensible defaults where applicable. /// /// # Errors /// /// Currently infallible but returns `Result` for consistency. pub fn from_env() -> Result { Ok(Self { ollama_url: std::env::var("OLLAMA_URL") .unwrap_or_else(|_| "http://localhost:11434".into()), ollama_model: std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()), searxng_url: std::env::var("SEARXNG_URL") .unwrap_or_else(|_| "http://localhost:8888".into()), langchain_url: optional_env("LANGCHAIN_URL"), langgraph_url: optional_env("LANGGRAPH_URL"), langfuse_url: optional_env("LANGFUSE_URL"), vectordb_url: optional_env("VECTORDB_URL"), s3_url: optional_env("S3_URL"), s3_access_key: optional_env("S3_ACCESS_KEY"), s3_secret_key: SecretString::from(optional_env("S3_SECRET_KEY")), }) } } // --------------------------------------------------------------------------- // StripeConfig // --------------------------------------------------------------------------- /// Stripe billing configuration. #[derive(Debug)] pub struct StripeConfig { /// Stripe secret API key (wrapped for debug safety). pub secret_key: SecretString, /// Stripe webhook signing secret (wrapped for debug safety). pub webhook_secret: SecretString, /// Stripe publishable key (safe to expose to the frontend). pub publishable_key: String, } impl StripeConfig { /// Load Stripe configuration from environment variables. /// /// # Errors /// /// Currently infallible but returns `Result` for consistency. pub fn from_env() -> Result { Ok(Self { secret_key: SecretString::from(optional_env("STRIPE_SECRET_KEY")), webhook_secret: SecretString::from(optional_env("STRIPE_WEBHOOK_SECRET")), publishable_key: optional_env("STRIPE_PUBLISHABLE_KEY"), }) } } // --------------------------------------------------------------------------- // LlmProvidersConfig // --------------------------------------------------------------------------- /// Comma-separated list of enabled LLM provider identifiers. /// /// For example: `LLM_PROVIDERS=ollama,openai,anthropic` #[derive(Debug)] pub struct LlmProvidersConfig { /// Parsed provider names. pub providers: Vec, } impl LlmProvidersConfig { /// Load the provider list from `LLM_PROVIDERS`. /// /// # Errors /// /// Currently infallible but returns `Result` for consistency. pub fn from_env() -> Result { let raw = optional_env("LLM_PROVIDERS"); let providers: Vec = raw .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); Ok(Self { providers }) } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; use pretty_assertions::assert_eq; use serial_test::serial; // ----------------------------------------------------------------------- // KeycloakConfig endpoint methods (no env vars needed) // ----------------------------------------------------------------------- fn sample_keycloak() -> KeycloakConfig { KeycloakConfig { url: "https://auth.example.com".into(), realm: "myrealm".into(), client_id: "dashboard".into(), redirect_uri: "https://app.example.com/callback".into(), app_url: "https://app.example.com".into(), admin_client_id: String::new(), admin_client_secret: SecretString::from(String::new()), } } #[test] fn keycloak_auth_endpoint() { let kc = sample_keycloak(); assert_eq!( kc.auth_endpoint(), "https://auth.example.com/realms/myrealm/protocol/openid-connect/auth" ); } #[test] fn keycloak_token_endpoint() { let kc = sample_keycloak(); assert_eq!( kc.token_endpoint(), "https://auth.example.com/realms/myrealm/protocol/openid-connect/token" ); } #[test] fn keycloak_userinfo_endpoint() { let kc = sample_keycloak(); assert_eq!( kc.userinfo_endpoint(), "https://auth.example.com/realms/myrealm/protocol/openid-connect/userinfo" ); } #[test] fn keycloak_logout_endpoint() { let kc = sample_keycloak(); assert_eq!( kc.logout_endpoint(), "https://auth.example.com/realms/myrealm/protocol/openid-connect/logout" ); } // ----------------------------------------------------------------------- // LlmProvidersConfig::from_env() // ----------------------------------------------------------------------- #[test] #[serial] fn llm_providers_empty_string() { std::env::set_var("LLM_PROVIDERS", ""); let cfg = LlmProvidersConfig::from_env().unwrap(); assert!(cfg.providers.is_empty()); std::env::remove_var("LLM_PROVIDERS"); } #[test] #[serial] fn llm_providers_single() { std::env::set_var("LLM_PROVIDERS", "ollama"); let cfg = LlmProvidersConfig::from_env().unwrap(); assert_eq!(cfg.providers, vec!["ollama"]); std::env::remove_var("LLM_PROVIDERS"); } #[test] #[serial] fn llm_providers_multiple() { std::env::set_var("LLM_PROVIDERS", "ollama,openai,anthropic"); let cfg = LlmProvidersConfig::from_env().unwrap(); assert_eq!(cfg.providers, vec!["ollama", "openai", "anthropic"]); std::env::remove_var("LLM_PROVIDERS"); } #[test] #[serial] fn llm_providers_trims_whitespace() { std::env::set_var("LLM_PROVIDERS", " ollama , openai "); let cfg = LlmProvidersConfig::from_env().unwrap(); assert_eq!(cfg.providers, vec!["ollama", "openai"]); std::env::remove_var("LLM_PROVIDERS"); } #[test] #[serial] fn llm_providers_filters_empty_entries() { std::env::set_var("LLM_PROVIDERS", "ollama,,openai,"); let cfg = LlmProvidersConfig::from_env().unwrap(); assert_eq!(cfg.providers, vec!["ollama", "openai"]); std::env::remove_var("LLM_PROVIDERS"); } // ----------------------------------------------------------------------- // ServiceUrls::from_env() defaults // ----------------------------------------------------------------------- #[test] #[serial] fn service_urls_default_ollama_url() { std::env::remove_var("OLLAMA_URL"); let svc = ServiceUrls::from_env().unwrap(); assert_eq!(svc.ollama_url, "http://localhost:11434"); } #[test] #[serial] fn service_urls_default_ollama_model() { std::env::remove_var("OLLAMA_MODEL"); let svc = ServiceUrls::from_env().unwrap(); assert_eq!(svc.ollama_model, "llama3.1:8b"); } #[test] #[serial] fn service_urls_default_searxng_url() { std::env::remove_var("SEARXNG_URL"); let svc = ServiceUrls::from_env().unwrap(); assert_eq!(svc.searxng_url, "http://localhost:8888"); } #[test] #[serial] fn service_urls_custom_ollama_url() { std::env::set_var("OLLAMA_URL", "http://gpu-host:11434"); let svc = ServiceUrls::from_env().unwrap(); assert_eq!(svc.ollama_url, "http://gpu-host:11434"); std::env::remove_var("OLLAMA_URL"); } #[test] #[serial] fn required_env_missing_returns_config_error() { std::env::remove_var("__TEST_REQUIRED_MISSING__"); let result = required_env("__TEST_REQUIRED_MISSING__"); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!(err_msg.contains("__TEST_REQUIRED_MISSING__")); } }