use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AppConfig { pub cache_quota_gb: u64, pub fast_image_loading: bool, pub case_insensitive_sort: bool, pub viewer_mode: ViewerMode, pub two_page_right_to_left: bool, pub locations: Vec, pub favorites: Vec, } impl Default for AppConfig { fn default() -> Self { Self { cache_quota_gb: 10, fast_image_loading: true, case_insensitive_sort: true, viewer_mode: ViewerMode::FitWidth, two_page_right_to_left: true, locations: Vec::new(), favorites: Vec::new(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocationProfile { pub name: String, pub kind: LocationKind, pub address: String, #[serde(default)] pub username: String, #[serde(default)] pub password: String, #[serde(default)] pub domain: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FavoriteEntry { pub name: String, pub uri: String, pub is_directory: bool, pub profile: LocationProfile, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LocationKind { Local, Smb, Ftp, Sftp, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum ViewerMode { ActualSize, #[default] FitWidth, FitHeight, TwoPage, Continuous, } impl ViewerMode { pub fn dropdown_index(self) -> u32 { match self { Self::ActualSize => 0, Self::FitWidth => 1, Self::FitHeight => 2, Self::TwoPage => 3, Self::Continuous => 4, } } pub fn from_dropdown_index(index: u32) -> Self { match index { 0 => Self::ActualSize, 2 => Self::FitHeight, 3 => Self::TwoPage, 4 => Self::Continuous, _ => Self::FitWidth, } } } impl AppConfig { pub fn load_or_create(path: &Path) -> Result { if path.exists() { restrict_file_permissions(path)?; let contents = fs::read_to_string(path) .with_context(|| format!("설정 파일을 읽을 수 없습니다: {}", path.display()))?; let config = toml::from_str(&contents) .with_context(|| format!("설정 파일 형식이 잘못되었습니다: {}", path.display()))?; return Ok(config); } let config = Self::default(); config.save(path)?; Ok(config) } pub fn save(&self, path: &Path) -> Result<()> { let temporary_path = temporary_path(path); let contents = toml::to_string_pretty(self).context("설정을 직렬화할 수 없습니다")?; let write_result = write_private_file(&temporary_path, contents.as_bytes()) .and_then(|()| fs::rename(&temporary_path, path).map_err(Into::into)); if write_result.is_err() { let _ = fs::remove_file(&temporary_path); } write_result .with_context(|| format!("설정 파일을 저장할 수 없습니다: {}", path.display()))?; restrict_file_permissions(path)?; Ok(()) } } fn temporary_path(path: &Path) -> PathBuf { let mut file_name = path .file_name() .map(ToOwned::to_owned) .unwrap_or_else(|| "config".into()); file_name.push(".tmp"); path.with_file_name(file_name) } fn write_private_file(path: &Path, contents: &[u8]) -> Result<()> { let mut options = OpenOptions::new(); options.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let mut file = options.open(path)?; file.write_all(contents)?; file.sync_all()?; Ok(()) } fn restrict_file_permissions(path: &Path) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut permissions = fs::metadata(path)?.permissions(); permissions.set_mode(0o600); fs::set_permissions(path, permissions)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn default_cache_quota_is_ten_gigabytes() { assert_eq!(AppConfig::default().cache_quota_gb, 10); assert!(AppConfig::default().case_insensitive_sort); } #[test] fn location_credentials_round_trip_as_plain_text() { let config = AppConfig { cache_quota_gb: 0, fast_image_loading: true, case_insensitive_sort: true, viewer_mode: ViewerMode::FitWidth, two_page_right_to_left: true, locations: vec![LocationProfile { name: "테스트".into(), kind: LocationKind::Sftp, address: "sftp://example.invalid/comics".into(), username: "reader".into(), password: "plain-password".into(), domain: String::new(), }], favorites: vec![FavoriteEntry { name: "원격 만화".into(), uri: "sftp://example.invalid/comics/book.zip".into(), is_directory: false, profile: LocationProfile { name: "즐겨찾기 서버".into(), kind: LocationKind::Sftp, address: "sftp://example.invalid/comics".into(), username: "favorite-reader".into(), password: "favorite-password".into(), domain: String::new(), }, }], }; let encoded = toml::to_string(&config).unwrap(); let decoded: AppConfig = toml::from_str(&encoded).unwrap(); assert_eq!(decoded.locations[0].password, "plain-password"); assert_eq!(decoded.favorites[0].profile.username, "favorite-reader"); assert_eq!(decoded.favorites[0].profile.password, "favorite-password"); } #[test] fn fast_image_loading_is_enabled_for_existing_configs() { let decoded: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); assert!(decoded.fast_image_loading); assert!(decoded.case_insensitive_sort); assert_eq!(decoded.viewer_mode, ViewerMode::FitWidth); assert!(decoded.two_page_right_to_left); assert!(decoded.favorites.is_empty()); } #[test] fn viewer_modes_round_trip_through_dropdown_indices() { for mode in [ ViewerMode::ActualSize, ViewerMode::FitWidth, ViewerMode::FitHeight, ViewerMode::TwoPage, ViewerMode::Continuous, ] { assert_eq!(ViewerMode::from_dropdown_index(mode.dropdown_index()), mode); } assert_eq!(ViewerMode::from_dropdown_index(99), ViewerMode::FitWidth); } #[test] fn two_page_direction_defaults_to_rtl_and_round_trips_ltr() { let existing: AppConfig = toml::from_str("cache_quota_gb = 10\n").unwrap(); assert!(existing.two_page_right_to_left); let config = AppConfig { two_page_right_to_left: false, ..AppConfig::default() }; let decoded: AppConfig = toml::from_str(&toml::to_string(&config).unwrap()).unwrap(); assert!(!decoded.two_page_right_to_left); } }