1use 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#[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 #[arg(
20 value_name = "BASE_PDF",
21 required_unless_present_any = ["generate_completions", "generate_man"]
22 )]
23 pub base_pdf: Option<PathBuf>,
24
25 #[arg(
27 value_name = "TAILORED_PDF",
28 required_unless_present_any = ["generate_completions", "generate_man"]
29 )]
30 pub tailored_pdf: Option<PathBuf>,
31
32 #[arg(short = 'o', long = "output", value_name = "PATH")]
34 pub output: Option<PathBuf>,
35
36 #[arg(short = 'f', long = "force")]
38 pub force: bool,
39
40 #[arg(long = "open")]
42 pub open: bool,
43
44 #[arg(long = "theme", value_enum, default_value_t = ThemeKind::IntelliJ)]
46 pub theme: ThemeKind,
47
48 #[arg(long = "granularity", value_enum, default_value_t = DiffGranularity::Word)]
50 pub granularity: DiffGranularity,
51
52 #[arg(long = "gutter-width", default_value_t = DEFAULT_GUTTER_WIDTH)]
54 pub gutter_width: f32,
55
56 #[arg(long = "no-header")]
58 pub no_header: bool,
59
60 #[arg(long = "max-pages", default_value_t = 250)]
62 pub max_pages: usize,
63
64 #[arg(short = 'v', long = "verbose")]
66 pub verbose: bool,
67
68 #[arg(long = "generate-completions", value_name = "SHELL", value_enum)]
70 pub generate_completions: Option<Shell>,
71
72 #[arg(long = "generate-man")]
74 pub generate_man: bool,
75}
76
77#[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#[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#[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 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 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
123pub 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
128pub 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}