Skip to main content

pdf_vdiff/
cli.rs

1//! CLI argument definitions and validation.
2
3use crate::diff::DiffGranularity;
4use crate::theme::{ThemeKind, DEFAULT_GUTTER_WIDTH, DEFAULT_HEADER_HEIGHT};
5use clap::{CommandFactory, Parser};
6use clap_complete::Shell;
7use std::path::{Path, PathBuf};
8
9/// CLI arguments for `pdf-vdiff`.
10#[derive(Parser, Debug, Clone)]
11#[command(
12    name = "pdf-vdiff",
13    version,
14    about = "Side-by-side visual PDF diffing tool preserving vector fidelity and searchability",
15    long_about = "A fast, standalone CLI tool that takes two PDF documents (e.g. base and tailored resumes) and produces a single side-by-side landscape PDF with IntelliJ/GitHub-style visual diff highlighting."
16)]
17pub struct CliArgs {
18    /// Path to the original / base PDF document
19    #[arg(
20        value_name = "BASE_PDF",
21        required_unless_present_any = ["generate_completions", "generate_man"]
22    )]
23    pub base_pdf: Option<PathBuf>,
24
25    /// Path to the modified / tailored PDF document
26    #[arg(
27        value_name = "TAILORED_PDF",
28        required_unless_present_any = ["generate_completions", "generate_man"]
29    )]
30    pub tailored_pdf: Option<PathBuf>,
31
32    /// Target output PDF file path [default: <base_stem>_vs_<tailored_stem>_diff.pdf]
33    #[arg(short = 'o', long = "output", value_name = "PATH")]
34    pub output: Option<PathBuf>,
35
36    /// Overwrite destination output file if it already exists
37    #[arg(short = 'f', long = "force")]
38    pub force: bool,
39
40    /// Automatically open the generated diff in the system default PDF viewer
41    #[arg(long = "open")]
42    pub open: bool,
43
44    /// Color palette theme (intellij, github, classic, high-contrast)
45    #[arg(long = "theme", value_enum, default_value_t = ThemeKind::IntelliJ)]
46    pub theme: ThemeKind,
47
48    /// Diff granularity mode (word, line, character)
49    #[arg(long = "granularity", value_enum, default_value_t = DiffGranularity::Word)]
50    pub granularity: DiffGranularity,
51
52    /// Spacing in points between left and right pages
53    #[arg(long = "gutter-width", default_value_t = DEFAULT_GUTTER_WIDTH)]
54    pub gutter_width: f32,
55
56    /// Suppress the top header/metadata banner
57    #[arg(long = "no-header")]
58    pub no_header: bool,
59
60    /// Maximum page count threshold to prevent unbounded processing
61    #[arg(long = "max-pages", default_value_t = 250)]
62    pub max_pages: usize,
63
64    /// Enable verbose structural logging (omits sensitive raw text PII)
65    #[arg(short = 'v', long = "verbose")]
66    pub verbose: bool,
67
68    /// Generate shell completion script to stdout (bash, zsh, fish, elvish, powershell)
69    #[arg(long = "generate-completions", value_name = "SHELL", value_enum)]
70    pub generate_completions: Option<Shell>,
71
72    /// Generate roff man page (Section 1) to stdout
73    #[arg(long = "generate-man")]
74    pub generate_man: bool,
75}
76
77/// Extract the file stem from an optional path or return fallback if absent or invalid UTF-8.
78#[inline]
79pub fn opt_path_stem_or<'a>(path: Option<&'a Path>, fallback: &'a str) -> &'a str {
80    path.and_then(|p| p.file_stem())
81        .and_then(|s| s.to_str())
82        .unwrap_or(fallback)
83}
84
85/// Extract the file stem as string or return fallback if not present or invalid UTF-8.
86#[inline]
87pub fn path_stem_or<'a>(path: &'a Path, fallback: &'a str) -> &'a str {
88    path.file_stem()
89        .and_then(|s| s.to_str())
90        .unwrap_or(fallback)
91}
92
93/// Extract the file name as string or return fallback if not present or invalid UTF-8.
94#[inline]
95pub fn path_file_name_or<'a>(path: &'a Path, fallback: &'a str) -> &'a str {
96    path.file_name()
97        .and_then(|s| s.to_str())
98        .unwrap_or(fallback)
99}
100
101impl CliArgs {
102    /// Resolves the effective output path, defaulting to `<base_stem>_vs_<tailored_stem>_diff.pdf`.
103    pub fn resolve_output_path(&self) -> PathBuf {
104        if let Some(ref path) = self.output {
105            path.clone()
106        } else {
107            let base_stem = opt_path_stem_or(self.base_pdf.as_deref(), "base");
108            let tailored_stem = opt_path_stem_or(self.tailored_pdf.as_deref(), "tailored");
109            PathBuf::from(format!("{}_vs_{}_diff.pdf", base_stem, tailored_stem))
110        }
111    }
112
113    /// Header height in points taking `--no-header` flag into account.
114    pub fn effective_header_height(&self) -> f32 {
115        if self.no_header {
116            0.0
117        } else {
118            DEFAULT_HEADER_HEIGHT
119        }
120    }
121}
122
123/// Render the roff man page (Section 1) for `pdf-vdiff` to the given writer.
124pub fn render_man_page<W: std::io::Write>(writer: &mut W) -> std::io::Result<()> {
125    clap_mangen::Man::new(CliArgs::command()).render(writer)
126}
127
128/// Render shell completion script for `pdf-vdiff` to the given writer.
129pub fn render_completions<W: std::io::Write>(shell: Shell, writer: &mut W) -> std::io::Result<()> {
130    let mut cmd = CliArgs::command();
131    clap_complete::generate(shell, &mut cmd, "pdf-vdiff", writer);
132    writer.flush()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_cli_default_output_path_resolution() {
141        let args = CliArgs {
142            base_pdf: Some(PathBuf::from("path/to/my_resume_v1.pdf")),
143            tailored_pdf: Some(PathBuf::from("other/path/my_resume_v2.pdf")),
144            output: None,
145            force: false,
146            open: false,
147            theme: ThemeKind::IntelliJ,
148            granularity: DiffGranularity::Word,
149            gutter_width: DEFAULT_GUTTER_WIDTH,
150            no_header: false,
151            max_pages: 250,
152            verbose: false,
153            generate_completions: None,
154            generate_man: false,
155        };
156
157        assert_eq!(
158            args.resolve_output_path(),
159            PathBuf::from("my_resume_v1_vs_my_resume_v2_diff.pdf")
160        );
161        assert_eq!(args.effective_header_height(), DEFAULT_HEADER_HEIGHT);
162    }
163
164    #[test]
165    fn test_cli_custom_output_path_and_no_header() {
166        let args = CliArgs {
167            base_pdf: Some(PathBuf::from("base.pdf")),
168            tailored_pdf: Some(PathBuf::from("tailored.pdf")),
169            output: Some(PathBuf::from("custom_out.pdf")),
170            force: true,
171            open: true,
172            theme: ThemeKind::GitHub,
173            granularity: DiffGranularity::Line,
174            gutter_width: 30.0,
175            no_header: true,
176            max_pages: 100,
177            verbose: true,
178            generate_completions: None,
179            generate_man: false,
180        };
181
182        assert_eq!(args.resolve_output_path(), PathBuf::from("custom_out.pdf"));
183        assert_eq!(args.effective_header_height(), 0.0);
184    }
185
186    #[test]
187    fn test_cli_asset_generation_methods() {
188        let mut man_buf = Vec::new();
189        render_man_page(&mut man_buf).expect("render man page");
190        let man_str = String::from_utf8(man_buf).expect("utf-8 man page");
191        assert!(man_str.contains(".TH pdf-vdiff 1"));
192        assert!(man_str.contains("pdf\\-vdiff"));
193
194        for shell in [
195            Shell::Bash,
196            Shell::Zsh,
197            Shell::Fish,
198            Shell::Elvish,
199            Shell::PowerShell,
200        ] {
201            let mut comp_buf = Vec::new();
202            render_completions(shell, &mut comp_buf).unwrap();
203            let comp_str = String::from_utf8(comp_buf).expect("utf-8 completion");
204            assert!(!comp_str.is_empty());
205            assert!(comp_str.contains("pdf-vdiff"));
206        }
207    }
208
209    #[test]
210    fn test_path_helpers() {
211        let p = Path::new("some/nested/file.pdf");
212        assert_eq!(path_stem_or(p, "fallback"), "file");
213        assert_eq!(path_file_name_or(p, "fallback"), "file.pdf");
214        assert_eq!(opt_path_stem_or(Some(p), "fallback"), "file");
215
216        let empty = Path::new("");
217        assert_eq!(path_stem_or(empty, "fallback"), "fallback");
218        assert_eq!(path_file_name_or(empty, "fallback"), "fallback");
219        assert_eq!(opt_path_stem_or(Some(empty), "fallback"), "fallback");
220        assert_eq!(opt_path_stem_or(None, "fallback"), "fallback");
221    }
222}