1use serde::{Deserialize, Serialize};
26use std::path::{Path, PathBuf};
27use thiserror::Error;
28
29use crate::PipelineConfig;
30
31#[derive(Debug, Error)]
33pub enum ConfigError {
34 #[error("IO error: {0}")]
36 Io(#[from] std::io::Error),
37
38 #[error("TOML parse error: {0}")]
40 TomlParse(#[from] toml::de::Error),
41
42 #[error("Config file not found: {0}")]
44 NotFound(PathBuf),
45}
46
47#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
49pub struct GeneralConfig {
50 #[serde(default)]
52 pub dpi: Option<u32>,
53
54 #[serde(default)]
56 pub threads: Option<usize>,
57
58 #[serde(default)]
60 pub verbose: Option<u8>,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
65pub struct ProcessingConfig {
66 #[serde(default)]
68 pub deskew: Option<bool>,
69
70 #[serde(default)]
72 pub margin_trim: Option<f64>,
73
74 #[serde(default)]
76 pub upscale: Option<bool>,
77
78 #[serde(default)]
80 pub gpu: Option<bool>,
81}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
85pub struct AdvancedConfig {
86 #[serde(default)]
88 pub internal_resolution: Option<bool>,
89
90 #[serde(default)]
92 pub color_correction: Option<bool>,
93
94 #[serde(default)]
96 pub offset_alignment: Option<bool>,
97
98 #[serde(default)]
100 pub output_height: Option<u32>,
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
105pub struct OcrConfig {
106 #[serde(default)]
108 pub enabled: Option<bool>,
109
110 #[serde(default)]
112 pub language: Option<String>,
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
117pub struct OutputConfig {
118 #[serde(default)]
120 pub jpeg_quality: Option<u8>,
121
122 #[serde(default)]
124 pub skip_existing: Option<bool>,
125}
126
127#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
129pub struct Config {
130 #[serde(default)]
132 pub general: GeneralConfig,
133
134 #[serde(default)]
136 pub processing: ProcessingConfig,
137
138 #[serde(default)]
140 pub advanced: AdvancedConfig,
141
142 #[serde(default)]
144 pub ocr: OcrConfig,
145
146 #[serde(default)]
148 pub output: OutputConfig,
149}
150
151impl Config {
152 pub fn new() -> Self {
154 Self::default()
155 }
156
157 pub fn load() -> Result<Self, ConfigError> {
164 let current_dir_config = PathBuf::from("superbook.toml");
166 if current_dir_config.exists() {
167 return Self::load_from_path(¤t_dir_config);
168 }
169
170 if let Some(config_dir) = dirs::config_dir() {
172 let user_config = config_dir.join("superbook-pdf").join("config.toml");
173 if user_config.exists() {
174 return Self::load_from_path(&user_config);
175 }
176 }
177
178 Ok(Self::default())
180 }
181
182 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
184 if !path.exists() {
185 return Err(ConfigError::NotFound(path.to_path_buf()));
186 }
187
188 let content = std::fs::read_to_string(path)?;
189 let config: Config = toml::from_str(&content)?;
190 Ok(config)
191 }
192
193 pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
195 let config: Config = toml::from_str(content)?;
196 Ok(config)
197 }
198
199 pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
201 toml::to_string_pretty(self)
202 }
203
204 pub fn to_pipeline_config(&self) -> PipelineConfig {
206 let mut config = PipelineConfig::default();
207
208 if let Some(dpi) = self.general.dpi {
210 config = config.with_dpi(dpi);
211 }
212 if let Some(threads) = self.general.threads {
213 config.threads = Some(threads);
214 }
215
216 if let Some(deskew) = self.processing.deskew {
218 config = config.with_deskew(deskew);
219 }
220 if let Some(margin_trim) = self.processing.margin_trim {
221 config = config.with_margin_trim(margin_trim);
222 }
223 if let Some(upscale) = self.processing.upscale {
224 config = config.with_upscale(upscale);
225 }
226 if let Some(gpu) = self.processing.gpu {
227 config = config.with_gpu(gpu);
228 }
229
230 if let Some(internal) = self.advanced.internal_resolution {
232 config.internal_resolution = internal;
233 }
234 if let Some(color) = self.advanced.color_correction {
235 config.color_correction = color;
236 }
237 if let Some(offset) = self.advanced.offset_alignment {
238 config.offset_alignment = offset;
239 }
240 if let Some(height) = self.advanced.output_height {
241 config.output_height = height;
242 }
243
244 if let Some(ocr) = self.ocr.enabled {
246 config = config.with_ocr(ocr);
247 }
248
249 if let Some(quality) = self.output.jpeg_quality {
251 config.jpeg_quality = quality;
252 }
253
254 config
255 }
256
257 pub fn merge_with_cli(&self, cli: &CliOverrides) -> PipelineConfig {
259 let mut config = self.to_pipeline_config();
260
261 if let Some(dpi) = cli.dpi {
263 config = config.with_dpi(dpi);
264 }
265 if let Some(deskew) = cli.deskew {
266 config = config.with_deskew(deskew);
267 }
268 if let Some(margin_trim) = cli.margin_trim {
269 config = config.with_margin_trim(margin_trim);
270 }
271 if let Some(upscale) = cli.upscale {
272 config = config.with_upscale(upscale);
273 }
274 if let Some(gpu) = cli.gpu {
275 config = config.with_gpu(gpu);
276 }
277 if let Some(ocr) = cli.ocr {
278 config = config.with_ocr(ocr);
279 }
280 if let Some(threads) = cli.threads {
281 config.threads = Some(threads);
282 }
283 if let Some(internal) = cli.internal_resolution {
284 config.internal_resolution = internal;
285 }
286 if let Some(color) = cli.color_correction {
287 config.color_correction = color;
288 }
289 if let Some(offset) = cli.offset_alignment {
290 config.offset_alignment = offset;
291 }
292 if let Some(height) = cli.output_height {
293 config.output_height = height;
294 }
295 if let Some(quality) = cli.jpeg_quality {
296 config.jpeg_quality = quality;
297 }
298 if let Some(max_pages) = cli.max_pages {
299 config = config.with_max_pages(Some(max_pages));
300 }
301 if let Some(save_debug) = cli.save_debug {
302 config.save_debug = save_debug;
303 }
304
305 config
306 }
307
308 pub fn search_paths() -> Vec<PathBuf> {
310 let mut paths = vec![PathBuf::from("superbook.toml")];
311
312 if let Some(config_dir) = dirs::config_dir() {
313 paths.push(config_dir.join("superbook-pdf").join("config.toml"));
314 }
315
316 paths
317 }
318}
319
320#[derive(Debug, Clone, Default)]
322pub struct CliOverrides {
323 pub dpi: Option<u32>,
324 pub deskew: Option<bool>,
325 pub margin_trim: Option<f64>,
326 pub upscale: Option<bool>,
327 pub gpu: Option<bool>,
328 pub ocr: Option<bool>,
329 pub threads: Option<usize>,
330 pub internal_resolution: Option<bool>,
331 pub color_correction: Option<bool>,
332 pub offset_alignment: Option<bool>,
333 pub output_height: Option<u32>,
334 pub jpeg_quality: Option<u8>,
335 pub max_pages: Option<usize>,
336 pub save_debug: Option<bool>,
337}
338
339impl CliOverrides {
340 pub fn new() -> Self {
342 Self::default()
343 }
344
345 pub fn with_dpi(mut self, dpi: u32) -> Self {
347 self.dpi = Some(dpi);
348 self
349 }
350
351 pub fn with_deskew(mut self, deskew: bool) -> Self {
353 self.deskew = Some(deskew);
354 self
355 }
356
357 pub fn with_margin_trim(mut self, margin_trim: f64) -> Self {
359 self.margin_trim = Some(margin_trim);
360 self
361 }
362
363 pub fn with_upscale(mut self, upscale: bool) -> Self {
365 self.upscale = Some(upscale);
366 self
367 }
368
369 pub fn with_gpu(mut self, gpu: bool) -> Self {
371 self.gpu = Some(gpu);
372 self
373 }
374
375 pub fn with_ocr(mut self, ocr: bool) -> Self {
377 self.ocr = Some(ocr);
378 self
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
388 fn test_config_default() {
389 let config = Config::default();
390 assert_eq!(config.general.dpi, None);
391 assert_eq!(config.processing.deskew, None);
392 assert_eq!(config.advanced.internal_resolution, None);
393 assert_eq!(config.ocr.enabled, None);
394 assert_eq!(config.output.jpeg_quality, None);
395 }
396
397 #[test]
399 fn test_config_load_from_path_existing() {
400 let dir = tempfile::tempdir().unwrap();
401 let config_path = dir.path().join("config.toml");
402 std::fs::write(
403 &config_path,
404 r#"
405[general]
406dpi = 600
407
408[processing]
409deskew = true
410"#,
411 )
412 .unwrap();
413
414 let config = Config::load_from_path(&config_path).unwrap();
415 assert_eq!(config.general.dpi, Some(600));
416 assert_eq!(config.processing.deskew, Some(true));
417 }
418
419 #[test]
421 fn test_config_load_from_path_not_found() {
422 let result = Config::load_from_path(Path::new("/nonexistent/config.toml"));
423 assert!(matches!(result, Err(ConfigError::NotFound(_))));
424 }
425
426 #[test]
428 fn test_config_search_paths() {
429 let paths = Config::search_paths();
430 assert!(!paths.is_empty());
431 assert_eq!(paths[0], PathBuf::from("superbook.toml"));
432 }
433
434 #[test]
436 fn test_config_merge_cli_priority() {
437 let config = Config {
438 general: GeneralConfig {
439 dpi: Some(300),
440 ..Default::default()
441 },
442 processing: ProcessingConfig {
443 deskew: Some(true),
444 ..Default::default()
445 },
446 ..Default::default()
447 };
448
449 let cli = CliOverrides::new().with_dpi(600).with_deskew(false);
450
451 let pipeline = config.merge_with_cli(&cli);
452 assert_eq!(pipeline.dpi, 600); assert!(!pipeline.deskew); }
455
456 #[test]
458 fn test_config_to_pipeline_config() {
459 let config = Config {
460 general: GeneralConfig {
461 dpi: Some(450),
462 threads: Some(8),
463 ..Default::default()
464 },
465 processing: ProcessingConfig {
466 deskew: Some(false),
467 margin_trim: Some(1.0),
468 upscale: Some(true),
469 gpu: Some(true),
470 },
471 advanced: AdvancedConfig {
472 internal_resolution: Some(true),
473 color_correction: Some(true),
474 offset_alignment: Some(true),
475 output_height: Some(4000),
476 },
477 ocr: OcrConfig {
478 enabled: Some(true),
479 ..Default::default()
480 },
481 output: OutputConfig {
482 jpeg_quality: Some(95),
483 ..Default::default()
484 },
485 };
486
487 let pipeline = config.to_pipeline_config();
488 assert_eq!(pipeline.dpi, 450);
489 assert_eq!(pipeline.threads, Some(8));
490 assert!(!pipeline.deskew);
491 assert!((pipeline.margin_trim - 1.0).abs() < f64::EPSILON);
492 assert!(pipeline.upscale);
493 assert!(pipeline.gpu);
494 assert!(pipeline.internal_resolution);
495 assert!(pipeline.color_correction);
496 assert!(pipeline.offset_alignment);
497 assert_eq!(pipeline.output_height, 4000);
498 assert!(pipeline.ocr);
499 assert_eq!(pipeline.jpeg_quality, 95);
500 }
501
502 #[test]
504 fn test_config_toml_parse_complete() {
505 let toml = r#"
506[general]
507dpi = 300
508threads = 4
509verbose = 2
510
511[processing]
512deskew = true
513margin_trim = 0.5
514upscale = true
515gpu = true
516
517[advanced]
518internal_resolution = true
519color_correction = true
520offset_alignment = true
521output_height = 3508
522
523[ocr]
524enabled = true
525language = "ja"
526
527[output]
528jpeg_quality = 90
529skip_existing = true
530"#;
531
532 let config = Config::from_toml(toml).unwrap();
533 assert_eq!(config.general.dpi, Some(300));
534 assert_eq!(config.general.threads, Some(4));
535 assert_eq!(config.general.verbose, Some(2));
536 assert_eq!(config.processing.deskew, Some(true));
537 assert_eq!(config.processing.margin_trim, Some(0.5));
538 assert_eq!(config.advanced.internal_resolution, Some(true));
539 assert_eq!(config.ocr.language, Some("ja".to_string()));
540 assert_eq!(config.output.jpeg_quality, Some(90));
541 assert_eq!(config.output.skip_existing, Some(true));
542 }
543
544 #[test]
546 fn test_config_toml_parse_partial() {
547 let toml = r#"
548[general]
549dpi = 600
550"#;
551
552 let config = Config::from_toml(toml).unwrap();
553 assert_eq!(config.general.dpi, Some(600));
554 assert_eq!(config.general.threads, None);
555 assert_eq!(config.processing.deskew, None);
556 }
557
558 #[test]
560 fn test_config_toml_parse_empty() {
561 let config = Config::from_toml("").unwrap();
562 assert_eq!(config, Config::default());
563 }
564
565 #[test]
567 fn test_config_toml_parse_invalid() {
568 let result = Config::from_toml("this is not valid toml [[[");
569 assert!(matches!(result, Err(ConfigError::TomlParse(_))));
570 }
571
572 #[test]
573 fn test_config_to_toml() {
574 let config = Config {
575 general: GeneralConfig {
576 dpi: Some(300),
577 ..Default::default()
578 },
579 ..Default::default()
580 };
581
582 let toml_str = config.to_toml().unwrap();
583 assert!(toml_str.contains("dpi = 300"));
584 }
585
586 #[test]
587 fn test_cli_overrides_builder() {
588 let overrides = CliOverrides::new()
589 .with_dpi(600)
590 .with_deskew(false)
591 .with_margin_trim(1.5)
592 .with_upscale(true)
593 .with_gpu(false)
594 .with_ocr(true);
595
596 assert_eq!(overrides.dpi, Some(600));
597 assert_eq!(overrides.deskew, Some(false));
598 assert_eq!(overrides.margin_trim, Some(1.5));
599 assert_eq!(overrides.upscale, Some(true));
600 assert_eq!(overrides.gpu, Some(false));
601 assert_eq!(overrides.ocr, Some(true));
602 }
603
604 #[test]
605 fn test_config_error_display() {
606 let err = ConfigError::NotFound(PathBuf::from("/test/path"));
607 assert!(err.to_string().contains("Config file not found"));
608 }
609
610 #[test]
611 fn test_config_new() {
612 let config = Config::new();
613 assert_eq!(config, Config::default());
614 }
615
616 #[test]
617 fn test_config_merge_empty_cli() {
618 let config = Config {
619 general: GeneralConfig {
620 dpi: Some(300),
621 ..Default::default()
622 },
623 ..Default::default()
624 };
625
626 let cli = CliOverrides::new();
627 let pipeline = config.merge_with_cli(&cli);
628 assert_eq!(pipeline.dpi, 300); }
630
631 #[test]
632 fn test_config_merge_partial_cli() {
633 let config = Config {
634 general: GeneralConfig {
635 dpi: Some(300),
636 threads: Some(4),
637 ..Default::default()
638 },
639 processing: ProcessingConfig {
640 deskew: Some(true),
641 margin_trim: Some(0.5),
642 ..Default::default()
643 },
644 ..Default::default()
645 };
646
647 let cli = CliOverrides::new().with_dpi(600);
648 let pipeline = config.merge_with_cli(&cli);
649 assert_eq!(pipeline.dpi, 600); assert_eq!(pipeline.threads, Some(4)); assert!(pipeline.deskew); }
653}