Skip to main content

semantic_diff/
cli.rs

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