Comicviewer/src/config.rs
2026-07-24 23:44:49 +09:00

251 lines
7.5 KiB
Rust

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<LocationProfile>,
pub favorites: Vec<FavoriteEntry>,
}
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<Self> {
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);
}
}