Skip to main content

superbook_pdf/
config.rs

1//! Configuration file support for superbook-pdf
2//!
3//! Supports TOML configuration files with the following search order:
4//! 1. `--config <path>` - explicitly specified path
5//! 2. `./superbook.toml` - current directory
6//! 3. `~/.config/superbook-pdf/config.toml` - user config
7//! 4. Default values
8//!
9//! # Example Configuration
10//!
11//! ```toml
12//! [general]
13//! dpi = 300
14//! threads = 4
15//!
16//! [processing]
17//! deskew = true
18//! margin_trim = 0.5
19//!
20//! [advanced]
21//! internal_resolution = true
22//! color_correction = true
23//! ```
24
25use serde::{Deserialize, Serialize};
26use std::path::{Path, PathBuf};
27use thiserror::Error;
28
29use crate::PipelineConfig;
30
31/// Configuration file errors
32#[derive(Debug, Error)]
33pub enum ConfigError {
34    /// IO error reading config file
35    #[error("IO error: {0}")]
36    Io(#[from] std::io::Error),
37
38    /// TOML parse error
39    #[error("TOML parse error: {0}")]
40    TomlParse(#[from] toml::de::Error),
41
42    /// File not found
43    #[error("Config file not found: {0}")]
44    NotFound(PathBuf),
45}
46
47/// General configuration options
48#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
49pub struct GeneralConfig {
50    /// Output DPI
51    #[serde(default)]
52    pub dpi: Option<u32>,
53
54    /// Number of threads for parallel processing
55    #[serde(default)]
56    pub threads: Option<usize>,
57
58    /// Verbosity level (0-2)
59    #[serde(default)]
60    pub verbose: Option<u8>,
61}
62
63/// Processing configuration options
64#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
65pub struct ProcessingConfig {
66    /// Enable deskew correction
67    #[serde(default)]
68    pub deskew: Option<bool>,
69
70    /// Margin trim percentage
71    #[serde(default)]
72    pub margin_trim: Option<f64>,
73
74    /// Enable AI upscaling
75    #[serde(default)]
76    pub upscale: Option<bool>,
77
78    /// Enable GPU processing
79    #[serde(default)]
80    pub gpu: Option<bool>,
81}
82
83/// Advanced processing configuration
84#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
85pub struct AdvancedConfig {
86    /// Enable internal resolution normalization (4960x7016)
87    #[serde(default)]
88    pub internal_resolution: Option<bool>,
89
90    /// Enable global color correction
91    #[serde(default)]
92    pub color_correction: Option<bool>,
93
94    /// Enable page number offset alignment
95    #[serde(default)]
96    pub offset_alignment: Option<bool>,
97
98    /// Output height in pixels
99    #[serde(default)]
100    pub output_height: Option<u32>,
101}
102
103/// OCR configuration
104#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
105pub struct OcrConfig {
106    /// Enable OCR
107    #[serde(default)]
108    pub enabled: Option<bool>,
109
110    /// OCR language
111    #[serde(default)]
112    pub language: Option<String>,
113}
114
115/// Output configuration
116#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
117pub struct OutputConfig {
118    /// JPEG quality (1-100)
119    #[serde(default)]
120    pub jpeg_quality: Option<u8>,
121
122    /// Skip existing files
123    #[serde(default)]
124    pub skip_existing: Option<bool>,
125}
126
127/// Main configuration structure
128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
129pub struct Config {
130    /// General settings
131    #[serde(default)]
132    pub general: GeneralConfig,
133
134    /// Processing settings
135    #[serde(default)]
136    pub processing: ProcessingConfig,
137
138    /// Advanced settings
139    #[serde(default)]
140    pub advanced: AdvancedConfig,
141
142    /// OCR settings
143    #[serde(default)]
144    pub ocr: OcrConfig,
145
146    /// Output settings
147    #[serde(default)]
148    pub output: OutputConfig,
149}
150
151impl Config {
152    /// Create a new default configuration
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Load configuration from the default search path
158    ///
159    /// Search order:
160    /// 1. `./superbook.toml`
161    /// 2. `~/.config/superbook-pdf/config.toml`
162    /// 3. Default values (if no file found)
163    pub fn load() -> Result<Self, ConfigError> {
164        // Try current directory first
165        let current_dir_config = PathBuf::from("superbook.toml");
166        if current_dir_config.exists() {
167            return Self::load_from_path(&current_dir_config);
168        }
169
170        // Try user config directory
171        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        // Return default config if no file found
179        Ok(Self::default())
180    }
181
182    /// Load configuration from a specific file path
183    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    /// Parse configuration from a TOML string
194    pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
195        let config: Config = toml::from_str(content)?;
196        Ok(config)
197    }
198
199    /// Serialize configuration to TOML string
200    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
201        toml::to_string_pretty(self)
202    }
203
204    /// Convert to PipelineConfig
205    pub fn to_pipeline_config(&self) -> PipelineConfig {
206        let mut config = PipelineConfig::default();
207
208        // Apply general settings
209        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        // Apply processing settings
217        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        // Apply advanced settings
231        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        // Apply OCR settings
245        if let Some(ocr) = self.ocr.enabled {
246            config = config.with_ocr(ocr);
247        }
248
249        // Apply output settings
250        if let Some(quality) = self.output.jpeg_quality {
251            config.jpeg_quality = quality;
252        }
253
254        config
255    }
256
257    /// Merge with CLI arguments (CLI takes precedence)
258    pub fn merge_with_cli(&self, cli: &CliOverrides) -> PipelineConfig {
259        let mut config = self.to_pipeline_config();
260
261        // CLI overrides take precedence
262        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    /// Get config file search paths
309    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/// CLI override values for merging with config file
321#[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    /// Create new empty overrides
341    pub fn new() -> Self {
342        Self::default()
343    }
344
345    /// Set DPI override
346    pub fn with_dpi(mut self, dpi: u32) -> Self {
347        self.dpi = Some(dpi);
348        self
349    }
350
351    /// Set deskew override
352    pub fn with_deskew(mut self, deskew: bool) -> Self {
353        self.deskew = Some(deskew);
354        self
355    }
356
357    /// Set margin trim override
358    pub fn with_margin_trim(mut self, margin_trim: f64) -> Self {
359        self.margin_trim = Some(margin_trim);
360        self
361    }
362
363    /// Set upscale override
364    pub fn with_upscale(mut self, upscale: bool) -> Self {
365        self.upscale = Some(upscale);
366        self
367    }
368
369    /// Set GPU override
370    pub fn with_gpu(mut self, gpu: bool) -> Self {
371        self.gpu = Some(gpu);
372        self
373    }
374
375    /// Set OCR override
376    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    // CFG-001: Config::default
387    #[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    // CFG-002: Config::load_from_path (existing file)
398    #[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    // CFG-003: Config::load_from_path (non-existent file)
420    #[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    // CFG-004: Config::load (search order)
427    #[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    // CFG-005: Config::merge (CLI priority)
435    #[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); // CLI wins
453        assert!(!pipeline.deskew); // CLI wins
454    }
455
456    // CFG-006: Config::to_pipeline_config
457    #[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    // CFG-007: TOML parse (complete config)
503    #[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    // CFG-008: TOML parse (partial config)
545    #[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    // CFG-009: TOML parse (empty file)
559    #[test]
560    fn test_config_toml_parse_empty() {
561        let config = Config::from_toml("").unwrap();
562        assert_eq!(config, Config::default());
563    }
564
565    // CFG-010: TOML parse (invalid format)
566    #[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); // Config value preserved
629    }
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); // CLI wins
650        assert_eq!(pipeline.threads, Some(4)); // Config preserved
651        assert!(pipeline.deskew); // Config preserved
652    }
653}