1use clap::Parser;
2use crate::theme::ThemeMode;
3
4#[derive(Parser, Debug)]
18#[command(name = "semantic-diff", version, about)]
19pub struct Cli {
20 #[arg(long, value_name = "MODE", env = "SEMANTIC_DIFF_THEME")]
24 pub theme: Option<String>,
25
26 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
28 pub git_args: Vec<String>,
29}
30
31impl Cli {
32 pub fn theme_mode(&self) -> ThemeMode {
33 match self.theme.as_deref() {
34 Some("dark") => ThemeMode::Dark,
35 Some("light") => ThemeMode::Light,
36 _ => ThemeMode::Auto,
37 }
38 }
39}
40
41impl Cli {
42 pub fn git_diff_args(&self) -> Vec<String> {
44 let mut args = vec!["diff".to_string(), "-M".to_string()];
45 args.extend(self.git_args.iter().cloned());
46 args
47 }
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53
54 #[test]
55 fn test_no_args_produces_bare_diff() {
56 let cli = Cli { theme: None, git_args: vec![] };
57 assert_eq!(cli.git_diff_args(), vec!["diff", "-M"]);
58 }
59
60 #[test]
61 fn test_head_arg() {
62 let cli = Cli {
63 theme: None,
64 git_args: vec!["HEAD".to_string()],
65 };
66 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "HEAD"]);
67 }
68
69 #[test]
70 fn test_staged_flag() {
71 let cli = Cli {
72 theme: None,
73 git_args: vec!["--staged".to_string()],
74 };
75 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "--staged"]);
76 }
77
78 #[test]
79 fn test_two_dot_range() {
80 let cli = Cli {
81 theme: None,
82 git_args: vec!["main..feature".to_string()],
83 };
84 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "main..feature"]);
85 }
86
87 #[test]
88 fn test_three_dot_range() {
89 let cli = Cli {
90 theme: None,
91 git_args: vec!["main...feature".to_string()],
92 };
93 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "main...feature"]);
94 }
95
96 #[test]
97 fn test_two_refs() {
98 let cli = Cli {
99 theme: None,
100 git_args: vec!["main".to_string(), "feature".to_string()],
101 };
102 assert_eq!(
103 cli.git_diff_args(),
104 vec!["diff", "-M", "main", "feature"]
105 );
106 }
107
108 #[test]
109 fn test_path_limiter() {
110 let cli = Cli {
111 theme: None,
112 git_args: vec![
113 "HEAD".to_string(),
114 "--".to_string(),
115 "src/".to_string(),
116 ],
117 };
118 assert_eq!(
119 cli.git_diff_args(),
120 vec!["diff", "-M", "HEAD", "--", "src/"]
121 );
122 }
123
124 #[test]
125 fn test_cached_alias() {
126 let cli = Cli {
127 theme: None,
128 git_args: vec!["--cached".to_string()],
129 };
130 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "--cached"]);
131 }
132
133 #[test]
136 fn test_head_tilde_syntax() {
137 let cli = Cli {
138 theme: None,
139 git_args: vec!["HEAD~3".to_string()],
140 };
141 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "HEAD~3"]);
142 }
143
144 #[test]
145 fn test_head_caret_syntax() {
146 let cli = Cli {
147 theme: None,
148 git_args: vec!["HEAD^".to_string()],
149 };
150 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "HEAD^"]);
151 }
152
153 #[test]
154 fn test_sha_refs() {
155 let cli = Cli {
156 theme: None,
157 git_args: vec![
158 "abc1234".to_string(),
159 "def5678".to_string(),
160 ],
161 };
162 assert_eq!(
163 cli.git_diff_args(),
164 vec!["diff", "-M", "abc1234", "def5678"]
165 );
166 }
167
168 #[test]
169 fn test_full_sha() {
170 let sha = "a".repeat(40);
171 let cli = Cli {
172 theme: None,
173 git_args: vec![sha.clone()],
174 };
175 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", &sha]);
176 }
177
178 #[test]
179 fn test_staged_with_ref() {
180 let cli = Cli {
181 theme: None,
182 git_args: vec!["--staged".to_string(), "HEAD~1".to_string()],
183 };
184 assert_eq!(
185 cli.git_diff_args(),
186 vec!["diff", "-M", "--staged", "HEAD~1"]
187 );
188 }
189
190 #[test]
191 fn test_multiple_path_limiters() {
192 let cli = Cli {
193 theme: None,
194 git_args: vec![
195 "HEAD".to_string(),
196 "--".to_string(),
197 "src/".to_string(),
198 "tests/".to_string(),
199 "Cargo.toml".to_string(),
200 ],
201 };
202 assert_eq!(
203 cli.git_diff_args(),
204 vec!["diff", "-M", "HEAD", "--", "src/", "tests/", "Cargo.toml"]
205 );
206 }
207
208 #[test]
209 fn test_two_dot_range_with_paths() {
210 let cli = Cli {
211 theme: None,
212 git_args: vec![
213 "main..feature".to_string(),
214 "--".to_string(),
215 "src/".to_string(),
216 ],
217 };
218 assert_eq!(
219 cli.git_diff_args(),
220 vec!["diff", "-M", "main..feature", "--", "src/"]
221 );
222 }
223
224 #[test]
225 fn test_three_dot_range_with_paths() {
226 let cli = Cli {
227 theme: None,
228 git_args: vec![
229 "origin/main...HEAD".to_string(),
230 "--".to_string(),
231 "*.rs".to_string(),
232 ],
233 };
234 assert_eq!(
235 cli.git_diff_args(),
236 vec!["diff", "-M", "origin/main...HEAD", "--", "*.rs"]
237 );
238 }
239
240 #[test]
241 fn test_merge_base_flag() {
242 let cli = Cli {
243 theme: None,
244 git_args: vec!["--merge-base".to_string(), "main".to_string()],
245 };
246 assert_eq!(
247 cli.git_diff_args(),
248 vec!["diff", "-M", "--merge-base", "main"]
249 );
250 }
251
252 #[test]
253 fn test_no_index_flag() {
254 let cli = Cli {
255 theme: None,
256 git_args: vec![
257 "--no-index".to_string(),
258 "file_a.txt".to_string(),
259 "file_b.txt".to_string(),
260 ],
261 };
262 assert_eq!(
263 cli.git_diff_args(),
264 vec!["diff", "-M", "--no-index", "file_a.txt", "file_b.txt"]
265 );
266 }
267
268 #[test]
269 fn test_many_positional_args_stress() {
270 let args: Vec<String> = (0..100).map(|i| format!("path_{i}.rs")).collect();
271 let cli = Cli {
272 theme: None,
273 git_args: args.clone(),
274 };
275 let result = cli.git_diff_args();
276 assert_eq!(result.len(), 102); assert_eq!(result[0], "diff");
278 assert_eq!(result[1], "-M");
279 assert_eq!(result[2], "path_0.rs");
280 assert_eq!(result[101], "path_99.rs");
281 }
282
283 #[test]
284 fn test_unicode_path() {
285 let cli = Cli {
286 theme: None,
287 git_args: vec![
288 "HEAD".to_string(),
289 "--".to_string(),
290 "src/日本語/ファイル.rs".to_string(),
291 ],
292 };
293 let result = cli.git_diff_args();
294 assert_eq!(result[4], "src/日本語/ファイル.rs");
295 }
296
297 #[test]
298 fn test_path_with_spaces() {
299 let cli = Cli {
300 theme: None,
301 git_args: vec![
302 "--".to_string(),
303 "path with spaces/file.rs".to_string(),
304 ],
305 };
306 let result = cli.git_diff_args();
307 assert_eq!(result[3], "path with spaces/file.rs");
308 }
309
310 #[test]
311 fn test_at_upstream_syntax() {
312 let cli = Cli {
313 theme: None,
314 git_args: vec!["@{upstream}".to_string()],
315 };
316 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "@{upstream}"]);
317 }
318
319 #[test]
320 fn test_stash_ref() {
321 let cli = Cli {
322 theme: None,
323 git_args: vec!["stash@{0}".to_string()],
324 };
325 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "stash@{0}"]);
326 }
327
328 #[test]
329 fn test_remote_tracking_branch() {
330 let cli = Cli {
331 theme: None,
332 git_args: vec![
333 "origin/main".to_string(),
334 "origin/feature/my-branch".to_string(),
335 ],
336 };
337 assert_eq!(
338 cli.git_diff_args(),
339 vec!["diff", "-M", "origin/main", "origin/feature/my-branch"]
340 );
341 }
342
343 #[test]
344 fn test_tag_ref() {
345 let cli = Cli {
346 theme: None,
347 git_args: vec!["v1.0.0".to_string(), "v2.0.0".to_string()],
348 };
349 assert_eq!(
350 cli.git_diff_args(),
351 vec!["diff", "-M", "v1.0.0", "v2.0.0"]
352 );
353 }
354
355 #[test]
356 fn test_diff_filter_flag_passthrough() {
357 let cli = Cli {
358 theme: None,
359 git_args: vec!["--diff-filter=ACMR".to_string(), "HEAD".to_string()],
360 };
361 assert_eq!(
362 cli.git_diff_args(),
363 vec!["diff", "-M", "--diff-filter=ACMR", "HEAD"]
364 );
365 }
366
367 #[test]
368 fn test_stat_flag_passthrough() {
369 let cli = Cli {
370 theme: None,
371 git_args: vec!["--stat".to_string(), "HEAD".to_string()],
372 };
373 assert_eq!(
374 cli.git_diff_args(),
375 vec!["diff", "-M", "--stat", "HEAD"]
376 );
377 }
378
379 #[test]
380 fn test_name_only_flag_passthrough() {
381 let cli = Cli {
382 theme: None,
383 git_args: vec!["--name-only".to_string()],
384 };
385 assert_eq!(
386 cli.git_diff_args(),
387 vec!["diff", "-M", "--name-only"]
388 );
389 }
390
391 #[test]
392 fn test_combined_flags_and_ranges() {
393 let cli = Cli {
394 theme: None,
395 git_args: vec![
396 "--staged".to_string(),
397 "--diff-filter=M".to_string(),
398 "HEAD~5".to_string(),
399 "--".to_string(),
400 "src/".to_string(),
401 ],
402 };
403 assert_eq!(
404 cli.git_diff_args(),
405 vec!["diff", "-M", "--staged", "--diff-filter=M", "HEAD~5", "--", "src/"]
406 );
407 }
408
409 #[test]
410 fn test_empty_string_arg() {
411 let cli = Cli {
412 theme: None,
413 git_args: vec!["".to_string()],
414 };
415 let result = cli.git_diff_args();
416 assert_eq!(result, vec!["diff", "-M", ""]);
417 }
418
419 #[test]
420 fn test_double_dash_only() {
421 let cli = Cli {
422 theme: None,
423 git_args: vec!["--".to_string()],
424 };
425 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "--"]);
426 }
427
428 #[test]
429 fn test_clap_parse_no_args() {
430 let cli = Cli::try_parse_from(["semantic-diff"]).unwrap();
432 assert!(cli.git_args.is_empty());
433 assert_eq!(cli.git_diff_args(), vec!["diff", "-M"]);
434 }
435
436 #[test]
437 fn test_clap_parse_head() {
438 let cli = Cli::try_parse_from(["semantic-diff", "HEAD"]).unwrap();
439 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "HEAD"]);
440 }
441
442 #[test]
443 fn test_clap_parse_staged() {
444 let cli = Cli::try_parse_from(["semantic-diff", "--staged"]).unwrap();
445 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "--staged"]);
446 }
447
448 #[test]
449 fn test_clap_parse_cached() {
450 let cli = Cli::try_parse_from(["semantic-diff", "--cached"]).unwrap();
451 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "--cached"]);
452 }
453
454 #[test]
455 fn test_clap_parse_two_dot_range() {
456 let cli = Cli::try_parse_from(["semantic-diff", "main..feature"]).unwrap();
457 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "main..feature"]);
458 }
459
460 #[test]
461 fn test_clap_parse_three_dot_range() {
462 let cli = Cli::try_parse_from(["semantic-diff", "main...feature"]).unwrap();
463 assert_eq!(cli.git_diff_args(), vec!["diff", "-M", "main...feature"]);
464 }
465
466 #[test]
467 fn test_clap_parse_two_refs() {
468 let cli = Cli::try_parse_from(["semantic-diff", "abc123", "def456"]).unwrap();
469 assert_eq!(
470 cli.git_diff_args(),
471 vec!["diff", "-M", "abc123", "def456"]
472 );
473 }
474
475 #[test]
476 fn test_clap_parse_ref_with_paths() {
477 let cli = Cli::try_parse_from([
478 "semantic-diff",
479 "HEAD~3",
480 "--",
481 "src/main.rs",
482 "src/lib.rs",
483 ])
484 .unwrap();
485 assert_eq!(
486 cli.git_diff_args(),
487 vec!["diff", "-M", "HEAD~3", "--", "src/main.rs", "src/lib.rs"]
488 );
489 }
490
491 #[test]
492 fn test_clap_parse_complex_scenario() {
493 let cli = Cli::try_parse_from([
494 "semantic-diff",
495 "--staged",
496 "--diff-filter=ACMR",
497 "HEAD~5",
498 "--",
499 "src/",
500 "tests/",
501 ])
502 .unwrap();
503 assert_eq!(
504 cli.git_diff_args(),
505 vec![
506 "diff",
507 "-M",
508 "--staged",
509 "--diff-filter=ACMR",
510 "HEAD~5",
511 "--",
512 "src/",
513 "tests/"
514 ]
515 );
516 }
517
518 #[test]
519 fn test_clap_parse_merge_base() {
520 let cli =
521 Cli::try_parse_from(["semantic-diff", "--merge-base", "main"]).unwrap();
522 assert_eq!(
523 cli.git_diff_args(),
524 vec!["diff", "-M", "--merge-base", "main"]
525 );
526 }
527
528 #[test]
529 fn test_clap_version_does_not_conflict() {
530 let result = Cli::try_parse_from(["semantic-diff", "--version"]);
532 assert!(result.is_err());
534 }
535
536 #[test]
537 fn test_clap_help_does_not_conflict() {
538 let result = Cli::try_parse_from(["semantic-diff", "--help"]);
539 assert!(result.is_err());
540 }
541}