use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::PipelineConfig;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("Config file not found: {0}")]
NotFound(PathBuf),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct GeneralConfig {
#[serde(default)]
pub dpi: Option<u32>,
#[serde(default)]
pub threads: Option<usize>,
#[serde(default)]
pub verbose: Option<u8>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ProcessingConfig {
#[serde(default)]
pub deskew: Option<bool>,
#[serde(default)]
pub margin_trim: Option<f64>,
#[serde(default)]
pub upscale: Option<bool>,
#[serde(default)]
pub gpu: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct AdvancedConfig {
#[serde(default)]
pub internal_resolution: Option<bool>,
#[serde(default)]
pub color_correction: Option<bool>,
#[serde(default)]
pub offset_alignment: Option<bool>,
#[serde(default)]
pub output_height: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OcrConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub language: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OutputConfig {
#[serde(default)]
pub jpeg_quality: Option<u8>,
#[serde(default)]
pub skip_existing: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub processing: ProcessingConfig,
#[serde(default)]
pub advanced: AdvancedConfig,
#[serde(default)]
pub ocr: OcrConfig,
#[serde(default)]
pub output: OutputConfig,
}
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn load() -> Result<Self, ConfigError> {
let current_dir_config = PathBuf::from("superbook.toml");
if current_dir_config.exists() {
return Self::load_from_path(¤t_dir_config);
}
if let Some(config_dir) = dirs::config_dir() {
let user_config = config_dir.join("superbook-pdf").join("config.toml");
if user_config.exists() {
return Self::load_from_path(&user_config);
}
}
Ok(Self::default())
}
pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Err(ConfigError::NotFound(path.to_path_buf()));
}
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
let config: Config = toml::from_str(content)?;
Ok(config)
}
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
pub fn to_pipeline_config(&self) -> PipelineConfig {
let mut config = PipelineConfig::default();
if let Some(dpi) = self.general.dpi {
config = config.with_dpi(dpi);
}
if let Some(threads) = self.general.threads {
config.threads = Some(threads);
}
if let Some(deskew) = self.processing.deskew {
config = config.with_deskew(deskew);
}
if let Some(margin_trim) = self.processing.margin_trim {
config = config.with_margin_trim(margin_trim);
}
if let Some(upscale) = self.processing.upscale {
config = config.with_upscale(upscale);
}
if let Some(gpu) = self.processing.gpu {
config = config.with_gpu(gpu);
}
if let Some(internal) = self.advanced.internal_resolution {
config.internal_resolution = internal;
}
if let Some(color) = self.advanced.color_correction {
config.color_correction = color;
}
if let Some(offset) = self.advanced.offset_alignment {
config.offset_alignment = offset;
}
if let Some(height) = self.advanced.output_height {
config.output_height = height;
}
if let Some(ocr) = self.ocr.enabled {
config = config.with_ocr(ocr);
}
if let Some(quality) = self.output.jpeg_quality {
config.jpeg_quality = quality;
}
config
}
pub fn merge_with_cli(&self, cli: &CliOverrides) -> PipelineConfig {
let mut config = self.to_pipeline_config();
if let Some(dpi) = cli.dpi {
config = config.with_dpi(dpi);
}
if let Some(deskew) = cli.deskew {
config = config.with_deskew(deskew);
}
if let Some(margin_trim) = cli.margin_trim {
config = config.with_margin_trim(margin_trim);
}
if let Some(upscale) = cli.upscale {
config = config.with_upscale(upscale);
}
if let Some(gpu) = cli.gpu {
config = config.with_gpu(gpu);
}
if let Some(ocr) = cli.ocr {
config = config.with_ocr(ocr);
}
if let Some(threads) = cli.threads {
config.threads = Some(threads);
}
if let Some(internal) = cli.internal_resolution {
config.internal_resolution = internal;
}
if let Some(color) = cli.color_correction {
config.color_correction = color;
}
if let Some(offset) = cli.offset_alignment {
config.offset_alignment = offset;
}
if let Some(height) = cli.output_height {
config.output_height = height;
}
if let Some(quality) = cli.jpeg_quality {
config.jpeg_quality = quality;
}
if let Some(max_pages) = cli.max_pages {
config = config.with_max_pages(Some(max_pages));
}
if let Some(save_debug) = cli.save_debug {
config.save_debug = save_debug;
}
config
}
pub fn search_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("superbook.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("superbook-pdf").join("config.toml"));
}
paths
}
}
#[derive(Debug, Clone, Default)]
pub struct CliOverrides {
pub dpi: Option<u32>,
pub deskew: Option<bool>,
pub margin_trim: Option<f64>,
pub upscale: Option<bool>,
pub gpu: Option<bool>,
pub ocr: Option<bool>,
pub threads: Option<usize>,
pub internal_resolution: Option<bool>,
pub color_correction: Option<bool>,
pub offset_alignment: Option<bool>,
pub output_height: Option<u32>,
pub jpeg_quality: Option<u8>,
pub max_pages: Option<usize>,
pub save_debug: Option<bool>,
}
impl CliOverrides {
pub fn new() -> Self {
Self::default()
}
pub fn with_dpi(mut self, dpi: u32) -> Self {
self.dpi = Some(dpi);
self
}
pub fn with_deskew(mut self, deskew: bool) -> Self {
self.deskew = Some(deskew);
self
}
pub fn with_margin_trim(mut self, margin_trim: f64) -> Self {
self.margin_trim = Some(margin_trim);
self
}
pub fn with_upscale(mut self, upscale: bool) -> Self {
self.upscale = Some(upscale);
self
}
pub fn with_gpu(mut self, gpu: bool) -> Self {
self.gpu = Some(gpu);
self
}
pub fn with_ocr(mut self, ocr: bool) -> Self {
self.ocr = Some(ocr);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.general.dpi, None);
assert_eq!(config.processing.deskew, None);
assert_eq!(config.advanced.internal_resolution, None);
assert_eq!(config.ocr.enabled, None);
assert_eq!(config.output.jpeg_quality, None);
}
#[test]
fn test_config_load_from_path_existing() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[general]
dpi = 600
[processing]
deskew = true
"#,
)
.unwrap();
let config = Config::load_from_path(&config_path).unwrap();
assert_eq!(config.general.dpi, Some(600));
assert_eq!(config.processing.deskew, Some(true));
}
#[test]
fn test_config_load_from_path_not_found() {
let result = Config::load_from_path(Path::new("/nonexistent/config.toml"));
assert!(matches!(result, Err(ConfigError::NotFound(_))));
}
#[test]
fn test_config_search_paths() {
let paths = Config::search_paths();
assert!(!paths.is_empty());
assert_eq!(paths[0], PathBuf::from("superbook.toml"));
}
#[test]
fn test_config_merge_cli_priority() {
let config = Config {
general: GeneralConfig {
dpi: Some(300),
..Default::default()
},
processing: ProcessingConfig {
deskew: Some(true),
..Default::default()
},
..Default::default()
};
let cli = CliOverrides::new().with_dpi(600).with_deskew(false);
let pipeline = config.merge_with_cli(&cli);
assert_eq!(pipeline.dpi, 600); assert!(!pipeline.deskew); }
#[test]
fn test_config_to_pipeline_config() {
let config = Config {
general: GeneralConfig {
dpi: Some(450),
threads: Some(8),
..Default::default()
},
processing: ProcessingConfig {
deskew: Some(false),
margin_trim: Some(1.0),
upscale: Some(true),
gpu: Some(true),
},
advanced: AdvancedConfig {
internal_resolution: Some(true),
color_correction: Some(true),
offset_alignment: Some(true),
output_height: Some(4000),
},
ocr: OcrConfig {
enabled: Some(true),
..Default::default()
},
output: OutputConfig {
jpeg_quality: Some(95),
..Default::default()
},
};
let pipeline = config.to_pipeline_config();
assert_eq!(pipeline.dpi, 450);
assert_eq!(pipeline.threads, Some(8));
assert!(!pipeline.deskew);
assert!((pipeline.margin_trim - 1.0).abs() < f64::EPSILON);
assert!(pipeline.upscale);
assert!(pipeline.gpu);
assert!(pipeline.internal_resolution);
assert!(pipeline.color_correction);
assert!(pipeline.offset_alignment);
assert_eq!(pipeline.output_height, 4000);
assert!(pipeline.ocr);
assert_eq!(pipeline.jpeg_quality, 95);
}
#[test]
fn test_config_toml_parse_complete() {
let toml = r#"
[general]
dpi = 300
threads = 4
verbose = 2
[processing]
deskew = true
margin_trim = 0.5
upscale = true
gpu = true
[advanced]
internal_resolution = true
color_correction = true
offset_alignment = true
output_height = 3508
[ocr]
enabled = true
language = "ja"
[output]
jpeg_quality = 90
skip_existing = true
"#;
let config = Config::from_toml(toml).unwrap();
assert_eq!(config.general.dpi, Some(300));
assert_eq!(config.general.threads, Some(4));
assert_eq!(config.general.verbose, Some(2));
assert_eq!(config.processing.deskew, Some(true));
assert_eq!(config.processing.margin_trim, Some(0.5));
assert_eq!(config.advanced.internal_resolution, Some(true));
assert_eq!(config.ocr.language, Some("ja".to_string()));
assert_eq!(config.output.jpeg_quality, Some(90));
assert_eq!(config.output.skip_existing, Some(true));
}
#[test]
fn test_config_toml_parse_partial() {
let toml = r#"
[general]
dpi = 600
"#;
let config = Config::from_toml(toml).unwrap();
assert_eq!(config.general.dpi, Some(600));
assert_eq!(config.general.threads, None);
assert_eq!(config.processing.deskew, None);
}
#[test]
fn test_config_toml_parse_empty() {
let config = Config::from_toml("").unwrap();
assert_eq!(config, Config::default());
}
#[test]
fn test_config_toml_parse_invalid() {
let result = Config::from_toml("this is not valid toml [[[");
assert!(matches!(result, Err(ConfigError::TomlParse(_))));
}
#[test]
fn test_config_to_toml() {
let config = Config {
general: GeneralConfig {
dpi: Some(300),
..Default::default()
},
..Default::default()
};
let toml_str = config.to_toml().unwrap();
assert!(toml_str.contains("dpi = 300"));
}
#[test]
fn test_cli_overrides_builder() {
let overrides = CliOverrides::new()
.with_dpi(600)
.with_deskew(false)
.with_margin_trim(1.5)
.with_upscale(true)
.with_gpu(false)
.with_ocr(true);
assert_eq!(overrides.dpi, Some(600));
assert_eq!(overrides.deskew, Some(false));
assert_eq!(overrides.margin_trim, Some(1.5));
assert_eq!(overrides.upscale, Some(true));
assert_eq!(overrides.gpu, Some(false));
assert_eq!(overrides.ocr, Some(true));
}
#[test]
fn test_config_error_display() {
let err = ConfigError::NotFound(PathBuf::from("/test/path"));
assert!(err.to_string().contains("Config file not found"));
}
#[test]
fn test_config_new() {
let config = Config::new();
assert_eq!(config, Config::default());
}
#[test]
fn test_config_merge_empty_cli() {
let config = Config {
general: GeneralConfig {
dpi: Some(300),
..Default::default()
},
..Default::default()
};
let cli = CliOverrides::new();
let pipeline = config.merge_with_cli(&cli);
assert_eq!(pipeline.dpi, 300); }
#[test]
fn test_config_merge_partial_cli() {
let config = Config {
general: GeneralConfig {
dpi: Some(300),
threads: Some(4),
..Default::default()
},
processing: ProcessingConfig {
deskew: Some(true),
margin_trim: Some(0.5),
..Default::default()
},
..Default::default()
};
let cli = CliOverrides::new().with_dpi(600);
let pipeline = config.merge_with_cli(&cli);
assert_eq!(pipeline.dpi, 600); assert_eq!(pipeline.threads, Some(4)); assert!(pipeline.deskew); }
}