Skip to main content

semantic_diff/
cli.rs

1use clap::Parser;
2use crate::theme::ThemeMode;
3
4/// A terminal diff viewer with AI-powered semantic grouping.
5///
6/// Drop-in replacement for `git diff` — all positional arguments and common
7/// flags (--staged, --cached, --merge-base, -- paths) are passed through
8/// to `git diff` directly.
9///
10/// Examples:
11///   semantic-diff                        # unstaged changes (same as git diff)
12///   semantic-diff HEAD                   # all changes vs HEAD
13///   semantic-diff --staged               # staged changes only
14///   semantic-diff main..feature          # two-dot range
15///   semantic-diff main...feature         # three-dot (merge-base) range
16///   semantic-diff HEAD~3 HEAD -- src/    # specific commits + path filter
17#[derive(Parser, Debug)]
18#[command(name = "semantic-diff", version, about)]
19pub struct Cli {
20    /// Color theme: auto, dark, or light. Auto-detects terminal background.
21    /// Can also be set via SEMANTIC_DIFF_THEME env var.
22    /// Use --theme=light for SSH/tmux sessions where auto-detection fails.
23    #[arg(long, value_name = "MODE", env = "SEMANTIC_DIFF_THEME")]
24    pub theme: Option<String>,
25
26    /// Arguments passed through to `git diff` (commits, ranges, --staged, -- paths, etc.)
27    #[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    /// Build the full argument list for `git diff`, prepending `-M` for rename detection.
43    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    // --- Stress / edge-case tests ---
134
135    #[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); // "diff" + "-M" + 100 paths
277        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        // Simulate: semantic-diff (no arguments)
431        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        // --version is handled by clap, should not be passed through
531        let result = Cli::try_parse_from(["semantic-diff", "--version"]);
532        // clap exits with a DisplayVersion error for --version
533        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}