Skip to main content

crabcamera/
config.rs

1//! Configuration management for CrabCamera
2//!
3//! Provides configuration loading, saving, and management for camera settings,
4//! quality thresholds, storage preferences, and other runtime options.
5
6use crate::constants::{
7    DEFAULT_BLUR_THRESHOLD, DEFAULT_DATE_FORMAT, DEFAULT_EXPOSURE_THRESHOLD,
8    DEFAULT_FOCUS_STACK_STEPS, DEFAULT_FPS, DEFAULT_HDR_BRACKETS, DEFAULT_IMAGE_FORMAT,
9    DEFAULT_JPEG_QUALITY, DEFAULT_MAX_RETRY_ATTEMPTS, DEFAULT_OUTPUT_DIRECTORY,
10    DEFAULT_OVERALL_THRESHOLD, DEFAULT_RECONNECT_ATTEMPTS, DEFAULT_RECONNECT_DELAY_MS,
11    DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH, DEFAULT_RETRY_DELAY_MS,
12};
13use crate::errors::CameraError;
14use serde::{Deserialize, Serialize};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18/// Root configuration structure
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CrabCameraConfig {
21    /// Camera hardware preferences and defaults.
22    pub camera: CameraConfig,
23    /// Image quality analysis thresholds.
24    pub quality: QualityConfig,
25    /// File storage paths and naming conventions.
26    pub storage: StorageConfig,
27    /// Experimental and advanced features.
28    pub advanced: AdvancedConfig,
29}
30
31/// Camera-specific configuration
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CameraConfig {
34    /// Default camera resolution [width, height]
35    pub default_resolution: [u32; 2],
36    /// Default frames per second
37    pub default_fps: u32,
38    /// Auto-reconnect on device disconnect
39    pub auto_reconnect: bool,
40    /// Reconnect retry attempts
41    pub reconnect_attempts: u32,
42    /// Reconnect delay in milliseconds
43    pub reconnect_delay_ms: u64,
44}
45
46/// Quality validation configuration
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct QualityConfig {
49    /// Enable automatic quality-based retry
50    pub auto_retry_enabled: bool,
51    /// Maximum retry attempts for quality capture
52    pub max_retry_attempts: u32,
53    /// Minimum acceptable blur threshold (0.0-1.0)
54    pub min_blur_threshold: f32,
55    /// Minimum acceptable exposure score (0.0-1.0)
56    pub min_exposure_score: f32,
57    /// Minimum overall quality score (0.0-1.0)
58    pub min_overall_score: f32,
59    /// Retry delay between attempts in milliseconds
60    pub retry_delay_ms: u64,
61}
62
63/// Storage and file management configuration
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct StorageConfig {
66    /// Default output directory for captures
67    pub output_directory: String,
68    /// Auto-organize files by date
69    pub auto_organize_by_date: bool,
70    /// Date format for organization (e.g., "YYYY-MM-DD")
71    pub date_format: String,
72    /// Default image format (jpeg, png, bmp)
73    pub default_format: String,
74    /// JPEG quality (0-100)
75    pub jpeg_quality: u8,
76    /// Auto-delete low quality captures
77    pub auto_delete_low_quality: bool,
78}
79
80/// Advanced features configuration
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct AdvancedConfig {
83    /// Enable focus stacking
84    pub focus_stacking_enabled: bool,
85    /// Number of focus steps for stacking
86    pub focus_stack_steps: u32,
87    /// Enable HDR capture
88    pub hdr_enabled: bool,
89    /// Number of exposure brackets for HDR
90    pub hdr_brackets: u32,
91}
92
93impl Default for CrabCameraConfig {
94    fn default() -> Self {
95        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
96        // f32→u32: DEFAULT_FPS is a known positive constant (30.0)
97        let default_fps_val = DEFAULT_FPS as u32;
98        Self {
99            camera: CameraConfig {
100                default_resolution: [DEFAULT_RESOLUTION_WIDTH, DEFAULT_RESOLUTION_HEIGHT],
101                default_fps: default_fps_val,
102                auto_reconnect: true,
103                reconnect_attempts: DEFAULT_RECONNECT_ATTEMPTS,
104                reconnect_delay_ms: DEFAULT_RECONNECT_DELAY_MS,
105            },
106            quality: QualityConfig {
107                auto_retry_enabled: true,
108                max_retry_attempts: DEFAULT_MAX_RETRY_ATTEMPTS,
109                min_blur_threshold: DEFAULT_BLUR_THRESHOLD,
110                min_exposure_score: DEFAULT_EXPOSURE_THRESHOLD,
111                min_overall_score: DEFAULT_OVERALL_THRESHOLD,
112                retry_delay_ms: DEFAULT_RETRY_DELAY_MS,
113            },
114            storage: StorageConfig {
115                output_directory: DEFAULT_OUTPUT_DIRECTORY.to_string(),
116                auto_organize_by_date: true,
117                date_format: DEFAULT_DATE_FORMAT.to_string(),
118                default_format: DEFAULT_IMAGE_FORMAT.to_string(),
119                jpeg_quality: DEFAULT_JPEG_QUALITY,
120                auto_delete_low_quality: false,
121            },
122            advanced: AdvancedConfig {
123                focus_stacking_enabled: false,
124                focus_stack_steps: DEFAULT_FOCUS_STACK_STEPS,
125                hdr_enabled: false,
126                hdr_brackets: DEFAULT_HDR_BRACKETS,
127            },
128        }
129    }
130}
131
132impl CrabCameraConfig {
133    /// Load configuration from TOML file
134    ///
135    /// # Errors
136    /// Returns a [`CameraError::InitializationError`] if the config file
137    /// cannot be read or if its contents cannot be parsed as TOML.
138    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, CameraError> {
139        let path = path.as_ref();
140
141        if !path.exists() {
142            log::info!(
143                "Config file not found at {}, using defaults",
144                path.display()
145            );
146            return Ok(Self::default());
147        }
148
149        let contents = fs::read_to_string(path).map_err(|e| {
150            CameraError::InitializationError(format!("Failed to read config file: {e}"))
151        })?;
152
153        let config: CrabCameraConfig = toml::from_str(&contents).map_err(|e| {
154            CameraError::InitializationError(format!("Failed to parse config file: {e}"))
155        })?;
156
157        log::info!("Loaded configuration from {}", path.display());
158        Ok(config)
159    }
160
161    /// Save configuration to TOML file
162    ///
163    /// # Errors
164    /// Returns a [`CameraError::InitializationError`] if the parent directory
165    /// cannot be created, if the config cannot be serialized to TOML, or if the
166    /// file cannot be written.
167    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), CameraError> {
168        let path = path.as_ref();
169
170        // Create parent directories if needed
171        if let Some(parent) = path.parent() {
172            fs::create_dir_all(parent).map_err(|e| {
173                CameraError::InitializationError(format!("Failed to create config directory: {e}"))
174            })?;
175        }
176
177        let toml_string = toml::to_string_pretty(self).map_err(|e| {
178            CameraError::InitializationError(format!("Failed to serialize config: {e}"))
179        })?;
180
181        fs::write(path, toml_string).map_err(|e| {
182            CameraError::InitializationError(format!("Failed to write config file: {e}"))
183        })?;
184
185        log::info!("Saved configuration to {}", path.display());
186        Ok(())
187    }
188
189    /// Get default config file path
190    pub fn default_path() -> PathBuf {
191        PathBuf::from("crabcamera.toml")
192    }
193
194    /// Load from default location or create with defaults
195    pub fn load_or_default() -> Self {
196        Self::load_from_file(Self::default_path()).unwrap_or_else(|e| {
197            log::warn!("Failed to load config, using defaults: {e}");
198            Self::default()
199        })
200    }
201
202    /// Validate configuration values
203    ///
204    /// # Errors
205    /// Returns an `Err` describing the first invalid value if any resolution,
206    /// FPS, quality threshold, JPEG quality, focus-stack step count, or HDR
207    /// bracket count is out of its allowed range.
208    pub fn validate(&self) -> Result<(), String> {
209        // Validate camera config
210        if self.camera.default_resolution[0] == 0 || self.camera.default_resolution[1] == 0 {
211            return Err("Invalid default resolution".to_string());
212        }
213        if self.camera.default_fps == 0 || self.camera.default_fps > 240 {
214            return Err("Invalid default FPS (must be 1-240)".to_string());
215        }
216
217        // Validate quality config
218        if !(0.0..=1.0).contains(&self.quality.min_blur_threshold) {
219            return Err("Blur threshold must be between 0.0 and 1.0".to_string());
220        }
221        if !(0.0..=1.0).contains(&self.quality.min_exposure_score) {
222            return Err("Exposure score must be between 0.0 and 1.0".to_string());
223        }
224        if !(0.0..=1.0).contains(&self.quality.min_overall_score) {
225            return Err("Overall score must be between 0.0 and 1.0".to_string());
226        }
227
228        // Validate storage config
229        if self.storage.jpeg_quality == 0 || self.storage.jpeg_quality > 100 {
230            return Err("JPEG quality must be between 1 and 100".to_string());
231        }
232
233        // Validate advanced config
234        if self.advanced.focus_stack_steps == 0 || self.advanced.focus_stack_steps > 100 {
235            return Err("Focus stack steps must be between 1 and 100".to_string());
236        }
237        if self.advanced.hdr_brackets == 0 || self.advanced.hdr_brackets > 10 {
238            return Err("HDR brackets must be between 1 and 10".to_string());
239        }
240
241        Ok(())
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_default_config() {
251        let config = CrabCameraConfig::default();
252        assert_eq!(config.camera.default_resolution, [1920, 1080]);
253        assert_eq!(config.camera.default_fps, 30);
254        assert!(config.quality.auto_retry_enabled);
255    }
256
257    #[test]
258    fn test_config_validation() {
259        let config = CrabCameraConfig::default();
260        assert!(config.validate().is_ok());
261
262        let mut bad_config = config.clone();
263        bad_config.camera.default_resolution = [0, 0];
264        assert!(bad_config.validate().is_err());
265
266        let mut bad_quality = CrabCameraConfig::default();
267        bad_quality.quality.min_blur_threshold = 1.5;
268        assert!(bad_quality.validate().is_err());
269    }
270
271    #[test]
272    fn test_config_save_and_load() {
273        let temp_dir = std::env::temp_dir();
274        let config_path = temp_dir.join("test_crabcamera.toml");
275
276        // Clean up any existing test file
277        let _ = fs::remove_file(&config_path);
278
279        let config = CrabCameraConfig::default();
280        assert!(config.save_to_file(&config_path).is_ok());
281
282        let loaded = CrabCameraConfig::load_from_file(&config_path).expect("load saved config");
283        assert_eq!(loaded.camera.default_fps, config.camera.default_fps);
284        assert_eq!(
285            loaded.quality.max_retry_attempts,
286            config.quality.max_retry_attempts
287        );
288
289        // Clean up
290        let _ = fs::remove_file(&config_path);
291    }
292
293    #[test]
294    fn test_config_toml_format() {
295        let config = CrabCameraConfig::default();
296        let toml_string = toml::to_string_pretty(&config).expect("serialize config to TOML");
297
298        // Verify TOML contains expected sections
299        assert!(toml_string.contains("[camera]"));
300        assert!(toml_string.contains("[quality]"));
301        assert!(toml_string.contains("[storage]"));
302        assert!(toml_string.contains("[advanced]"));
303        assert!(toml_string.contains("default_resolution"));
304        assert!(toml_string.contains("auto_retry_enabled"));
305    }
306
307    #[test]
308    fn test_load_nonexistent_file() {
309        let result = CrabCameraConfig::load_from_file("nonexistent_file.toml");
310        assert!(result.is_ok()); // Should return default
311        assert_eq!(result.expect("load default config").camera.default_fps, 30);
312    }
313
314    #[test]
315    fn test_validate_all_remaining_error_branches() {
316        let mut cfg = CrabCameraConfig::default();
317
318        cfg.camera.default_fps = 0;
319        assert_eq!(
320            cfg.validate().expect_err("fps=0 should fail"),
321            "Invalid default FPS (must be 1-240)"
322        );
323
324        cfg = CrabCameraConfig::default();
325        cfg.camera.default_fps = 241;
326        assert_eq!(
327            cfg.validate().expect_err("fps>240 should fail"),
328            "Invalid default FPS (must be 1-240)"
329        );
330
331        cfg = CrabCameraConfig::default();
332        cfg.quality.min_exposure_score = 1.2;
333        assert_eq!(
334            cfg.validate().expect_err("exposure>1 should fail"),
335            "Exposure score must be between 0.0 and 1.0"
336        );
337
338        cfg = CrabCameraConfig::default();
339        cfg.quality.min_overall_score = -0.1;
340        assert_eq!(
341            cfg.validate().expect_err("overall<0 should fail"),
342            "Overall score must be between 0.0 and 1.0"
343        );
344
345        cfg = CrabCameraConfig::default();
346        cfg.storage.jpeg_quality = 0;
347        assert_eq!(
348            cfg.validate().expect_err("jpeg quality 0 should fail"),
349            "JPEG quality must be between 1 and 100"
350        );
351
352        cfg = CrabCameraConfig::default();
353        cfg.storage.jpeg_quality = 101;
354        assert_eq!(
355            cfg.validate().expect_err("jpeg quality >100 should fail"),
356            "JPEG quality must be between 1 and 100"
357        );
358
359        cfg = CrabCameraConfig::default();
360        cfg.advanced.focus_stack_steps = 0;
361        assert_eq!(
362            cfg.validate().expect_err("focus stack 0 should fail"),
363            "Focus stack steps must be between 1 and 100"
364        );
365
366        cfg = CrabCameraConfig::default();
367        cfg.advanced.focus_stack_steps = 101;
368        assert_eq!(
369            cfg.validate().expect_err("focus stack >100 should fail"),
370            "Focus stack steps must be between 1 and 100"
371        );
372
373        cfg = CrabCameraConfig::default();
374        cfg.advanced.hdr_brackets = 0;
375        assert_eq!(
376            cfg.validate().expect_err("hdr 0 should fail"),
377            "HDR brackets must be between 1 and 10"
378        );
379
380        cfg = CrabCameraConfig::default();
381        cfg.advanced.hdr_brackets = 11;
382        assert_eq!(
383            cfg.validate().expect_err("hdr >10 should fail"),
384            "HDR brackets must be between 1 and 10"
385        );
386    }
387
388    #[test]
389    fn test_default_path_and_load_or_default() {
390        assert_eq!(
391            CrabCameraConfig::default_path(),
392            PathBuf::from("crabcamera.toml")
393        );
394
395        // Ensure missing default file path still returns a usable default.
396        let loaded = CrabCameraConfig::load_or_default();
397        assert_eq!(
398            loaded.camera.default_fps,
399            CrabCameraConfig::default().camera.default_fps
400        );
401    }
402
403    #[test]
404    fn test_load_from_file_parse_error() {
405        let temp_dir = std::env::temp_dir();
406        let bad_path = temp_dir.join("test_crabcamera_invalid.toml");
407
408        let _ = fs::remove_file(&bad_path);
409        fs::write(&bad_path, "this-is-not-valid-toml = = =").expect("write invalid toml");
410
411        let result = CrabCameraConfig::load_from_file(&bad_path);
412        assert!(result.is_err());
413        let msg = result.expect_err("invalid toml should error").to_string();
414        assert!(msg.contains("Failed to parse config file"));
415
416        let _ = fs::remove_file(&bad_path);
417    }
418
419    #[test]
420    fn test_save_to_file_create_parent_directory() {
421        let base = std::env::temp_dir().join("crabcamera_config_nested_test");
422        let nested = base.join("deep").join("crabcamera.toml");
423        let _ = fs::remove_dir_all(&base);
424
425        let cfg = CrabCameraConfig::default();
426        cfg.save_to_file(&nested)
427            .expect("save should create parent dirs");
428        assert!(nested.exists());
429
430        let loaded = CrabCameraConfig::load_from_file(&nested).expect("load saved config");
431        assert_eq!(loaded.storage.default_format, cfg.storage.default_format);
432
433        let _ = fs::remove_dir_all(&base);
434    }
435}