use crate::constants::{
DEFAULT_BLUR_THRESHOLD, DEFAULT_DATE_FORMAT, DEFAULT_EXPOSURE_THRESHOLD,
DEFAULT_FOCUS_STACK_STEPS, DEFAULT_FPS, DEFAULT_HDR_BRACKETS, DEFAULT_IMAGE_FORMAT,
DEFAULT_JPEG_QUALITY, DEFAULT_MAX_RETRY_ATTEMPTS, DEFAULT_OUTPUT_DIRECTORY,
DEFAULT_OVERALL_THRESHOLD, DEFAULT_RECONNECT_ATTEMPTS, DEFAULT_RECONNECT_DELAY_MS,
DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH, DEFAULT_RETRY_DELAY_MS,
};
use crate::errors::CameraError;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrabCameraConfig {
pub camera: CameraConfig,
pub quality: QualityConfig,
pub storage: StorageConfig,
pub advanced: AdvancedConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CameraConfig {
pub default_resolution: [u32; 2],
pub default_fps: u32,
pub auto_reconnect: bool,
pub reconnect_attempts: u32,
pub reconnect_delay_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualityConfig {
pub auto_retry_enabled: bool,
pub max_retry_attempts: u32,
pub min_blur_threshold: f32,
pub min_exposure_score: f32,
pub min_overall_score: f32,
pub retry_delay_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageConfig {
pub output_directory: String,
pub auto_organize_by_date: bool,
pub date_format: String,
pub default_format: String,
pub jpeg_quality: u8,
pub auto_delete_low_quality: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdvancedConfig {
pub focus_stacking_enabled: bool,
pub focus_stack_steps: u32,
pub hdr_enabled: bool,
pub hdr_brackets: u32,
}
impl Default for CrabCameraConfig {
fn default() -> Self {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let default_fps_val = DEFAULT_FPS as u32;
Self {
camera: CameraConfig {
default_resolution: [DEFAULT_RESOLUTION_WIDTH, DEFAULT_RESOLUTION_HEIGHT],
default_fps: default_fps_val,
auto_reconnect: true,
reconnect_attempts: DEFAULT_RECONNECT_ATTEMPTS,
reconnect_delay_ms: DEFAULT_RECONNECT_DELAY_MS,
},
quality: QualityConfig {
auto_retry_enabled: true,
max_retry_attempts: DEFAULT_MAX_RETRY_ATTEMPTS,
min_blur_threshold: DEFAULT_BLUR_THRESHOLD,
min_exposure_score: DEFAULT_EXPOSURE_THRESHOLD,
min_overall_score: DEFAULT_OVERALL_THRESHOLD,
retry_delay_ms: DEFAULT_RETRY_DELAY_MS,
},
storage: StorageConfig {
output_directory: DEFAULT_OUTPUT_DIRECTORY.to_string(),
auto_organize_by_date: true,
date_format: DEFAULT_DATE_FORMAT.to_string(),
default_format: DEFAULT_IMAGE_FORMAT.to_string(),
jpeg_quality: DEFAULT_JPEG_QUALITY,
auto_delete_low_quality: false,
},
advanced: AdvancedConfig {
focus_stacking_enabled: false,
focus_stack_steps: DEFAULT_FOCUS_STACK_STEPS,
hdr_enabled: false,
hdr_brackets: DEFAULT_HDR_BRACKETS,
},
}
}
}
impl CrabCameraConfig {
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, CameraError> {
let path = path.as_ref();
if !path.exists() {
log::info!(
"Config file not found at {}, using defaults",
path.display()
);
return Ok(Self::default());
}
let contents = fs::read_to_string(path).map_err(|e| {
CameraError::InitializationError(format!("Failed to read config file: {e}"))
})?;
let config: CrabCameraConfig = toml::from_str(&contents).map_err(|e| {
CameraError::InitializationError(format!("Failed to parse config file: {e}"))
})?;
log::info!("Loaded configuration from {}", path.display());
Ok(config)
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), CameraError> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
CameraError::InitializationError(format!("Failed to create config directory: {e}"))
})?;
}
let toml_string = toml::to_string_pretty(self).map_err(|e| {
CameraError::InitializationError(format!("Failed to serialize config: {e}"))
})?;
fs::write(path, toml_string).map_err(|e| {
CameraError::InitializationError(format!("Failed to write config file: {e}"))
})?;
log::info!("Saved configuration to {}", path.display());
Ok(())
}
pub fn default_path() -> PathBuf {
PathBuf::from("crabcamera.toml")
}
pub fn load_or_default() -> Self {
Self::load_from_file(Self::default_path()).unwrap_or_else(|e| {
log::warn!("Failed to load config, using defaults: {e}");
Self::default()
})
}
pub fn validate(&self) -> Result<(), String> {
if self.camera.default_resolution[0] == 0 || self.camera.default_resolution[1] == 0 {
return Err("Invalid default resolution".to_string());
}
if self.camera.default_fps == 0 || self.camera.default_fps > 240 {
return Err("Invalid default FPS (must be 1-240)".to_string());
}
if !(0.0..=1.0).contains(&self.quality.min_blur_threshold) {
return Err("Blur threshold must be between 0.0 and 1.0".to_string());
}
if !(0.0..=1.0).contains(&self.quality.min_exposure_score) {
return Err("Exposure score must be between 0.0 and 1.0".to_string());
}
if !(0.0..=1.0).contains(&self.quality.min_overall_score) {
return Err("Overall score must be between 0.0 and 1.0".to_string());
}
if self.storage.jpeg_quality == 0 || self.storage.jpeg_quality > 100 {
return Err("JPEG quality must be between 1 and 100".to_string());
}
if self.advanced.focus_stack_steps == 0 || self.advanced.focus_stack_steps > 100 {
return Err("Focus stack steps must be between 1 and 100".to_string());
}
if self.advanced.hdr_brackets == 0 || self.advanced.hdr_brackets > 10 {
return Err("HDR brackets must be between 1 and 10".to_string());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = CrabCameraConfig::default();
assert_eq!(config.camera.default_resolution, [1920, 1080]);
assert_eq!(config.camera.default_fps, 30);
assert!(config.quality.auto_retry_enabled);
}
#[test]
fn test_config_validation() {
let config = CrabCameraConfig::default();
assert!(config.validate().is_ok());
let mut bad_config = config.clone();
bad_config.camera.default_resolution = [0, 0];
assert!(bad_config.validate().is_err());
let mut bad_quality = CrabCameraConfig::default();
bad_quality.quality.min_blur_threshold = 1.5;
assert!(bad_quality.validate().is_err());
}
#[test]
fn test_config_save_and_load() {
let temp_dir = std::env::temp_dir();
let config_path = temp_dir.join("test_crabcamera.toml");
let _ = fs::remove_file(&config_path);
let config = CrabCameraConfig::default();
assert!(config.save_to_file(&config_path).is_ok());
let loaded = CrabCameraConfig::load_from_file(&config_path).expect("load saved config");
assert_eq!(loaded.camera.default_fps, config.camera.default_fps);
assert_eq!(
loaded.quality.max_retry_attempts,
config.quality.max_retry_attempts
);
let _ = fs::remove_file(&config_path);
}
#[test]
fn test_config_toml_format() {
let config = CrabCameraConfig::default();
let toml_string = toml::to_string_pretty(&config).expect("serialize config to TOML");
assert!(toml_string.contains("[camera]"));
assert!(toml_string.contains("[quality]"));
assert!(toml_string.contains("[storage]"));
assert!(toml_string.contains("[advanced]"));
assert!(toml_string.contains("default_resolution"));
assert!(toml_string.contains("auto_retry_enabled"));
}
#[test]
fn test_load_nonexistent_file() {
let result = CrabCameraConfig::load_from_file("nonexistent_file.toml");
assert!(result.is_ok()); assert_eq!(result.expect("load default config").camera.default_fps, 30);
}
#[test]
fn test_validate_all_remaining_error_branches() {
let mut cfg = CrabCameraConfig::default();
cfg.camera.default_fps = 0;
assert_eq!(
cfg.validate().expect_err("fps=0 should fail"),
"Invalid default FPS (must be 1-240)"
);
cfg = CrabCameraConfig::default();
cfg.camera.default_fps = 241;
assert_eq!(
cfg.validate().expect_err("fps>240 should fail"),
"Invalid default FPS (must be 1-240)"
);
cfg = CrabCameraConfig::default();
cfg.quality.min_exposure_score = 1.2;
assert_eq!(
cfg.validate().expect_err("exposure>1 should fail"),
"Exposure score must be between 0.0 and 1.0"
);
cfg = CrabCameraConfig::default();
cfg.quality.min_overall_score = -0.1;
assert_eq!(
cfg.validate().expect_err("overall<0 should fail"),
"Overall score must be between 0.0 and 1.0"
);
cfg = CrabCameraConfig::default();
cfg.storage.jpeg_quality = 0;
assert_eq!(
cfg.validate().expect_err("jpeg quality 0 should fail"),
"JPEG quality must be between 1 and 100"
);
cfg = CrabCameraConfig::default();
cfg.storage.jpeg_quality = 101;
assert_eq!(
cfg.validate().expect_err("jpeg quality >100 should fail"),
"JPEG quality must be between 1 and 100"
);
cfg = CrabCameraConfig::default();
cfg.advanced.focus_stack_steps = 0;
assert_eq!(
cfg.validate().expect_err("focus stack 0 should fail"),
"Focus stack steps must be between 1 and 100"
);
cfg = CrabCameraConfig::default();
cfg.advanced.focus_stack_steps = 101;
assert_eq!(
cfg.validate().expect_err("focus stack >100 should fail"),
"Focus stack steps must be between 1 and 100"
);
cfg = CrabCameraConfig::default();
cfg.advanced.hdr_brackets = 0;
assert_eq!(
cfg.validate().expect_err("hdr 0 should fail"),
"HDR brackets must be between 1 and 10"
);
cfg = CrabCameraConfig::default();
cfg.advanced.hdr_brackets = 11;
assert_eq!(
cfg.validate().expect_err("hdr >10 should fail"),
"HDR brackets must be between 1 and 10"
);
}
#[test]
fn test_default_path_and_load_or_default() {
assert_eq!(
CrabCameraConfig::default_path(),
PathBuf::from("crabcamera.toml")
);
let loaded = CrabCameraConfig::load_or_default();
assert_eq!(
loaded.camera.default_fps,
CrabCameraConfig::default().camera.default_fps
);
}
#[test]
fn test_load_from_file_parse_error() {
let temp_dir = std::env::temp_dir();
let bad_path = temp_dir.join("test_crabcamera_invalid.toml");
let _ = fs::remove_file(&bad_path);
fs::write(&bad_path, "this-is-not-valid-toml = = =").expect("write invalid toml");
let result = CrabCameraConfig::load_from_file(&bad_path);
assert!(result.is_err());
let msg = result.expect_err("invalid toml should error").to_string();
assert!(msg.contains("Failed to parse config file"));
let _ = fs::remove_file(&bad_path);
}
#[test]
fn test_save_to_file_create_parent_directory() {
let base = std::env::temp_dir().join("crabcamera_config_nested_test");
let nested = base.join("deep").join("crabcamera.toml");
let _ = fs::remove_dir_all(&base);
let cfg = CrabCameraConfig::default();
cfg.save_to_file(&nested)
.expect("save should create parent dirs");
assert!(nested.exists());
let loaded = CrabCameraConfig::load_from_file(&nested).expect("load saved config");
assert_eq!(loaded.storage.default_format, cfg.storage.default_format);
let _ = fs::remove_dir_all(&base);
}
}