Skip to main content

superbook_pdf/
cli.rs

1//! CLI interface module
2//!
3//! Provides command-line interface using clap derive macros.
4
5use clap::{Args, Parser, Subcommand};
6use indicatif::{ProgressBar, ProgressStyle};
7use std::path::PathBuf;
8
9/// Exit codes for the CLI
10///
11/// These codes follow standard Unix conventions and provide
12/// specific error categories for scripting and automation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[repr(i32)]
15pub enum ExitCode {
16    /// 正常終了
17    Success = 0,
18    /// 一般的なエラー
19    GeneralError = 1,
20    /// 引数エラー
21    InvalidArgs = 2,
22    /// 入力ファイル/ディレクトリが見つからない
23    InputNotFound = 3,
24    /// 出力エラー(書き込み権限など)
25    OutputError = 4,
26    /// 処理中のエラー
27    ProcessingError = 5,
28    /// GPU初期化/処理エラー
29    GpuError = 6,
30    /// 外部ツール(Python等)エラー
31    ExternalToolError = 7,
32}
33
34impl ExitCode {
35    /// Convert to process exit code
36    #[must_use]
37    pub fn code(self) -> i32 {
38        self as i32
39    }
40
41    /// Get human-readable description
42    #[must_use]
43    pub fn description(self) -> &'static str {
44        match self {
45            ExitCode::Success => "Success",
46            ExitCode::GeneralError => "General error",
47            ExitCode::InvalidArgs => "Invalid arguments",
48            ExitCode::InputNotFound => "Input file or directory not found",
49            ExitCode::OutputError => "Output error (permission denied, disk full, etc.)",
50            ExitCode::ProcessingError => "Processing error",
51            ExitCode::GpuError => "GPU initialization or processing error",
52            ExitCode::ExternalToolError => "External tool error (Python, ImageMagick, etc.)",
53        }
54    }
55}
56
57impl From<ExitCode> for i32 {
58    fn from(code: ExitCode) -> Self {
59        code.code()
60    }
61}
62
63impl From<ExitCode> for std::process::ExitCode {
64    fn from(code: ExitCode) -> Self {
65        std::process::ExitCode::from(code.code() as u8)
66    }
67}
68
69/// High-quality PDF converter for scanned books
70#[derive(Parser, Debug)]
71#[command(name = "superbook-pdf")]
72#[command(author = "DN_SuperBook_PDF_Converter Contributors")]
73#[command(version)]
74#[command(about = "High-quality PDF converter for scanned books")]
75#[command(after_help = r#"
76Quick Start:
77  superbook-pdf convert input.pdf -o output/           # 基本変換
78  superbook-pdf convert input.pdf -o output/ --advanced --ocr  # 高品質変換
79  superbook-pdf serve --port 8080                      # Web UI起動
80
81詳細は `superbook-pdf <COMMAND> --help` を参照
82"#)]
83pub struct Cli {
84    #[command(subcommand)]
85    pub command: Commands,
86}
87
88/// Available commands
89#[derive(Subcommand, Debug)]
90pub enum Commands {
91    /// Convert PDF files with AI enhancement
92    Convert(ConvertArgs),
93    /// Reprocess failed pages from a previous conversion
94    Reprocess(ReprocessArgs),
95    /// Show system information
96    Info,
97    /// Show cache information for a processed file
98    CacheInfo(CacheInfoArgs),
99    /// Start web server for browser-based conversion
100    #[cfg(feature = "web")]
101    Serve(ServeArgs),
102}
103
104/// Arguments for the cache-info command
105#[derive(Args, Debug)]
106pub struct CacheInfoArgs {
107    /// Path to the output PDF file (to show cache info)
108    #[arg(value_name = "OUTPUT_PDF")]
109    pub output_pdf: std::path::PathBuf,
110}
111
112/// Arguments for the reprocess command
113#[derive(Args, Debug, Clone)]
114pub struct ReprocessArgs {
115    /// PDF file or state file path (.superbook-state.json)
116    #[arg(value_name = "INPUT")]
117    pub input: PathBuf,
118
119    /// Specific pages to retry (comma-separated, 0-indexed)
120    #[arg(short, long, value_delimiter = ',')]
121    pub pages: Option<Vec<usize>>,
122
123    /// Maximum retry attempts per page
124    #[arg(long, default_value = "3")]
125    pub max_retries: u32,
126
127    /// Force reprocess all failed pages
128    #[arg(short, long)]
129    pub force: bool,
130
131    /// Show status only, don't process
132    #[arg(long)]
133    pub status: bool,
134
135    /// Output directory (only if input is PDF, not state file)
136    #[arg(short, long)]
137    pub output: Option<PathBuf>,
138
139    /// Keep intermediate files
140    #[arg(long)]
141    pub keep_intermediates: bool,
142
143    /// Verbosity level (-v, -vv, -vvv)
144    #[arg(short, long, action = clap::ArgAction::Count)]
145    pub verbose: u8,
146
147    /// Suppress progress output
148    #[arg(short, long)]
149    pub quiet: bool,
150}
151
152impl ReprocessArgs {
153    /// Check if the input is a state file
154    pub fn is_state_file(&self) -> bool {
155        self.input
156            .to_string_lossy()
157            .ends_with(".superbook-state.json")
158    }
159
160    /// Get page indices to reprocess (empty means all failed)
161    pub fn page_indices(&self) -> Vec<usize> {
162        self.pages.clone().unwrap_or_default()
163    }
164}
165
166/// Arguments for the serve command (web server)
167#[cfg(feature = "web")]
168#[derive(Args, Debug)]
169pub struct ServeArgs {
170    /// Port to listen on
171    #[arg(short, long, default_value = "8080")]
172    pub port: u16,
173
174    /// Address to bind to
175    #[arg(short, long, default_value = "127.0.0.1")]
176    pub bind: String,
177
178    /// Maximum upload size in MB
179    #[arg(long, default_value = "500")]
180    pub upload_limit: usize,
181
182    /// Job timeout in seconds
183    #[arg(long, default_value = "3600")]
184    pub job_timeout: u64,
185
186    /// Enable CORS (default: enabled)
187    #[arg(long, default_value = "true")]
188    pub cors: bool,
189
190    /// Allowed CORS origins (can be specified multiple times)
191    #[arg(long = "cors-origin")]
192    pub cors_origins: Vec<String>,
193
194    /// Disable CORS
195    #[arg(long)]
196    pub no_cors: bool,
197}
198
199/// Arguments for the convert command
200#[derive(clap::Args, Debug)]
201#[command(after_help = r#"
202Examples:
203  # 基本的な変換
204  superbook-pdf convert input.pdf -o output/
205
206  # AI超解像 + OCR付き高品質変換
207  superbook-pdf convert input.pdf -o output/ --advanced --ocr
208
209  # GPU無効化 (CPUのみ)
210  superbook-pdf convert input.pdf -o output/ --no-gpu
211
212  # 最初の10ページのみテスト
213  superbook-pdf convert input.pdf -o output/ --max-pages 10
214
215  # 詳細ログ出力
216  superbook-pdf convert input.pdf -o output/ -vvv
217"#)]
218pub struct ConvertArgs {
219    /// Input PDF file or directory
220    pub input: PathBuf,
221
222    /// Output directory
223    #[arg(short = 'o', long = "output", default_value = "./output")]
224    pub output: PathBuf,
225
226    /// Configuration file path (TOML format)
227    #[arg(short = 'c', long)]
228    pub config: Option<PathBuf>,
229
230    /// Enable Japanese OCR (YomiToku)
231    #[arg(long)]
232    pub ocr: bool,
233
234    /// Enable AI upscaling (RealESRGAN)
235    #[arg(short, long, default_value_t = true)]
236    #[arg(action = clap::ArgAction::Set)]
237    pub upscale: bool,
238
239    /// Disable AI upscaling
240    #[arg(long = "no-upscale")]
241    #[arg(action = clap::ArgAction::SetTrue)]
242    no_upscale: bool,
243
244    /// Enable deskew correction
245    #[arg(short, long, default_value_t = true)]
246    #[arg(action = clap::ArgAction::Set)]
247    pub deskew: bool,
248
249    /// Disable deskew correction
250    #[arg(long = "no-deskew")]
251    #[arg(action = clap::ArgAction::SetTrue)]
252    no_deskew: bool,
253
254    /// Margin trim percentage
255    #[arg(short, long, default_value_t = 0.5)]
256    pub margin_trim: f32,
257
258    /// Output DPI (1-4800)
259    #[arg(long, default_value_t = 300, value_parser = clap::value_parser!(u32).range(1..=4800))]
260    pub dpi: u32,
261
262    /// JPEG quality for PDF image compression (1-100, higher = better quality, larger file)
263    #[arg(long, default_value_t = 90, value_parser = clap::value_parser!(u8).range(1..=100))]
264    pub jpeg_quality: u8,
265
266    /// Number of parallel threads
267    #[arg(short = 't', long)]
268    pub threads: Option<usize>,
269
270    /// Chunk size for memory-controlled parallel processing (0 = process all at once)
271    #[arg(long, default_value_t = 0)]
272    pub chunk_size: usize,
273
274    /// Enable GPU processing
275    #[arg(short, long, default_value_t = true)]
276    #[arg(action = clap::ArgAction::Set)]
277    pub gpu: bool,
278
279    /// Disable GPU processing
280    #[arg(long = "no-gpu")]
281    #[arg(action = clap::ArgAction::SetTrue)]
282    no_gpu: bool,
283
284    /// Verbosity level (-v, -vv, -vvv)
285    #[arg(short, long, action = clap::ArgAction::Count)]
286    pub verbose: u8,
287
288    /// Suppress progress output
289    #[arg(short, long)]
290    pub quiet: bool,
291
292    /// Show execution plan without processing
293    #[arg(long)]
294    pub dry_run: bool,
295
296    // === Phase 6: Advanced processing options ===
297    /// Enable internal resolution normalization (4960x7016)
298    #[arg(long)]
299    pub internal_resolution: bool,
300
301    /// Enable global color correction
302    #[arg(long)]
303    pub color_correction: bool,
304
305    /// Enable page number offset alignment
306    #[arg(long)]
307    pub offset_alignment: bool,
308
309    /// Output height in pixels (default: 3508)
310    #[arg(long, default_value_t = 3508)]
311    pub output_height: u32,
312
313    /// Enable advanced processing for best quality output
314    /// (includes: internal resolution normalization, color correction, offset alignment)
315    #[arg(long)]
316    pub advanced: bool,
317
318    /// Skip files if output already exists
319    #[arg(long)]
320    pub skip_existing: bool,
321
322    /// Force re-processing even if cache is valid
323    #[arg(long, short = 'f')]
324    pub force: bool,
325
326    // === Debug options ===
327    /// Maximum pages to process (for debugging)
328    #[arg(long)]
329    pub max_pages: Option<usize>,
330
331    /// Save intermediate debug images
332    #[arg(long)]
333    pub save_debug: bool,
334}
335
336impl ConvertArgs {
337    /// Get effective upscale setting (considering --no-upscale flag)
338    pub fn effective_upscale(&self) -> bool {
339        self.upscale && !self.no_upscale
340    }
341
342    /// Get effective deskew setting (considering --no-deskew flag)
343    pub fn effective_deskew(&self) -> bool {
344        self.deskew && !self.no_deskew
345    }
346
347    /// Get effective GPU setting (considering --no-gpu flag)
348    pub fn effective_gpu(&self) -> bool {
349        self.gpu && !self.no_gpu
350    }
351
352    /// Get thread count (default to available CPUs)
353    pub fn thread_count(&self) -> usize {
354        self.threads.unwrap_or_else(num_cpus::get)
355    }
356
357    /// Get effective internal resolution setting (considering --advanced flag)
358    pub fn effective_internal_resolution(&self) -> bool {
359        self.internal_resolution || self.advanced
360    }
361
362    /// Get effective color correction setting (considering --advanced flag)
363    pub fn effective_color_correction(&self) -> bool {
364        self.color_correction || self.advanced
365    }
366
367    /// Get effective offset alignment setting (considering --advanced flag)
368    pub fn effective_offset_alignment(&self) -> bool {
369        self.offset_alignment || self.advanced
370    }
371}
372
373/// Create a styled progress bar for file processing
374pub fn create_progress_bar(total: u64) -> ProgressBar {
375    let pb = ProgressBar::new(total);
376    pb.set_style(
377        ProgressStyle::default_bar()
378            .template(
379                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
380            )
381            .expect("Invalid progress bar template")
382            .progress_chars("#>-"),
383    );
384    pb
385}
386
387/// Create a spinner for indeterminate progress
388pub fn create_spinner(message: &str) -> ProgressBar {
389    let pb = ProgressBar::new_spinner();
390    pb.set_style(
391        ProgressStyle::default_spinner()
392            .template("{spinner:.green} {msg}")
393            .expect("Invalid spinner template"),
394    );
395    pb.set_message(message.to_string());
396    pb
397}
398
399/// Create a progress bar for page processing
400pub fn create_page_progress_bar(total: u64) -> ProgressBar {
401    let pb = ProgressBar::new(total);
402    pb.set_style(
403        ProgressStyle::default_bar()
404            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] Page {pos}/{len} ({percent}%) - {msg}")
405            .expect("Invalid progress bar template")
406            .progress_chars("█▓░"),
407    );
408    pb
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use clap::CommandFactory;
415
416    #[test]
417    fn test_cli_parse() {
418        // Verify CLI can be built
419        Cli::command().debug_assert();
420    }
421
422    // TC-CLI-001: ヘルプ表示
423    #[test]
424    fn test_help_display() {
425        let mut cmd = Cli::command();
426        let help = cmd.render_help().to_string();
427        assert!(help.contains("superbook-pdf"));
428        assert!(help.contains("convert"));
429    }
430
431    // TC-CLI-002: バージョン表示
432    #[test]
433    fn test_version_display() {
434        let cmd = Cli::command();
435        let version = cmd.get_version().unwrap_or("unknown");
436        assert!(!version.is_empty());
437    }
438
439    // TC-CLI-003: 入力ファイルなしエラー
440    #[test]
441    fn test_missing_input_error() {
442        let result = Cli::try_parse_from(["superbook-pdf", "convert"]);
443        assert!(result.is_err());
444        let err = result.unwrap_err();
445        assert!(err.to_string().contains("required"));
446    }
447
448    // TC-CLI-004: 存在しないファイルエラー(パース時はエラーにならない、実行時にチェック)
449    #[test]
450    fn test_nonexistent_file_parse() {
451        // Note: CLI parsing accepts any path, existence check is at runtime
452        let result = Cli::try_parse_from(["superbook-pdf", "convert", "/nonexistent/file.pdf"]);
453        assert!(result.is_ok()); // Parsing succeeds
454    }
455
456    // TC-CLI-005: オプション解析
457    #[test]
458    fn test_option_parsing() {
459        let cli = Cli::try_parse_from([
460            "superbook-pdf",
461            "convert",
462            "input.pdf",
463            "--ocr",
464            "--no-upscale",
465            "--dpi",
466            "600",
467            "-vvv",
468        ])
469        .unwrap();
470
471        if let Commands::Convert(args) = cli.command {
472            assert!(args.ocr);
473            assert!(!args.effective_upscale());
474            assert_eq!(args.dpi, 600);
475            assert_eq!(args.verbose, 3);
476        } else {
477            panic!("Expected Convert command");
478        }
479    }
480
481    // TC-CLI-006: デフォルト値
482    #[test]
483    fn test_default_values() {
484        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
485
486        if let Commands::Convert(args) = cli.command {
487            assert!(!args.ocr);
488            assert!(args.effective_upscale());
489            assert!(args.effective_deskew());
490            assert_eq!(args.margin_trim, 0.5);
491            assert_eq!(args.dpi, 300);
492            assert!(args.effective_gpu());
493            assert_eq!(args.verbose, 0);
494            assert!(!args.quiet);
495            assert!(!args.dry_run);
496        } else {
497            panic!("Expected Convert command");
498        }
499    }
500
501    #[test]
502    fn test_directory_input() {
503        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "/tmp/test_dir", "--dry-run"])
504            .unwrap();
505
506        if let Commands::Convert(args) = cli.command {
507            assert!(args.dry_run);
508        } else {
509            panic!("Expected Convert command");
510        }
511    }
512
513    #[test]
514    fn test_info_command() {
515        let cli = Cli::try_parse_from(["superbook-pdf", "info"]).unwrap();
516
517        assert!(matches!(cli.command, Commands::Info));
518    }
519
520    // TC-CLI-007: Progress bar display
521    #[test]
522    fn test_progress_bar_display() {
523        // Test that progress bar can be created and styled
524        let pb = create_progress_bar(100);
525        assert_eq!(pb.length(), Some(100));
526
527        // Test progress updates
528        pb.set_position(50);
529        assert_eq!(pb.position(), 50);
530
531        pb.finish_with_message("done");
532    }
533
534    #[test]
535    fn test_spinner_creation() {
536        let spinner = create_spinner("Processing...");
537        assert_eq!(spinner.message(), "Processing...");
538        spinner.finish_with_message("Complete");
539    }
540
541    #[test]
542    fn test_page_progress_bar() {
543        let pb = create_page_progress_bar(10);
544        assert_eq!(pb.length(), Some(10));
545
546        for i in 0..10 {
547            pb.set_position(i);
548            pb.set_message(format!("page_{}.png", i));
549        }
550        pb.finish_with_message("All pages processed");
551    }
552
553    // Exit code tests
554    #[test]
555    fn test_exit_code_values() {
556        assert_eq!(ExitCode::Success.code(), 0);
557        assert_eq!(ExitCode::GeneralError.code(), 1);
558        assert_eq!(ExitCode::InvalidArgs.code(), 2);
559        assert_eq!(ExitCode::InputNotFound.code(), 3);
560        assert_eq!(ExitCode::OutputError.code(), 4);
561        assert_eq!(ExitCode::ProcessingError.code(), 5);
562        assert_eq!(ExitCode::GpuError.code(), 6);
563        assert_eq!(ExitCode::ExternalToolError.code(), 7);
564    }
565
566    #[test]
567    fn test_exit_code_descriptions() {
568        assert_eq!(ExitCode::Success.description(), "Success");
569        assert!(!ExitCode::GeneralError.description().is_empty());
570        assert!(!ExitCode::InvalidArgs.description().is_empty());
571        assert!(!ExitCode::InputNotFound.description().is_empty());
572        assert!(!ExitCode::OutputError.description().is_empty());
573        assert!(!ExitCode::ProcessingError.description().is_empty());
574        assert!(!ExitCode::GpuError.description().is_empty());
575        assert!(!ExitCode::ExternalToolError.description().is_empty());
576    }
577
578    #[test]
579    fn test_exit_code_into_i32() {
580        let code: i32 = ExitCode::Success.into();
581        assert_eq!(code, 0);
582
583        let code: i32 = ExitCode::ExternalToolError.into();
584        assert_eq!(code, 7);
585    }
586
587    #[test]
588    fn test_exit_code_equality() {
589        assert_eq!(ExitCode::Success, ExitCode::Success);
590        assert_ne!(ExitCode::Success, ExitCode::GeneralError);
591    }
592
593    #[test]
594    fn test_exit_code_clone_copy() {
595        let code = ExitCode::ProcessingError;
596        let cloned = code;
597        let copied = code;
598        assert_eq!(code, cloned);
599        assert_eq!(code, copied);
600    }
601
602    // Additional CLI tests
603
604    #[test]
605    fn test_thread_count_default() {
606        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
607
608        if let Commands::Convert(args) = cli.command {
609            // Should default to available CPUs
610            assert!(args.thread_count() > 0);
611        } else {
612            panic!("Expected Convert command");
613        }
614    }
615
616    #[test]
617    fn test_thread_count_explicit() {
618        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--threads", "4"])
619            .unwrap();
620
621        if let Commands::Convert(args) = cli.command {
622            assert_eq!(args.thread_count(), 4);
623        } else {
624            panic!("Expected Convert command");
625        }
626    }
627
628    #[test]
629    fn test_all_flags_combination() {
630        let cli = Cli::try_parse_from([
631            "superbook-pdf",
632            "convert",
633            "input.pdf",
634            "--ocr",
635            "--no-upscale",
636            "--no-deskew",
637            "--no-gpu",
638            "--quiet",
639            "--dry-run",
640            "-vvv",
641        ])
642        .unwrap();
643
644        if let Commands::Convert(args) = cli.command {
645            assert!(args.ocr);
646            assert!(!args.effective_upscale());
647            assert!(!args.effective_deskew());
648            assert!(!args.effective_gpu());
649            assert!(args.quiet);
650            assert!(args.dry_run);
651            assert_eq!(args.verbose, 3);
652        } else {
653            panic!("Expected Convert command");
654        }
655    }
656
657    #[test]
658    fn test_margin_trim_setting() {
659        let cli = Cli::try_parse_from([
660            "superbook-pdf",
661            "convert",
662            "input.pdf",
663            "--margin-trim",
664            "1.5",
665        ])
666        .unwrap();
667
668        if let Commands::Convert(args) = cli.command {
669            assert_eq!(args.margin_trim, 1.5);
670        } else {
671            panic!("Expected Convert command");
672        }
673    }
674
675    #[test]
676    fn test_dpi_setting() {
677        let cli =
678            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--dpi", "600"]).unwrap();
679
680        if let Commands::Convert(args) = cli.command {
681            assert_eq!(args.dpi, 600);
682        } else {
683            panic!("Expected Convert command");
684        }
685    }
686
687    #[test]
688    fn test_exit_code_to_process_exit_code() {
689        let code: std::process::ExitCode = ExitCode::Success.into();
690        // ExitCode doesn't expose its value, but we can verify conversion works
691        let _ = code;
692
693        let code: std::process::ExitCode = ExitCode::GeneralError.into();
694        let _ = code;
695    }
696
697    // Test all exit codes
698    #[test]
699    fn test_all_exit_codes() {
700        let codes = [
701            (ExitCode::Success, 0),
702            (ExitCode::GeneralError, 1),
703            (ExitCode::InvalidArgs, 2),
704            (ExitCode::InputNotFound, 3),
705            (ExitCode::OutputError, 4),
706            (ExitCode::ProcessingError, 5),
707            (ExitCode::GpuError, 6),
708            (ExitCode::ExternalToolError, 7),
709        ];
710
711        for (exit_code, expected) in codes {
712            assert_eq!(exit_code.code(), expected);
713        }
714    }
715
716    // Test version command
717    #[test]
718    fn test_version_flag() {
719        // --version should trigger version output (handled by clap)
720        let result = Cli::try_parse_from(["superbook-pdf", "--version"]);
721        // clap returns an error for --version (it's a special flag)
722        assert!(result.is_err());
723    }
724
725    // Test help flag
726    #[test]
727    fn test_help_flag() {
728        let result = Cli::try_parse_from(["superbook-pdf", "--help"]);
729        // clap returns an error for --help (it's a special flag)
730        assert!(result.is_err());
731    }
732
733    // Test invalid command
734    #[test]
735    fn test_invalid_command() {
736        let result = Cli::try_parse_from(["superbook-pdf", "invalid-command"]);
737        assert!(result.is_err());
738    }
739
740    // Test missing required argument
741    #[test]
742    fn test_missing_input_file() {
743        let result = Cli::try_parse_from(["superbook-pdf", "convert"]);
744        assert!(result.is_err());
745    }
746
747    // Test negative DPI (invalid)
748    #[test]
749    fn test_invalid_dpi() {
750        let result =
751            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--dpi", "-100"]);
752        // Should fail parsing or validation
753        assert!(result.is_err());
754    }
755
756    // Test zero threads (explicitly set)
757    #[test]
758    fn test_zero_threads() {
759        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--threads", "0"])
760            .unwrap();
761
762        if let Commands::Convert(args) = cli.command {
763            // threads=0 is explicitly set, returned as-is
764            let count = args.thread_count();
765            // When Some(0) is provided, it returns 0
766            assert_eq!(count, 0);
767        } else {
768            panic!("Expected Convert command");
769        }
770    }
771
772    // Test very high thread count
773    #[test]
774    fn test_high_thread_count() {
775        let cli =
776            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--threads", "1024"])
777                .unwrap();
778
779        if let Commands::Convert(args) = cli.command {
780            assert_eq!(args.thread_count(), 1024);
781        } else {
782            panic!("Expected Convert command");
783        }
784    }
785
786    // Test output path argument (--output / -o)
787    #[test]
788    fn test_output_path() {
789        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-o", "/custom/output"])
790            .unwrap();
791
792        if let Commands::Convert(args) = cli.command {
793            assert_eq!(args.output, PathBuf::from("/custom/output"));
794        } else {
795            panic!("Expected Convert command");
796        }
797    }
798
799    // Test default output path
800    #[test]
801    fn test_default_output_path() {
802        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
803
804        if let Commands::Convert(args) = cli.command {
805            // Default output is "./output"
806            assert_eq!(args.output, PathBuf::from("./output"));
807        } else {
808            panic!("Expected Convert command");
809        }
810    }
811
812    // Test margin trim boundary values
813    #[test]
814    fn test_margin_trim_boundaries() {
815        // Zero margin
816        let cli = Cli::try_parse_from([
817            "superbook-pdf",
818            "convert",
819            "input.pdf",
820            "--margin-trim",
821            "0.0",
822        ])
823        .unwrap();
824
825        if let Commands::Convert(args) = cli.command {
826            assert_eq!(args.margin_trim, 0.0);
827        } else {
828            panic!("Expected Convert command");
829        }
830
831        // Large margin
832        let cli = Cli::try_parse_from([
833            "superbook-pdf",
834            "convert",
835            "input.pdf",
836            "--margin-trim",
837            "10.0",
838        ])
839        .unwrap();
840
841        if let Commands::Convert(args) = cli.command {
842            assert_eq!(args.margin_trim, 10.0);
843        } else {
844            panic!("Expected Convert command");
845        }
846    }
847
848    // Test verbosity levels
849    #[test]
850    fn test_verbosity_levels() {
851        // No verbosity
852        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
853        if let Commands::Convert(args) = cli.command {
854            assert_eq!(args.verbose, 0);
855        }
856
857        // -v
858        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-v"]).unwrap();
859        if let Commands::Convert(args) = cli.command {
860            assert_eq!(args.verbose, 1);
861        }
862
863        // -vv
864        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-vv"]).unwrap();
865        if let Commands::Convert(args) = cli.command {
866            assert_eq!(args.verbose, 2);
867        }
868    }
869
870    // Test quiet flag
871    #[test]
872    fn test_quiet_flag() {
873        let cli =
874            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--quiet"]).unwrap();
875
876        if let Commands::Convert(args) = cli.command {
877            assert!(args.quiet);
878        } else {
879            panic!("Expected Convert command");
880        }
881    }
882
883    // Test dry-run flag
884    #[test]
885    fn test_dry_run_flag() {
886        let cli =
887            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--dry-run"]).unwrap();
888
889        if let Commands::Convert(args) = cli.command {
890            assert!(args.dry_run);
891        } else {
892            panic!("Expected Convert command");
893        }
894    }
895
896    // ============ Debug Implementation Tests ============
897
898    #[test]
899    fn test_exit_code_debug_impl() {
900        let code = ExitCode::Success;
901        let debug_str = format!("{:?}", code);
902        assert!(debug_str.contains("Success"));
903
904        let code2 = ExitCode::ProcessingError;
905        let debug_str2 = format!("{:?}", code2);
906        assert!(debug_str2.contains("ProcessingError"));
907    }
908
909    #[test]
910    fn test_cli_debug_impl() {
911        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
912        let debug_str = format!("{:?}", cli);
913        assert!(debug_str.contains("Cli"));
914        assert!(debug_str.contains("command"));
915    }
916
917    #[test]
918    fn test_commands_debug_impl() {
919        let cli = Cli::try_parse_from(["superbook-pdf", "info"]).unwrap();
920        let debug_str = format!("{:?}", cli.command);
921        assert!(debug_str.contains("Info"));
922
923        let cli2 = Cli::try_parse_from(["superbook-pdf", "convert", "test.pdf"]).unwrap();
924        let debug_str2 = format!("{:?}", cli2.command);
925        assert!(debug_str2.contains("Convert"));
926    }
927
928    #[test]
929    fn test_convert_args_debug_impl() {
930        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--ocr"]).unwrap();
931        if let Commands::Convert(args) = cli.command {
932            let debug_str = format!("{:?}", args);
933            assert!(debug_str.contains("ConvertArgs"));
934            assert!(debug_str.contains("input"));
935            assert!(debug_str.contains("ocr"));
936        }
937    }
938
939    // ============ Clone/Copy Tests ============
940
941    #[test]
942    fn test_exit_code_copy() {
943        let original = ExitCode::GpuError;
944        let copied: ExitCode = original; // Copy
945        let _still_valid = original; // original still valid due to Copy
946        assert_eq!(copied, ExitCode::GpuError);
947    }
948
949    #[test]
950    fn test_exit_code_clone() {
951        let original = ExitCode::OutputError;
952        let cloned = original;
953        assert_eq!(original, cloned);
954    }
955
956    // ============ ExitCode Additional Tests ============
957
958    #[test]
959    fn test_all_exit_code_descriptions_non_empty() {
960        let codes = [
961            ExitCode::Success,
962            ExitCode::GeneralError,
963            ExitCode::InvalidArgs,
964            ExitCode::InputNotFound,
965            ExitCode::OutputError,
966            ExitCode::ProcessingError,
967            ExitCode::GpuError,
968            ExitCode::ExternalToolError,
969        ];
970
971        for code in codes {
972            let desc = code.description();
973            assert!(!desc.is_empty(), "Description for {:?} is empty", code);
974        }
975    }
976
977    #[test]
978    fn test_exit_code_unique_values() {
979        let codes = [
980            ExitCode::Success,
981            ExitCode::GeneralError,
982            ExitCode::InvalidArgs,
983            ExitCode::InputNotFound,
984            ExitCode::OutputError,
985            ExitCode::ProcessingError,
986            ExitCode::GpuError,
987            ExitCode::ExternalToolError,
988        ];
989
990        // All codes should have unique values
991        let values: Vec<i32> = codes.iter().map(|c| c.code()).collect();
992        for (i, v1) in values.iter().enumerate() {
993            for (j, v2) in values.iter().enumerate() {
994                if i != j {
995                    assert_ne!(v1, v2, "Duplicate code value found");
996                }
997            }
998        }
999    }
1000
1001    #[test]
1002    fn test_exit_code_follows_unix_convention() {
1003        // 0 = success is standard Unix convention
1004        assert_eq!(ExitCode::Success.code(), 0);
1005        // All error codes should be non-zero
1006        assert_ne!(ExitCode::GeneralError.code(), 0);
1007        assert_ne!(ExitCode::InvalidArgs.code(), 0);
1008        assert_ne!(ExitCode::InputNotFound.code(), 0);
1009    }
1010
1011    // ============ Path Handling Tests ============
1012
1013    #[test]
1014    fn test_absolute_input_path() {
1015        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "/absolute/path/to/file.pdf"])
1016            .unwrap();
1017        if let Commands::Convert(args) = cli.command {
1018            assert!(args.input.is_absolute());
1019        }
1020    }
1021
1022    #[test]
1023    fn test_relative_input_path() {
1024        let cli =
1025            Cli::try_parse_from(["superbook-pdf", "convert", "relative/path/file.pdf"]).unwrap();
1026        if let Commands::Convert(args) = cli.command {
1027            assert!(args.input.is_relative());
1028        }
1029    }
1030
1031    #[test]
1032    fn test_path_with_spaces() {
1033        let cli =
1034            Cli::try_parse_from(["superbook-pdf", "convert", "/path/with spaces/document.pdf"])
1035                .unwrap();
1036        if let Commands::Convert(args) = cli.command {
1037            assert!(args.input.to_string_lossy().contains("with spaces"));
1038        }
1039    }
1040
1041    #[test]
1042    fn test_path_with_unicode() {
1043        let cli =
1044            Cli::try_parse_from(["superbook-pdf", "convert", "/パス/日本語/ドキュメント.pdf"])
1045                .unwrap();
1046        if let Commands::Convert(args) = cli.command {
1047            assert!(args.input.to_string_lossy().contains("日本語"));
1048        }
1049    }
1050
1051    #[test]
1052    fn test_directory_as_output() {
1053        let cli = Cli::try_parse_from([
1054            "superbook-pdf",
1055            "convert",
1056            "input.pdf",
1057            "-o",
1058            "/custom/output/directory",
1059        ])
1060        .unwrap();
1061        if let Commands::Convert(args) = cli.command {
1062            assert!(args.output.to_string_lossy().contains("directory"));
1063        }
1064    }
1065
1066    // ============ Flag Combinations Tests ============
1067
1068    #[test]
1069    fn test_upscale_flag_explicit_true() {
1070        let cli =
1071            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--upscale", "true"])
1072                .unwrap();
1073        if let Commands::Convert(args) = cli.command {
1074            assert!(args.effective_upscale());
1075        }
1076    }
1077
1078    #[test]
1079    fn test_upscale_flag_explicit_false() {
1080        let cli = Cli::try_parse_from([
1081            "superbook-pdf",
1082            "convert",
1083            "input.pdf",
1084            "--upscale",
1085            "false",
1086        ])
1087        .unwrap();
1088        if let Commands::Convert(args) = cli.command {
1089            assert!(!args.effective_upscale());
1090        }
1091    }
1092
1093    #[test]
1094    fn test_deskew_flag_explicit_true() {
1095        let cli =
1096            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--deskew", "true"])
1097                .unwrap();
1098        if let Commands::Convert(args) = cli.command {
1099            assert!(args.effective_deskew());
1100        }
1101    }
1102
1103    #[test]
1104    fn test_deskew_flag_explicit_false() {
1105        let cli =
1106            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--deskew", "false"])
1107                .unwrap();
1108        if let Commands::Convert(args) = cli.command {
1109            assert!(!args.effective_deskew());
1110        }
1111    }
1112
1113    #[test]
1114    fn test_gpu_flag_explicit_true() {
1115        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--gpu", "true"])
1116            .unwrap();
1117        if let Commands::Convert(args) = cli.command {
1118            assert!(args.effective_gpu());
1119        }
1120    }
1121
1122    #[test]
1123    fn test_gpu_flag_explicit_false() {
1124        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--gpu", "false"])
1125            .unwrap();
1126        if let Commands::Convert(args) = cli.command {
1127            assert!(!args.effective_gpu());
1128        }
1129    }
1130
1131    #[test]
1132    fn test_no_flags_override_explicit() {
1133        // --upscale true --no-upscale should result in false (no-* takes precedence)
1134        let cli = Cli::try_parse_from([
1135            "superbook-pdf",
1136            "convert",
1137            "input.pdf",
1138            "--upscale",
1139            "true",
1140            "--no-upscale",
1141        ])
1142        .unwrap();
1143        if let Commands::Convert(args) = cli.command {
1144            assert!(!args.effective_upscale());
1145        }
1146    }
1147
1148    // ============ DPI Value Tests ============
1149
1150    #[test]
1151    fn test_dpi_common_values() {
1152        let dpi_values = [72, 150, 300, 600, 1200];
1153        for dpi in dpi_values {
1154            let cli = Cli::try_parse_from([
1155                "superbook-pdf",
1156                "convert",
1157                "input.pdf",
1158                "--dpi",
1159                &dpi.to_string(),
1160            ])
1161            .unwrap();
1162            if let Commands::Convert(args) = cli.command {
1163                assert_eq!(args.dpi, dpi);
1164            }
1165        }
1166    }
1167
1168    #[test]
1169    fn test_dpi_minimum() {
1170        let cli =
1171            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--dpi", "1"]).unwrap();
1172        if let Commands::Convert(args) = cli.command {
1173            assert_eq!(args.dpi, 1);
1174        }
1175    }
1176
1177    #[test]
1178    fn test_dpi_very_high() {
1179        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--dpi", "2400"])
1180            .unwrap();
1181        if let Commands::Convert(args) = cli.command {
1182            assert_eq!(args.dpi, 2400);
1183        }
1184    }
1185
1186    // ============ Progress Bar Additional Tests ============
1187
1188    #[test]
1189    fn test_progress_bar_zero_total() {
1190        let pb = create_progress_bar(0);
1191        assert_eq!(pb.length(), Some(0));
1192    }
1193
1194    #[test]
1195    fn test_progress_bar_large_total() {
1196        let pb = create_progress_bar(1_000_000);
1197        assert_eq!(pb.length(), Some(1_000_000));
1198        pb.set_position(500_000);
1199        assert_eq!(pb.position(), 500_000);
1200    }
1201
1202    #[test]
1203    fn test_spinner_empty_message() {
1204        let spinner = create_spinner("");
1205        assert_eq!(spinner.message(), "");
1206    }
1207
1208    #[test]
1209    fn test_spinner_unicode_message() {
1210        let spinner = create_spinner("処理中... 🔄");
1211        assert!(spinner.message().contains("処理中"));
1212    }
1213
1214    #[test]
1215    fn test_page_progress_bar_single_page() {
1216        let pb = create_page_progress_bar(1);
1217        assert_eq!(pb.length(), Some(1));
1218        pb.set_position(0);
1219        pb.set_message("page_0.png");
1220        pb.finish_with_message("Done");
1221    }
1222
1223    #[test]
1224    fn test_page_progress_bar_many_pages() {
1225        let pb = create_page_progress_bar(1000);
1226        assert_eq!(pb.length(), Some(1000));
1227        pb.set_position(999);
1228        assert_eq!(pb.position(), 999);
1229    }
1230
1231    // ============ Short Flag Tests ============
1232
1233    #[test]
1234    fn test_short_flag_o_output() {
1235        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-o", "/custom/out"]).unwrap();
1236        if let Commands::Convert(args) = cli.command {
1237            assert_eq!(args.output, PathBuf::from("/custom/out"));
1238        }
1239    }
1240
1241    #[test]
1242    fn test_short_flag_m_margin() {
1243        let cli =
1244            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-m", "2.0"]).unwrap();
1245        if let Commands::Convert(args) = cli.command {
1246            assert_eq!(args.margin_trim, 2.0);
1247        }
1248    }
1249
1250    #[test]
1251    fn test_short_flag_t_threads() {
1252        let cli =
1253            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-t", "8"]).unwrap();
1254        if let Commands::Convert(args) = cli.command {
1255            assert_eq!(args.thread_count(), 8);
1256        }
1257    }
1258
1259    #[test]
1260    fn test_short_flag_g_gpu() {
1261        let cli =
1262            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-g", "false"]).unwrap();
1263        if let Commands::Convert(args) = cli.command {
1264            assert!(!args.effective_gpu());
1265        }
1266    }
1267
1268    #[test]
1269    fn test_short_flag_q_quiet() {
1270        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-q"]).unwrap();
1271        if let Commands::Convert(args) = cli.command {
1272            assert!(args.quiet);
1273        }
1274    }
1275
1276    // ============ Input Path Extension Tests ============
1277
1278    #[test]
1279    fn test_various_input_extensions() {
1280        let extensions = ["pdf", "PDF", "Pdf"];
1281        for ext in extensions {
1282            let path = format!("document.{}", ext);
1283            let cli = Cli::try_parse_from(["superbook-pdf", "convert", &path]).unwrap();
1284            if let Commands::Convert(args) = cli.command {
1285                assert!(args.input.to_string_lossy().ends_with(ext));
1286            }
1287        }
1288    }
1289
1290    #[test]
1291    fn test_input_without_extension() {
1292        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "document"]).unwrap();
1293        if let Commands::Convert(args) = cli.command {
1294            assert_eq!(args.input, PathBuf::from("document"));
1295        }
1296    }
1297
1298    // ============ Effective Methods Tests ============
1299
1300    #[test]
1301    fn test_effective_methods_all_enabled() {
1302        let cli = Cli::try_parse_from([
1303            "superbook-pdf",
1304            "convert",
1305            "input.pdf",
1306            "--upscale",
1307            "true",
1308            "--deskew",
1309            "true",
1310            "--gpu",
1311            "true",
1312        ])
1313        .unwrap();
1314        if let Commands::Convert(args) = cli.command {
1315            assert!(args.effective_upscale());
1316            assert!(args.effective_deskew());
1317            assert!(args.effective_gpu());
1318        }
1319    }
1320
1321    #[test]
1322    fn test_effective_methods_all_disabled() {
1323        let cli = Cli::try_parse_from([
1324            "superbook-pdf",
1325            "convert",
1326            "input.pdf",
1327            "--no-upscale",
1328            "--no-deskew",
1329            "--no-gpu",
1330        ])
1331        .unwrap();
1332        if let Commands::Convert(args) = cli.command {
1333            assert!(!args.effective_upscale());
1334            assert!(!args.effective_deskew());
1335            assert!(!args.effective_gpu());
1336        }
1337    }
1338
1339    // ============ Command Metadata Tests ============
1340
1341    #[test]
1342    fn test_command_name() {
1343        let cmd = Cli::command();
1344        assert_eq!(cmd.get_name(), "superbook-pdf");
1345    }
1346
1347    #[test]
1348    fn test_command_about() {
1349        let cmd = Cli::command();
1350        let about = cmd.get_about().map(|s| s.to_string()).unwrap_or_default();
1351        assert!(!about.is_empty());
1352    }
1353
1354    #[test]
1355    fn test_subcommands_present() {
1356        let cmd = Cli::command();
1357        let subcommands: Vec<_> = cmd.get_subcommands().collect();
1358        assert!(subcommands.len() >= 2); // convert and info
1359    }
1360
1361    // ============ Error Message Tests ============
1362
1363    #[test]
1364    fn test_invalid_dpi_format_error() {
1365        let result = Cli::try_parse_from([
1366            "superbook-pdf",
1367            "convert",
1368            "input.pdf",
1369            "--dpi",
1370            "not_a_number",
1371        ]);
1372        assert!(result.is_err());
1373        let err_msg = result.unwrap_err().to_string();
1374        // Error should mention invalid value
1375        assert!(
1376            err_msg.contains("invalid") || err_msg.contains("error") || err_msg.contains("parse")
1377        );
1378    }
1379
1380    #[test]
1381    fn test_invalid_thread_format_error() {
1382        let result =
1383            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--threads", "abc"]);
1384        assert!(result.is_err());
1385    }
1386
1387    #[test]
1388    fn test_invalid_margin_format_error() {
1389        let result = Cli::try_parse_from([
1390            "superbook-pdf",
1391            "convert",
1392            "input.pdf",
1393            "--margin-trim",
1394            "invalid",
1395        ]);
1396        assert!(result.is_err());
1397    }
1398
1399    // ============ Concurrency Tests ============
1400
1401    #[test]
1402    fn test_cli_types_send_sync() {
1403        fn assert_send_sync<T: Send + Sync>() {}
1404        assert_send_sync::<Cli>();
1405        assert_send_sync::<Commands>();
1406        assert_send_sync::<ConvertArgs>();
1407        assert_send_sync::<ExitCode>();
1408    }
1409
1410    #[test]
1411    fn test_concurrent_cli_parsing() {
1412        use std::thread;
1413        let handles: Vec<_> = (0..4)
1414            .map(|i| {
1415                thread::spawn(move || {
1416                    let cli = Cli::try_parse_from([
1417                        "superbook-pdf",
1418                        "convert",
1419                        &format!("input_{}.pdf", i),
1420                        "--dpi",
1421                        &(300 + i * 100).to_string(),
1422                    ])
1423                    .unwrap();
1424                    if let Commands::Convert(args) = cli.command {
1425                        args.dpi
1426                    } else {
1427                        0
1428                    }
1429                })
1430            })
1431            .collect();
1432
1433        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1434        assert_eq!(results.len(), 4);
1435        for (i, dpi) in results.iter().enumerate() {
1436            assert_eq!(*dpi, 300 + (i as u32) * 100);
1437        }
1438    }
1439
1440    #[test]
1441    fn test_exit_code_thread_transfer() {
1442        use std::thread;
1443
1444        let codes = vec![
1445            ExitCode::Success,
1446            ExitCode::GeneralError,
1447            ExitCode::InvalidArgs,
1448            ExitCode::InputNotFound,
1449        ];
1450
1451        let handles: Vec<_> = codes
1452            .into_iter()
1453            .map(|code| {
1454                thread::spawn(move || {
1455                    let c = code.code();
1456                    let d = code.description().to_string();
1457                    (c, d)
1458                })
1459            })
1460            .collect();
1461
1462        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1463        assert_eq!(results.len(), 4);
1464        assert_eq!(results[0].0, 0);
1465        assert_eq!(results[1].0, 1);
1466        assert_eq!(results[2].0, 2);
1467        assert_eq!(results[3].0, 3);
1468    }
1469
1470    #[test]
1471    fn test_convert_args_clone_across_threads() {
1472        use std::sync::Arc;
1473        use std::thread;
1474
1475        let cli =
1476            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--dpi", "600"]).unwrap();
1477        if let Commands::Convert(args) = cli.command {
1478            let shared = Arc::new(args);
1479
1480            let handles: Vec<_> = (0..4)
1481                .map(|_| {
1482                    let args_clone = Arc::clone(&shared);
1483                    thread::spawn(move || {
1484                        assert_eq!(args_clone.dpi, 600);
1485                        args_clone.effective_deskew()
1486                    })
1487                })
1488                .collect();
1489
1490            for handle in handles {
1491                let _ = handle.join().unwrap();
1492            }
1493        }
1494    }
1495
1496    // ============ Boundary Value Tests ============
1497
1498    #[test]
1499    fn test_dpi_boundary_minimum() {
1500        let cli =
1501            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--dpi", "1"]).unwrap();
1502        if let Commands::Convert(args) = cli.command {
1503            assert_eq!(args.dpi, 1);
1504        }
1505    }
1506
1507    #[test]
1508    fn test_dpi_boundary_maximum() {
1509        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--dpi", "2400"])
1510            .unwrap();
1511        if let Commands::Convert(args) = cli.command {
1512            assert_eq!(args.dpi, 2400);
1513        }
1514    }
1515
1516    // ============ JPEG Quality Tests ============
1517
1518    #[test]
1519    fn test_jpeg_quality_default() {
1520        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
1521        if let Commands::Convert(args) = cli.command {
1522            assert_eq!(args.jpeg_quality, 90);
1523        }
1524    }
1525
1526    #[test]
1527    fn test_jpeg_quality_custom() {
1528        let cli = Cli::try_parse_from([
1529            "superbook-pdf",
1530            "convert",
1531            "input.pdf",
1532            "--jpeg-quality",
1533            "75",
1534        ])
1535        .unwrap();
1536        if let Commands::Convert(args) = cli.command {
1537            assert_eq!(args.jpeg_quality, 75);
1538        }
1539    }
1540
1541    #[test]
1542    fn test_jpeg_quality_boundary_minimum() {
1543        let cli = Cli::try_parse_from([
1544            "superbook-pdf",
1545            "convert",
1546            "input.pdf",
1547            "--jpeg-quality",
1548            "1",
1549        ])
1550        .unwrap();
1551        if let Commands::Convert(args) = cli.command {
1552            assert_eq!(args.jpeg_quality, 1);
1553        }
1554    }
1555
1556    #[test]
1557    fn test_jpeg_quality_boundary_maximum() {
1558        let cli = Cli::try_parse_from([
1559            "superbook-pdf",
1560            "convert",
1561            "input.pdf",
1562            "--jpeg-quality",
1563            "100",
1564        ])
1565        .unwrap();
1566        if let Commands::Convert(args) = cli.command {
1567            assert_eq!(args.jpeg_quality, 100);
1568        }
1569    }
1570
1571    #[test]
1572    fn test_jpeg_quality_invalid_zero() {
1573        let result = Cli::try_parse_from([
1574            "superbook-pdf",
1575            "convert",
1576            "input.pdf",
1577            "--jpeg-quality",
1578            "0",
1579        ]);
1580        assert!(result.is_err());
1581    }
1582
1583    #[test]
1584    fn test_jpeg_quality_invalid_over_100() {
1585        let result = Cli::try_parse_from([
1586            "superbook-pdf",
1587            "convert",
1588            "input.pdf",
1589            "--jpeg-quality",
1590            "101",
1591        ]);
1592        assert!(result.is_err());
1593    }
1594
1595    #[test]
1596    fn test_threads_boundary_one() {
1597        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--threads", "1"])
1598            .unwrap();
1599        if let Commands::Convert(args) = cli.command {
1600            assert_eq!(args.threads, Some(1));
1601        }
1602    }
1603
1604    #[test]
1605    fn test_threads_boundary_large() {
1606        let cli =
1607            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--threads", "128"])
1608                .unwrap();
1609        if let Commands::Convert(args) = cli.command {
1610            assert_eq!(args.threads, Some(128));
1611        }
1612    }
1613
1614    // ============ Chunk size tests ============
1615
1616    #[test]
1617    fn test_chunk_size_default() {
1618        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
1619        if let Commands::Convert(args) = cli.command {
1620            assert_eq!(args.chunk_size, 0);
1621        }
1622    }
1623
1624    #[test]
1625    fn test_chunk_size_explicit() {
1626        let cli =
1627            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--chunk-size", "10"])
1628                .unwrap();
1629        if let Commands::Convert(args) = cli.command {
1630            assert_eq!(args.chunk_size, 10);
1631        }
1632    }
1633
1634    #[test]
1635    fn test_chunk_size_large() {
1636        let cli =
1637            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--chunk-size", "100"])
1638                .unwrap();
1639        if let Commands::Convert(args) = cli.command {
1640            assert_eq!(args.chunk_size, 100);
1641        }
1642    }
1643
1644    #[test]
1645    fn test_chunk_size_with_threads() {
1646        let cli = Cli::try_parse_from([
1647            "superbook-pdf",
1648            "convert",
1649            "input.pdf",
1650            "--threads",
1651            "4",
1652            "--chunk-size",
1653            "20",
1654        ])
1655        .unwrap();
1656        if let Commands::Convert(args) = cli.command {
1657            assert_eq!(args.threads, Some(4));
1658            assert_eq!(args.chunk_size, 20);
1659        }
1660    }
1661
1662    #[test]
1663    fn test_margin_trim_boundary_zero() {
1664        let cli = Cli::try_parse_from([
1665            "superbook-pdf",
1666            "convert",
1667            "input.pdf",
1668            "--margin-trim",
1669            "0.0",
1670        ])
1671        .unwrap();
1672        if let Commands::Convert(args) = cli.command {
1673            assert_eq!(args.margin_trim, 0.0);
1674        }
1675    }
1676
1677    #[test]
1678    fn test_margin_trim_boundary_large() {
1679        let cli = Cli::try_parse_from([
1680            "superbook-pdf",
1681            "convert",
1682            "input.pdf",
1683            "--margin-trim",
1684            "50.0",
1685        ])
1686        .unwrap();
1687        if let Commands::Convert(args) = cli.command {
1688            assert_eq!(args.margin_trim, 50.0);
1689        }
1690    }
1691
1692    #[test]
1693    fn test_verbose_boundary_maximum() {
1694        let cli = Cli::try_parse_from([
1695            "superbook-pdf",
1696            "convert",
1697            "input.pdf",
1698            "-vvvvvvvv", // 8 v's
1699        ])
1700        .unwrap();
1701        if let Commands::Convert(args) = cli.command {
1702            assert_eq!(args.verbose, 8);
1703        }
1704    }
1705
1706    #[test]
1707    fn test_exit_code_all_variants() {
1708        let codes = [
1709            ExitCode::Success,
1710            ExitCode::GeneralError,
1711            ExitCode::InvalidArgs,
1712            ExitCode::InputNotFound,
1713            ExitCode::OutputError,
1714            ExitCode::ProcessingError,
1715            ExitCode::GpuError,
1716            ExitCode::ExternalToolError,
1717        ];
1718
1719        for code in codes {
1720            assert!(!code.description().is_empty());
1721            assert!(code.code() <= 7);
1722        }
1723    }
1724
1725    #[test]
1726    fn test_path_with_special_characters() {
1727        let paths = [
1728            "file with spaces.pdf",
1729            "日本語ファイル.pdf",
1730            "file-with-dashes.pdf",
1731            "file_with_underscores.pdf",
1732        ];
1733
1734        for path in paths {
1735            let cli = Cli::try_parse_from(["superbook-pdf", "convert", path]).unwrap();
1736            if let Commands::Convert(args) = cli.command {
1737                assert_eq!(args.input.to_string_lossy(), path);
1738            }
1739        }
1740    }
1741
1742    #[test]
1743    fn test_output_path_variants() {
1744        let outputs = ["./output", "/tmp/out", "../parent/out", "relative/path"];
1745
1746        for output in outputs {
1747            let cli =
1748                Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-o", output]).unwrap();
1749            if let Commands::Convert(args) = cli.command {
1750                assert_eq!(args.output.to_string_lossy(), output);
1751            }
1752        }
1753    }
1754
1755    // ============ Phase 6: Advanced Processing Options Tests ============
1756
1757    #[test]
1758    fn test_internal_resolution_flag() {
1759        let cli = Cli::try_parse_from([
1760            "superbook-pdf",
1761            "convert",
1762            "input.pdf",
1763            "--internal-resolution",
1764        ])
1765        .unwrap();
1766        if let Commands::Convert(args) = cli.command {
1767            assert!(args.internal_resolution);
1768            assert!(args.effective_internal_resolution());
1769        }
1770    }
1771
1772    #[test]
1773    fn test_color_correction_flag() {
1774        let cli = Cli::try_parse_from([
1775            "superbook-pdf",
1776            "convert",
1777            "input.pdf",
1778            "--color-correction",
1779        ])
1780        .unwrap();
1781        if let Commands::Convert(args) = cli.command {
1782            assert!(args.color_correction);
1783            assert!(args.effective_color_correction());
1784        }
1785    }
1786
1787    #[test]
1788    fn test_offset_alignment_flag() {
1789        let cli = Cli::try_parse_from([
1790            "superbook-pdf",
1791            "convert",
1792            "input.pdf",
1793            "--offset-alignment",
1794        ])
1795        .unwrap();
1796        if let Commands::Convert(args) = cli.command {
1797            assert!(args.offset_alignment);
1798            assert!(args.effective_offset_alignment());
1799        }
1800    }
1801
1802    #[test]
1803    fn test_output_height_default() {
1804        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
1805        if let Commands::Convert(args) = cli.command {
1806            assert_eq!(args.output_height, 3508);
1807        }
1808    }
1809
1810    #[test]
1811    fn test_output_height_custom() {
1812        let cli = Cli::try_parse_from([
1813            "superbook-pdf",
1814            "convert",
1815            "input.pdf",
1816            "--output-height",
1817            "7016",
1818        ])
1819        .unwrap();
1820        if let Commands::Convert(args) = cli.command {
1821            assert_eq!(args.output_height, 7016);
1822        }
1823    }
1824
1825    #[test]
1826    fn test_advanced_flag_enables_all() {
1827        let cli =
1828            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--advanced"]).unwrap();
1829        if let Commands::Convert(args) = cli.command {
1830            assert!(args.advanced);
1831            assert!(args.effective_internal_resolution());
1832            assert!(args.effective_color_correction());
1833            assert!(args.effective_offset_alignment());
1834        }
1835    }
1836
1837    #[test]
1838    fn test_advanced_flag_combined() {
1839        let cli = Cli::try_parse_from([
1840            "superbook-pdf",
1841            "convert",
1842            "input.pdf",
1843            "--advanced",
1844            "--output-height",
1845            "4000",
1846        ])
1847        .unwrap();
1848        if let Commands::Convert(args) = cli.command {
1849            assert!(args.advanced);
1850            assert_eq!(args.output_height, 4000);
1851            assert!(args.effective_internal_resolution());
1852        }
1853    }
1854
1855    #[test]
1856    fn test_individual_flags_without_advanced() {
1857        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
1858        if let Commands::Convert(args) = cli.command {
1859            assert!(!args.advanced);
1860            assert!(!args.internal_resolution);
1861            assert!(!args.color_correction);
1862            assert!(!args.offset_alignment);
1863            assert!(!args.effective_internal_resolution());
1864            assert!(!args.effective_color_correction());
1865            assert!(!args.effective_offset_alignment());
1866        }
1867    }
1868
1869    // ============ Skip Existing Option Tests ============
1870
1871    #[test]
1872    fn test_skip_existing_default() {
1873        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
1874        if let Commands::Convert(args) = cli.command {
1875            assert!(!args.skip_existing);
1876        }
1877    }
1878
1879    #[test]
1880    fn test_skip_existing_enabled() {
1881        let cli =
1882            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--skip-existing"])
1883                .unwrap();
1884        if let Commands::Convert(args) = cli.command {
1885            assert!(args.skip_existing);
1886        }
1887    }
1888
1889    #[test]
1890    fn test_skip_existing_with_other_options() {
1891        let cli = Cli::try_parse_from([
1892            "superbook-pdf",
1893            "convert",
1894            "input.pdf",
1895            "--skip-existing",
1896            "--advanced",
1897            "-v",
1898        ])
1899        .unwrap();
1900        if let Commands::Convert(args) = cli.command {
1901            assert!(args.skip_existing);
1902            assert!(args.advanced);
1903            assert_eq!(args.verbose, 1);
1904        }
1905    }
1906
1907    // ============ Debug Options Tests ============
1908
1909    #[test]
1910    fn test_max_pages_default() {
1911        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
1912        if let Commands::Convert(args) = cli.command {
1913            assert!(args.max_pages.is_none());
1914        }
1915    }
1916
1917    #[test]
1918    fn test_max_pages_explicit() {
1919        let cli =
1920            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--max-pages", "10"])
1921                .unwrap();
1922        if let Commands::Convert(args) = cli.command {
1923            assert_eq!(args.max_pages, Some(10));
1924        }
1925    }
1926
1927    #[test]
1928    fn test_save_debug_default() {
1929        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
1930        if let Commands::Convert(args) = cli.command {
1931            assert!(!args.save_debug);
1932        }
1933    }
1934
1935    #[test]
1936    fn test_save_debug_enabled() {
1937        let cli =
1938            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--save-debug"]).unwrap();
1939        if let Commands::Convert(args) = cli.command {
1940            assert!(args.save_debug);
1941        }
1942    }
1943
1944    #[test]
1945    fn test_debug_options_combined() {
1946        let cli = Cli::try_parse_from([
1947            "superbook-pdf",
1948            "convert",
1949            "input.pdf",
1950            "--max-pages",
1951            "5",
1952            "--save-debug",
1953        ])
1954        .unwrap();
1955        if let Commands::Convert(args) = cli.command {
1956            assert_eq!(args.max_pages, Some(5));
1957            assert!(args.save_debug);
1958        }
1959    }
1960
1961    // ============ Force Option Tests ============
1962
1963    #[test]
1964    fn test_force_default() {
1965        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf"]).unwrap();
1966        if let Commands::Convert(args) = cli.command {
1967            assert!(!args.force);
1968        }
1969    }
1970
1971    #[test]
1972    fn test_force_long() {
1973        let cli =
1974            Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "--force"]).unwrap();
1975        if let Commands::Convert(args) = cli.command {
1976            assert!(args.force);
1977        }
1978    }
1979
1980    #[test]
1981    fn test_force_short() {
1982        let cli = Cli::try_parse_from(["superbook-pdf", "convert", "input.pdf", "-f"]).unwrap();
1983        if let Commands::Convert(args) = cli.command {
1984            assert!(args.force);
1985        }
1986    }
1987
1988    #[test]
1989    fn test_force_with_skip_existing() {
1990        // Both flags can be used together (--force takes precedence)
1991        let cli = Cli::try_parse_from([
1992            "superbook-pdf",
1993            "convert",
1994            "input.pdf",
1995            "--skip-existing",
1996            "--force",
1997        ])
1998        .unwrap();
1999        if let Commands::Convert(args) = cli.command {
2000            assert!(args.skip_existing);
2001            assert!(args.force);
2002        }
2003    }
2004
2005    // ============ Cache Info Command Tests ============
2006
2007    #[test]
2008    fn test_cache_info_command() {
2009        let cli =
2010            Cli::try_parse_from(["superbook-pdf", "cache-info", "output.pdf"]).unwrap();
2011        if let Commands::CacheInfo(args) = cli.command {
2012            assert_eq!(args.output_pdf, PathBuf::from("output.pdf"));
2013        } else {
2014            panic!("Expected CacheInfo command");
2015        }
2016    }
2017
2018    #[test]
2019    fn test_cache_info_with_path() {
2020        let cli = Cli::try_parse_from([
2021            "superbook-pdf",
2022            "cache-info",
2023            "/path/to/output.pdf",
2024        ])
2025        .unwrap();
2026        if let Commands::CacheInfo(args) = cli.command {
2027            assert_eq!(args.output_pdf, PathBuf::from("/path/to/output.pdf"));
2028        } else {
2029            panic!("Expected CacheInfo command");
2030        }
2031    }
2032
2033    #[test]
2034    fn test_cache_info_missing_path() {
2035        let result = Cli::try_parse_from(["superbook-pdf", "cache-info"]);
2036        assert!(result.is_err());
2037    }
2038
2039    // ============ Reprocess Command Tests ============
2040
2041    #[test]
2042    fn test_reprocess_command_basic() {
2043        let cli = Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf"]).unwrap();
2044        if let Commands::Reprocess(args) = cli.command {
2045            assert_eq!(args.input, PathBuf::from("input.pdf"));
2046            assert!(args.pages.is_none());
2047            assert_eq!(args.max_retries, 3);
2048            assert!(!args.force);
2049            assert!(!args.status);
2050        } else {
2051            panic!("Expected Reprocess command");
2052        }
2053    }
2054
2055    #[test]
2056    fn test_reprocess_with_status() {
2057        let cli =
2058            Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf", "--status"]).unwrap();
2059        if let Commands::Reprocess(args) = cli.command {
2060            assert!(args.status);
2061        } else {
2062            panic!("Expected Reprocess command");
2063        }
2064    }
2065
2066    #[test]
2067    fn test_reprocess_with_pages() {
2068        let cli = Cli::try_parse_from([
2069            "superbook-pdf",
2070            "reprocess",
2071            "input.pdf",
2072            "--pages",
2073            "1,3,5,10",
2074        ])
2075        .unwrap();
2076        if let Commands::Reprocess(args) = cli.command {
2077            assert_eq!(args.pages, Some(vec![1, 3, 5, 10]));
2078            assert_eq!(args.page_indices(), vec![1, 3, 5, 10]);
2079        } else {
2080            panic!("Expected Reprocess command");
2081        }
2082    }
2083
2084    #[test]
2085    fn test_reprocess_with_max_retries() {
2086        let cli = Cli::try_parse_from([
2087            "superbook-pdf",
2088            "reprocess",
2089            "input.pdf",
2090            "--max-retries",
2091            "5",
2092        ])
2093        .unwrap();
2094        if let Commands::Reprocess(args) = cli.command {
2095            assert_eq!(args.max_retries, 5);
2096        } else {
2097            panic!("Expected Reprocess command");
2098        }
2099    }
2100
2101    #[test]
2102    fn test_reprocess_with_force() {
2103        let cli =
2104            Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf", "--force"]).unwrap();
2105        if let Commands::Reprocess(args) = cli.command {
2106            assert!(args.force);
2107        } else {
2108            panic!("Expected Reprocess command");
2109        }
2110    }
2111
2112    #[test]
2113    fn test_reprocess_with_force_short() {
2114        let cli = Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf", "-f"]).unwrap();
2115        if let Commands::Reprocess(args) = cli.command {
2116            assert!(args.force);
2117        } else {
2118            panic!("Expected Reprocess command");
2119        }
2120    }
2121
2122    #[test]
2123    fn test_reprocess_with_output() {
2124        let cli = Cli::try_parse_from([
2125            "superbook-pdf",
2126            "reprocess",
2127            "input.pdf",
2128            "--output",
2129            "/custom/output",
2130        ])
2131        .unwrap();
2132        if let Commands::Reprocess(args) = cli.command {
2133            assert_eq!(args.output, Some(PathBuf::from("/custom/output")));
2134        } else {
2135            panic!("Expected Reprocess command");
2136        }
2137    }
2138
2139    #[test]
2140    fn test_reprocess_with_keep_intermediates() {
2141        let cli = Cli::try_parse_from([
2142            "superbook-pdf",
2143            "reprocess",
2144            "input.pdf",
2145            "--keep-intermediates",
2146        ])
2147        .unwrap();
2148        if let Commands::Reprocess(args) = cli.command {
2149            assert!(args.keep_intermediates);
2150        } else {
2151            panic!("Expected Reprocess command");
2152        }
2153    }
2154
2155    #[test]
2156    fn test_reprocess_with_verbose() {
2157        let cli =
2158            Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf", "-vvv"]).unwrap();
2159        if let Commands::Reprocess(args) = cli.command {
2160            assert_eq!(args.verbose, 3);
2161        } else {
2162            panic!("Expected Reprocess command");
2163        }
2164    }
2165
2166    #[test]
2167    fn test_reprocess_with_quiet() {
2168        let cli =
2169            Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf", "-q"]).unwrap();
2170        if let Commands::Reprocess(args) = cli.command {
2171            assert!(args.quiet);
2172        } else {
2173            panic!("Expected Reprocess command");
2174        }
2175    }
2176
2177    #[test]
2178    fn test_reprocess_state_file_detection() {
2179        let cli = Cli::try_parse_from([
2180            "superbook-pdf",
2181            "reprocess",
2182            "output/.superbook-state.json",
2183        ])
2184        .unwrap();
2185        if let Commands::Reprocess(args) = cli.command {
2186            assert!(args.is_state_file());
2187        } else {
2188            panic!("Expected Reprocess command");
2189        }
2190
2191        let cli2 = Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf"]).unwrap();
2192        if let Commands::Reprocess(args) = cli2.command {
2193            assert!(!args.is_state_file());
2194        } else {
2195            panic!("Expected Reprocess command");
2196        }
2197    }
2198
2199    #[test]
2200    fn test_reprocess_page_indices_empty() {
2201        let cli = Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf"]).unwrap();
2202        if let Commands::Reprocess(args) = cli.command {
2203            assert!(args.page_indices().is_empty());
2204        } else {
2205            panic!("Expected Reprocess command");
2206        }
2207    }
2208
2209    #[test]
2210    fn test_reprocess_full_options() {
2211        let cli = Cli::try_parse_from([
2212            "superbook-pdf",
2213            "reprocess",
2214            "input.pdf",
2215            "--pages",
2216            "1,2,3",
2217            "--max-retries",
2218            "5",
2219            "--force",
2220            "--output",
2221            "/custom/output",
2222            "--keep-intermediates",
2223            "-vv",
2224        ])
2225        .unwrap();
2226        if let Commands::Reprocess(args) = cli.command {
2227            assert_eq!(args.input, PathBuf::from("input.pdf"));
2228            assert_eq!(args.pages, Some(vec![1, 2, 3]));
2229            assert_eq!(args.max_retries, 5);
2230            assert!(args.force);
2231            assert_eq!(args.output, Some(PathBuf::from("/custom/output")));
2232            assert!(args.keep_intermediates);
2233            assert_eq!(args.verbose, 2);
2234        } else {
2235            panic!("Expected Reprocess command");
2236        }
2237    }
2238
2239    #[test]
2240    fn test_reprocess_missing_input() {
2241        let result = Cli::try_parse_from(["superbook-pdf", "reprocess"]);
2242        assert!(result.is_err());
2243    }
2244
2245    #[test]
2246    fn test_reprocess_debug_impl() {
2247        let cli = Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf"]).unwrap();
2248        let debug_str = format!("{:?}", cli.command);
2249        assert!(debug_str.contains("Reprocess"));
2250    }
2251
2252    #[test]
2253    fn test_reprocess_args_clone() {
2254        let cli = Cli::try_parse_from(["superbook-pdf", "reprocess", "input.pdf", "--force"])
2255            .unwrap();
2256        if let Commands::Reprocess(args) = cli.command {
2257            let cloned = args.clone();
2258            assert_eq!(args.input, cloned.input);
2259            assert_eq!(args.force, cloned.force);
2260        } else {
2261            panic!("Expected Reprocess command");
2262        }
2263    }
2264}