use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use crate::camera::{CameraConfig, CameraSource}; use crate::storage::{StorageConfig, VideoQuality}; use crate::communication::CommunicationConfig; /// Configuration structure for the meteor edge client #[derive(Debug, Serialize, Deserialize)] pub struct Config { /// Whether the device has been successfully registered pub registered: bool, /// The hardware ID used for registration pub hardware_id: String, /// Timestamp of when registration was completed pub registered_at: Option, /// The user profile ID this device is registered to pub user_profile_id: Option, /// Device ID returned from the registration API pub device_id: Option, /// JWT token for authentication with backend services pub jwt_token: Option, } impl Config { /// Creates a new unregistered configuration pub fn new(hardware_id: String) -> Self { Self { registered: false, hardware_id, registered_at: None, user_profile_id: None, device_id: None, jwt_token: None, } } /// Marks the configuration as registered with the given details pub fn mark_registered(&mut self, user_profile_id: String, device_id: String, jwt_token: String) { self.registered = true; self.user_profile_id = Some(user_profile_id); self.device_id = Some(device_id); self.jwt_token = Some(jwt_token); self.registered_at = Some( chrono::Utc::now().to_rfc3339() ); } } /// Configuration manager handles reading and writing config files pub struct ConfigManager { config_path: PathBuf, } impl ConfigManager { /// Creates a new configuration manager /// Uses system-appropriate config directory or falls back to a local path pub fn new() -> Self { let config_path = get_config_file_path(); Self { config_path } } /// Creates a configuration manager with a custom path (useful for testing) pub fn with_path>(path: P) -> Self { Self { config_path: path.as_ref().to_path_buf(), } } /// Checks if a configuration file exists pub fn config_exists(&self) -> bool { self.config_path.exists() } /// Loads configuration from the file system pub fn load_config(&self) -> Result { let content = fs::read_to_string(&self.config_path) .with_context(|| format!("Failed to read config file: {:?}", self.config_path))?; let config: Config = toml::from_str(&content) .context("Failed to parse config file as TOML")?; Ok(config) } /// Saves configuration to the file system pub fn save_config(&self, config: &Config) -> Result<()> { // Ensure the parent directory exists if let Some(parent) = self.config_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("Failed to create config directory: {:?}", parent))?; } let content = toml::to_string_pretty(config) .context("Failed to serialize config to TOML")?; fs::write(&self.config_path, content) .with_context(|| format!("Failed to write config file: {:?}", self.config_path))?; println!("✅ Configuration saved to: {:?}", self.config_path); Ok(()) } /// Gets the path where the config file is stored pub fn get_config_path(&self) -> &Path { &self.config_path } } /// Camera-specific configuration structure for TOML parsing #[derive(Debug, Serialize, Deserialize)] pub struct CameraConfigToml { pub source: String, // "device" or file path pub device_id: Option, pub fps: Option, pub width: Option, pub height: Option, } impl Default for CameraConfigToml { fn default() -> Self { Self { source: "device".to_string(), device_id: Some(0), fps: Some(30.0), width: Some(640), height: Some(480), } } } /// Storage-specific configuration structure for TOML parsing #[derive(Debug, Serialize, Deserialize)] pub struct StorageConfigToml { pub frame_buffer_size: Option, pub storage_path: Option, pub retention_days: Option, pub video_quality: Option, // "low", "medium", "high" pub cleanup_interval_hours: Option, } impl Default for StorageConfigToml { fn default() -> Self { Self { frame_buffer_size: Some(200), storage_path: Some("./meteor_events".to_string()), retention_days: Some(30), video_quality: Some("medium".to_string()), cleanup_interval_hours: Some(24), } } } /// Communication-specific configuration structure for TOML parsing #[derive(Debug, Serialize, Deserialize)] pub struct CommunicationConfigToml { pub api_base_url: Option, pub retry_attempts: Option, pub retry_delay_seconds: Option, pub max_retry_delay_seconds: Option, pub request_timeout_seconds: Option, pub heartbeat_interval_seconds: Option, } impl Default for CommunicationConfigToml { fn default() -> Self { Self { api_base_url: Some("http://localhost:3000".to_string()), retry_attempts: Some(3), retry_delay_seconds: Some(2), max_retry_delay_seconds: Some(60), request_timeout_seconds: Some(300), heartbeat_interval_seconds: Some(300), } } } /// Top-level configuration structure with camera, storage, and communication settings #[derive(Debug, Serialize, Deserialize)] pub struct AppConfig { pub camera: Option, pub storage: Option, pub communication: Option, } /// Load camera configuration from TOML file pub fn load_camera_config() -> Result { let config_path = get_app_config_file_path(); let camera_config = if config_path.exists() { let content = fs::read_to_string(&config_path) .with_context(|| format!("Failed to read app config file: {:?}", config_path))?; let app_config: AppConfig = toml::from_str(&content) .context("Failed to parse app config file as TOML")?; app_config.camera.unwrap_or_default() } else { println!("📄 No app config file found, using default camera settings"); CameraConfigToml::default() }; // Convert TOML config to CameraConfig let source = if camera_config.source == "device" { CameraSource::Device(camera_config.device_id.unwrap_or(0)) } else { CameraSource::File(camera_config.source.clone()) }; Ok(CameraConfig { source, fps: camera_config.fps.unwrap_or(30.0), width: camera_config.width, height: camera_config.height, }) } /// Load storage configuration from TOML file pub fn load_storage_config() -> Result { let config_path = get_app_config_file_path(); let storage_config = if config_path.exists() { let content = fs::read_to_string(&config_path) .with_context(|| format!("Failed to read app config file: {:?}", config_path))?; let app_config: AppConfig = toml::from_str(&content) .context("Failed to parse app config file as TOML")?; app_config.storage.unwrap_or_default() } else { println!("📄 No app config file found, using default storage settings"); StorageConfigToml::default() }; // Convert TOML config to StorageConfig let video_quality = match storage_config.video_quality.as_deref() { Some("low") => VideoQuality::Low, Some("high") => VideoQuality::High, _ => VideoQuality::Medium, // Default and fallback for "medium" }; let base_storage_path = PathBuf::from( storage_config.storage_path.unwrap_or_else(|| "./meteor_events".to_string()) ); Ok(StorageConfig { frame_buffer_size: storage_config.frame_buffer_size.unwrap_or(200), base_storage_path, retention_days: storage_config.retention_days.unwrap_or(30), video_quality, cleanup_interval_hours: storage_config.cleanup_interval_hours.unwrap_or(24), }) } /// Load communication configuration from TOML file pub fn load_communication_config() -> Result { let config_path = get_app_config_file_path(); let communication_config = if config_path.exists() { let content = fs::read_to_string(&config_path) .with_context(|| format!("Failed to read app config file: {:?}", config_path))?; let app_config: AppConfig = toml::from_str(&content) .context("Failed to parse app config file as TOML")?; app_config.communication.unwrap_or_default() } else { println!("📄 No app config file found, using default communication settings"); CommunicationConfigToml::default() }; // Convert TOML config to CommunicationConfig Ok(CommunicationConfig { api_base_url: communication_config.api_base_url.unwrap_or_else(|| "http://localhost:3000".to_string()), retry_attempts: communication_config.retry_attempts.unwrap_or(3), retry_delay_seconds: communication_config.retry_delay_seconds.unwrap_or(2), max_retry_delay_seconds: communication_config.max_retry_delay_seconds.unwrap_or(60), request_timeout_seconds: communication_config.request_timeout_seconds.unwrap_or(300), heartbeat_interval_seconds: communication_config.heartbeat_interval_seconds.unwrap_or(300), }) } /// Create a sample app configuration file pub fn create_sample_app_config() -> Result<()> { let config_path = get_app_config_file_path(); if config_path.exists() { println!("📄 App config file already exists at: {:?}", config_path); return Ok(()); } let sample_config = AppConfig { camera: Some(CameraConfigToml::default()), storage: Some(StorageConfigToml::default()), communication: Some(CommunicationConfigToml::default()), }; // Ensure the parent directory exists if let Some(parent) = config_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("Failed to create config directory: {:?}", parent))?; } let content = toml::to_string_pretty(&sample_config) .context("Failed to serialize sample config to TOML")?; fs::write(&config_path, content) .with_context(|| format!("Failed to write sample config file: {:?}", config_path))?; println!("✅ Sample app config created at: {:?}", config_path); Ok(()) } /// Get the path for app configuration file (separate from device config) fn get_app_config_file_path() -> PathBuf { // Try standard system config location first let system_config = Path::new("/etc/meteor-client/app-config.toml"); if system_config.parent().map_or(false, |p| p.exists()) { return system_config.to_path_buf(); } // Fallback to user config directory if let Some(config_dir) = dirs::config_dir() { let user_config = config_dir.join("meteor-client").join("app-config.toml"); return user_config; } // Last resort: local directory PathBuf::from("meteor-app-config.toml") } /// Determines the appropriate config file path based on the system fn get_config_file_path() -> PathBuf { // Try standard system config location first let system_config = Path::new("/etc/meteor-client/config.toml"); if system_config.parent().map_or(false, |p| p.exists()) { return system_config.to_path_buf(); } // Fallback to user config directory if let Some(config_dir) = dirs::config_dir() { let user_config = config_dir.join("meteor-client").join("config.toml"); return user_config; } // Last resort: local directory PathBuf::from("meteor-client-config.toml") } #[cfg(test)] mod tests { use super::*; use tempfile::NamedTempFile; #[test] fn test_config_creation() { let config = Config::new("TEST_DEVICE_123".to_string()); assert!(!config.registered); assert_eq!(config.hardware_id, "TEST_DEVICE_123"); assert!(config.user_profile_id.is_none()); assert!(config.device_id.is_none()); } #[test] fn test_config_mark_registered() { let mut config = Config::new("TEST_DEVICE_123".to_string()); config.mark_registered("user-456".to_string(), "device-789".to_string(), "test-jwt-token".to_string()); assert!(config.registered); assert_eq!(config.user_profile_id.as_ref().unwrap(), "user-456"); assert_eq!(config.device_id.as_ref().unwrap(), "device-789"); assert_eq!(config.jwt_token.as_ref().unwrap(), "test-jwt-token"); assert!(config.registered_at.is_some()); } #[test] fn test_config_save_and_load() -> Result<()> { let temp_file = NamedTempFile::new()?; let config_manager = ConfigManager::with_path(temp_file.path()); let mut config = Config::new("TEST_DEVICE_456".to_string()); config.mark_registered("user-123".to_string(), "device-456".to_string(), "test-jwt-456".to_string()); // Save config config_manager.save_config(&config)?; assert!(config_manager.config_exists()); // Load config let loaded_config = config_manager.load_config()?; assert!(loaded_config.registered); assert_eq!(loaded_config.hardware_id, "TEST_DEVICE_456"); assert_eq!(loaded_config.user_profile_id.as_ref().unwrap(), "user-123"); assert_eq!(loaded_config.device_id.as_ref().unwrap(), "device-456"); assert_eq!(loaded_config.jwt_token.as_ref().unwrap(), "test-jwt-456"); Ok(()) } }