Skip to main content

codex_shell_command/
parse_command.rs

1use crate::bash::extract_bash_command;
2use crate::bash::try_parse_shell;
3use crate::bash::try_parse_word_only_commands_sequence;
4use crate::powershell::extract_powershell_command;
5use codex_protocol::parse_command::ParsedCommand;
6use shlex::split as shlex_split;
7use shlex::try_join as shlex_try_join;
8use std::path::PathBuf;
9
10pub fn shlex_join(tokens: &[String]) -> String {
11    shlex_try_join(tokens.iter().map(String::as_str))
12        .unwrap_or_else(|_| "<command included NUL byte>".to_string())
13}
14
15/// Extracts the shell and script from a command, regardless of platform
16pub fn extract_shell_command(command: &[String]) -> Option<(&str, &str)> {
17    extract_bash_command(command).or_else(|| extract_powershell_command(command))
18}
19
20/// DO NOT REVIEW THIS CODE BY HAND
21/// This parsing code is quite complex and not easy to hand-modify.
22/// The easiest way to iterate is to add unit tests and have Codex fix the implementation.
23/// To encourage this, the tests have been put directly below this function rather than at the bottom of the
24///
25/// Parses metadata out of an arbitrary command.
26/// These commands are model driven and could include just about anything.
27/// The parsing is slightly lossy due to the ~infinite expressiveness of an arbitrary command.
28/// The goal of the parsed metadata is to be able to provide the user with a human readable gis
29/// of what it is doing.
30pub fn parse_command(command: &[String]) -> Vec<ParsedCommand> {
31    // Parse and then collapse consecutive duplicate commands to avoid redundant summaries.
32    let parsed = parse_command_impl(command);
33    let mut deduped: Vec<ParsedCommand> = Vec::with_capacity(parsed.len());
34    for cmd in parsed.into_iter() {
35        if deduped.last().is_some_and(|prev| prev == &cmd) {
36            continue;
37        }
38        deduped.push(cmd);
39    }
40    if deduped
41        .iter()
42        .any(|cmd| matches!(cmd, ParsedCommand::Unknown { .. }))
43    {
44        vec![single_unknown_for_command(command)]
45    } else {
46        deduped
47    }
48}
49
50fn single_unknown_for_command(command: &[String]) -> ParsedCommand {
51    if let Some((_, shell_command)) = extract_shell_command(command) {
52        ParsedCommand::Unknown {
53            cmd: shell_command.to_string(),
54        }
55    } else {
56        ParsedCommand::Unknown {
57            cmd: shlex_join(command),
58        }
59    }
60}
61
62#[cfg(test)]
63#[allow(clippy::items_after_test_module)]
64/// Tests are at the top to encourage using TDD + Codex to fix the implementation.
65mod tests {
66    use super::*;
67    use pretty_assertions::assert_eq;
68    use std::path::PathBuf;
69    use std::string::ToString;
70
71    fn shlex_split_safe(s: &str) -> Vec<String> {
72        shlex_split(s).unwrap_or_else(|| s.split_whitespace().map(ToString::to_string).collect())
73    }
74
75    fn vec_str(args: &[&str]) -> Vec<String> {
76        args.iter().map(ToString::to_string).collect()
77    }
78
79    fn assert_parsed(args: &[String], expected: Vec<ParsedCommand>) {
80        let out = parse_command(args);
81        assert_eq!(out, expected);
82    }
83
84    #[test]
85    fn git_status_is_unknown() {
86        assert_parsed(
87            &vec_str(&["git", "status"]),
88            vec![ParsedCommand::Unknown {
89                cmd: "git status".to_string(),
90            }],
91        );
92    }
93
94    #[test]
95    fn supports_git_grep_and_ls_files() {
96        assert_parsed(
97            &shlex_split_safe("git grep TODO src"),
98            vec![ParsedCommand::Search {
99                cmd: "git grep TODO src".to_string(),
100                query: Some("TODO".to_string()),
101                path: Some("src".to_string()),
102            }],
103        );
104        assert_parsed(
105            &shlex_split_safe("git grep -l TODO src"),
106            vec![ParsedCommand::Search {
107                cmd: "git grep -l TODO src".to_string(),
108                query: Some("TODO".to_string()),
109                path: Some("src".to_string()),
110            }],
111        );
112        assert_parsed(
113            &shlex_split_safe("git ls-files"),
114            vec![ParsedCommand::ListFiles {
115                cmd: "git ls-files".to_string(),
116                path: None,
117            }],
118        );
119        assert_parsed(
120            &shlex_split_safe("git ls-files src"),
121            vec![ParsedCommand::ListFiles {
122                cmd: "git ls-files src".to_string(),
123                path: Some("src".to_string()),
124            }],
125        );
126        assert_parsed(
127            &shlex_split_safe("git ls-files --exclude target src"),
128            vec![ParsedCommand::ListFiles {
129                cmd: "git ls-files --exclude target src".to_string(),
130                path: Some("src".to_string()),
131            }],
132        );
133    }
134
135    #[test]
136    fn handles_git_pipe_wc() {
137        let inner = "git status | wc -l";
138        assert_parsed(
139            &vec_str(&["bash", "-lc", inner]),
140            vec![ParsedCommand::Unknown {
141                cmd: inner.to_string(),
142            }],
143        );
144    }
145
146    #[test]
147    fn bash_lc_redirect_not_quoted() {
148        let inner = "echo foo > bar";
149        assert_parsed(
150            &vec_str(&["bash", "-lc", inner]),
151            vec![ParsedCommand::Unknown {
152                cmd: "echo foo > bar".to_string(),
153            }],
154        );
155    }
156
157    #[test]
158    fn handles_complex_bash_command_head() {
159        let inner =
160            "rg --version && node -v && pnpm -v && rg --files | wc -l && rg --files | head -n 40";
161        assert_parsed(
162            &vec_str(&["bash", "-lc", inner]),
163            vec![ParsedCommand::Unknown {
164                cmd: inner.to_string(),
165            }],
166        );
167    }
168
169    #[test]
170    fn supports_searching_for_navigate_to_route() -> anyhow::Result<()> {
171        let inner = "rg -n \"navigate-to-route\" -S";
172        assert_parsed(
173            &vec_str(&["bash", "-lc", inner]),
174            vec![ParsedCommand::Search {
175                cmd: "rg -n navigate-to-route -S".to_string(),
176                query: Some("navigate-to-route".to_string()),
177                path: None,
178            }],
179        );
180        Ok(())
181    }
182
183    #[test]
184    fn handles_complex_bash_command() {
185        let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200";
186        assert_parsed(
187            &vec_str(&["bash", "-lc", inner]),
188            vec![ParsedCommand::Search {
189                cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(),
190                query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()),
191                path: None,
192            }],
193        );
194    }
195
196    #[test]
197    fn supports_rg_files_with_path_and_pipe() {
198        let inner = "rg --files webview/src | sed -n";
199        assert_parsed(
200            &vec_str(&["bash", "-lc", inner]),
201            vec![ParsedCommand::ListFiles {
202                cmd: "rg --files webview/src".to_string(),
203                path: Some("webview".to_string()),
204            }],
205        );
206    }
207
208    #[test]
209    fn supports_rg_files_then_head() {
210        let inner = "rg --files | head -n 50";
211        assert_parsed(
212            &vec_str(&["bash", "-lc", inner]),
213            vec![ParsedCommand::ListFiles {
214                cmd: "rg --files".to_string(),
215                path: None,
216            }],
217        );
218    }
219
220    #[test]
221    fn keeps_mutating_xargs_pipeline() {
222        let inner = r#"rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'"#;
223        assert_parsed(
224            &vec_str(&["bash", "-lc", inner]),
225            vec![ParsedCommand::Unknown {
226                cmd: inner.to_string(),
227            }],
228        );
229    }
230
231    #[test]
232    fn collapses_plain_pipeline_when_any_stage_is_unknown() {
233        let command = shlex_split_safe(
234            "rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'",
235        );
236        assert_parsed(
237            &command,
238            vec![ParsedCommand::Unknown {
239                cmd: shlex_join(&command),
240            }],
241        );
242    }
243
244    #[test]
245    fn collapses_pipeline_with_helper_when_later_stage_is_unknown() {
246        let command = shlex_split_safe("rg --files | nl -ba | foo");
247        assert_parsed(
248            &command,
249            vec![ParsedCommand::Unknown {
250                cmd: shlex_join(&command),
251            }],
252        );
253    }
254
255    #[test]
256    fn rg_files_with_matches_flags_are_search() {
257        assert_parsed(
258            &shlex_split_safe("rg -l TODO src"),
259            vec![ParsedCommand::Search {
260                cmd: "rg -l TODO src".to_string(),
261                query: Some("TODO".to_string()),
262                path: Some("src".to_string()),
263            }],
264        );
265        assert_parsed(
266            &shlex_split_safe("rg --files-with-matches TODO src"),
267            vec![ParsedCommand::Search {
268                cmd: "rg --files-with-matches TODO src".to_string(),
269                query: Some("TODO".to_string()),
270                path: Some("src".to_string()),
271            }],
272        );
273        assert_parsed(
274            &shlex_split_safe("rg -L TODO src"),
275            vec![ParsedCommand::Search {
276                cmd: "rg -L TODO src".to_string(),
277                query: Some("TODO".to_string()),
278                path: Some("src".to_string()),
279            }],
280        );
281        assert_parsed(
282            &shlex_split_safe("rg --files-without-match TODO src"),
283            vec![ParsedCommand::Search {
284                cmd: "rg --files-without-match TODO src".to_string(),
285                query: Some("TODO".to_string()),
286                path: Some("src".to_string()),
287            }],
288        );
289        assert_parsed(
290            &shlex_split_safe("rga -l TODO src"),
291            vec![ParsedCommand::Search {
292                cmd: "rga -l TODO src".to_string(),
293                query: Some("TODO".to_string()),
294                path: Some("src".to_string()),
295            }],
296        );
297    }
298
299    #[test]
300    fn supports_cat() {
301        let inner = "cat webview/README.md";
302        assert_parsed(
303            &vec_str(&["bash", "-lc", inner]),
304            vec![ParsedCommand::Read {
305                cmd: inner.to_string(),
306                name: "README.md".to_string(),
307                path: PathBuf::from("webview/README.md"),
308            }],
309        );
310    }
311
312    #[test]
313    fn zsh_lc_supports_cat() {
314        let inner = "cat README.md";
315        assert_parsed(
316            &vec_str(&["zsh", "-lc", inner]),
317            vec![ParsedCommand::Read {
318                cmd: inner.to_string(),
319                name: "README.md".to_string(),
320                path: PathBuf::from("README.md"),
321            }],
322        );
323    }
324
325    #[test]
326    fn supports_bat() {
327        let inner = "bat --theme TwoDark README.md";
328        assert_parsed(
329            &vec_str(&["bash", "-lc", inner]),
330            vec![ParsedCommand::Read {
331                cmd: inner.to_string(),
332                name: "README.md".to_string(),
333                path: PathBuf::from("README.md"),
334            }],
335        );
336    }
337
338    #[test]
339    fn supports_batcat() {
340        let inner = "batcat README.md";
341        assert_parsed(
342            &vec_str(&["bash", "-lc", inner]),
343            vec![ParsedCommand::Read {
344                cmd: inner.to_string(),
345                name: "README.md".to_string(),
346                path: PathBuf::from("README.md"),
347            }],
348        );
349    }
350
351    #[test]
352    fn supports_less() {
353        let inner = "less -p TODO README.md";
354        assert_parsed(
355            &vec_str(&["bash", "-lc", inner]),
356            vec![ParsedCommand::Read {
357                cmd: inner.to_string(),
358                name: "README.md".to_string(),
359                path: PathBuf::from("README.md"),
360            }],
361        );
362    }
363
364    #[test]
365    fn supports_more() {
366        let inner = "more README.md";
367        assert_parsed(
368            &vec_str(&["bash", "-lc", inner]),
369            vec![ParsedCommand::Read {
370                cmd: inner.to_string(),
371                name: "README.md".to_string(),
372                path: PathBuf::from("README.md"),
373            }],
374        );
375    }
376
377    #[test]
378    fn cd_then_cat_is_single_read() {
379        assert_parsed(
380            &shlex_split_safe("cd foo && cat foo.txt"),
381            vec![ParsedCommand::Read {
382                cmd: "cat foo.txt".to_string(),
383                name: "foo.txt".to_string(),
384                path: PathBuf::from("foo/foo.txt"),
385            }],
386        );
387    }
388
389    #[test]
390    fn cd_with_double_dash_then_cat_is_read() {
391        assert_parsed(
392            &shlex_split_safe("cd -- -weird && cat foo.txt"),
393            vec![ParsedCommand::Read {
394                cmd: "cat foo.txt".to_string(),
395                name: "foo.txt".to_string(),
396                path: PathBuf::from("-weird/foo.txt"),
397            }],
398        );
399    }
400
401    #[test]
402    fn cd_with_multiple_operands_uses_last() {
403        assert_parsed(
404            &shlex_split_safe("cd dir1 dir2 && cat foo.txt"),
405            vec![ParsedCommand::Read {
406                cmd: "cat foo.txt".to_string(),
407                name: "foo.txt".to_string(),
408                path: PathBuf::from("dir2/foo.txt"),
409            }],
410        );
411    }
412
413    #[test]
414    fn bash_cd_then_bar_is_same_as_bar() {
415        // Ensure a leading `cd` inside bash -lc is dropped when followed by another command.
416        assert_parsed(
417            &shlex_split_safe("bash -lc 'cd foo && bar'"),
418            vec![ParsedCommand::Unknown {
419                cmd: "cd foo && bar".to_string(),
420            }],
421        );
422    }
423
424    #[test]
425    fn bash_cd_then_cat_is_read() {
426        assert_parsed(
427            &shlex_split_safe("bash -lc 'cd foo && cat foo.txt'"),
428            vec![ParsedCommand::Read {
429                cmd: "cat foo.txt".to_string(),
430                name: "foo.txt".to_string(),
431                path: PathBuf::from("foo/foo.txt"),
432            }],
433        );
434    }
435
436    #[test]
437    fn supports_ls_with_pipe() {
438        let inner = "ls -la | sed -n '1,120p'";
439        assert_parsed(
440            &vec_str(&["bash", "-lc", inner]),
441            vec![ParsedCommand::ListFiles {
442                cmd: "ls -la".to_string(),
443                path: None,
444            }],
445        );
446    }
447
448    #[test]
449    fn supports_eza_exa_tree_du() {
450        assert_parsed(
451            &shlex_split_safe("eza --color=always src"),
452            vec![ParsedCommand::ListFiles {
453                cmd: "eza '--color=always' src".to_string(),
454                path: Some("src".to_string()),
455            }],
456        );
457        assert_parsed(
458            &shlex_split_safe("exa -I target ."),
459            vec![ParsedCommand::ListFiles {
460                cmd: "exa -I target .".to_string(),
461                path: Some(".".to_string()),
462            }],
463        );
464        assert_parsed(
465            &shlex_split_safe("tree -L 2 src"),
466            vec![ParsedCommand::ListFiles {
467                cmd: "tree -L 2 src".to_string(),
468                path: Some("src".to_string()),
469            }],
470        );
471        assert_parsed(
472            &shlex_split_safe("du -d 2 ."),
473            vec![ParsedCommand::ListFiles {
474                cmd: "du -d 2 .".to_string(),
475                path: Some(".".to_string()),
476            }],
477        );
478    }
479
480    #[test]
481    fn supports_head_n() {
482        let inner = "head -n 50 Cargo.toml";
483        assert_parsed(
484            &vec_str(&["bash", "-lc", inner]),
485            vec![ParsedCommand::Read {
486                cmd: inner.to_string(),
487                name: "Cargo.toml".to_string(),
488                path: PathBuf::from("Cargo.toml"),
489            }],
490        );
491    }
492
493    #[test]
494    fn supports_head_file_only() {
495        let inner = "head Cargo.toml";
496        assert_parsed(
497            &vec_str(&["bash", "-lc", inner]),
498            vec![ParsedCommand::Read {
499                cmd: inner.to_string(),
500                name: "Cargo.toml".to_string(),
501                path: PathBuf::from("Cargo.toml"),
502            }],
503        );
504    }
505
506    #[test]
507    fn supports_cat_sed_n() {
508        let inner = "cat tui/Cargo.toml | sed -n '1,200p'";
509        assert_parsed(
510            &vec_str(&["bash", "-lc", inner]),
511            vec![ParsedCommand::Read {
512                cmd: inner.to_string(),
513                name: "Cargo.toml".to_string(),
514                path: PathBuf::from("tui/Cargo.toml"),
515            }],
516        );
517    }
518
519    #[test]
520    fn supports_tail_n_plus() {
521        let inner = "tail -n +522 README.md";
522        assert_parsed(
523            &vec_str(&["bash", "-lc", inner]),
524            vec![ParsedCommand::Read {
525                cmd: inner.to_string(),
526                name: "README.md".to_string(),
527                path: PathBuf::from("README.md"),
528            }],
529        );
530    }
531
532    #[test]
533    fn supports_tail_n_last_lines() {
534        let inner = "tail -n 30 README.md";
535        let out = parse_command(&vec_str(&["bash", "-lc", inner]));
536        assert_eq!(
537            out,
538            vec![ParsedCommand::Read {
539                cmd: inner.to_string(),
540                name: "README.md".to_string(),
541                path: PathBuf::from("README.md"),
542            }]
543        );
544    }
545
546    #[test]
547    fn supports_tail_file_only() {
548        let inner = "tail README.md";
549        assert_parsed(
550            &vec_str(&["bash", "-lc", inner]),
551            vec![ParsedCommand::Read {
552                cmd: inner.to_string(),
553                name: "README.md".to_string(),
554                path: PathBuf::from("README.md"),
555            }],
556        );
557    }
558
559    #[test]
560    fn supports_npm_run_build_is_unknown() {
561        assert_parsed(
562            &vec_str(&["npm", "run", "build"]),
563            vec![ParsedCommand::Unknown {
564                cmd: "npm run build".to_string(),
565            }],
566        );
567    }
568
569    #[test]
570    fn supports_grep_recursive_current_dir() {
571        assert_parsed(
572            &vec_str(&["grep", "-R", "CODEX_SANDBOX_ENV_VAR", "-n", "."]),
573            vec![ParsedCommand::Search {
574                cmd: "grep -R CODEX_SANDBOX_ENV_VAR -n .".to_string(),
575                query: Some("CODEX_SANDBOX_ENV_VAR".to_string()),
576                path: Some(".".to_string()),
577            }],
578        );
579    }
580
581    #[test]
582    fn supports_grep_recursive_specific_file() {
583        assert_parsed(
584            &vec_str(&[
585                "grep",
586                "-R",
587                "CODEX_SANDBOX_ENV_VAR",
588                "-n",
589                "core/src/spawn.rs",
590            ]),
591            vec![ParsedCommand::Search {
592                cmd: "grep -R CODEX_SANDBOX_ENV_VAR -n core/src/spawn.rs".to_string(),
593                query: Some("CODEX_SANDBOX_ENV_VAR".to_string()),
594                path: Some("spawn.rs".to_string()),
595            }],
596        );
597    }
598
599    #[test]
600    fn supports_egrep_and_fgrep() {
601        assert_parsed(
602            &shlex_split_safe("egrep -R TODO src"),
603            vec![ParsedCommand::Search {
604                cmd: "egrep -R TODO src".to_string(),
605                query: Some("TODO".to_string()),
606                path: Some("src".to_string()),
607            }],
608        );
609        assert_parsed(
610            &shlex_split_safe("fgrep -l TODO src"),
611            vec![ParsedCommand::Search {
612                cmd: "fgrep -l TODO src".to_string(),
613                query: Some("TODO".to_string()),
614                path: Some("src".to_string()),
615            }],
616        );
617    }
618
619    #[test]
620    fn grep_files_with_matches_flags_are_search() {
621        assert_parsed(
622            &shlex_split_safe("grep -l TODO src"),
623            vec![ParsedCommand::Search {
624                cmd: "grep -l TODO src".to_string(),
625                query: Some("TODO".to_string()),
626                path: Some("src".to_string()),
627            }],
628        );
629        assert_parsed(
630            &shlex_split_safe("grep --files-with-matches TODO src"),
631            vec![ParsedCommand::Search {
632                cmd: "grep --files-with-matches TODO src".to_string(),
633                query: Some("TODO".to_string()),
634                path: Some("src".to_string()),
635            }],
636        );
637        assert_parsed(
638            &shlex_split_safe("grep -L TODO src"),
639            vec![ParsedCommand::Search {
640                cmd: "grep -L TODO src".to_string(),
641                query: Some("TODO".to_string()),
642                path: Some("src".to_string()),
643            }],
644        );
645        assert_parsed(
646            &shlex_split_safe("grep --files-without-match TODO src"),
647            vec![ParsedCommand::Search {
648                cmd: "grep --files-without-match TODO src".to_string(),
649                query: Some("TODO".to_string()),
650                path: Some("src".to_string()),
651            }],
652        );
653    }
654
655    #[test]
656    fn supports_grep_query_with_slashes_not_shortened() {
657        // Query strings may contain slashes and should not be shortened to the basename.
658        // Previously, grep queries were passed through short_display_path, which is incorrect.
659        assert_parsed(
660            &shlex_split_safe("grep -R src/main.rs -n ."),
661            vec![ParsedCommand::Search {
662                cmd: "grep -R src/main.rs -n .".to_string(),
663                query: Some("src/main.rs".to_string()),
664                path: Some(".".to_string()),
665            }],
666        );
667    }
668
669    #[test]
670    fn supports_grep_weird_backtick_in_query() {
671        assert_parsed(
672            &shlex_split_safe("grep -R COD`EX_SANDBOX -n"),
673            vec![ParsedCommand::Search {
674                cmd: "grep -R 'COD`EX_SANDBOX' -n".to_string(),
675                query: Some("COD`EX_SANDBOX".to_string()),
676                path: None,
677            }],
678        );
679    }
680
681    #[test]
682    fn supports_cd_and_rg_files() {
683        assert_parsed(
684            &shlex_split_safe("cd codex-rs && rg --files"),
685            vec![ParsedCommand::ListFiles {
686                cmd: "rg --files".to_string(),
687                path: None,
688            }],
689        );
690    }
691
692    #[test]
693    fn supports_single_string_script_with_cd_and_pipe() {
694        let inner = r#"cd /Users/pakrym/code/codex && rg -n "codex_api" codex-rs -S | head -n 50"#;
695        assert_parsed(
696            &vec_str(&["bash", "-lc", inner]),
697            vec![ParsedCommand::Search {
698                cmd: "rg -n codex_api codex-rs -S".to_string(),
699                query: Some("codex_api".to_string()),
700                path: Some("codex-rs".to_string()),
701            }],
702        );
703    }
704
705    #[test]
706    fn supports_python_walks_files() {
707        let inner = r#"python -c "import os; print(os.listdir('.'))""#;
708        assert_parsed(
709            &vec_str(&["bash", "-lc", inner]),
710            vec![ParsedCommand::ListFiles {
711                cmd: shlex_join(&shlex_split_safe(inner)),
712                path: None,
713            }],
714        );
715    }
716
717    #[test]
718    fn supports_python3_walks_files() {
719        let inner = r#"python3 -c "import glob; print(glob.glob('*.rs'))""#;
720        assert_parsed(
721            &vec_str(&["bash", "-lc", inner]),
722            vec![ParsedCommand::ListFiles {
723                cmd: shlex_join(&shlex_split_safe(inner)),
724                path: None,
725            }],
726        );
727    }
728
729    #[test]
730    fn python_without_file_walk_is_unknown() {
731        let inner = r#"python -c "print('hello')""#;
732        assert_parsed(
733            &vec_str(&["bash", "-lc", inner]),
734            vec![ParsedCommand::Unknown {
735                cmd: shlex_join(&shlex_split_safe(inner)),
736            }],
737        );
738    }
739
740    // ---- is_small_formatting_command unit tests ----
741    #[test]
742    fn small_formatting_always_true_commands() {
743        for cmd in ["wc", "tr", "cut", "sort", "uniq", "xargs", "tee", "column"] {
744            assert!(is_small_formatting_command(&shlex_split_safe(cmd)));
745            assert!(is_small_formatting_command(&shlex_split_safe(&format!(
746                "{cmd} -x"
747            ))));
748        }
749    }
750
751    #[test]
752    fn awk_behavior() {
753        assert!(is_small_formatting_command(&shlex_split_safe(
754            "awk '{print $1}'"
755        )));
756        assert!(!is_small_formatting_command(&shlex_split_safe(
757            "awk '{print $1}' Cargo.toml"
758        )));
759        assert!(!is_small_formatting_command(&shlex_split_safe(
760            "awk -f script.awk Cargo.toml"
761        )));
762    }
763
764    #[test]
765    fn head_behavior() {
766        // No args -> small formatting
767        assert!(is_small_formatting_command(&vec_str(&["head"])));
768        // Numeric count only -> formatting
769        assert!(is_small_formatting_command(&shlex_split_safe("head -n 40")));
770        // With explicit file -> not small formatting
771        assert!(!is_small_formatting_command(&shlex_split_safe(
772            "head -n 40 file.txt"
773        )));
774        // File only (no count) -> not formatting
775        assert!(!is_small_formatting_command(&vec_str(&[
776            "head", "file.txt"
777        ])));
778    }
779
780    #[test]
781    fn tail_behavior() {
782        // No args -> small formatting
783        assert!(is_small_formatting_command(&vec_str(&["tail"])));
784        // Numeric with plus offset -> formatting
785        assert!(is_small_formatting_command(&shlex_split_safe(
786            "tail -n +10"
787        )));
788        assert!(!is_small_formatting_command(&shlex_split_safe(
789            "tail -n +10 file.txt"
790        )));
791        // Numeric count -> formatting
792        assert!(is_small_formatting_command(&shlex_split_safe("tail -n 30")));
793        assert!(!is_small_formatting_command(&shlex_split_safe(
794            "tail -n 30 file.txt"
795        )));
796        // Byte count -> formatting
797        assert!(is_small_formatting_command(&shlex_split_safe("tail -c 30")));
798        assert!(is_small_formatting_command(&shlex_split_safe(
799            "tail -c +10"
800        )));
801        // File only (no count) -> not formatting
802        assert!(!is_small_formatting_command(&vec_str(&[
803            "tail", "file.txt"
804        ])));
805    }
806
807    #[test]
808    fn sed_behavior() {
809        // Plain sed -> small formatting
810        assert!(is_small_formatting_command(&vec_str(&["sed"])));
811        // sed -n <range> (no file) -> still small formatting
812        assert!(is_small_formatting_command(&vec_str(&["sed", "-n", "10p"])));
813        // Valid range with file -> not small formatting
814        assert!(!is_small_formatting_command(&shlex_split_safe(
815            "sed -n 10p file.txt"
816        )));
817        assert!(!is_small_formatting_command(&shlex_split_safe(
818            "sed -n -e 10p file.txt"
819        )));
820        assert!(!is_small_formatting_command(&shlex_split_safe(
821            "sed -n 10p -- file.txt"
822        )));
823        assert!(!is_small_formatting_command(&shlex_split_safe(
824            "sed -n 1,200p file.txt"
825        )));
826        // Invalid ranges with file -> small formatting
827        assert!(is_small_formatting_command(&shlex_split_safe(
828            "sed -n p file.txt"
829        )));
830        assert!(is_small_formatting_command(&shlex_split_safe(
831            "sed -n +10p file.txt"
832        )));
833    }
834
835    #[test]
836    fn empty_tokens_is_not_small() {
837        let empty: Vec<String> = Vec::new();
838        assert!(!is_small_formatting_command(&empty));
839    }
840
841    #[test]
842    fn supports_nl_then_sed_reading() {
843        let inner = "nl -ba core/src/parse_command.rs | sed -n '1200,1720p'";
844        assert_parsed(
845            &vec_str(&["bash", "-lc", inner]),
846            vec![ParsedCommand::Read {
847                cmd: inner.to_string(),
848                name: "parse_command.rs".to_string(),
849                path: PathBuf::from("core/src/parse_command.rs"),
850            }],
851        );
852    }
853
854    #[test]
855    fn supports_sed_n() {
856        let inner = "sed -n '2000,2200p' tui/src/history_cell.rs";
857        assert_parsed(
858            &vec_str(&["bash", "-lc", inner]),
859            vec![ParsedCommand::Read {
860                cmd: inner.to_string(),
861                name: "history_cell.rs".to_string(),
862                path: PathBuf::from("tui/src/history_cell.rs"),
863            }],
864        );
865    }
866
867    #[test]
868    fn supports_awk_with_file() {
869        let inner = "awk '{print $1}' Cargo.toml";
870        assert_parsed(
871            &vec_str(&["bash", "-lc", inner]),
872            vec![ParsedCommand::Read {
873                cmd: inner.to_string(),
874                name: "Cargo.toml".to_string(),
875                path: PathBuf::from("Cargo.toml"),
876            }],
877        );
878    }
879
880    #[test]
881    fn filters_out_printf() {
882        let inner =
883            r#"printf "\n===== ansi-escape/Cargo.toml =====\n"; cat -- ansi-escape/Cargo.toml"#;
884        assert_parsed(
885            &vec_str(&["bash", "-lc", inner]),
886            vec![ParsedCommand::Read {
887                cmd: "cat -- ansi-escape/Cargo.toml".to_string(),
888                name: "Cargo.toml".to_string(),
889                path: PathBuf::from("ansi-escape/Cargo.toml"),
890            }],
891        );
892    }
893
894    #[test]
895    fn drops_yes_in_pipelines() {
896        // Inside bash -lc, `yes | rg --files` should focus on the primary command.
897        let inner = "yes | rg --files";
898        assert_parsed(
899            &vec_str(&["bash", "-lc", inner]),
900            vec![ParsedCommand::ListFiles {
901                cmd: "rg --files".to_string(),
902                path: None,
903            }],
904        );
905    }
906
907    #[test]
908    fn supports_sed_n_then_nl_as_search() {
909        // Ensure `sed -n '<range>' <file> | nl -ba` is summarized as a search for that file.
910        let args = shlex_split_safe(
911            "sed -n '260,640p' exec/src/event_processor_with_human_output.rs | nl -ba",
912        );
913        assert_parsed(
914            &args,
915            vec![ParsedCommand::Read {
916                cmd: "sed -n '260,640p' exec/src/event_processor_with_human_output.rs".to_string(),
917                name: "event_processor_with_human_output.rs".to_string(),
918                path: PathBuf::from("exec/src/event_processor_with_human_output.rs"),
919            }],
920        );
921    }
922
923    #[test]
924    fn preserves_rg_with_spaces() {
925        assert_parsed(
926            &shlex_split_safe("yes | rg -n 'foo bar' -S"),
927            vec![ParsedCommand::Search {
928                cmd: "rg -n 'foo bar' -S".to_string(),
929                query: Some("foo bar".to_string()),
930                path: None,
931            }],
932        );
933    }
934
935    #[test]
936    fn ls_with_glob() {
937        assert_parsed(
938            &shlex_split_safe("ls -I '*.test.js'"),
939            vec![ParsedCommand::ListFiles {
940                cmd: "ls -I '*.test.js'".to_string(),
941                path: None,
942            }],
943        );
944    }
945
946    #[test]
947    fn strips_true_in_sequence() {
948        // `true` should be dropped from parsed sequences
949        assert_parsed(
950            &shlex_split_safe("true && rg --files"),
951            vec![ParsedCommand::ListFiles {
952                cmd: "rg --files".to_string(),
953                path: None,
954            }],
955        );
956
957        assert_parsed(
958            &shlex_split_safe("rg --files && true"),
959            vec![ParsedCommand::ListFiles {
960                cmd: "rg --files".to_string(),
961                path: None,
962            }],
963        );
964    }
965
966    #[test]
967    fn strips_true_inside_bash_lc() {
968        let inner = "true && rg --files";
969        assert_parsed(
970            &vec_str(&["bash", "-lc", inner]),
971            vec![ParsedCommand::ListFiles {
972                cmd: "rg --files".to_string(),
973                path: None,
974            }],
975        );
976
977        let inner2 = "rg --files || true";
978        assert_parsed(
979            &vec_str(&["bash", "-lc", inner2]),
980            vec![ParsedCommand::ListFiles {
981                cmd: "rg --files".to_string(),
982                path: None,
983            }],
984        );
985    }
986
987    #[test]
988    fn shorten_path_on_windows() {
989        assert_parsed(
990            &shlex_split_safe(r#"cat "pkg\src\main.rs""#),
991            vec![ParsedCommand::Read {
992                cmd: r#"cat "pkg\\src\\main.rs""#.to_string(),
993                name: "main.rs".to_string(),
994                path: PathBuf::from(r#"pkg\src\main.rs"#),
995            }],
996        );
997    }
998
999    #[test]
1000    fn head_with_no_space() {
1001        assert_parsed(
1002            &shlex_split_safe("bash -lc 'head -n50 Cargo.toml'"),
1003            vec![ParsedCommand::Read {
1004                cmd: "head -n50 Cargo.toml".to_string(),
1005                name: "Cargo.toml".to_string(),
1006                path: PathBuf::from("Cargo.toml"),
1007            }],
1008        );
1009    }
1010
1011    #[test]
1012    fn bash_dash_c_pipeline_parsing() {
1013        // Ensure -c is handled similarly to -lc by shell parsing
1014        let inner = "rg --files | head -n 1";
1015        assert_parsed(
1016            &vec_str(&["bash", "-c", inner]),
1017            vec![ParsedCommand::ListFiles {
1018                cmd: "rg --files".to_string(),
1019                path: None,
1020            }],
1021        );
1022    }
1023
1024    #[test]
1025    fn tail_with_no_space() {
1026        assert_parsed(
1027            &shlex_split_safe("bash -lc 'tail -n+10 README.md'"),
1028            vec![ParsedCommand::Read {
1029                cmd: "tail -n+10 README.md".to_string(),
1030                name: "README.md".to_string(),
1031                path: PathBuf::from("README.md"),
1032            }],
1033        );
1034    }
1035
1036    #[test]
1037    fn grep_with_query_and_path() {
1038        assert_parsed(
1039            &shlex_split_safe("grep -R TODO src"),
1040            vec![ParsedCommand::Search {
1041                cmd: "grep -R TODO src".to_string(),
1042                query: Some("TODO".to_string()),
1043                path: Some("src".to_string()),
1044            }],
1045        );
1046    }
1047
1048    #[test]
1049    fn supports_ag_ack_pt_rga() {
1050        assert_parsed(
1051            &shlex_split_safe("ag TODO src"),
1052            vec![ParsedCommand::Search {
1053                cmd: "ag TODO src".to_string(),
1054                query: Some("TODO".to_string()),
1055                path: Some("src".to_string()),
1056            }],
1057        );
1058        assert_parsed(
1059            &shlex_split_safe("ack TODO src"),
1060            vec![ParsedCommand::Search {
1061                cmd: "ack TODO src".to_string(),
1062                query: Some("TODO".to_string()),
1063                path: Some("src".to_string()),
1064            }],
1065        );
1066        assert_parsed(
1067            &shlex_split_safe("pt TODO src"),
1068            vec![ParsedCommand::Search {
1069                cmd: "pt TODO src".to_string(),
1070                query: Some("TODO".to_string()),
1071                path: Some("src".to_string()),
1072            }],
1073        );
1074        assert_parsed(
1075            &shlex_split_safe("rga TODO src"),
1076            vec![ParsedCommand::Search {
1077                cmd: "rga TODO src".to_string(),
1078                query: Some("TODO".to_string()),
1079                path: Some("src".to_string()),
1080            }],
1081        );
1082    }
1083
1084    #[test]
1085    fn ag_ack_pt_files_with_matches_flags_are_search() {
1086        assert_parsed(
1087            &shlex_split_safe("ag -l TODO src"),
1088            vec![ParsedCommand::Search {
1089                cmd: "ag -l TODO src".to_string(),
1090                query: Some("TODO".to_string()),
1091                path: Some("src".to_string()),
1092            }],
1093        );
1094        assert_parsed(
1095            &shlex_split_safe("ack -l TODO src"),
1096            vec![ParsedCommand::Search {
1097                cmd: "ack -l TODO src".to_string(),
1098                query: Some("TODO".to_string()),
1099                path: Some("src".to_string()),
1100            }],
1101        );
1102        assert_parsed(
1103            &shlex_split_safe("pt -l TODO src"),
1104            vec![ParsedCommand::Search {
1105                cmd: "pt -l TODO src".to_string(),
1106                query: Some("TODO".to_string()),
1107                path: Some("src".to_string()),
1108            }],
1109        );
1110    }
1111
1112    #[test]
1113    fn rg_with_equals_style_flags() {
1114        assert_parsed(
1115            &shlex_split_safe("rg --colors=never -n foo src"),
1116            vec![ParsedCommand::Search {
1117                cmd: "rg '--colors=never' -n foo src".to_string(),
1118                query: Some("foo".to_string()),
1119                path: Some("src".to_string()),
1120            }],
1121        );
1122    }
1123
1124    #[test]
1125    fn cat_with_double_dash_and_sed_ranges() {
1126        // cat -- <file> should be treated as a read of that file
1127        assert_parsed(
1128            &shlex_split_safe("cat -- ./-strange-file-name"),
1129            vec![ParsedCommand::Read {
1130                cmd: "cat -- ./-strange-file-name".to_string(),
1131                name: "-strange-file-name".to_string(),
1132                path: PathBuf::from("./-strange-file-name"),
1133            }],
1134        );
1135
1136        // sed -n <range> <file> should be treated as a read of <file>
1137        assert_parsed(
1138            &shlex_split_safe("sed -n '12,20p' Cargo.toml"),
1139            vec![ParsedCommand::Read {
1140                cmd: "sed -n '12,20p' Cargo.toml".to_string(),
1141                name: "Cargo.toml".to_string(),
1142                path: PathBuf::from("Cargo.toml"),
1143            }],
1144        );
1145    }
1146
1147    #[test]
1148    fn drop_trailing_nl_in_pipeline() {
1149        // When an `nl` stage has only flags, it should be dropped from the summary
1150        assert_parsed(
1151            &shlex_split_safe("rg --files | nl -ba"),
1152            vec![ParsedCommand::ListFiles {
1153                cmd: "rg --files".to_string(),
1154                path: None,
1155            }],
1156        );
1157    }
1158
1159    #[test]
1160    fn ls_with_time_style_and_path() {
1161        assert_parsed(
1162            &shlex_split_safe("ls --time-style=long-iso ./dist"),
1163            vec![ParsedCommand::ListFiles {
1164                cmd: "ls '--time-style=long-iso' ./dist".to_string(),
1165                // short_display_path drops "dist" and shows "." as the last useful segment
1166                path: Some(".".to_string()),
1167            }],
1168        );
1169    }
1170
1171    #[test]
1172    fn fd_file_finder_variants() {
1173        assert_parsed(
1174            &shlex_split_safe("fd -t f src/"),
1175            vec![ParsedCommand::ListFiles {
1176                cmd: "fd -t f src/".to_string(),
1177                path: Some("src".to_string()),
1178            }],
1179        );
1180
1181        // fd with query and path should capture both
1182        assert_parsed(
1183            &shlex_split_safe("fd main src"),
1184            vec![ParsedCommand::Search {
1185                cmd: "fd main src".to_string(),
1186                query: Some("main".to_string()),
1187                path: Some("src".to_string()),
1188            }],
1189        );
1190    }
1191
1192    #[test]
1193    fn find_basic_name_filter() {
1194        assert_parsed(
1195            &shlex_split_safe("find . -name '*.rs'"),
1196            vec![ParsedCommand::Search {
1197                cmd: "find . -name '*.rs'".to_string(),
1198                query: Some("*.rs".to_string()),
1199                path: Some(".".to_string()),
1200            }],
1201        );
1202    }
1203
1204    #[test]
1205    fn find_type_only_path() {
1206        assert_parsed(
1207            &shlex_split_safe("find src -type f"),
1208            vec![ParsedCommand::ListFiles {
1209                cmd: "find src -type f".to_string(),
1210                path: Some("src".to_string()),
1211            }],
1212        );
1213    }
1214
1215    #[test]
1216    fn bin_bash_lc_sed() {
1217        assert_parsed(
1218            &shlex_split_safe("/bin/bash -lc 'sed -n '1,10p' Cargo.toml'"),
1219            vec![ParsedCommand::Read {
1220                cmd: "sed -n '1,10p' Cargo.toml".to_string(),
1221                name: "Cargo.toml".to_string(),
1222                path: PathBuf::from("Cargo.toml"),
1223            }],
1224        );
1225    }
1226    #[test]
1227    fn bin_zsh_lc_sed() {
1228        assert_parsed(
1229            &shlex_split_safe("/bin/zsh -lc 'sed -n '1,10p' Cargo.toml'"),
1230            vec![ParsedCommand::Read {
1231                cmd: "sed -n '1,10p' Cargo.toml".to_string(),
1232                name: "Cargo.toml".to_string(),
1233                path: PathBuf::from("Cargo.toml"),
1234            }],
1235        );
1236    }
1237
1238    #[test]
1239    fn powershell_command_is_stripped() {
1240        assert_parsed(
1241            &vec_str(&["powershell", "-Command", "Get-ChildItem"]),
1242            vec![ParsedCommand::Unknown {
1243                cmd: "Get-ChildItem".to_string(),
1244            }],
1245        );
1246    }
1247
1248    #[test]
1249    fn pwsh_with_noprofile_and_c_alias_is_stripped() {
1250        assert_parsed(
1251            &vec_str(&["pwsh", "-NoProfile", "-c", "Write-Host hi"]),
1252            vec![ParsedCommand::Unknown {
1253                cmd: "Write-Host hi".to_string(),
1254            }],
1255        );
1256    }
1257
1258    #[test]
1259    fn powershell_with_path_is_stripped() {
1260        let command = if cfg!(windows) {
1261            "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
1262        } else {
1263            "/usr/local/bin/powershell.exe"
1264        };
1265
1266        assert_parsed(
1267            &vec_str(&[command, "-NoProfile", "-c", "Write-Host hi"]),
1268            vec![ParsedCommand::Unknown {
1269                cmd: "Write-Host hi".to_string(),
1270            }],
1271        );
1272    }
1273}
1274
1275pub fn parse_command_impl(command: &[String]) -> Vec<ParsedCommand> {
1276    if let Some(commands) = parse_shell_lc_commands(command) {
1277        return commands;
1278    }
1279
1280    if let Some((_, script)) = extract_powershell_command(command) {
1281        return vec![ParsedCommand::Unknown {
1282            cmd: script.to_string(),
1283        }];
1284    }
1285
1286    let normalized = normalize_tokens(command);
1287
1288    let parts = if contains_connectors(&normalized) {
1289        split_on_connectors(&normalized)
1290    } else {
1291        vec![normalized]
1292    };
1293
1294    // Preserve left-to-right execution order for all commands, including bash -c/-lc
1295    // so summaries reflect the order they will run.
1296
1297    // Map each pipeline segment to its parsed summary, tracking `cd` to compute paths.
1298    let mut commands: Vec<ParsedCommand> = Vec::new();
1299    let mut cwd: Option<String> = None;
1300    for tokens in &parts {
1301        if let Some((head, tail)) = tokens.split_first()
1302            && head == "cd"
1303        {
1304            if let Some(dir) = cd_target(tail) {
1305                cwd = Some(match &cwd {
1306                    Some(base) => join_paths(base, &dir),
1307                    None => dir.clone(),
1308                });
1309            }
1310            continue;
1311        }
1312        let parsed = summarize_main_tokens(tokens);
1313        let parsed = match parsed {
1314            ParsedCommand::Read { cmd, name, path } => {
1315                if let Some(base) = &cwd {
1316                    let full = join_paths(base, &path.to_string_lossy());
1317                    ParsedCommand::Read {
1318                        cmd,
1319                        name,
1320                        path: PathBuf::from(full),
1321                    }
1322                } else {
1323                    ParsedCommand::Read { cmd, name, path }
1324                }
1325            }
1326            other => other,
1327        };
1328        commands.push(parsed);
1329    }
1330
1331    while let Some(next) = simplify_once(&commands) {
1332        commands = next;
1333    }
1334
1335    commands
1336}
1337
1338fn simplify_once(commands: &[ParsedCommand]) -> Option<Vec<ParsedCommand>> {
1339    if commands.len() <= 1 {
1340        return None;
1341    }
1342
1343    // echo ... && ...rest => ...rest
1344    if let ParsedCommand::Unknown { cmd } = &commands[0]
1345        && shlex_split(cmd).is_some_and(|t| t.first().map(String::as_str) == Some("echo"))
1346    {
1347        return Some(commands[1..].to_vec());
1348    }
1349
1350    // cd foo && [any command] => [any command] (keep non-cd when a cd is followed by something)
1351    if let Some(idx) = commands.iter().position(|pc| match pc {
1352        ParsedCommand::Unknown { cmd } => {
1353            shlex_split(cmd).is_some_and(|t| t.first().map(String::as_str) == Some("cd"))
1354        }
1355        _ => false,
1356    }) && commands.len() > idx + 1
1357    {
1358        let mut out = Vec::with_capacity(commands.len() - 1);
1359        out.extend_from_slice(&commands[..idx]);
1360        out.extend_from_slice(&commands[idx + 1..]);
1361        return Some(out);
1362    }
1363
1364    // cmd || true => cmd
1365    if let Some(idx) = commands
1366        .iter()
1367        .position(|pc| matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"))
1368    {
1369        let mut out = Vec::with_capacity(commands.len() - 1);
1370        out.extend_from_slice(&commands[..idx]);
1371        out.extend_from_slice(&commands[idx + 1..]);
1372        return Some(out);
1373    }
1374
1375    // nl -[any_flags] && ...rest => ...rest
1376    if let Some(idx) = commands.iter().position(|pc| match pc {
1377        ParsedCommand::Unknown { cmd } => {
1378            if let Some(tokens) = shlex_split(cmd) {
1379                tokens.first().is_some_and(|s| s.as_str() == "nl")
1380                    && tokens.iter().skip(1).all(|t| t.starts_with('-'))
1381            } else {
1382                false
1383            }
1384        }
1385        _ => false,
1386    }) {
1387        let mut out = Vec::with_capacity(commands.len() - 1);
1388        out.extend_from_slice(&commands[..idx]);
1389        out.extend_from_slice(&commands[idx + 1..]);
1390        return Some(out);
1391    }
1392
1393    None
1394}
1395
1396/// Validates that this is a `sed -n 123,123p` command.
1397fn is_valid_sed_n_arg(arg: Option<&str>) -> bool {
1398    let s = match arg {
1399        Some(s) => s,
1400        None => return false,
1401    };
1402    let core = match s.strip_suffix('p') {
1403        Some(rest) => rest,
1404        None => return false,
1405    };
1406    let parts: Vec<&str> = core.split(',').collect();
1407    match parts.as_slice() {
1408        [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
1409        [a, b] => {
1410            !a.is_empty()
1411                && !b.is_empty()
1412                && a.chars().all(|c| c.is_ascii_digit())
1413                && b.chars().all(|c| c.is_ascii_digit())
1414        }
1415        _ => false,
1416    }
1417}
1418
1419fn sed_read_path(args: &[String]) -> Option<String> {
1420    let args_no_connector = trim_at_connector(args);
1421    if !args_no_connector.iter().any(|arg| arg == "-n") {
1422        return None;
1423    }
1424    let mut has_range_script = false;
1425    let mut i = 0;
1426    while i < args_no_connector.len() {
1427        let arg = &args_no_connector[i];
1428        if matches!(arg.as_str(), "-e" | "--expression") {
1429            if is_valid_sed_n_arg(args_no_connector.get(i + 1).map(String::as_str)) {
1430                has_range_script = true;
1431            }
1432            i += 2;
1433            continue;
1434        }
1435        if matches!(arg.as_str(), "-f" | "--file") {
1436            i += 2;
1437            continue;
1438        }
1439        i += 1;
1440    }
1441    if !has_range_script {
1442        has_range_script = args_no_connector
1443            .iter()
1444            .any(|arg| !arg.starts_with('-') && is_valid_sed_n_arg(Some(arg)));
1445    }
1446    if !has_range_script {
1447        return None;
1448    }
1449    let candidates = skip_flag_values(&args_no_connector, &["-e", "-f", "--expression", "--file"]);
1450    let non_flags: Vec<String> = candidates
1451        .into_iter()
1452        .filter(|arg| !arg.starts_with('-'))
1453        .cloned()
1454        .collect();
1455    match non_flags.as_slice() {
1456        [] => None,
1457        [first, rest @ ..] if is_valid_sed_n_arg(Some(first)) => rest.first().cloned(),
1458        [first, ..] => Some(first.clone()),
1459    }
1460}
1461
1462/// Normalize a command by:
1463/// - Removing `yes`/`no`/`bash -c`/`bash -lc`/`zsh -c`/`zsh -lc` prefixes.
1464/// - Splitting on `|` and `&&`/`||`/`;
1465fn normalize_tokens(cmd: &[String]) -> Vec<String> {
1466    match cmd {
1467        [first, pipe, rest @ ..] if (first == "yes" || first == "y") && pipe == "|" => {
1468            // Do not re-shlex already-tokenized input; just drop the prefix.
1469            rest.to_vec()
1470        }
1471        [first, pipe, rest @ ..] if (first == "no" || first == "n") && pipe == "|" => {
1472            // Do not re-shlex already-tokenized input; just drop the prefix.
1473            rest.to_vec()
1474        }
1475        [shell, flag, script]
1476            if (shell == "bash" || shell == "zsh") && (flag == "-c" || flag == "-lc") =>
1477        {
1478            shlex_split(script).unwrap_or_else(|| vec![shell.clone(), flag.clone(), script.clone()])
1479        }
1480        _ => cmd.to_vec(),
1481    }
1482}
1483
1484fn contains_connectors(tokens: &[String]) -> bool {
1485    tokens
1486        .iter()
1487        .any(|t| t == "&&" || t == "||" || t == "|" || t == ";")
1488}
1489
1490fn split_on_connectors(tokens: &[String]) -> Vec<Vec<String>> {
1491    let mut out: Vec<Vec<String>> = Vec::new();
1492    let mut cur: Vec<String> = Vec::new();
1493    for t in tokens {
1494        if t == "&&" || t == "||" || t == "|" || t == ";" {
1495            if !cur.is_empty() {
1496                out.push(std::mem::take(&mut cur));
1497            }
1498        } else {
1499            cur.push(t.clone());
1500        }
1501    }
1502    if !cur.is_empty() {
1503        out.push(cur);
1504    }
1505    out
1506}
1507
1508fn trim_at_connector(tokens: &[String]) -> Vec<String> {
1509    let idx = tokens
1510        .iter()
1511        .position(|t| t == "|" || t == "&&" || t == "||" || t == ";")
1512        .unwrap_or(tokens.len());
1513    tokens[..idx].to_vec()
1514}
1515
1516/// Shorten a path to the last component, excluding `build`/`dist`/`node_modules`/`src`.
1517/// It also pulls out a useful path from a directory such as:
1518/// - webview/src -> webview
1519/// - foo/src/ -> foo
1520/// - packages/app/node_modules/ -> app
1521fn short_display_path(path: &str) -> String {
1522    // Normalize separators and drop any trailing slash for display.
1523    let normalized = path.replace('\\', "/");
1524    let trimmed = normalized.trim_end_matches('/');
1525    let mut parts = trimmed.split('/').rev().filter(|p| {
1526        !p.is_empty() && *p != "build" && *p != "dist" && *p != "node_modules" && *p != "src"
1527    });
1528    parts
1529        .next()
1530        .map(str::to_string)
1531        .unwrap_or_else(|| trimmed.to_string())
1532}
1533
1534// Skip values consumed by specific flags and ignore --flag=value style arguments.
1535fn skip_flag_values<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a String> {
1536    let mut out: Vec<&'a String> = Vec::new();
1537    let mut skip_next = false;
1538    for (i, a) in args.iter().enumerate() {
1539        if skip_next {
1540            skip_next = false;
1541            continue;
1542        }
1543        if a == "--" {
1544            // From here on, everything is positional operands; push the rest and break.
1545            for rest in &args[i + 1..] {
1546                out.push(rest);
1547            }
1548            break;
1549        }
1550        if a.starts_with("--") && a.contains('=') {
1551            // --flag=value form: treat as a flag taking a value; skip entirely.
1552            continue;
1553        }
1554        if flags_with_vals.contains(&a.as_str()) {
1555            // This flag consumes the next argument as its value.
1556            if i + 1 < args.len() {
1557                skip_next = true;
1558            }
1559            continue;
1560        }
1561        out.push(a);
1562    }
1563    out
1564}
1565
1566fn first_non_flag_operand(args: &[String], flags_with_vals: &[&str]) -> Option<String> {
1567    positional_operands(args, flags_with_vals)
1568        .into_iter()
1569        .next()
1570        .cloned()
1571}
1572
1573fn single_non_flag_operand(args: &[String], flags_with_vals: &[&str]) -> Option<String> {
1574    let mut operands = positional_operands(args, flags_with_vals).into_iter();
1575    let first = operands.next()?;
1576    if operands.next().is_some() {
1577        return None;
1578    }
1579    Some(first.clone())
1580}
1581
1582fn positional_operands<'a>(args: &'a [String], flags_with_vals: &[&str]) -> Vec<&'a String> {
1583    let mut out = Vec::new();
1584    let mut after_double_dash = false;
1585    let mut skip_next = false;
1586    for (i, arg) in args.iter().enumerate() {
1587        if skip_next {
1588            skip_next = false;
1589            continue;
1590        }
1591        if after_double_dash {
1592            out.push(arg);
1593            continue;
1594        }
1595        if arg == "--" {
1596            after_double_dash = true;
1597            continue;
1598        }
1599        if arg.starts_with("--") && arg.contains('=') {
1600            continue;
1601        }
1602        if flags_with_vals.contains(&arg.as_str()) {
1603            if i + 1 < args.len() {
1604                skip_next = true;
1605            }
1606            continue;
1607        }
1608        if arg.starts_with('-') {
1609            continue;
1610        }
1611        out.push(arg);
1612    }
1613    out
1614}
1615
1616fn parse_grep_like(main_cmd: &[String], args: &[String]) -> ParsedCommand {
1617    let args_no_connector = trim_at_connector(args);
1618    let mut operands = Vec::new();
1619    let mut pattern: Option<String> = None;
1620    let mut after_double_dash = false;
1621    let mut iter = args_no_connector.iter().peekable();
1622    while let Some(arg) = iter.next() {
1623        if after_double_dash {
1624            operands.push(arg);
1625            continue;
1626        }
1627        if arg == "--" {
1628            after_double_dash = true;
1629            continue;
1630        }
1631        match arg.as_str() {
1632            "-e" | "--regexp" => {
1633                if let Some(pat) = iter.next()
1634                    && pattern.is_none()
1635                {
1636                    pattern = Some(pat.clone());
1637                }
1638                continue;
1639            }
1640            "-f" | "--file" => {
1641                if let Some(pat_file) = iter.next()
1642                    && pattern.is_none()
1643                {
1644                    pattern = Some(pat_file.clone());
1645                }
1646                continue;
1647            }
1648            "-m" | "--max-count" | "-C" | "--context" | "-A" | "--after-context" | "-B"
1649            | "--before-context" => {
1650                iter.next();
1651                continue;
1652            }
1653            _ => {}
1654        }
1655        if arg.starts_with('-') {
1656            continue;
1657        }
1658        operands.push(arg);
1659    }
1660    // Do not shorten the query: grep patterns may legitimately contain slashes
1661    // and should be preserved verbatim. Only paths should be shortened.
1662    let has_pattern = pattern.is_some();
1663    let query = pattern.or_else(|| operands.first().cloned().map(String::from));
1664    let path_index = if has_pattern { 0 } else { 1 };
1665    let path = operands.get(path_index).map(|s| short_display_path(s));
1666    ParsedCommand::Search {
1667        cmd: shlex_join(main_cmd),
1668        query,
1669        path,
1670    }
1671}
1672
1673fn awk_data_file_operand(args: &[String]) -> Option<String> {
1674    if args.is_empty() {
1675        return None;
1676    }
1677    let args_no_connector = trim_at_connector(args);
1678    let has_script_file = args_no_connector
1679        .iter()
1680        .any(|arg| arg == "-f" || arg == "--file");
1681    let candidates = skip_flag_values(
1682        &args_no_connector,
1683        &["-F", "-v", "-f", "--field-separator", "--assign", "--file"],
1684    );
1685    let non_flags: Vec<&String> = candidates
1686        .into_iter()
1687        .filter(|arg| !arg.starts_with('-'))
1688        .collect();
1689    if has_script_file {
1690        return non_flags.first().cloned().cloned();
1691    }
1692    if non_flags.len() >= 2 {
1693        return Some(non_flags[1].clone());
1694    }
1695    None
1696}
1697
1698fn python_walks_files(args: &[String]) -> bool {
1699    let args_no_connector = trim_at_connector(args);
1700    let mut iter = args_no_connector.iter();
1701    while let Some(arg) = iter.next() {
1702        if arg == "-c"
1703            && let Some(script) = iter.next()
1704        {
1705            return script.contains("os.walk")
1706                || script.contains("os.listdir")
1707                || script.contains("os.scandir")
1708                || script.contains("glob.glob")
1709                || script.contains("glob.iglob")
1710                || script.contains("pathlib.Path")
1711                || script.contains(".rglob(");
1712        }
1713    }
1714    false
1715}
1716
1717fn is_python_command(cmd: &str) -> bool {
1718    cmd == "python"
1719        || cmd == "python2"
1720        || cmd == "python3"
1721        || cmd.starts_with("python2.")
1722        || cmd.starts_with("python3.")
1723}
1724
1725fn cd_target(args: &[String]) -> Option<String> {
1726    if args.is_empty() {
1727        return None;
1728    }
1729    let mut i = 0;
1730    let mut target: Option<String> = None;
1731    while i < args.len() {
1732        let arg = &args[i];
1733        if arg == "--" {
1734            return args.get(i + 1).cloned();
1735        }
1736        if matches!(arg.as_str(), "-L" | "-P") {
1737            i += 1;
1738            continue;
1739        }
1740        if arg.starts_with('-') {
1741            i += 1;
1742            continue;
1743        }
1744        target = Some(arg.clone());
1745        i += 1;
1746    }
1747    target
1748}
1749
1750fn is_pathish(s: &str) -> bool {
1751    s == "."
1752        || s == ".."
1753        || s.starts_with("./")
1754        || s.starts_with("../")
1755        || s.contains('/')
1756        || s.contains('\\')
1757}
1758
1759fn parse_fd_query_and_path(tail: &[String]) -> (Option<String>, Option<String>) {
1760    let args_no_connector = trim_at_connector(tail);
1761    // fd has several flags that take values (e.g., -t/--type, -e/--extension).
1762    // Skip those values when extracting positional operands.
1763    let candidates = skip_flag_values(
1764        &args_no_connector,
1765        &[
1766            "-t",
1767            "--type",
1768            "-e",
1769            "--extension",
1770            "-E",
1771            "--exclude",
1772            "--search-path",
1773        ],
1774    );
1775    let non_flags: Vec<&String> = candidates
1776        .into_iter()
1777        .filter(|p| !p.starts_with('-'))
1778        .collect();
1779    match non_flags.as_slice() {
1780        [one] => {
1781            if is_pathish(one) {
1782                (None, Some(short_display_path(one)))
1783            } else {
1784                (Some((*one).clone()), None)
1785            }
1786        }
1787        [q, p, ..] => (Some((*q).clone()), Some(short_display_path(p))),
1788        _ => (None, None),
1789    }
1790}
1791
1792fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>) {
1793    let args_no_connector = trim_at_connector(tail);
1794    // First positional argument (excluding common unary operators) is the root path
1795    let mut path: Option<String> = None;
1796    for a in &args_no_connector {
1797        if !a.starts_with('-') && *a != "!" && *a != "(" && *a != ")" {
1798            path = Some(short_display_path(a));
1799            break;
1800        }
1801    }
1802    // Extract a common name/path/regex pattern if present
1803    let mut query: Option<String> = None;
1804    let mut i = 0;
1805    while i < args_no_connector.len() {
1806        let a = &args_no_connector[i];
1807        if a == "-name" || a == "-iname" || a == "-path" || a == "-regex" {
1808            if i + 1 < args_no_connector.len() {
1809                query = Some(args_no_connector[i + 1].clone());
1810            }
1811            break;
1812        }
1813        i += 1;
1814    }
1815    (query, path)
1816}
1817
1818fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
1819    // Only handle bash/zsh here; PowerShell is stripped separately without bash parsing.
1820    let (_, script) = extract_bash_command(original)?;
1821    Some(parse_shell_script(script))
1822}
1823
1824/// Parses command metadata from a Bash-compatible shell script.
1825pub fn parse_shell_script(script: &str) -> Vec<ParsedCommand> {
1826    if let Some(tree) = try_parse_shell(script)
1827        && let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script)
1828        && !all_commands.is_empty()
1829    {
1830        let script_tokens = shlex_split(script).unwrap_or_else(|| vec![script.to_string()]);
1831        // Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
1832        // bias toward the primary command when pipelines are present.
1833        // First, drop obvious small formatting helpers (e.g., wc/awk/etc).
1834        let had_multiple_commands = all_commands.len() > 1;
1835        // Commands arrive in source order; drop formatting helpers while preserving it.
1836        let filtered_commands = drop_small_formatting_commands(all_commands);
1837        if filtered_commands.is_empty() {
1838            return vec![ParsedCommand::Unknown {
1839                cmd: script.to_string(),
1840            }];
1841        }
1842        // Build parsed commands, tracking `cd` segments to compute effective file paths.
1843        let mut commands: Vec<ParsedCommand> = Vec::new();
1844        let mut cwd: Option<String> = None;
1845        for tokens in filtered_commands.into_iter() {
1846            if let Some((head, tail)) = tokens.split_first()
1847                && head == "cd"
1848            {
1849                if let Some(dir) = cd_target(tail) {
1850                    cwd = Some(match &cwd {
1851                        Some(base) => join_paths(base, &dir),
1852                        None => dir.clone(),
1853                    });
1854                }
1855                continue;
1856            }
1857            let parsed = summarize_main_tokens(&tokens);
1858            let parsed = match parsed {
1859                ParsedCommand::Read { cmd, name, path } => {
1860                    if let Some(base) = &cwd {
1861                        let full = join_paths(base, &path.to_string_lossy());
1862                        ParsedCommand::Read {
1863                            cmd,
1864                            name,
1865                            path: PathBuf::from(full),
1866                        }
1867                    } else {
1868                        ParsedCommand::Read { cmd, name, path }
1869                    }
1870                }
1871                other => other,
1872            };
1873            commands.push(parsed);
1874        }
1875
1876        if commands.len() > 1 {
1877            commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
1878            // Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`.
1879            while let Some(next) = simplify_once(&commands) {
1880                commands = next;
1881            }
1882        }
1883        if commands.len() == 1 {
1884            // If we reduced to a single command, attribute the full original script
1885            // for clearer UX in file-reading and listing scenarios, or when there were
1886            // no connectors in the original script. For pipeline commands (e.g.
1887            // `rg --files | sed -n`), keep only the primary command.
1888            let had_connectors = had_multiple_commands
1889                || script_tokens
1890                    .iter()
1891                    .any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
1892            commands = commands
1893                .into_iter()
1894                .map(|pc| match pc {
1895                    ParsedCommand::Read { name, cmd, path } => {
1896                        if had_connectors {
1897                            let has_pipe = script_tokens.iter().any(|t| t == "|");
1898                            let has_sed_n = script_tokens.windows(2).any(|w| {
1899                                w.first().map(String::as_str) == Some("sed")
1900                                    && w.get(1).map(String::as_str) == Some("-n")
1901                            });
1902                            if has_pipe && has_sed_n {
1903                                ParsedCommand::Read {
1904                                    cmd: script.to_string(),
1905                                    name,
1906                                    path,
1907                                }
1908                            } else {
1909                                ParsedCommand::Read { cmd, name, path }
1910                            }
1911                        } else {
1912                            ParsedCommand::Read {
1913                                cmd: shlex_join(&script_tokens),
1914                                name,
1915                                path,
1916                            }
1917                        }
1918                    }
1919                    ParsedCommand::ListFiles { path, cmd, .. } => {
1920                        if had_connectors {
1921                            ParsedCommand::ListFiles { cmd, path }
1922                        } else {
1923                            ParsedCommand::ListFiles {
1924                                cmd: shlex_join(&script_tokens),
1925                                path,
1926                            }
1927                        }
1928                    }
1929                    ParsedCommand::Search {
1930                        query, path, cmd, ..
1931                    } => {
1932                        if had_connectors {
1933                            ParsedCommand::Search { cmd, query, path }
1934                        } else {
1935                            ParsedCommand::Search {
1936                                cmd: shlex_join(&script_tokens),
1937                                query,
1938                                path,
1939                            }
1940                        }
1941                    }
1942                    other => other,
1943                })
1944                .collect();
1945        }
1946        return commands;
1947    }
1948    vec![ParsedCommand::Unknown {
1949        cmd: script.to_string(),
1950    }]
1951}
1952
1953/// Return true if this looks like a small formatting helper in a pipeline.
1954/// Examples: `head -n 40`, `tail -n +10`, `wc -l`, `awk ...`, `cut ...`, `tr ...`.
1955/// We try to keep variants that clearly include a file path (e.g. `tail -n 30 file`).
1956fn is_small_formatting_command(tokens: &[String]) -> bool {
1957    if tokens.is_empty() {
1958        return false;
1959    }
1960    let cmd = tokens[0].as_str();
1961    match cmd {
1962        // Always formatting; typically used in pipes.
1963        // `nl` is special-cased below to allow `nl <file>` to be treated as a read command.
1964        "wc" | "tr" | "cut" | "sort" | "uniq" | "tee" | "column" | "yes" | "printf" => true,
1965        "xargs" => !is_mutating_xargs_command(tokens),
1966        "awk" => awk_data_file_operand(&tokens[1..]).is_none(),
1967        "head" => {
1968            // Treat as formatting when no explicit file operand is present.
1969            // Common forms: `head -n 40`, `head -c 100`.
1970            // Keep cases like `head -n 40 file`.
1971            match tokens {
1972                // `head`
1973                [_] => true,
1974                // `head <file>` or `head -n50`/`head -c100`
1975                [_, arg] => arg.starts_with('-'),
1976                // `head -n 40` / `head -c 100` (no file operand)
1977                [_, flag, count]
1978                    if (flag == "-n" || flag == "-c")
1979                        && count.chars().all(|c| c.is_ascii_digit()) =>
1980                {
1981                    true
1982                }
1983                _ => false,
1984            }
1985        }
1986        "tail" => {
1987            // Treat as formatting when no explicit file operand is present.
1988            // Common forms: `tail -n +10`, `tail -n 30`, `tail -c 100`.
1989            // Keep cases like `tail -n 30 file`.
1990            match tokens {
1991                // `tail`
1992                [_] => true,
1993                // `tail <file>` or `tail -n30`/`tail -n+10`
1994                [_, arg] => arg.starts_with('-'),
1995                // `tail -n 30` / `tail -n +10` (no file operand)
1996                [_, flag, count]
1997                    if flag == "-n"
1998                        && (count.chars().all(|c| c.is_ascii_digit())
1999                            || (count.starts_with('+')
2000                                && count[1..].chars().all(|c| c.is_ascii_digit()))) =>
2001                {
2002                    true
2003                }
2004                // `tail -c 100` / `tail -c +10` (no file operand)
2005                [_, flag, count]
2006                    if flag == "-c"
2007                        && (count.chars().all(|c| c.is_ascii_digit())
2008                            || (count.starts_with('+')
2009                                && count[1..].chars().all(|c| c.is_ascii_digit()))) =>
2010                {
2011                    true
2012                }
2013                _ => false,
2014            }
2015        }
2016        "sed" => {
2017            // Keep `sed -n <range> file` (treated as a file read elsewhere);
2018            // otherwise consider it a formatting helper in a pipeline.
2019            sed_read_path(&tokens[1..]).is_none()
2020        }
2021        _ => false,
2022    }
2023}
2024
2025fn is_mutating_xargs_command(tokens: &[String]) -> bool {
2026    xargs_subcommand(tokens).is_some_and(xargs_is_mutating_subcommand)
2027}
2028
2029fn xargs_subcommand(tokens: &[String]) -> Option<&[String]> {
2030    if tokens.first().map(String::as_str) != Some("xargs") {
2031        return None;
2032    }
2033    let mut i = 1;
2034    while i < tokens.len() {
2035        let token = &tokens[i];
2036        if token == "--" {
2037            return tokens.get(i + 1..).filter(|rest| !rest.is_empty());
2038        }
2039        if !token.starts_with('-') {
2040            return tokens.get(i..).filter(|rest| !rest.is_empty());
2041        }
2042        let takes_value = matches!(
2043            token.as_str(),
2044            "-E" | "-e" | "-I" | "-L" | "-n" | "-P" | "-s"
2045        );
2046        if takes_value && token.len() == 2 {
2047            i += 2;
2048        } else {
2049            i += 1;
2050        }
2051    }
2052    None
2053}
2054
2055fn xargs_is_mutating_subcommand(tokens: &[String]) -> bool {
2056    let Some((head, tail)) = tokens.split_first() else {
2057        return false;
2058    };
2059    match head.as_str() {
2060        "perl" | "ruby" => xargs_has_in_place_flag(tail),
2061        "sed" => xargs_has_in_place_flag(tail) || tail.iter().any(|token| token == "--in-place"),
2062        "rg" => tail.iter().any(|token| token == "--replace"),
2063        _ => false,
2064    }
2065}
2066
2067fn xargs_has_in_place_flag(tokens: &[String]) -> bool {
2068    tokens.iter().any(|token| {
2069        token == "-i" || token.starts_with("-i") || token == "-pi" || token.starts_with("-pi")
2070    })
2071}
2072
2073fn drop_small_formatting_commands(mut commands: Vec<Vec<String>>) -> Vec<Vec<String>> {
2074    commands.retain(|tokens| !is_small_formatting_command(tokens));
2075    commands
2076}
2077
2078fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
2079    match main_cmd.split_first() {
2080        Some((head, tail)) if matches!(head.as_str(), "ls" | "eza" | "exa") => {
2081            let flags_with_vals: &[&str] = match head.as_str() {
2082                "ls" => &[
2083                    "-I",
2084                    "-w",
2085                    "--block-size",
2086                    "--format",
2087                    "--time-style",
2088                    "--color",
2089                    "--quoting-style",
2090                ],
2091                "eza" | "exa" => &[
2092                    "-I",
2093                    "--ignore-glob",
2094                    "--color",
2095                    "--sort",
2096                    "--time-style",
2097                    "--time",
2098                ],
2099                _ => &[],
2100            };
2101            let path =
2102                first_non_flag_operand(tail, flags_with_vals).map(|p| short_display_path(&p));
2103            ParsedCommand::ListFiles {
2104                cmd: shlex_join(main_cmd),
2105                path,
2106            }
2107        }
2108        Some((head, tail)) if head == "tree" => {
2109            let path = first_non_flag_operand(
2110                tail,
2111                &["-L", "-P", "-I", "--charset", "--filelimit", "--sort"],
2112            )
2113            .map(|p| short_display_path(&p));
2114            ParsedCommand::ListFiles {
2115                cmd: shlex_join(main_cmd),
2116                path,
2117            }
2118        }
2119        Some((head, tail)) if head == "du" => {
2120            let path = first_non_flag_operand(
2121                tail,
2122                &[
2123                    "-d",
2124                    "--max-depth",
2125                    "-B",
2126                    "--block-size",
2127                    "--exclude",
2128                    "--time-style",
2129                ],
2130            )
2131            .map(|p| short_display_path(&p));
2132            ParsedCommand::ListFiles {
2133                cmd: shlex_join(main_cmd),
2134                path,
2135            }
2136        }
2137        Some((head, tail)) if head == "rg" || head == "rga" || head == "ripgrep-all" => {
2138            let args_no_connector = trim_at_connector(tail);
2139            let has_files_flag = args_no_connector.iter().any(|a| a == "--files");
2140            let candidates = skip_flag_values(
2141                &args_no_connector,
2142                &[
2143                    "-g",
2144                    "--glob",
2145                    "--iglob",
2146                    "-t",
2147                    "--type",
2148                    "--type-add",
2149                    "--type-not",
2150                    "-m",
2151                    "--max-count",
2152                    "-A",
2153                    "-B",
2154                    "-C",
2155                    "--context",
2156                    "--max-depth",
2157                ],
2158            );
2159            let non_flags: Vec<&String> = candidates
2160                .into_iter()
2161                .filter(|p| !p.starts_with('-'))
2162                .collect();
2163            if has_files_flag {
2164                let path = non_flags.first().map(|s| short_display_path(s));
2165                ParsedCommand::ListFiles {
2166                    cmd: shlex_join(main_cmd),
2167                    path,
2168                }
2169            } else {
2170                let query = non_flags.first().cloned().map(String::from);
2171                let path = non_flags.get(1).map(|s| short_display_path(s));
2172                ParsedCommand::Search {
2173                    cmd: shlex_join(main_cmd),
2174                    query,
2175                    path,
2176                }
2177            }
2178        }
2179        Some((head, tail)) if head == "git" => match tail.split_first() {
2180            Some((subcmd, sub_tail)) if subcmd == "grep" => parse_grep_like(main_cmd, sub_tail),
2181            Some((subcmd, sub_tail)) if subcmd == "ls-files" => {
2182                let path = first_non_flag_operand(
2183                    sub_tail,
2184                    &["--exclude", "--exclude-from", "--pathspec-from-file"],
2185                )
2186                .map(|p| short_display_path(&p));
2187                ParsedCommand::ListFiles {
2188                    cmd: shlex_join(main_cmd),
2189                    path,
2190                }
2191            }
2192            _ => ParsedCommand::Unknown {
2193                cmd: shlex_join(main_cmd),
2194            },
2195        },
2196        Some((head, tail)) if head == "fd" => {
2197            let (query, path) = parse_fd_query_and_path(tail);
2198            if query.is_some() {
2199                ParsedCommand::Search {
2200                    cmd: shlex_join(main_cmd),
2201                    query,
2202                    path,
2203                }
2204            } else {
2205                ParsedCommand::ListFiles {
2206                    cmd: shlex_join(main_cmd),
2207                    path,
2208                }
2209            }
2210        }
2211        Some((head, tail)) if head == "find" => {
2212            // Basic find support: capture path and common name filter
2213            let (query, path) = parse_find_query_and_path(tail);
2214            if query.is_some() {
2215                ParsedCommand::Search {
2216                    cmd: shlex_join(main_cmd),
2217                    query,
2218                    path,
2219                }
2220            } else {
2221                ParsedCommand::ListFiles {
2222                    cmd: shlex_join(main_cmd),
2223                    path,
2224                }
2225            }
2226        }
2227        Some((head, tail)) if matches!(head.as_str(), "grep" | "egrep" | "fgrep") => {
2228            parse_grep_like(main_cmd, tail)
2229        }
2230        Some((head, tail)) if matches!(head.as_str(), "ag" | "ack" | "pt") => {
2231            let args_no_connector = trim_at_connector(tail);
2232            let candidates = skip_flag_values(
2233                &args_no_connector,
2234                &[
2235                    "-G",
2236                    "-g",
2237                    "--file-search-regex",
2238                    "--ignore-dir",
2239                    "--ignore-file",
2240                    "--path-to-ignore",
2241                ],
2242            );
2243            let non_flags: Vec<&String> = candidates
2244                .into_iter()
2245                .filter(|p| !p.starts_with('-'))
2246                .collect();
2247            let query = non_flags.first().cloned().map(String::from);
2248            let path = non_flags.get(1).map(|s| short_display_path(s));
2249            ParsedCommand::Search {
2250                cmd: shlex_join(main_cmd),
2251                query,
2252                path,
2253            }
2254        }
2255        Some((head, tail)) if head == "cat" => {
2256            if let Some(path) = single_non_flag_operand(tail, &[]) {
2257                let name = short_display_path(&path);
2258                ParsedCommand::Read {
2259                    cmd: shlex_join(main_cmd),
2260                    name,
2261                    path: PathBuf::from(path),
2262                }
2263            } else {
2264                ParsedCommand::Unknown {
2265                    cmd: shlex_join(main_cmd),
2266                }
2267            }
2268        }
2269        Some((head, tail)) if matches!(head.as_str(), "bat" | "batcat") => {
2270            if let Some(path) = single_non_flag_operand(
2271                tail,
2272                &[
2273                    "--theme",
2274                    "--language",
2275                    "--style",
2276                    "--terminal-width",
2277                    "--tabs",
2278                    "--line-range",
2279                    "--map-syntax",
2280                ],
2281            ) {
2282                let name = short_display_path(&path);
2283                ParsedCommand::Read {
2284                    cmd: shlex_join(main_cmd),
2285                    name,
2286                    path: PathBuf::from(path),
2287                }
2288            } else {
2289                ParsedCommand::Unknown {
2290                    cmd: shlex_join(main_cmd),
2291                }
2292            }
2293        }
2294        Some((head, tail)) if head == "less" => {
2295            if let Some(path) = single_non_flag_operand(
2296                tail,
2297                &[
2298                    "-p",
2299                    "-P",
2300                    "-x",
2301                    "-y",
2302                    "-z",
2303                    "-j",
2304                    "--pattern",
2305                    "--prompt",
2306                    "--tabs",
2307                    "--shift",
2308                    "--jump-target",
2309                ],
2310            ) {
2311                let name = short_display_path(&path);
2312                ParsedCommand::Read {
2313                    cmd: shlex_join(main_cmd),
2314                    name,
2315                    path: PathBuf::from(path),
2316                }
2317            } else {
2318                ParsedCommand::Unknown {
2319                    cmd: shlex_join(main_cmd),
2320                }
2321            }
2322        }
2323        Some((head, tail)) if head == "more" => {
2324            if let Some(path) = single_non_flag_operand(tail, &[]) {
2325                let name = short_display_path(&path);
2326                ParsedCommand::Read {
2327                    cmd: shlex_join(main_cmd),
2328                    name,
2329                    path: PathBuf::from(path),
2330                }
2331            } else {
2332                ParsedCommand::Unknown {
2333                    cmd: shlex_join(main_cmd),
2334                }
2335            }
2336        }
2337        Some((head, tail)) if head == "head" => {
2338            // Support `head -n 50 file` and `head -n50 file` forms.
2339            let has_valid_n = match tail.split_first() {
2340                Some((first, rest)) if first == "-n" => rest
2341                    .first()
2342                    .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())),
2343                Some((first, _)) if first.starts_with("-n") => {
2344                    first[2..].chars().all(|c| c.is_ascii_digit())
2345                }
2346                _ => false,
2347            };
2348            if has_valid_n {
2349                // Build candidates skipping the numeric value consumed by `-n` when separated.
2350                let mut candidates: Vec<&String> = Vec::new();
2351                let mut i = 0;
2352                while i < tail.len() {
2353                    if i == 0 && tail[i] == "-n" && i + 1 < tail.len() {
2354                        let n = &tail[i + 1];
2355                        if n.chars().all(|c| c.is_ascii_digit()) {
2356                            i += 2;
2357                            continue;
2358                        }
2359                    }
2360                    candidates.push(&tail[i]);
2361                    i += 1;
2362                }
2363                if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
2364                    let path = p.clone();
2365                    let name = short_display_path(&path);
2366                    return ParsedCommand::Read {
2367                        cmd: shlex_join(main_cmd),
2368                        name,
2369                        path: PathBuf::from(path),
2370                    };
2371                }
2372            }
2373            if let [path] = tail
2374                && !path.starts_with('-')
2375            {
2376                let name = short_display_path(path);
2377                return ParsedCommand::Read {
2378                    cmd: shlex_join(main_cmd),
2379                    name,
2380                    path: PathBuf::from(path),
2381                };
2382            }
2383            ParsedCommand::Unknown {
2384                cmd: shlex_join(main_cmd),
2385            }
2386        }
2387        Some((head, tail)) if head == "tail" => {
2388            // Support `tail -n +10 file` and `tail -n+10 file` forms.
2389            let has_valid_n = match tail.split_first() {
2390                Some((first, rest)) if first == "-n" => rest.first().is_some_and(|n| {
2391                    let s = n.strip_prefix('+').unwrap_or(n);
2392                    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
2393                }),
2394                Some((first, _)) if first.starts_with("-n") => {
2395                    let v = &first[2..];
2396                    let s = v.strip_prefix('+').unwrap_or(v);
2397                    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
2398                }
2399                _ => false,
2400            };
2401            if has_valid_n {
2402                // Build candidates skipping the numeric value consumed by `-n` when separated.
2403                let mut candidates: Vec<&String> = Vec::new();
2404                let mut i = 0;
2405                while i < tail.len() {
2406                    if i == 0 && tail[i] == "-n" && i + 1 < tail.len() {
2407                        let n = &tail[i + 1];
2408                        let s = n.strip_prefix('+').unwrap_or(n);
2409                        if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
2410                            i += 2;
2411                            continue;
2412                        }
2413                    }
2414                    candidates.push(&tail[i]);
2415                    i += 1;
2416                }
2417                if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
2418                    let path = p.clone();
2419                    let name = short_display_path(&path);
2420                    return ParsedCommand::Read {
2421                        cmd: shlex_join(main_cmd),
2422                        name,
2423                        path: PathBuf::from(path),
2424                    };
2425                }
2426            }
2427            if let [path] = tail
2428                && !path.starts_with('-')
2429            {
2430                let name = short_display_path(path);
2431                return ParsedCommand::Read {
2432                    cmd: shlex_join(main_cmd),
2433                    name,
2434                    path: PathBuf::from(path),
2435                };
2436            }
2437            ParsedCommand::Unknown {
2438                cmd: shlex_join(main_cmd),
2439            }
2440        }
2441        Some((head, tail)) if head == "awk" => {
2442            if let Some(path) = awk_data_file_operand(tail) {
2443                let name = short_display_path(&path);
2444                ParsedCommand::Read {
2445                    cmd: shlex_join(main_cmd),
2446                    name,
2447                    path: PathBuf::from(path),
2448                }
2449            } else {
2450                ParsedCommand::Unknown {
2451                    cmd: shlex_join(main_cmd),
2452                }
2453            }
2454        }
2455        Some((head, tail)) if head == "nl" => {
2456            // Avoid treating option values as paths (e.g., nl -s "  ").
2457            let candidates = skip_flag_values(tail, &["-s", "-w", "-v", "-i", "-b"]);
2458            if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) {
2459                let path = p.clone();
2460                let name = short_display_path(&path);
2461                ParsedCommand::Read {
2462                    cmd: shlex_join(main_cmd),
2463                    name,
2464                    path: PathBuf::from(path),
2465                }
2466            } else {
2467                ParsedCommand::Unknown {
2468                    cmd: shlex_join(main_cmd),
2469                }
2470            }
2471        }
2472        Some((head, tail)) if head == "sed" => {
2473            if let Some(path) = sed_read_path(tail) {
2474                let name = short_display_path(&path);
2475                ParsedCommand::Read {
2476                    cmd: shlex_join(main_cmd),
2477                    name,
2478                    path: PathBuf::from(path),
2479                }
2480            } else {
2481                ParsedCommand::Unknown {
2482                    cmd: shlex_join(main_cmd),
2483                }
2484            }
2485        }
2486        Some((head, tail)) if is_python_command(head) => {
2487            if python_walks_files(tail) {
2488                ParsedCommand::ListFiles {
2489                    cmd: shlex_join(main_cmd),
2490                    path: None,
2491                }
2492            } else {
2493                ParsedCommand::Unknown {
2494                    cmd: shlex_join(main_cmd),
2495                }
2496            }
2497        }
2498        // Other commands
2499        _ => ParsedCommand::Unknown {
2500            cmd: shlex_join(main_cmd),
2501        },
2502    }
2503}
2504
2505fn is_abs_like(path: &str) -> bool {
2506    if std::path::Path::new(path).is_absolute() {
2507        return true;
2508    }
2509    let mut chars = path.chars();
2510    match (chars.next(), chars.next(), chars.next()) {
2511        // Windows drive path like C:\
2512        (Some(d), Some(':'), Some('\\')) if d.is_ascii_alphabetic() => return true,
2513        // UNC path like \\server\share
2514        (Some('\\'), Some('\\'), _) => return true,
2515        _ => {}
2516    }
2517    false
2518}
2519
2520fn join_paths(base: &str, rel: &str) -> String {
2521    if is_abs_like(rel) {
2522        return rel.to_string();
2523    }
2524    if base.is_empty() {
2525        return rel.to_string();
2526    }
2527    let mut buf = PathBuf::from(base);
2528    buf.push(rel);
2529    buf.to_string_lossy().to_string()
2530}