use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; /// Expand environment variables in a string. /// Supports both ${VAR_NAME} and $VAR_NAME syntax. /// Returns an error if a referenced variable is not set. fn expand_env_var_string(input: &str) -> crate::error::Result { let mut result = String::new(); let mut chars = input.chars().peekable(); while let Some(ch) = chars.next() { if ch == '$' { // Check if it's ${VAR} or $VAR syntax let use_braces = chars.peek() == Some(&'{'); if use_braces { chars.next(); // consume '{' } // Collect variable name let mut var_name = String::new(); while let Some(&next_ch) = chars.peek() { if use_braces { if next_ch == '}' { chars.next(); // consume '}' break; } var_name.push(next_ch); chars.next(); } else { // For $VAR syntax, stop at non-alphanumeric/underscore if next_ch.is_alphanumeric() || next_ch == '_' { var_name.push(next_ch); chars.next(); } else { break; } } } if var_name.is_empty() { return Err(crate::error::PinakesError::Config( "empty environment variable name".to_string(), )); } // Look up the environment variable match std::env::var(&var_name) { Ok(value) => result.push_str(&value), Err(_) => { return Err(crate::error::PinakesError::Config(format!( "environment variable not set: {}", var_name ))); } } } else if ch == '\\' { // Handle escaped characters if let Some(&next_ch) = chars.peek() { if next_ch == '$' { chars.next(); // consume the escaped $ result.push('$'); } else { result.push(ch); } } else { result.push(ch); } } else { result.push(ch); } } Ok(result) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub storage: StorageConfig, pub directories: DirectoryConfig, pub scanning: ScanningConfig, pub server: ServerConfig, #[serde(default)] pub ui: UiConfig, #[serde(default)] pub accounts: AccountsConfig, #[serde(default)] pub jobs: JobsConfig, #[serde(default)] pub thumbnails: ThumbnailConfig, #[serde(default)] pub webhooks: Vec, #[serde(default)] pub scheduled_tasks: Vec, #[serde(default)] pub plugins: PluginsConfig, #[serde(default)] pub transcoding: TranscodingConfig, #[serde(default)] pub enrichment: EnrichmentConfig, #[serde(default)] pub cloud: CloudConfig, #[serde(default)] pub analytics: AnalyticsConfig, #[serde(default)] pub photos: PhotoConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledTaskConfig { pub id: String, pub enabled: bool, pub schedule: crate::scheduler::Schedule, pub last_run: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobsConfig { #[serde(default = "default_worker_count")] pub worker_count: usize, #[serde(default = "default_cache_ttl")] pub cache_ttl_secs: u64, } fn default_worker_count() -> usize { 2 } fn default_cache_ttl() -> u64 { 60 } impl Default for JobsConfig { fn default() -> Self { Self { worker_count: default_worker_count(), cache_ttl_secs: default_cache_ttl(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThumbnailConfig { #[serde(default = "default_thumb_size")] pub size: u32, #[serde(default = "default_thumb_quality")] pub quality: u8, #[serde(default)] pub ffmpeg_path: Option, #[serde(default = "default_video_seek")] pub video_seek_secs: u32, } fn default_thumb_size() -> u32 { 320 } fn default_thumb_quality() -> u8 { 80 } fn default_video_seek() -> u32 { 2 } impl Default for ThumbnailConfig { fn default() -> Self { Self { size: default_thumb_size(), quality: default_thumb_quality(), ffmpeg_path: None, video_seek_secs: default_video_seek(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookConfig { pub url: String, pub events: Vec, #[serde(default)] pub secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiConfig { #[serde(default = "default_theme")] pub theme: String, #[serde(default = "default_view")] pub default_view: String, #[serde(default = "default_page_size")] pub default_page_size: usize, #[serde(default = "default_view_mode")] pub default_view_mode: String, #[serde(default)] pub auto_play_media: bool, #[serde(default = "default_true")] pub show_thumbnails: bool, #[serde(default)] pub sidebar_collapsed: bool, } fn default_theme() -> String { "dark".to_string() } fn default_view() -> String { "library".to_string() } fn default_page_size() -> usize { 48 } fn default_view_mode() -> String { "grid".to_string() } fn default_true() -> bool { true } impl Default for UiConfig { fn default() -> Self { Self { theme: default_theme(), default_view: default_view(), default_page_size: default_page_size(), default_view_mode: default_view_mode(), auto_play_media: false, show_thumbnails: true, sidebar_collapsed: false, } } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AccountsConfig { #[serde(default)] pub enabled: bool, #[serde(default)] pub users: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserAccount { pub username: String, pub password_hash: String, #[serde(default)] pub role: UserRole, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum UserRole { Admin, Editor, #[default] Viewer, } impl UserRole { pub fn can_read(self) -> bool { true } pub fn can_write(self) -> bool { matches!(self, Self::Admin | Self::Editor) } pub fn can_admin(self) -> bool { matches!(self, Self::Admin) } } impl std::fmt::Display for UserRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Admin => write!(f, "admin"), Self::Editor => write!(f, "editor"), Self::Viewer => write!(f, "viewer"), } } } // ===== Plugin Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginsConfig { #[serde(default)] pub enabled: bool, #[serde(default = "default_plugin_data_dir")] pub data_dir: PathBuf, #[serde(default = "default_plugin_cache_dir")] pub cache_dir: PathBuf, #[serde(default)] pub plugin_dirs: Vec, #[serde(default)] pub enable_hot_reload: bool, #[serde(default)] pub allow_unsigned: bool, #[serde(default = "default_max_concurrent_ops")] pub max_concurrent_ops: usize, #[serde(default = "default_plugin_timeout")] pub plugin_timeout_secs: u64, } fn default_plugin_data_dir() -> PathBuf { Config::default_data_dir().join("plugins").join("data") } fn default_plugin_cache_dir() -> PathBuf { Config::default_data_dir().join("plugins").join("cache") } fn default_max_concurrent_ops() -> usize { 4 } fn default_plugin_timeout() -> u64 { 30 } impl Default for PluginsConfig { fn default() -> Self { Self { enabled: false, data_dir: default_plugin_data_dir(), cache_dir: default_plugin_cache_dir(), plugin_dirs: vec![], enable_hot_reload: false, allow_unsigned: false, max_concurrent_ops: default_max_concurrent_ops(), plugin_timeout_secs: default_plugin_timeout(), } } } // ===== Transcoding Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TranscodingConfig { #[serde(default)] pub enabled: bool, #[serde(default)] pub cache_dir: Option, #[serde(default = "default_cache_ttl_hours")] pub cache_ttl_hours: u64, #[serde(default = "default_max_concurrent_transcodes")] pub max_concurrent: usize, #[serde(default)] pub hardware_acceleration: Option, #[serde(default)] pub profiles: Vec, } fn default_cache_ttl_hours() -> u64 { 48 } fn default_max_concurrent_transcodes() -> usize { 2 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TranscodeProfile { pub name: String, pub video_codec: String, pub audio_codec: String, pub max_bitrate_kbps: u32, pub max_resolution: String, } impl Default for TranscodingConfig { fn default() -> Self { Self { enabled: false, cache_dir: None, cache_ttl_hours: default_cache_ttl_hours(), max_concurrent: default_max_concurrent_transcodes(), hardware_acceleration: None, profiles: vec![ TranscodeProfile { name: "high".to_string(), video_codec: "h264".to_string(), audio_codec: "aac".to_string(), max_bitrate_kbps: 8000, max_resolution: "1080p".to_string(), }, TranscodeProfile { name: "medium".to_string(), video_codec: "h264".to_string(), audio_codec: "aac".to_string(), max_bitrate_kbps: 4000, max_resolution: "720p".to_string(), }, ], } } } // ===== Enrichment Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EnrichmentConfig { #[serde(default)] pub enabled: bool, #[serde(default)] pub auto_enrich_on_import: bool, #[serde(default)] pub sources: EnrichmentSources, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct EnrichmentSources { #[serde(default)] pub musicbrainz: EnrichmentSource, #[serde(default)] pub tmdb: EnrichmentSource, #[serde(default)] pub lastfm: EnrichmentSource, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EnrichmentSource { #[serde(default)] pub enabled: bool, #[serde(default)] pub api_key: Option, #[serde(default)] pub api_endpoint: Option, } // ===== Cloud Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloudConfig { #[serde(default)] pub enabled: bool, #[serde(default = "default_auto_sync_interval")] pub auto_sync_interval_mins: u64, #[serde(default)] pub accounts: Vec, } fn default_auto_sync_interval() -> u64 { 60 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloudAccount { pub id: String, pub provider: String, #[serde(default)] pub enabled: bool, #[serde(default)] pub sync_rules: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloudSyncRule { pub local_path: PathBuf, pub remote_path: String, pub direction: CloudSyncDirection, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CloudSyncDirection { Upload, Download, Bidirectional, } impl Default for CloudConfig { fn default() -> Self { Self { enabled: false, auto_sync_interval_mins: default_auto_sync_interval(), accounts: vec![], } } } // ===== Analytics Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnalyticsConfig { #[serde(default)] pub enabled: bool, #[serde(default = "default_true")] pub track_usage: bool, #[serde(default = "default_retention_days")] pub retention_days: u64, } fn default_retention_days() -> u64 { 90 } impl Default for AnalyticsConfig { fn default() -> Self { Self { enabled: false, track_usage: true, retention_days: default_retention_days(), } } } // ===== Photo Management Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PhotoConfig { /// Generate perceptual hashes for image duplicate detection (CPU-intensive) #[serde(default = "default_true")] pub generate_perceptual_hash: bool, /// Automatically create tags from EXIF keywords #[serde(default)] pub auto_tag_from_exif: bool, /// Generate multi-resolution thumbnails (tiny, grid, preview) #[serde(default)] pub multi_resolution_thumbnails: bool, /// Auto-detect photo events/albums based on time and location #[serde(default)] pub enable_event_detection: bool, /// Minimum number of photos to form an event #[serde(default = "default_min_event_photos")] pub min_event_photos: usize, /// Maximum time gap between photos in the same event (in seconds) #[serde(default = "default_event_time_gap")] pub event_time_gap_secs: i64, /// Maximum distance between photos in the same event (in kilometers) #[serde(default = "default_event_distance")] pub event_max_distance_km: f64, } fn default_min_event_photos() -> usize { 5 } fn default_event_time_gap() -> i64 { 2 * 60 * 60 // 2 hours } fn default_event_distance() -> f64 { 1.0 // 1 km } impl Default for PhotoConfig { fn default() -> Self { Self { generate_perceptual_hash: true, auto_tag_from_exif: false, multi_resolution_thumbnails: false, enable_event_detection: false, min_event_photos: default_min_event_photos(), event_time_gap_secs: default_event_time_gap(), event_max_distance_km: default_event_distance(), } } } // ===== Storage Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageConfig { pub backend: StorageBackendType, pub sqlite: Option, pub postgres: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum StorageBackendType { Sqlite, Postgres, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SqliteConfig { pub path: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PostgresConfig { pub host: String, pub port: u16, pub database: String, pub username: String, pub password: String, pub max_connections: usize, /// Enable TLS for PostgreSQL connections #[serde(default)] pub tls_enabled: bool, /// Verify TLS certificates (default: true) #[serde(default = "default_true")] pub tls_verify_ca: bool, /// Path to custom CA certificate file (PEM format) #[serde(default)] pub tls_ca_cert_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DirectoryConfig { pub roots: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScanningConfig { pub watch: bool, pub poll_interval_secs: u64, pub ignore_patterns: Vec, #[serde(default = "default_import_concurrency")] pub import_concurrency: usize, } fn default_import_concurrency() -> usize { 8 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { pub host: String, pub port: u16, /// Optional API key for bearer token authentication. /// If set, all requests (except /health) must include `Authorization: Bearer `. /// Can also be set via `PINAKES_API_KEY` environment variable. pub api_key: Option, /// Explicitly disable authentication (INSECURE - use only for development). /// When true, all requests are allowed without authentication. /// This must be explicitly set to true; empty api_key alone is not sufficient. #[serde(default)] pub authentication_disabled: bool, /// TLS/HTTPS configuration #[serde(default)] pub tls: TlsConfig, } /// TLS/HTTPS configuration for secure connections #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TlsConfig { /// Enable TLS (HTTPS) #[serde(default)] pub enabled: bool, /// Path to the TLS certificate file (PEM format) #[serde(default)] pub cert_path: Option, /// Path to the TLS private key file (PEM format) #[serde(default)] pub key_path: Option, /// Enable HTTP to HTTPS redirect (starts a second listener on http_port) #[serde(default)] pub redirect_http: bool, /// Port for HTTP redirect listener (default: 80) #[serde(default = "default_http_port")] pub http_port: u16, /// Enable HSTS (HTTP Strict Transport Security) header #[serde(default = "default_true")] pub hsts_enabled: bool, /// HSTS max-age in seconds (default: 1 year) #[serde(default = "default_hsts_max_age")] pub hsts_max_age: u64, } fn default_http_port() -> u16 { 80 } fn default_hsts_max_age() -> u64 { 31536000 // 1 year in seconds } impl Default for TlsConfig { fn default() -> Self { Self { enabled: false, cert_path: None, key_path: None, redirect_http: false, http_port: default_http_port(), hsts_enabled: true, hsts_max_age: default_hsts_max_age(), } } } impl TlsConfig { /// Validate TLS configuration pub fn validate(&self) -> Result<(), String> { if self.enabled { if self.cert_path.is_none() { return Err("TLS enabled but cert_path not specified".into()); } if self.key_path.is_none() { return Err("TLS enabled but key_path not specified".into()); } if let Some(ref cert_path) = self.cert_path && !cert_path.exists() { return Err(format!( "TLS certificate file not found: {}", cert_path.display() )); } if let Some(ref key_path) = self.key_path && !key_path.exists() { return Err(format!("TLS key file not found: {}", key_path.display())); } } Ok(()) } } impl Config { pub fn from_file(path: &Path) -> crate::error::Result { let content = std::fs::read_to_string(path).map_err(|e| { crate::error::PinakesError::Config(format!("failed to read config file: {e}")) })?; let mut config: Self = toml::from_str(&content).map_err(|e| { crate::error::PinakesError::Config(format!("failed to parse config: {e}")) })?; config.expand_env_vars()?; Ok(config) } /// Expand environment variables in secret fields. /// Supports ${VAR_NAME} and $VAR_NAME syntax. fn expand_env_vars(&mut self) -> crate::error::Result<()> { // Postgres password if let Some(ref mut postgres) = self.storage.postgres { postgres.password = expand_env_var_string(&postgres.password)?; } // Server API key if let Some(ref api_key) = self.server.api_key { self.server.api_key = Some(expand_env_var_string(api_key)?); } // Webhook secrets for webhook in &mut self.webhooks { if let Some(ref secret) = webhook.secret { webhook.secret = Some(expand_env_var_string(secret)?); } } // Enrichment API keys if let Some(ref api_key) = self.enrichment.sources.musicbrainz.api_key { self.enrichment.sources.musicbrainz.api_key = Some(expand_env_var_string(api_key)?); } if let Some(ref api_key) = self.enrichment.sources.tmdb.api_key { self.enrichment.sources.tmdb.api_key = Some(expand_env_var_string(api_key)?); } if let Some(ref api_key) = self.enrichment.sources.lastfm.api_key { self.enrichment.sources.lastfm.api_key = Some(expand_env_var_string(api_key)?); } Ok(()) } /// Try loading from file, falling back to defaults if the file doesn't exist. pub fn load_or_default(path: &Path) -> crate::error::Result { if path.exists() { Self::from_file(path) } else { let config = Self::default(); // Ensure the data directory exists for the default SQLite database config.ensure_dirs()?; Ok(config) } } /// Save the current config to a TOML file. pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let content = toml::to_string_pretty(self).map_err(|e| { crate::error::PinakesError::Config(format!("failed to serialize config: {e}")) })?; std::fs::write(path, content)?; Ok(()) } /// Ensure all directories needed by this config exist and are writable. pub fn ensure_dirs(&self) -> crate::error::Result<()> { if let Some(ref sqlite) = self.storage.sqlite && let Some(parent) = sqlite.path.parent() { std::fs::create_dir_all(parent)?; let metadata = std::fs::metadata(parent)?; if metadata.permissions().readonly() { return Err(crate::error::PinakesError::Config(format!( "directory is not writable: {}", parent.display() ))); } } Ok(()) } /// Returns the default config file path following XDG conventions. pub fn default_config_path() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { PathBuf::from(xdg).join("pinakes").join("pinakes.toml") } else if let Ok(home) = std::env::var("HOME") { PathBuf::from(home) .join(".config") .join("pinakes") .join("pinakes.toml") } else { PathBuf::from("pinakes.toml") } } /// Validate configuration values for correctness. pub fn validate(&self) -> Result<(), String> { if self.server.port == 0 { return Err("server port cannot be 0".into()); } if self.server.host.is_empty() { return Err("server host cannot be empty".into()); } if self.scanning.poll_interval_secs == 0 { return Err("poll interval cannot be 0".into()); } if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 { return Err("import_concurrency must be between 1 and 256".into()); } // Validate authentication configuration let has_api_key = self.server.api_key.as_ref().is_some_and(|k| !k.is_empty()); let has_accounts = !self.accounts.users.is_empty(); let auth_disabled = self.server.authentication_disabled; if !auth_disabled && !has_api_key && !has_accounts { return Err( "authentication is not configured: set an api_key, configure user accounts, \ or explicitly set authentication_disabled = true" .into(), ); } // Empty API key is not allowed (must use authentication_disabled flag) if let Some(ref api_key) = self.server.api_key && api_key.is_empty() { return Err("empty api_key is not allowed. To disable authentication, \ set authentication_disabled = true instead" .into()); } // Require TLS when authentication is enabled on non-localhost let is_localhost = self.server.host == "127.0.0.1" || self.server.host == "localhost" || self.server.host == "::1"; if (has_api_key || has_accounts) && !auth_disabled && !is_localhost && !self.server.tls.enabled { return Err( "TLS must be enabled when authentication is used on non-localhost hosts. \ Set server.tls.enabled = true or bind to localhost only" .into(), ); } // Validate TLS configuration self.server.tls.validate()?; Ok(()) } /// Returns the default data directory following XDG conventions. pub fn default_data_dir() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { PathBuf::from(xdg).join("pinakes") } else if let Ok(home) = std::env::var("HOME") { PathBuf::from(home) .join(".local") .join("share") .join("pinakes") } else { PathBuf::from("pinakes-data") } } } impl Default for Config { fn default() -> Self { let data_dir = Self::default_data_dir(); Self { storage: StorageConfig { backend: StorageBackendType::Sqlite, sqlite: Some(SqliteConfig { path: data_dir.join("pinakes.db"), }), postgres: None, }, directories: DirectoryConfig { roots: vec![] }, scanning: ScanningConfig { watch: false, poll_interval_secs: 300, ignore_patterns: vec![ ".*".to_string(), "node_modules".to_string(), "__pycache__".to_string(), "target".to_string(), ], import_concurrency: default_import_concurrency(), }, server: ServerConfig { host: "127.0.0.1".to_string(), port: 3000, api_key: None, authentication_disabled: false, tls: TlsConfig::default(), }, ui: UiConfig::default(), accounts: AccountsConfig::default(), jobs: JobsConfig::default(), thumbnails: ThumbnailConfig::default(), webhooks: vec![], scheduled_tasks: vec![], plugins: PluginsConfig::default(), transcoding: TranscodingConfig::default(), enrichment: EnrichmentConfig::default(), cloud: CloudConfig::default(), analytics: AnalyticsConfig::default(), photos: PhotoConfig::default(), } } } #[cfg(test)] mod tests { use super::*; fn test_config_with_concurrency(concurrency: usize) -> Config { let mut config = Config::default(); config.scanning.import_concurrency = concurrency; config.server.authentication_disabled = true; // Disable auth for concurrency tests config } #[test] fn test_validate_import_concurrency_zero() { let config = test_config_with_concurrency(0); assert!(config.validate().is_err()); assert!( config .validate() .unwrap_err() .contains("import_concurrency") ); } #[test] fn test_validate_import_concurrency_too_high() { let config = test_config_with_concurrency(257); assert!(config.validate().is_err()); assert!( config .validate() .unwrap_err() .contains("import_concurrency") ); } #[test] fn test_validate_import_concurrency_valid() { let config = test_config_with_concurrency(8); assert!(config.validate().is_ok()); } #[test] fn test_validate_import_concurrency_boundary_low() { let config = test_config_with_concurrency(1); assert!(config.validate().is_ok()); } #[test] fn test_validate_import_concurrency_boundary_high() { let config = test_config_with_concurrency(256); assert!(config.validate().is_ok()); } // Environment variable expansion tests #[test] fn test_expand_env_var_simple() { unsafe { std::env::set_var("TEST_VAR_SIMPLE", "test_value"); } let result = expand_env_var_string("$TEST_VAR_SIMPLE"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "test_value"); unsafe { std::env::remove_var("TEST_VAR_SIMPLE"); } } #[test] fn test_expand_env_var_braces() { unsafe { std::env::set_var("TEST_VAR_BRACES", "test_value"); } let result = expand_env_var_string("${TEST_VAR_BRACES}"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "test_value"); unsafe { std::env::remove_var("TEST_VAR_BRACES"); } } #[test] fn test_expand_env_var_embedded() { unsafe { std::env::set_var("TEST_VAR_EMBEDDED", "value"); } let result = expand_env_var_string("prefix_${TEST_VAR_EMBEDDED}_suffix"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "prefix_value_suffix"); unsafe { std::env::remove_var("TEST_VAR_EMBEDDED"); } } #[test] fn test_expand_env_var_multiple() { unsafe { std::env::set_var("VAR1", "value1"); std::env::set_var("VAR2", "value2"); } let result = expand_env_var_string("${VAR1}_${VAR2}"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "value1_value2"); unsafe { std::env::remove_var("VAR1"); std::env::remove_var("VAR2"); } } #[test] fn test_expand_env_var_missing() { let result = expand_env_var_string("${NONEXISTENT_VAR}"); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("environment variable not set") ); } #[test] fn test_expand_env_var_empty_name() { let result = expand_env_var_string("${}"); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("empty environment variable name") ); } #[test] fn test_expand_env_var_escaped() { let result = expand_env_var_string("\\$NOT_A_VAR"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "$NOT_A_VAR"); } #[test] fn test_expand_env_var_no_vars() { let result = expand_env_var_string("plain_text"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "plain_text"); } #[test] fn test_expand_env_var_underscore() { unsafe { std::env::set_var("TEST_VAR_NAME", "value"); } let result = expand_env_var_string("$TEST_VAR_NAME"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "value"); unsafe { std::env::remove_var("TEST_VAR_NAME"); } } #[test] fn test_expand_env_var_mixed_syntax() { unsafe { std::env::set_var("VAR1_MIXED", "v1"); std::env::set_var("VAR2_MIXED", "v2"); } let result = expand_env_var_string("$VAR1_MIXED and ${VAR2_MIXED}"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "v1 and v2"); unsafe { std::env::remove_var("VAR1_MIXED"); std::env::remove_var("VAR2_MIXED"); } } }