1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CrabCameraConfig {
21 pub camera: CameraConfig,
23 pub quality: QualityConfig,
25 pub storage: StorageConfig,
27 pub advanced: AdvancedConfig,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CameraConfig {
34 pub default_resolution: [u32; 2],
36 pub default_fps: u32,
38 pub auto_reconnect: bool,
40 pub reconnect_attempts: u32,
42 pub reconnect_delay_ms: u64,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct QualityConfig {
49 pub auto_retry_enabled: bool,
51 pub max_retry_attempts: u32,
53 pub min_blur_threshold: f32,
55 pub min_exposure_score: f32,
57 pub min_overall_score: f32,
59 pub retry_delay_ms: u64,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct StorageConfig {
66 pub output_directory: String,
68 pub auto_organize_by_date: bool,
70 pub date_format: String,
72 pub default_format: String,
74 pub jpeg_quality: u8,
76 pub auto_delete_low_quality: bool,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct AdvancedConfig {
83 pub focus_stacking_enabled: bool,
85 pub focus_stack_steps: u32,
87 pub hdr_enabled: bool,
89 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 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 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 pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), CameraError> {
168 let path = path.as_ref();
169
170 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 pub fn default_path() -> PathBuf {
191 PathBuf::from("crabcamera.toml")
192 }
193
194 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 pub fn validate(&self) -> Result<(), String> {
209 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 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 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 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 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 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 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()); 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 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}