Skip to main content

with_watch/
analysis.rs

1use std::{ffi::OsString, fs, path::Path};
2
3use crate::{
4    error::Result,
5    parser::{ParsedShellExpression, ShellRedirect, ShellRedirectOperator},
6    snapshot::{absolutize, PathSnapshotMode, WatchInput, WatchInputKind},
7};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10enum ExplicitCommandHandler {
11    EnvWrapper,
12    NiceWrapper,
13    NohupWrapper,
14    StdbufWrapper,
15    TimeoutWrapper,
16    CopyLike,
17    MoveLike,
18    Install,
19    LinkLike,
20    RemoveLike,
21    Sort,
22    Uniq,
23    Split,
24    Csplit,
25    Tee,
26    Grep,
27    Sed,
28    Awk,
29    Find,
30    LsLike,
31    Xargs,
32    Tar,
33    Touch,
34    Truncate,
35    ChangeAttributes,
36    Dd,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum HelpInventoryGroup {
41    Wrapper,
42    DedicatedBuiltIn,
43}
44
45#[derive(Debug, Clone, Copy)]
46struct ExplicitCommandSpec {
47    aliases: &'static [&'static str],
48    handler: ExplicitCommandHandler,
49    help_group: HelpInventoryGroup,
50    safe_current_dir_default: bool,
51}
52
53impl ExplicitCommandSpec {
54    const fn wrapper(aliases: &'static [&'static str], handler: ExplicitCommandHandler) -> Self {
55        Self {
56            aliases,
57            handler,
58            help_group: HelpInventoryGroup::Wrapper,
59            safe_current_dir_default: false,
60        }
61    }
62
63    const fn dedicated(aliases: &'static [&'static str], handler: ExplicitCommandHandler) -> Self {
64        Self {
65            aliases,
66            handler,
67            help_group: HelpInventoryGroup::DedicatedBuiltIn,
68            safe_current_dir_default: false,
69        }
70    }
71
72    const fn dedicated_with_safe_current_dir_default(
73        aliases: &'static [&'static str],
74        handler: ExplicitCommandHandler,
75    ) -> Self {
76        Self {
77            aliases,
78            handler,
79            help_group: HelpInventoryGroup::DedicatedBuiltIn,
80            safe_current_dir_default: true,
81        }
82    }
83}
84
85const EXPLICIT_COMMAND_SPECS: &[ExplicitCommandSpec] = &[
86    ExplicitCommandSpec::wrapper(&["env"], ExplicitCommandHandler::EnvWrapper),
87    ExplicitCommandSpec::wrapper(&["nice"], ExplicitCommandHandler::NiceWrapper),
88    ExplicitCommandSpec::wrapper(&["nohup"], ExplicitCommandHandler::NohupWrapper),
89    ExplicitCommandSpec::wrapper(&["stdbuf"], ExplicitCommandHandler::StdbufWrapper),
90    ExplicitCommandSpec::wrapper(&["timeout"], ExplicitCommandHandler::TimeoutWrapper),
91    ExplicitCommandSpec::dedicated(&["cp"], ExplicitCommandHandler::CopyLike),
92    ExplicitCommandSpec::dedicated(&["mv"], ExplicitCommandHandler::MoveLike),
93    ExplicitCommandSpec::dedicated(&["install"], ExplicitCommandHandler::Install),
94    ExplicitCommandSpec::dedicated(&["ln", "link"], ExplicitCommandHandler::LinkLike),
95    ExplicitCommandSpec::dedicated(
96        &["rm", "unlink", "rmdir", "shred"],
97        ExplicitCommandHandler::RemoveLike,
98    ),
99    ExplicitCommandSpec::dedicated(&["sort"], ExplicitCommandHandler::Sort),
100    ExplicitCommandSpec::dedicated(&["uniq"], ExplicitCommandHandler::Uniq),
101    ExplicitCommandSpec::dedicated(&["split"], ExplicitCommandHandler::Split),
102    ExplicitCommandSpec::dedicated(&["csplit"], ExplicitCommandHandler::Csplit),
103    ExplicitCommandSpec::dedicated(&["tee"], ExplicitCommandHandler::Tee),
104    ExplicitCommandSpec::dedicated(&["grep", "egrep", "fgrep"], ExplicitCommandHandler::Grep),
105    ExplicitCommandSpec::dedicated(&["sed"], ExplicitCommandHandler::Sed),
106    ExplicitCommandSpec::dedicated(
107        &["awk", "gawk", "mawk", "nawk"],
108        ExplicitCommandHandler::Awk,
109    ),
110    ExplicitCommandSpec::dedicated_with_safe_current_dir_default(
111        &["find"],
112        ExplicitCommandHandler::Find,
113    ),
114    ExplicitCommandSpec::dedicated_with_safe_current_dir_default(
115        &["ls", "dir", "vdir"],
116        ExplicitCommandHandler::LsLike,
117    ),
118    ExplicitCommandSpec::dedicated(&["xargs"], ExplicitCommandHandler::Xargs),
119    ExplicitCommandSpec::dedicated(&["tar"], ExplicitCommandHandler::Tar),
120    ExplicitCommandSpec::dedicated(&["touch"], ExplicitCommandHandler::Touch),
121    ExplicitCommandSpec::dedicated(&["truncate"], ExplicitCommandHandler::Truncate),
122    ExplicitCommandSpec::dedicated(
123        &["chmod", "chown", "chgrp"],
124        ExplicitCommandHandler::ChangeAttributes,
125    ),
126    ExplicitCommandSpec::dedicated(&["dd"], ExplicitCommandHandler::Dd),
127];
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum CommandAdapterId {
131    WrapperEnv,
132    WrapperNice,
133    WrapperNohup,
134    WrapperStdbuf,
135    WrapperTimeout,
136    CopyLike,
137    MoveLike,
138    Install,
139    LinkLike,
140    RemoveLike,
141    ReadPaths,
142    DefaultCurrentDir,
143    Grep,
144    Sed,
145    Awk,
146    Find,
147    Xargs,
148    Tar,
149    Sort,
150    Uniq,
151    Split,
152    Csplit,
153    Tee,
154    Touch,
155    Truncate,
156    ChangeAttributes,
157    Dd,
158    NonWatchable,
159    Fallback,
160}
161
162impl CommandAdapterId {
163    pub fn as_str(self) -> &'static str {
164        match self {
165            Self::WrapperEnv => "wrapper-env",
166            Self::WrapperNice => "wrapper-nice",
167            Self::WrapperNohup => "wrapper-nohup",
168            Self::WrapperStdbuf => "wrapper-stdbuf",
169            Self::WrapperTimeout => "wrapper-timeout",
170            Self::CopyLike => "copy-like",
171            Self::MoveLike => "move-like",
172            Self::Install => "install",
173            Self::LinkLike => "link-like",
174            Self::RemoveLike => "remove-like",
175            Self::ReadPaths => "read-paths",
176            Self::DefaultCurrentDir => "default-current-dir",
177            Self::Grep => "grep",
178            Self::Sed => "sed",
179            Self::Awk => "awk",
180            Self::Find => "find",
181            Self::Xargs => "xargs",
182            Self::Tar => "tar",
183            Self::Sort => "sort",
184            Self::Uniq => "uniq",
185            Self::Split => "split",
186            Self::Csplit => "csplit",
187            Self::Tee => "tee",
188            Self::Touch => "touch",
189            Self::Truncate => "truncate",
190            Self::ChangeAttributes => "change-attributes",
191            Self::Dd => "dd",
192            Self::NonWatchable => "non-watchable",
193            Self::Fallback => "fallback",
194        }
195    }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
199pub enum SideEffectProfile {
200    ReadOnly,
201    WritesExcludedOutputs,
202    WritesWatchedInputs,
203}
204
205impl SideEffectProfile {
206    pub fn as_str(self) -> &'static str {
207        match self {
208            Self::ReadOnly => "read-only",
209            Self::WritesExcludedOutputs => "writes-excluded-outputs",
210            Self::WritesWatchedInputs => "writes-watched-inputs",
211        }
212    }
213
214    fn merge(self, other: Self) -> Self {
215        self.max(other)
216    }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum CommandAnalysisStatus {
221    Resolved,
222    NoInputs,
223    AmbiguousFallback,
224}
225
226impl CommandAnalysisStatus {
227    pub fn as_str(self) -> &'static str {
228        match self {
229            Self::Resolved => "resolved",
230            Self::NoInputs => "no-inputs",
231            Self::AmbiguousFallback => "ambiguous-fallback",
232        }
233    }
234}
235
236#[derive(Debug, Clone)]
237pub struct CommandAnalysis {
238    pub inputs: Vec<WatchInput>,
239    pub adapter_ids: Vec<CommandAdapterId>,
240    pub fallback_used: bool,
241    pub default_watch_root_used: bool,
242    pub filtered_output_count: usize,
243    pub side_effect_profile: SideEffectProfile,
244    pub status: CommandAnalysisStatus,
245}
246
247impl CommandAnalysis {
248    pub fn adapter_field(&self) -> String {
249        self.adapter_ids
250            .iter()
251            .map(|adapter| adapter.as_str())
252            .collect::<Vec<_>>()
253            .join(",")
254    }
255}
256
257#[derive(Debug, Clone)]
258struct SingleCommandAnalysis {
259    inputs: Vec<WatchInput>,
260    adapter_ids: Vec<CommandAdapterId>,
261    fallback_used: bool,
262    default_watch_root_used: bool,
263    filtered_output_count: usize,
264    side_effect_profile: SideEffectProfile,
265    status: CommandAnalysisStatus,
266}
267
268impl SingleCommandAnalysis {
269    fn empty(adapter_id: CommandAdapterId) -> Self {
270        Self {
271            inputs: Vec::new(),
272            adapter_ids: vec![adapter_id],
273            fallback_used: adapter_id == CommandAdapterId::Fallback,
274            default_watch_root_used: false,
275            filtered_output_count: 0,
276            side_effect_profile: SideEffectProfile::ReadOnly,
277            status: CommandAnalysisStatus::NoInputs,
278        }
279    }
280
281    fn finalize(mut self) -> Self {
282        if self.status != CommandAnalysisStatus::AmbiguousFallback {
283            self.status = if self.inputs.is_empty() {
284                CommandAnalysisStatus::NoInputs
285            } else {
286                CommandAnalysisStatus::Resolved
287            };
288        }
289        self
290    }
291}
292
293pub fn analyze_argv(argv: &[OsString], cwd: &Path) -> Result<CommandAnalysis> {
294    let argv = argv
295        .iter()
296        .map(|value| value.to_string_lossy().into_owned())
297        .collect::<Vec<_>>();
298
299    let analysis = if argv.is_empty() {
300        SingleCommandAnalysis::empty(CommandAdapterId::NonWatchable)
301    } else {
302        analyze_command_tokens(&argv, &[], cwd)?
303    };
304
305    Ok(aggregate_analyses([analysis]))
306}
307
308pub fn analyze_shell_expression(
309    expression: &ParsedShellExpression,
310    cwd: &Path,
311) -> Result<CommandAnalysis> {
312    let mut analyses = Vec::new();
313
314    for command in &expression.commands {
315        analyses.push(analyze_command_tokens(
316            &command.argv,
317            &command.redirects,
318            cwd,
319        )?);
320    }
321
322    Ok(aggregate_analyses(analyses))
323}
324
325fn aggregate_analyses<I>(analyses: I) -> CommandAnalysis
326where
327    I: IntoIterator<Item = SingleCommandAnalysis>,
328{
329    let mut inputs = Vec::new();
330    let mut adapter_ids = Vec::new();
331    let mut fallback_used = false;
332    let mut default_watch_root_used = false;
333    let mut filtered_output_count = 0usize;
334    let mut side_effect_profile = SideEffectProfile::ReadOnly;
335    let mut status = CommandAnalysisStatus::NoInputs;
336
337    for analysis in analyses {
338        for input in analysis.inputs {
339            if !inputs.contains(&input) {
340                inputs.push(input);
341            }
342        }
343        for adapter_id in analysis.adapter_ids {
344            if !adapter_ids.contains(&adapter_id) {
345                adapter_ids.push(adapter_id);
346            }
347        }
348        fallback_used |= analysis.fallback_used;
349        default_watch_root_used |= analysis.default_watch_root_used;
350        filtered_output_count += analysis.filtered_output_count;
351        side_effect_profile = side_effect_profile.merge(analysis.side_effect_profile);
352        if analysis.status == CommandAnalysisStatus::AmbiguousFallback {
353            status = CommandAnalysisStatus::AmbiguousFallback;
354        } else if analysis.status == CommandAnalysisStatus::Resolved
355            && status != CommandAnalysisStatus::AmbiguousFallback
356        {
357            status = CommandAnalysisStatus::Resolved;
358        }
359    }
360
361    if status != CommandAnalysisStatus::AmbiguousFallback && inputs.is_empty() {
362        status = CommandAnalysisStatus::NoInputs;
363    }
364
365    CommandAnalysis {
366        inputs,
367        adapter_ids,
368        fallback_used,
369        default_watch_root_used,
370        filtered_output_count,
371        side_effect_profile,
372        status,
373    }
374}
375
376fn analyze_command_tokens(
377    argv: &[String],
378    redirects: &[ShellRedirect],
379    cwd: &Path,
380) -> Result<SingleCommandAnalysis> {
381    if argv.is_empty() {
382        return Ok(SingleCommandAnalysis::empty(CommandAdapterId::NonWatchable));
383    }
384
385    let command_name = command_name(argv[0].as_str());
386    let mut analysis = if let Some(handler) = explicit_command_handler(command_name.as_str()) {
387        analyze_explicit_command(handler, argv, redirects, cwd)?
388    } else {
389        match command_name.as_str() {
390            name if DEFAULT_CURRENT_DIR_COMMANDS.contains(&name) => {
391                analyze_default_current_dir_reader(argv, redirects, cwd)?
392            }
393            name if NONWATCHABLE_COMMANDS.contains(&name) => {
394                analyze_non_watchable(argv, redirects, cwd)?
395            }
396            name if GENERIC_READ_PATH_COMMANDS.contains(&name) => {
397                analyze_generic_read_paths(argv, redirects, cwd)?
398            }
399            _ => analyze_fallback(argv, redirects, cwd)?,
400        }
401    };
402
403    if analysis.adapter_ids.is_empty() {
404        analysis.adapter_ids.push(CommandAdapterId::Fallback);
405    }
406
407    Ok(analysis.finalize())
408}
409
410const DEFAULT_CURRENT_DIR_COMMANDS: &[&str] = &["du"];
411const NONWATCHABLE_COMMANDS: &[&str] = &[
412    "echo", "printf", "seq", "yes", "sleep", "date", "uname", "pwd", "true", "false", "basename",
413    "dirname", "nproc", "printenv", "whoami", "logname", "users", "hostid", "numfmt", "mktemp",
414    "mkdir", "mkfifo", "mknod",
415];
416const GENERIC_READ_PATH_COMMANDS: &[&str] = &[
417    "cat",
418    "tac",
419    "head",
420    "tail",
421    "wc",
422    "nl",
423    "od",
424    "cut",
425    "fmt",
426    "fold",
427    "paste",
428    "pr",
429    "tr",
430    "expand",
431    "unexpand",
432    "stat",
433    "readlink",
434    "realpath",
435    "md5sum",
436    "b2sum",
437    "cksum",
438    "sum",
439    "sha1sum",
440    "sha224sum",
441    "sha256sum",
442    "sha384sum",
443    "sha512sum",
444    "sha512_224sum",
445    "sha512_256sum",
446    "base32",
447    "base64",
448    "basenc",
449    "comm",
450    "join",
451    "cmp",
452    "tsort",
453    "shuf",
454];
455
456struct HelpInventory {
457    wrapper_commands: Vec<&'static str>,
458    dedicated_built_ins: Vec<&'static str>,
459    generic_read_path_commands: &'static [&'static str],
460    safe_current_dir_defaults: Vec<&'static str>,
461    non_watchable_commands: &'static [&'static str],
462}
463
464fn help_inventory() -> HelpInventory {
465    let mut wrapper_commands = Vec::new();
466    let mut dedicated_built_ins = Vec::new();
467    let mut safe_current_dir_defaults = Vec::new();
468
469    for spec in EXPLICIT_COMMAND_SPECS {
470        match spec.help_group {
471            HelpInventoryGroup::Wrapper => wrapper_commands.extend_from_slice(spec.aliases),
472            HelpInventoryGroup::DedicatedBuiltIn => {
473                dedicated_built_ins.extend_from_slice(spec.aliases)
474            }
475        }
476
477        if spec.safe_current_dir_default {
478            safe_current_dir_defaults.extend_from_slice(spec.aliases);
479        }
480    }
481
482    safe_current_dir_defaults.extend_from_slice(DEFAULT_CURRENT_DIR_COMMANDS);
483
484    HelpInventory {
485        wrapper_commands,
486        dedicated_built_ins,
487        generic_read_path_commands: GENERIC_READ_PATH_COMMANDS,
488        safe_current_dir_defaults,
489        non_watchable_commands: NONWATCHABLE_COMMANDS,
490    }
491}
492
493pub fn render_after_long_help() -> String {
494    let inventory = help_inventory();
495
496    format!(
497        "Command modes:\n  Passthrough: with-watch [--no-hash] [--clear] <utility> [args...]\n  \
498         Shell: with-watch [--no-hash] [--clear] --shell '<expr>'\n  Explicit inputs: with-watch \
499         exec [--no-hash] [--clear] --input <glob>... -- <command> [args...]\n\nWrapper \
500         commands:\n  {}\n\nDedicated built-in adapters and aliases:\n  {}\n\nGeneric read-path \
501         commands:\n  {}\n\nSafe current-directory defaults:\n  {}\n\nRecognized but not \
502         auto-watchable commands:\n  {}\n  These commands are recognized, but they do not expose \
503         stable filesystem inputs on their own.\n\nexec --input escape hatch:\n  Use `with-watch \
504         exec --input <glob>... -- <command> [args...]` when inference is ambiguous, when a \
505         command has no stable filesystem inputs, or when you want an explicit watch set.",
506        join_command_names(&inventory.wrapper_commands),
507        join_command_names(&inventory.dedicated_built_ins),
508        join_command_names(inventory.generic_read_path_commands),
509        join_command_names(&inventory.safe_current_dir_defaults),
510        join_command_names(inventory.non_watchable_commands),
511    )
512}
513
514fn join_command_names(commands: &[&str]) -> String {
515    commands.join(", ")
516}
517
518fn explicit_command_handler(command_name: &str) -> Option<ExplicitCommandHandler> {
519    EXPLICIT_COMMAND_SPECS
520        .iter()
521        .find(|spec| spec.aliases.contains(&command_name))
522        .map(|spec| spec.handler)
523}
524
525fn analyze_explicit_command(
526    handler: ExplicitCommandHandler,
527    argv: &[String],
528    redirects: &[ShellRedirect],
529    cwd: &Path,
530) -> Result<SingleCommandAnalysis> {
531    match handler {
532        ExplicitCommandHandler::EnvWrapper => analyze_env_wrapper(argv, redirects, cwd),
533        ExplicitCommandHandler::NiceWrapper => analyze_nice_wrapper(argv, redirects, cwd),
534        ExplicitCommandHandler::NohupWrapper => analyze_nohup_wrapper(argv, redirects, cwd),
535        ExplicitCommandHandler::StdbufWrapper => analyze_stdbuf_wrapper(argv, redirects, cwd),
536        ExplicitCommandHandler::TimeoutWrapper => analyze_timeout_wrapper(argv, redirects, cwd),
537        ExplicitCommandHandler::CopyLike => analyze_copy_like(
538            argv,
539            CommandAdapterId::CopyLike,
540            SideEffectProfile::WritesExcludedOutputs,
541            redirects,
542            cwd,
543        ),
544        ExplicitCommandHandler::MoveLike => analyze_copy_like(
545            argv,
546            CommandAdapterId::MoveLike,
547            SideEffectProfile::WritesWatchedInputs,
548            redirects,
549            cwd,
550        ),
551        ExplicitCommandHandler::Install => analyze_install(argv, redirects, cwd),
552        ExplicitCommandHandler::LinkLike => analyze_link_like(argv, redirects, cwd),
553        ExplicitCommandHandler::RemoveLike => analyze_remove_like(argv, redirects, cwd),
554        ExplicitCommandHandler::Sort => analyze_sort(argv, redirects, cwd),
555        ExplicitCommandHandler::Uniq => analyze_uniq(argv, redirects, cwd),
556        ExplicitCommandHandler::Split => analyze_split(argv, redirects, cwd),
557        ExplicitCommandHandler::Csplit => analyze_csplit(argv, redirects, cwd),
558        ExplicitCommandHandler::Tee => analyze_tee(argv, redirects, cwd),
559        ExplicitCommandHandler::Grep => analyze_grep(argv, redirects, cwd),
560        ExplicitCommandHandler::Sed => analyze_sed(argv, redirects, cwd),
561        ExplicitCommandHandler::Awk => analyze_awk(argv, redirects, cwd),
562        ExplicitCommandHandler::Find => analyze_find(argv, redirects, cwd),
563        ExplicitCommandHandler::LsLike => analyze_ls_like(argv, redirects, cwd),
564        ExplicitCommandHandler::Xargs => analyze_xargs(argv, redirects, cwd),
565        ExplicitCommandHandler::Tar => analyze_tar(argv, redirects, cwd),
566        ExplicitCommandHandler::Touch => {
567            analyze_touch_like(argv, CommandAdapterId::Touch, redirects, cwd)
568        }
569        ExplicitCommandHandler::Truncate => {
570            analyze_touch_like(argv, CommandAdapterId::Truncate, redirects, cwd)
571        }
572        ExplicitCommandHandler::ChangeAttributes => analyze_change_attributes(argv, redirects, cwd),
573        ExplicitCommandHandler::Dd => analyze_dd(argv, redirects, cwd),
574    }
575}
576
577fn command_name(program: &str) -> String {
578    Path::new(program)
579        .file_name()
580        .unwrap_or_else(|| program.as_ref())
581        .to_string_lossy()
582        .to_ascii_lowercase()
583}
584
585fn analyze_env_wrapper(
586    argv: &[String],
587    redirects: &[ShellRedirect],
588    cwd: &Path,
589) -> Result<SingleCommandAnalysis> {
590    let mut index = 1usize;
591
592    while index < argv.len() {
593        let token = argv[index].as_str();
594        if token == "--" || token == "-" {
595            index += 1;
596            break;
597        }
598        if token == "-u" || token == "--unset" || token == "-C" || token == "--chdir" {
599            index += 2;
600            continue;
601        }
602        if token == "-S"
603            || token == "--split-string"
604            || token.starts_with("--unset=")
605            || token.starts_with("--chdir=")
606            || token.starts_with("--split-string=")
607            || token == "-i"
608            || token == "--ignore-environment"
609        {
610            index += 1;
611            continue;
612        }
613        if token.contains('=') && !token.starts_with('=') {
614            index += 1;
615            continue;
616        }
617        break;
618    }
619
620    wrap_analysis(CommandAdapterId::WrapperEnv, &argv[index..], redirects, cwd)
621}
622
623fn analyze_nice_wrapper(
624    argv: &[String],
625    redirects: &[ShellRedirect],
626    cwd: &Path,
627) -> Result<SingleCommandAnalysis> {
628    let mut index = 1usize;
629
630    while index < argv.len() {
631        let token = argv[index].as_str();
632        if token == "--" {
633            index += 1;
634            break;
635        }
636        if token == "-n" || token == "--adjustment" {
637            index += 2;
638            continue;
639        }
640        if token.starts_with("--adjustment=")
641            || token == "--help"
642            || token == "--version"
643            || is_signed_integer(token)
644        {
645            index += 1;
646            continue;
647        }
648        break;
649    }
650
651    wrap_analysis(
652        CommandAdapterId::WrapperNice,
653        &argv[index..],
654        redirects,
655        cwd,
656    )
657}
658
659fn analyze_nohup_wrapper(
660    argv: &[String],
661    redirects: &[ShellRedirect],
662    cwd: &Path,
663) -> Result<SingleCommandAnalysis> {
664    let mut index = 1usize;
665    if index < argv.len() && argv[index] == "--" {
666        index += 1;
667    }
668    wrap_analysis(
669        CommandAdapterId::WrapperNohup,
670        &argv[index..],
671        redirects,
672        cwd,
673    )
674}
675
676fn analyze_stdbuf_wrapper(
677    argv: &[String],
678    redirects: &[ShellRedirect],
679    cwd: &Path,
680) -> Result<SingleCommandAnalysis> {
681    let mut index = 1usize;
682
683    while index < argv.len() {
684        let token = argv[index].as_str();
685        if token == "--" {
686            index += 1;
687            break;
688        }
689        if token == "-i" || token == "-o" || token == "-e" {
690            index += 2;
691            continue;
692        }
693        if token.starts_with("--input=")
694            || token.starts_with("--output=")
695            || token.starts_with("--error=")
696        {
697            index += 1;
698            continue;
699        }
700        if token == "--input" || token == "--output" || token == "--error" {
701            index += 2;
702            continue;
703        }
704        break;
705    }
706
707    wrap_analysis(
708        CommandAdapterId::WrapperStdbuf,
709        &argv[index..],
710        redirects,
711        cwd,
712    )
713}
714
715fn analyze_timeout_wrapper(
716    argv: &[String],
717    redirects: &[ShellRedirect],
718    cwd: &Path,
719) -> Result<SingleCommandAnalysis> {
720    let mut index = 1usize;
721
722    while index < argv.len() {
723        let token = argv[index].as_str();
724        if token == "--" {
725            index += 1;
726            break;
727        }
728        if token == "-s" || token == "--signal" || token == "-k" || token == "--kill-after" {
729            index += 2;
730            continue;
731        }
732        if token.starts_with("--signal=")
733            || token.starts_with("--kill-after=")
734            || token == "--foreground"
735            || token == "--preserve-status"
736            || token == "--verbose"
737        {
738            index += 1;
739            continue;
740        }
741        break;
742    }
743
744    if index < argv.len() {
745        index += 1;
746    }
747
748    wrap_analysis(
749        CommandAdapterId::WrapperTimeout,
750        &argv[index..],
751        redirects,
752        cwd,
753    )
754}
755
756fn wrap_analysis(
757    wrapper_id: CommandAdapterId,
758    inner_argv: &[String],
759    redirects: &[ShellRedirect],
760    cwd: &Path,
761) -> Result<SingleCommandAnalysis> {
762    let mut analysis = if inner_argv.is_empty() {
763        SingleCommandAnalysis::empty(wrapper_id)
764    } else {
765        analyze_command_tokens(inner_argv, redirects, cwd)?
766    };
767
768    if !analysis.adapter_ids.contains(&wrapper_id) {
769        analysis.adapter_ids.insert(0, wrapper_id);
770    }
771    Ok(analysis)
772}
773
774fn analyze_copy_like(
775    argv: &[String],
776    adapter_id: CommandAdapterId,
777    side_effect_profile: SideEffectProfile,
778    redirects: &[ShellRedirect],
779    cwd: &Path,
780) -> Result<SingleCommandAnalysis> {
781    let mut inputs = Vec::new();
782    let mut filtered_output_count = 0usize;
783    let mut target_directory = None::<String>;
784    let mut operands = Vec::new();
785    let mut positional_only = false;
786    let mut index = 1usize;
787
788    while index < argv.len() {
789        let token = argv[index].as_str();
790        if !positional_only && token == "--" {
791            positional_only = true;
792            index += 1;
793            continue;
794        }
795
796        if !positional_only {
797            if token == "-t" || token == "--target-directory" {
798                if let Some(value) = argv.get(index + 1) {
799                    target_directory = Some(value.clone());
800                }
801                index += 2;
802                continue;
803            }
804            if let Some(value) = token.strip_prefix("--target-directory=") {
805                target_directory = Some(value.to_string());
806                index += 1;
807                continue;
808            }
809            if token.starts_with('-') {
810                index += 1;
811                continue;
812            }
813        }
814
815        operands.push(argv[index].clone());
816        index += 1;
817    }
818
819    if target_directory.is_some() {
820        filtered_output_count += 1;
821        for operand in operands {
822            push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
823        }
824        let mut analysis = SingleCommandAnalysis {
825            inputs,
826            adapter_ids: vec![adapter_id],
827            fallback_used: false,
828            default_watch_root_used: false,
829            filtered_output_count,
830            side_effect_profile,
831            status: CommandAnalysisStatus::NoInputs,
832        };
833        apply_redirects(&mut analysis, redirects, cwd)?;
834        return Ok(analysis);
835    }
836
837    let split_index = operands.len().saturating_sub(1);
838    for operand in &operands[..split_index] {
839        push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
840    }
841    if operands.len() >= 2 {
842        filtered_output_count += 1;
843    } else if let Some(operand) = operands.first() {
844        push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
845    }
846
847    let mut analysis = SingleCommandAnalysis {
848        inputs,
849        adapter_ids: vec![adapter_id],
850        fallback_used: false,
851        default_watch_root_used: false,
852        filtered_output_count,
853        side_effect_profile,
854        status: CommandAnalysisStatus::NoInputs,
855    };
856    apply_redirects(&mut analysis, redirects, cwd)?;
857    Ok(analysis)
858}
859
860fn analyze_install(
861    argv: &[String],
862    redirects: &[ShellRedirect],
863    cwd: &Path,
864) -> Result<SingleCommandAnalysis> {
865    let mut inputs = Vec::new();
866    let mut filtered_output_count = 0usize;
867    let mut target_directory = None::<String>;
868    let mut compare_reference = None::<String>;
869    let mut operands = Vec::new();
870    let mut positional_only = false;
871    let mut index = 1usize;
872
873    while index < argv.len() {
874        let token = argv[index].as_str();
875        if !positional_only && token == "--" {
876            positional_only = true;
877            index += 1;
878            continue;
879        }
880
881        if !positional_only {
882            if token == "-t" || token == "--target-directory" {
883                if let Some(value) = argv.get(index + 1) {
884                    target_directory = Some(value.clone());
885                }
886                index += 2;
887                continue;
888            }
889            if token == "-C" || token == "--compare" {
890                index += 1;
891                continue;
892            }
893            if token == "--compare-with" {
894                if let Some(value) = argv.get(index + 1) {
895                    compare_reference = Some(value.clone());
896                }
897                index += 2;
898                continue;
899            }
900            if let Some(value) = token.strip_prefix("--target-directory=") {
901                target_directory = Some(value.to_string());
902                index += 1;
903                continue;
904            }
905            if let Some(value) = token.strip_prefix("--compare-with=") {
906                compare_reference = Some(value.to_string());
907                index += 1;
908                continue;
909            }
910            if token.starts_with('-') {
911                index += 1;
912                continue;
913            }
914        }
915
916        operands.push(argv[index].clone());
917        index += 1;
918    }
919
920    if let Some(compare_reference) = compare_reference {
921        push_inferred_input(&mut inputs, compare_reference.as_str(), cwd)?;
922    }
923
924    if let Some(_target_directory) = target_directory {
925        filtered_output_count += 1;
926        for operand in operands {
927            push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
928        }
929    } else {
930        let split_index = operands.len().saturating_sub(1);
931        for operand in &operands[..split_index] {
932            push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
933        }
934        if operands.len() >= 2 {
935            filtered_output_count += 1;
936        } else if let Some(operand) = operands.first() {
937            push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
938        }
939    }
940
941    let mut analysis = SingleCommandAnalysis {
942        inputs,
943        adapter_ids: vec![CommandAdapterId::Install],
944        fallback_used: false,
945        default_watch_root_used: false,
946        filtered_output_count,
947        side_effect_profile: SideEffectProfile::WritesExcludedOutputs,
948        status: CommandAnalysisStatus::NoInputs,
949    };
950    apply_redirects(&mut analysis, redirects, cwd)?;
951    Ok(analysis)
952}
953
954fn analyze_link_like(
955    argv: &[String],
956    redirects: &[ShellRedirect],
957    cwd: &Path,
958) -> Result<SingleCommandAnalysis> {
959    analyze_copy_like(
960        argv,
961        CommandAdapterId::LinkLike,
962        SideEffectProfile::WritesExcludedOutputs,
963        redirects,
964        cwd,
965    )
966}
967
968fn analyze_remove_like(
969    argv: &[String],
970    redirects: &[ShellRedirect],
971    cwd: &Path,
972) -> Result<SingleCommandAnalysis> {
973    let mut inputs = Vec::new();
974    let mut positional_only = false;
975    let mut index = 1usize;
976
977    while index < argv.len() {
978        let token = argv[index].as_str();
979        if !positional_only && token == "--" {
980            positional_only = true;
981            index += 1;
982            continue;
983        }
984        if !positional_only && token.starts_with('-') {
985            index += 1;
986            continue;
987        }
988        push_inferred_input(&mut inputs, token, cwd)?;
989        index += 1;
990    }
991
992    let mut analysis = SingleCommandAnalysis {
993        inputs,
994        adapter_ids: vec![CommandAdapterId::RemoveLike],
995        fallback_used: false,
996        default_watch_root_used: false,
997        filtered_output_count: 0,
998        side_effect_profile: SideEffectProfile::WritesWatchedInputs,
999        status: CommandAnalysisStatus::NoInputs,
1000    };
1001    apply_redirects(&mut analysis, redirects, cwd)?;
1002    Ok(analysis)
1003}
1004
1005fn analyze_sort(
1006    argv: &[String],
1007    redirects: &[ShellRedirect],
1008    cwd: &Path,
1009) -> Result<SingleCommandAnalysis> {
1010    let mut inputs = Vec::new();
1011    let mut filtered_output_count = 0usize;
1012    let mut operands = Vec::new();
1013    let mut positional_only = false;
1014    let mut index = 1usize;
1015
1016    while index < argv.len() {
1017        let token = argv[index].as_str();
1018        if !positional_only && token == "--" {
1019            positional_only = true;
1020            index += 1;
1021            continue;
1022        }
1023        if !positional_only {
1024            if token == "-o" || token == "--output" {
1025                if argv.get(index + 1).is_some() {
1026                    filtered_output_count += 1;
1027                }
1028                index += 2;
1029                continue;
1030            }
1031            if token == "--files0-from" {
1032                if let Some(value) = argv.get(index + 1) {
1033                    push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1034                }
1035                index += 2;
1036                continue;
1037            }
1038            if token.starts_with("-o") && token.len() > 2 {
1039                filtered_output_count += 1;
1040                index += 1;
1041                continue;
1042            }
1043            if let Some(value) = token.strip_prefix("--output=") {
1044                if !value.is_empty() {
1045                    filtered_output_count += 1;
1046                }
1047                index += 1;
1048                continue;
1049            }
1050            if let Some(value) = token.strip_prefix("--files0-from=") {
1051                push_inferred_input(&mut inputs, value, cwd)?;
1052                index += 1;
1053                continue;
1054            }
1055            if token.starts_with('-') {
1056                index += 1;
1057                continue;
1058            }
1059        }
1060
1061        operands.push(argv[index].clone());
1062        index += 1;
1063    }
1064
1065    for operand in operands {
1066        if operand != "-" {
1067            push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
1068        }
1069    }
1070
1071    let mut analysis = SingleCommandAnalysis {
1072        inputs,
1073        adapter_ids: vec![CommandAdapterId::Sort],
1074        fallback_used: false,
1075        default_watch_root_used: false,
1076        filtered_output_count,
1077        side_effect_profile: if filtered_output_count > 0 {
1078            SideEffectProfile::WritesExcludedOutputs
1079        } else {
1080            SideEffectProfile::ReadOnly
1081        },
1082        status: CommandAnalysisStatus::NoInputs,
1083    };
1084    apply_redirects(&mut analysis, redirects, cwd)?;
1085    Ok(analysis)
1086}
1087
1088fn analyze_uniq(
1089    argv: &[String],
1090    redirects: &[ShellRedirect],
1091    cwd: &Path,
1092) -> Result<SingleCommandAnalysis> {
1093    let mut inputs = Vec::new();
1094    let mut filtered_output_count = 0usize;
1095    let mut operands = Vec::new();
1096    let mut positional_only = false;
1097    let mut index = 1usize;
1098
1099    while index < argv.len() {
1100        let token = argv[index].as_str();
1101        if !positional_only && token == "--" {
1102            positional_only = true;
1103            index += 1;
1104            continue;
1105        }
1106        if !positional_only && token.starts_with('-') {
1107            index += 1;
1108            continue;
1109        }
1110        operands.push(argv[index].clone());
1111        index += 1;
1112    }
1113
1114    if let Some(input) = operands.first() {
1115        if input != "-" {
1116            push_inferred_input(&mut inputs, input.as_str(), cwd)?;
1117        }
1118    }
1119    if operands.len() >= 2 {
1120        filtered_output_count += 1;
1121    }
1122
1123    let mut analysis = SingleCommandAnalysis {
1124        inputs,
1125        adapter_ids: vec![CommandAdapterId::Uniq],
1126        fallback_used: false,
1127        default_watch_root_used: false,
1128        filtered_output_count,
1129        side_effect_profile: if filtered_output_count > 0 {
1130            SideEffectProfile::WritesExcludedOutputs
1131        } else {
1132            SideEffectProfile::ReadOnly
1133        },
1134        status: CommandAnalysisStatus::NoInputs,
1135    };
1136    apply_redirects(&mut analysis, redirects, cwd)?;
1137    Ok(analysis)
1138}
1139
1140fn analyze_split(
1141    argv: &[String],
1142    redirects: &[ShellRedirect],
1143    cwd: &Path,
1144) -> Result<SingleCommandAnalysis> {
1145    let mut inputs = Vec::new();
1146    let mut filtered_output_count = 0usize;
1147    let mut operands = Vec::new();
1148    let mut positional_only = false;
1149    let mut index = 1usize;
1150
1151    while index < argv.len() {
1152        let token = argv[index].as_str();
1153        if !positional_only && token == "--" {
1154            positional_only = true;
1155            index += 1;
1156            continue;
1157        }
1158        if !positional_only {
1159            if token == "--filter"
1160                || token == "--separator"
1161                || token == "--additional-suffix"
1162                || token == "--number"
1163            {
1164                index += 2;
1165                continue;
1166            }
1167            if token == "-n"
1168                || token == "-a"
1169                || token == "-b"
1170                || token == "-C"
1171                || token == "-l"
1172                || token == "-t"
1173            {
1174                index += 2;
1175                continue;
1176            }
1177            if token.starts_with("--filter=")
1178                || token.starts_with("--separator=")
1179                || token.starts_with("--additional-suffix=")
1180                || token.starts_with("--number=")
1181                || token.starts_with("-n")
1182                || token.starts_with("-a")
1183                || token.starts_with("-b")
1184                || token.starts_with("-C")
1185                || token.starts_with("-l")
1186                || token.starts_with("-t")
1187            {
1188                index += 1;
1189                continue;
1190            }
1191            if token.starts_with('-') {
1192                index += 1;
1193                continue;
1194            }
1195        }
1196
1197        operands.push(argv[index].clone());
1198        index += 1;
1199    }
1200
1201    if let Some(input) = operands.first() {
1202        if input != "-" {
1203            push_inferred_input(&mut inputs, input.as_str(), cwd)?;
1204        }
1205    }
1206    if operands.len() >= 2 {
1207        filtered_output_count += 1;
1208    }
1209
1210    let mut analysis = SingleCommandAnalysis {
1211        inputs,
1212        adapter_ids: vec![CommandAdapterId::Split],
1213        fallback_used: false,
1214        default_watch_root_used: false,
1215        filtered_output_count,
1216        side_effect_profile: SideEffectProfile::WritesExcludedOutputs,
1217        status: CommandAnalysisStatus::NoInputs,
1218    };
1219    apply_redirects(&mut analysis, redirects, cwd)?;
1220    Ok(analysis)
1221}
1222
1223fn analyze_csplit(
1224    argv: &[String],
1225    redirects: &[ShellRedirect],
1226    cwd: &Path,
1227) -> Result<SingleCommandAnalysis> {
1228    let mut inputs = Vec::new();
1229    let mut filtered_output_count = 0usize;
1230    let mut first_operand = None::<String>;
1231    let mut positional_only = false;
1232    let mut index = 1usize;
1233
1234    while index < argv.len() {
1235        let token = argv[index].as_str();
1236        if !positional_only && token == "--" {
1237            positional_only = true;
1238            index += 1;
1239            continue;
1240        }
1241        if !positional_only {
1242            if token == "-f" || token == "--prefix" || token == "-b" || token == "--suffix-format" {
1243                if token == "-f" || token == "--prefix" {
1244                    filtered_output_count += 1;
1245                }
1246                index += 2;
1247                continue;
1248            }
1249            if token.starts_with("--prefix=") {
1250                filtered_output_count += 1;
1251                index += 1;
1252                continue;
1253            }
1254            if token.starts_with("--suffix-format=") || token.starts_with('-') {
1255                index += 1;
1256                continue;
1257            }
1258        }
1259
1260        if first_operand.is_none() {
1261            first_operand = Some(argv[index].clone());
1262        }
1263        index += 1;
1264    }
1265
1266    if let Some(input) = first_operand {
1267        if input != "-" {
1268            push_inferred_input(&mut inputs, input.as_str(), cwd)?;
1269        }
1270    }
1271
1272    let mut analysis = SingleCommandAnalysis {
1273        inputs,
1274        adapter_ids: vec![CommandAdapterId::Csplit],
1275        fallback_used: false,
1276        default_watch_root_used: false,
1277        filtered_output_count,
1278        side_effect_profile: SideEffectProfile::WritesExcludedOutputs,
1279        status: CommandAnalysisStatus::NoInputs,
1280    };
1281    apply_redirects(&mut analysis, redirects, cwd)?;
1282    Ok(analysis)
1283}
1284
1285fn analyze_tee(
1286    _argv: &[String],
1287    redirects: &[ShellRedirect],
1288    cwd: &Path,
1289) -> Result<SingleCommandAnalysis> {
1290    let mut analysis = SingleCommandAnalysis::empty(CommandAdapterId::Tee);
1291    analysis.side_effect_profile = SideEffectProfile::WritesExcludedOutputs;
1292    analysis.filtered_output_count = 1;
1293    apply_redirects(&mut analysis, redirects, cwd)?;
1294    Ok(analysis)
1295}
1296
1297fn analyze_grep(
1298    argv: &[String],
1299    redirects: &[ShellRedirect],
1300    cwd: &Path,
1301) -> Result<SingleCommandAnalysis> {
1302    let mut inputs = Vec::new();
1303    let mut explicit_pattern = false;
1304    let mut consumed_pattern = false;
1305    let mut positional_only = false;
1306    let mut index = 1usize;
1307
1308    while index < argv.len() {
1309        let token = argv[index].as_str();
1310        if !positional_only && token == "--" {
1311            positional_only = true;
1312            index += 1;
1313            continue;
1314        }
1315
1316        if !positional_only {
1317            if token == "-e" || token == "--regexp" {
1318                explicit_pattern = true;
1319                index += 2;
1320                continue;
1321            }
1322            if token == "-f" || token == "--file" {
1323                explicit_pattern = true;
1324                if let Some(value) = argv.get(index + 1) {
1325                    push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1326                }
1327                index += 2;
1328                continue;
1329            }
1330            if let Some(value) = token.strip_prefix("--regexp=") {
1331                explicit_pattern = true;
1332                let _ = value;
1333                index += 1;
1334                continue;
1335            }
1336            if let Some(value) = token.strip_prefix("--file=") {
1337                explicit_pattern = true;
1338                push_inferred_input(&mut inputs, value, cwd)?;
1339                index += 1;
1340                continue;
1341            }
1342            if let Some(option) = parse_grep_short_pattern_option(token) {
1343                explicit_pattern = true;
1344                match option {
1345                    GrepShortPatternOption::Inline => {}
1346                    GrepShortPatternOption::Next => {
1347                        index += 2;
1348                        continue;
1349                    }
1350                    GrepShortPatternOption::FileInline(value) => {
1351                        push_inferred_input(&mut inputs, value, cwd)?;
1352                    }
1353                    GrepShortPatternOption::FileNext => {
1354                        if let Some(value) = argv.get(index + 1) {
1355                            push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1356                        }
1357                        index += 2;
1358                        continue;
1359                    }
1360                }
1361                index += 1;
1362                continue;
1363            }
1364            if token.starts_with('-') {
1365                index += 1;
1366                continue;
1367            }
1368        }
1369
1370        if !explicit_pattern && !consumed_pattern {
1371            consumed_pattern = true;
1372        } else if token != "-" {
1373            push_inferred_input(&mut inputs, token, cwd)?;
1374        }
1375        index += 1;
1376    }
1377
1378    let mut analysis = SingleCommandAnalysis {
1379        inputs,
1380        adapter_ids: vec![CommandAdapterId::Grep],
1381        fallback_used: false,
1382        default_watch_root_used: false,
1383        filtered_output_count: 0,
1384        side_effect_profile: SideEffectProfile::ReadOnly,
1385        status: CommandAnalysisStatus::NoInputs,
1386    };
1387    apply_redirects(&mut analysis, redirects, cwd)?;
1388    Ok(analysis)
1389}
1390
1391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1392enum GrepShortPatternOption<'a> {
1393    Inline,
1394    Next,
1395    FileInline(&'a str),
1396    FileNext,
1397}
1398
1399fn parse_grep_short_pattern_option(token: &str) -> Option<GrepShortPatternOption<'_>> {
1400    if !token.starts_with('-') || token == "-" || token.starts_with("--") {
1401        return None;
1402    }
1403
1404    let flags = token.trim_start_matches('-');
1405    for (index, flag) in flags.char_indices() {
1406        let value = &flags[index + flag.len_utf8()..];
1407        match flag {
1408            'e' if value.is_empty() => return Some(GrepShortPatternOption::Next),
1409            'e' => return Some(GrepShortPatternOption::Inline),
1410            'f' if value.is_empty() => return Some(GrepShortPatternOption::FileNext),
1411            'f' => return Some(GrepShortPatternOption::FileInline(value)),
1412            _ => {}
1413        }
1414    }
1415
1416    None
1417}
1418
1419fn analyze_sed(
1420    argv: &[String],
1421    redirects: &[ShellRedirect],
1422    cwd: &Path,
1423) -> Result<SingleCommandAnalysis> {
1424    let mut inputs = Vec::new();
1425    let mut explicit_script = false;
1426    let mut consumed_script = false;
1427    let mut in_place = false;
1428    let mut positional_only = false;
1429    let mut index = 1usize;
1430
1431    while index < argv.len() {
1432        let token = argv[index].as_str();
1433        if !positional_only && token == "--" {
1434            positional_only = true;
1435            index += 1;
1436            continue;
1437        }
1438
1439        if !positional_only {
1440            if token == "-e" || token == "--expression" {
1441                explicit_script = true;
1442                index += 2;
1443                continue;
1444            }
1445            if token == "-f" || token == "--file" {
1446                explicit_script = true;
1447                if let Some(value) = argv.get(index + 1) {
1448                    push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1449                }
1450                index += 2;
1451                continue;
1452            }
1453            if token == "-i" || token == "--in-place" {
1454                in_place = true;
1455                if argv
1456                    .get(index + 1)
1457                    .is_some_and(|value| !value.starts_with('-'))
1458                {
1459                    index += 2;
1460                } else {
1461                    index += 1;
1462                }
1463                continue;
1464            }
1465            if token.starts_with("--expression=") {
1466                explicit_script = true;
1467                index += 1;
1468                continue;
1469            }
1470            if let Some(value) = token.strip_prefix("--file=") {
1471                explicit_script = true;
1472                push_inferred_input(&mut inputs, value, cwd)?;
1473                index += 1;
1474                continue;
1475            }
1476            if token.starts_with("--in-place=") || token.starts_with("-i") {
1477                in_place = true;
1478                index += 1;
1479                continue;
1480            }
1481            if token.starts_with("-e") && token.len() > 2 {
1482                explicit_script = true;
1483                index += 1;
1484                continue;
1485            }
1486            if let Some(value) = token.strip_prefix("-f") {
1487                if !value.is_empty() {
1488                    explicit_script = true;
1489                    push_inferred_input(&mut inputs, value, cwd)?;
1490                    index += 1;
1491                    continue;
1492                }
1493            }
1494            if token.starts_with('-') {
1495                index += 1;
1496                continue;
1497            }
1498        }
1499
1500        if !explicit_script && !consumed_script {
1501            consumed_script = true;
1502        } else if token != "-" {
1503            push_inferred_input(&mut inputs, token, cwd)?;
1504        }
1505        index += 1;
1506    }
1507
1508    let mut analysis = SingleCommandAnalysis {
1509        inputs,
1510        adapter_ids: vec![CommandAdapterId::Sed],
1511        fallback_used: false,
1512        default_watch_root_used: false,
1513        filtered_output_count: 0,
1514        side_effect_profile: if in_place {
1515            SideEffectProfile::WritesWatchedInputs
1516        } else {
1517            SideEffectProfile::ReadOnly
1518        },
1519        status: CommandAnalysisStatus::NoInputs,
1520    };
1521    apply_redirects(&mut analysis, redirects, cwd)?;
1522    Ok(analysis)
1523}
1524
1525fn analyze_awk(
1526    argv: &[String],
1527    redirects: &[ShellRedirect],
1528    cwd: &Path,
1529) -> Result<SingleCommandAnalysis> {
1530    let mut inputs = Vec::new();
1531    let mut explicit_program = false;
1532    let mut consumed_program = false;
1533    let mut positional_only = false;
1534    let mut index = 1usize;
1535
1536    while index < argv.len() {
1537        let token = argv[index].as_str();
1538        if !positional_only && token == "--" {
1539            positional_only = true;
1540            index += 1;
1541            continue;
1542        }
1543
1544        if !positional_only {
1545            if token == "-f" || token == "--file" {
1546                explicit_program = true;
1547                if let Some(value) = argv.get(index + 1) {
1548                    push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1549                }
1550                index += 2;
1551                continue;
1552            }
1553            if token == "-v" || token == "-F" {
1554                index += 2;
1555                continue;
1556            }
1557            if let Some(value) = token.strip_prefix("--file=") {
1558                explicit_program = true;
1559                push_inferred_input(&mut inputs, value, cwd)?;
1560                index += 1;
1561                continue;
1562            }
1563            if let Some(value) = token.strip_prefix("-f") {
1564                if !value.is_empty() {
1565                    explicit_program = true;
1566                    push_inferred_input(&mut inputs, value, cwd)?;
1567                    index += 1;
1568                    continue;
1569                }
1570            }
1571            if token.starts_with("-v") || token.starts_with("-F") || token.starts_with('-') {
1572                index += 1;
1573                continue;
1574            }
1575        }
1576
1577        if !explicit_program && !consumed_program {
1578            consumed_program = true;
1579        } else if token != "-" && !looks_like_variable_assignment(token) {
1580            push_inferred_input(&mut inputs, token, cwd)?;
1581        }
1582        index += 1;
1583    }
1584
1585    let mut analysis = SingleCommandAnalysis {
1586        inputs,
1587        adapter_ids: vec![CommandAdapterId::Awk],
1588        fallback_used: false,
1589        default_watch_root_used: false,
1590        filtered_output_count: 0,
1591        side_effect_profile: SideEffectProfile::ReadOnly,
1592        status: CommandAnalysisStatus::NoInputs,
1593    };
1594    apply_redirects(&mut analysis, redirects, cwd)?;
1595    Ok(analysis)
1596}
1597
1598fn analyze_find(
1599    argv: &[String],
1600    redirects: &[ShellRedirect],
1601    cwd: &Path,
1602) -> Result<SingleCommandAnalysis> {
1603    let mut inputs = Vec::new();
1604    let mut saw_expression = false;
1605    let mut index = 1usize;
1606
1607    while index < argv.len() {
1608        let token = argv[index].as_str();
1609        if token == "--" {
1610            index += 1;
1611            continue;
1612        }
1613        if !saw_expression {
1614            if let Some(next_index) = consume_find_global_option(argv, index) {
1615                index = next_index;
1616                continue;
1617            }
1618        }
1619        if !saw_expression && !is_find_expression_token(token) {
1620            push_inferred_input(&mut inputs, token, cwd)?;
1621        } else {
1622            saw_expression = true;
1623        }
1624        index += 1;
1625    }
1626
1627    let mut analysis = SingleCommandAnalysis {
1628        inputs,
1629        adapter_ids: vec![CommandAdapterId::Find],
1630        fallback_used: false,
1631        default_watch_root_used: false,
1632        filtered_output_count: 0,
1633        side_effect_profile: SideEffectProfile::ReadOnly,
1634        status: CommandAnalysisStatus::NoInputs,
1635    };
1636
1637    if analysis.inputs.is_empty() {
1638        push_inferred_input(&mut analysis.inputs, ".", cwd)?;
1639        analysis.default_watch_root_used = true;
1640    }
1641
1642    apply_redirects(&mut analysis, redirects, cwd)?;
1643    Ok(analysis)
1644}
1645
1646fn consume_find_global_option(argv: &[String], index: usize) -> Option<usize> {
1647    let token = argv[index].as_str();
1648    match token {
1649        "-H" | "-L" | "-P" => Some(index + 1),
1650        "-D" => Some((index + 2).min(argv.len())),
1651        "-O" => {
1652            if argv
1653                .get(index + 1)
1654                .is_some_and(|value| is_unsigned_integer(value))
1655            {
1656                Some(index + 2)
1657            } else {
1658                Some(index + 1)
1659            }
1660        }
1661        _ if token.starts_with("-D") && token.len() > 2 => Some(index + 1),
1662        _ if token.starts_with("-O") && token.len() > 2 => Some(index + 1),
1663        _ => None,
1664    }
1665}
1666
1667fn is_unsigned_integer(token: &str) -> bool {
1668    let trimmed = token.trim();
1669    !trimmed.is_empty() && trimmed.chars().all(|character| character.is_ascii_digit())
1670}
1671
1672fn analyze_xargs(
1673    argv: &[String],
1674    redirects: &[ShellRedirect],
1675    cwd: &Path,
1676) -> Result<SingleCommandAnalysis> {
1677    let mut inputs = Vec::new();
1678    let mut index = 1usize;
1679
1680    while index < argv.len() {
1681        let token = argv[index].as_str();
1682        if token == "--" {
1683            break;
1684        }
1685        if token == "-a" || token == "--arg-file" {
1686            if let Some(value) = argv.get(index + 1) {
1687                push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1688            }
1689            index += 2;
1690            continue;
1691        }
1692        if token == "-I" || token == "-i" || token == "--replace" {
1693            index += 2;
1694            continue;
1695        }
1696        if token == "-n"
1697            || token == "-L"
1698            || token == "-s"
1699            || token == "-P"
1700            || token == "-E"
1701            || token == "--delimiter"
1702            || token == "--eof"
1703            || token == "--max-args"
1704            || token == "--max-lines"
1705            || token == "--max-procs"
1706            || token == "--max-chars"
1707        {
1708            index += 2;
1709            continue;
1710        }
1711        if let Some(value) = token.strip_prefix("--arg-file=") {
1712            push_inferred_input(&mut inputs, value, cwd)?;
1713        }
1714        index += 1;
1715    }
1716
1717    let mut analysis = SingleCommandAnalysis {
1718        inputs,
1719        adapter_ids: vec![CommandAdapterId::Xargs],
1720        fallback_used: false,
1721        default_watch_root_used: false,
1722        filtered_output_count: 0,
1723        side_effect_profile: SideEffectProfile::ReadOnly,
1724        status: CommandAnalysisStatus::NoInputs,
1725    };
1726    apply_redirects(&mut analysis, redirects, cwd)?;
1727    Ok(analysis)
1728}
1729
1730fn analyze_tar(
1731    argv: &[String],
1732    redirects: &[ShellRedirect],
1733    cwd: &Path,
1734) -> Result<SingleCommandAnalysis> {
1735    let mut inputs = Vec::new();
1736    let mut filtered_output_count = 0usize;
1737    let mut mode = TarMode::Unknown;
1738    let mut archive_path = None::<String>;
1739    let mut positional_operands = Vec::new();
1740    let mut positional_only = false;
1741    let mut index = 1usize;
1742
1743    while index < argv.len() {
1744        let token = argv[index].as_str();
1745        if !positional_only && token == "--" {
1746            positional_only = true;
1747            index += 1;
1748            continue;
1749        }
1750
1751        if !positional_only {
1752            if token == "-f" || token == "--file" {
1753                if let Some(value) = argv.get(index + 1) {
1754                    archive_path = Some(value.clone());
1755                }
1756                index += 2;
1757                continue;
1758            }
1759            if token == "-C" || token == "--directory" {
1760                filtered_output_count += usize::from(matches!(mode, TarMode::ReadArchive));
1761                index += 2;
1762                continue;
1763            }
1764            if let Some(value) = token.strip_prefix("--file=") {
1765                archive_path = Some(value.to_string());
1766                index += 1;
1767                continue;
1768            }
1769            if let Some(value) = token.strip_prefix("--directory=") {
1770                if matches!(mode, TarMode::ReadArchive) {
1771                    let _ = value;
1772                    filtered_output_count += 1;
1773                }
1774                index += 1;
1775                continue;
1776            }
1777            if token.starts_with("--create")
1778                || token.starts_with("--append")
1779                || token.starts_with("--update")
1780            {
1781                mode = TarMode::CreateLike;
1782                index += 1;
1783                continue;
1784            }
1785            if token.starts_with("--extract")
1786                || token.starts_with("--get")
1787                || token.starts_with("--list")
1788                || token.starts_with("--diff")
1789                || token.starts_with("--compare")
1790            {
1791                mode = TarMode::ReadArchive;
1792                index += 1;
1793                continue;
1794            }
1795            if token.starts_with('-') {
1796                mode = mode.merge(parse_tar_short_mode(token));
1797                if let Some(archive_value) = parse_tar_short_archive(token) {
1798                    archive_path = Some(archive_value);
1799                    index += 1;
1800                    continue;
1801                }
1802                if tar_short_option_consumes_next_archive(token) {
1803                    if let Some(value) = argv.get(index + 1) {
1804                        archive_path = Some(value.clone());
1805                    }
1806                    index += 2;
1807                    continue;
1808                }
1809                index += 1;
1810                continue;
1811            }
1812        }
1813
1814        positional_operands.push(argv[index].clone());
1815        index += 1;
1816    }
1817
1818    match mode {
1819        TarMode::CreateLike => {
1820            if let Some(archive_path) = archive_path {
1821                let _ = archive_path;
1822                filtered_output_count += 1;
1823            }
1824            for operand in positional_operands {
1825                push_inferred_input(&mut inputs, operand.as_str(), cwd)?;
1826            }
1827        }
1828        TarMode::ReadArchive | TarMode::Unknown => {
1829            if let Some(archive_path) = archive_path {
1830                push_inferred_input(&mut inputs, archive_path.as_str(), cwd)?;
1831            }
1832        }
1833    }
1834
1835    let mut analysis = SingleCommandAnalysis {
1836        inputs,
1837        adapter_ids: vec![CommandAdapterId::Tar],
1838        fallback_used: false,
1839        default_watch_root_used: false,
1840        filtered_output_count,
1841        side_effect_profile: if matches!(mode, TarMode::CreateLike) {
1842            SideEffectProfile::WritesExcludedOutputs
1843        } else {
1844            SideEffectProfile::ReadOnly
1845        },
1846        status: CommandAnalysisStatus::NoInputs,
1847    };
1848    apply_redirects(&mut analysis, redirects, cwd)?;
1849    Ok(analysis)
1850}
1851
1852#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1853enum TarMode {
1854    Unknown,
1855    CreateLike,
1856    ReadArchive,
1857}
1858
1859impl TarMode {
1860    fn merge(self, other: Self) -> Self {
1861        match (self, other) {
1862            (Self::CreateLike, _) | (_, Self::CreateLike) => Self::CreateLike,
1863            (Self::ReadArchive, _) | (_, Self::ReadArchive) => Self::ReadArchive,
1864            _ => Self::Unknown,
1865        }
1866    }
1867}
1868
1869fn parse_tar_short_mode(token: &str) -> TarMode {
1870    let raw = token.trim_start_matches('-');
1871    if raw.contains('c') || raw.contains('r') || raw.contains('u') {
1872        TarMode::CreateLike
1873    } else if raw.contains('x') || raw.contains('t') || raw.contains('d') {
1874        TarMode::ReadArchive
1875    } else {
1876        TarMode::Unknown
1877    }
1878}
1879
1880fn parse_tar_short_archive(token: &str) -> Option<String> {
1881    let raw = token.trim_start_matches('-');
1882    let index = raw.find('f')?;
1883    let attached = &raw[index + 1..];
1884    if attached.is_empty() {
1885        None
1886    } else {
1887        Some(attached.to_string())
1888    }
1889}
1890
1891fn tar_short_option_consumes_next_archive(token: &str) -> bool {
1892    let raw = token.trim_start_matches('-');
1893    raw.contains('f') && parse_tar_short_archive(token).is_none()
1894}
1895
1896fn analyze_touch_like(
1897    argv: &[String],
1898    adapter_id: CommandAdapterId,
1899    redirects: &[ShellRedirect],
1900    cwd: &Path,
1901) -> Result<SingleCommandAnalysis> {
1902    let mut inputs = Vec::new();
1903    let mut positional_only = false;
1904    let mut index = 1usize;
1905
1906    while index < argv.len() {
1907        let token = argv[index].as_str();
1908        if !positional_only && token == "--" {
1909            positional_only = true;
1910            index += 1;
1911            continue;
1912        }
1913        if !positional_only {
1914            if token == "-r" || token == "--reference" {
1915                if let Some(value) = argv.get(index + 1) {
1916                    push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1917                }
1918                index += 2;
1919                continue;
1920            }
1921            if let Some(value) = token.strip_prefix("--reference=") {
1922                push_inferred_input(&mut inputs, value, cwd)?;
1923                index += 1;
1924                continue;
1925            }
1926            if token.starts_with('-') {
1927                index += 1;
1928                continue;
1929            }
1930        }
1931        push_inferred_input(&mut inputs, token, cwd)?;
1932        index += 1;
1933    }
1934
1935    let mut analysis = SingleCommandAnalysis {
1936        inputs,
1937        adapter_ids: vec![adapter_id],
1938        fallback_used: false,
1939        default_watch_root_used: false,
1940        filtered_output_count: 0,
1941        side_effect_profile: SideEffectProfile::WritesWatchedInputs,
1942        status: CommandAnalysisStatus::NoInputs,
1943    };
1944    apply_redirects(&mut analysis, redirects, cwd)?;
1945    Ok(analysis)
1946}
1947
1948fn analyze_change_attributes(
1949    argv: &[String],
1950    redirects: &[ShellRedirect],
1951    cwd: &Path,
1952) -> Result<SingleCommandAnalysis> {
1953    let mut inputs = Vec::new();
1954    let mut positional_only = false;
1955    let mut index = 1usize;
1956    let mut metadata_arg_consumed = false;
1957
1958    while index < argv.len() {
1959        let token = argv[index].as_str();
1960        if !positional_only && token == "--" {
1961            positional_only = true;
1962            index += 1;
1963            continue;
1964        }
1965        if !positional_only {
1966            if token == "--reference" {
1967                if let Some(value) = argv.get(index + 1) {
1968                    push_inferred_input(&mut inputs, value.as_str(), cwd)?;
1969                }
1970                index += 2;
1971                continue;
1972            }
1973            if let Some(value) = token.strip_prefix("--reference=") {
1974                push_inferred_input(&mut inputs, value, cwd)?;
1975                index += 1;
1976                continue;
1977            }
1978            if token.starts_with('-') {
1979                index += 1;
1980                continue;
1981            }
1982        }
1983        if !metadata_arg_consumed {
1984            metadata_arg_consumed = true;
1985        } else {
1986            push_inferred_input(&mut inputs, token, cwd)?;
1987        }
1988        index += 1;
1989    }
1990
1991    let mut analysis = SingleCommandAnalysis {
1992        inputs,
1993        adapter_ids: vec![CommandAdapterId::ChangeAttributes],
1994        fallback_used: false,
1995        default_watch_root_used: false,
1996        filtered_output_count: 0,
1997        side_effect_profile: SideEffectProfile::WritesWatchedInputs,
1998        status: CommandAnalysisStatus::NoInputs,
1999    };
2000    apply_redirects(&mut analysis, redirects, cwd)?;
2001    Ok(analysis)
2002}
2003
2004fn analyze_dd(
2005    argv: &[String],
2006    redirects: &[ShellRedirect],
2007    cwd: &Path,
2008) -> Result<SingleCommandAnalysis> {
2009    let mut inputs = Vec::new();
2010    let mut filtered_output_count = 0usize;
2011
2012    for token in argv.iter().skip(1) {
2013        if let Some(value) = token.strip_prefix("if=") {
2014            push_inferred_input(&mut inputs, value, cwd)?;
2015        } else if token.starts_with("iflag=") {
2016        } else if token.starts_with("of=") || token.starts_with("seek=") {
2017            filtered_output_count += 1;
2018        }
2019    }
2020
2021    let mut analysis = SingleCommandAnalysis {
2022        inputs,
2023        adapter_ids: vec![CommandAdapterId::Dd],
2024        fallback_used: false,
2025        default_watch_root_used: false,
2026        filtered_output_count,
2027        side_effect_profile: if filtered_output_count > 0 {
2028            SideEffectProfile::WritesExcludedOutputs
2029        } else {
2030            SideEffectProfile::ReadOnly
2031        },
2032        status: CommandAnalysisStatus::NoInputs,
2033    };
2034    apply_redirects(&mut analysis, redirects, cwd)?;
2035    Ok(analysis)
2036}
2037
2038fn analyze_default_current_dir_reader(
2039    argv: &[String],
2040    redirects: &[ShellRedirect],
2041    cwd: &Path,
2042) -> Result<SingleCommandAnalysis> {
2043    let mut inputs = Vec::new();
2044    let mut positional_only = false;
2045    let mut index = 1usize;
2046
2047    while index < argv.len() {
2048        let token = argv[index].as_str();
2049        if !positional_only && token == "--" {
2050            positional_only = true;
2051            index += 1;
2052            continue;
2053        }
2054        if !positional_only && token.starts_with('-') {
2055            index += 1;
2056            continue;
2057        }
2058        push_inferred_input(&mut inputs, token, cwd)?;
2059        index += 1;
2060    }
2061
2062    let mut analysis = SingleCommandAnalysis {
2063        inputs,
2064        adapter_ids: vec![CommandAdapterId::DefaultCurrentDir],
2065        fallback_used: false,
2066        default_watch_root_used: false,
2067        filtered_output_count: 0,
2068        side_effect_profile: SideEffectProfile::ReadOnly,
2069        status: CommandAnalysisStatus::NoInputs,
2070    };
2071
2072    if analysis.inputs.is_empty() {
2073        push_inferred_input(&mut analysis.inputs, ".", cwd)?;
2074        analysis.default_watch_root_used = true;
2075    }
2076
2077    apply_redirects(&mut analysis, redirects, cwd)?;
2078    Ok(analysis)
2079}
2080
2081fn analyze_ls_like(
2082    argv: &[String],
2083    redirects: &[ShellRedirect],
2084    cwd: &Path,
2085) -> Result<SingleCommandAnalysis> {
2086    let mut inputs = Vec::new();
2087    let mut positional_only = false;
2088    let mut recursive = false;
2089    let mut directory_mode = false;
2090    let mut index = 1usize;
2091
2092    while index < argv.len() {
2093        let token = argv[index].as_str();
2094        if !positional_only && token == "--" {
2095            positional_only = true;
2096            index += 1;
2097            continue;
2098        }
2099
2100        if !positional_only {
2101            if token == "-R" || token == "--recursive" {
2102                recursive = true;
2103                index += 1;
2104                continue;
2105            }
2106            if token == "-d" || token == "--directory" {
2107                directory_mode = true;
2108                index += 1;
2109                continue;
2110            }
2111            if token.starts_with("--") {
2112                index += 1;
2113                continue;
2114            }
2115            if token.starts_with('-') && token != "-" {
2116                recursive |= token.contains('R');
2117                directory_mode |= token.contains('d');
2118                index += 1;
2119                continue;
2120            }
2121        }
2122
2123        push_inferred_path_with_mode(
2124            &mut inputs,
2125            token,
2126            cwd,
2127            ls_like_snapshot_mode(token, cwd, recursive, directory_mode),
2128        )?;
2129        index += 1;
2130    }
2131
2132    if inputs.is_empty() {
2133        push_inferred_path_with_mode(
2134            &mut inputs,
2135            ".",
2136            cwd,
2137            default_ls_snapshot_mode(recursive, directory_mode),
2138        )?;
2139    }
2140
2141    let mut analysis = SingleCommandAnalysis {
2142        inputs,
2143        adapter_ids: vec![CommandAdapterId::DefaultCurrentDir],
2144        fallback_used: false,
2145        default_watch_root_used: false,
2146        filtered_output_count: 0,
2147        side_effect_profile: SideEffectProfile::ReadOnly,
2148        status: CommandAnalysisStatus::NoInputs,
2149    };
2150
2151    if argv.len() == 1
2152        || argv
2153            .iter()
2154            .skip(1)
2155            .all(|token| token == "--" || (token.starts_with('-') && token != "-"))
2156    {
2157        analysis.default_watch_root_used = true;
2158    }
2159
2160    apply_redirects(&mut analysis, redirects, cwd)?;
2161    Ok(analysis)
2162}
2163
2164fn default_ls_snapshot_mode(recursive: bool, directory_mode: bool) -> PathSnapshotMode {
2165    if directory_mode {
2166        PathSnapshotMode::MetadataPath
2167    } else if recursive {
2168        PathSnapshotMode::MetadataTree
2169    } else {
2170        PathSnapshotMode::MetadataChildren
2171    }
2172}
2173
2174fn ls_like_snapshot_mode(
2175    raw: &str,
2176    cwd: &Path,
2177    recursive: bool,
2178    directory_mode: bool,
2179) -> PathSnapshotMode {
2180    if directory_mode {
2181        return PathSnapshotMode::MetadataPath;
2182    }
2183
2184    let absolute_path = absolutize(raw, cwd);
2185    match fs::metadata(&absolute_path) {
2186        Ok(metadata) if metadata.is_dir() => {
2187            if recursive {
2188                PathSnapshotMode::MetadataTree
2189            } else {
2190                PathSnapshotMode::MetadataChildren
2191            }
2192        }
2193        Ok(_) | Err(_) => PathSnapshotMode::MetadataPath,
2194    }
2195}
2196
2197fn analyze_non_watchable(
2198    _argv: &[String],
2199    redirects: &[ShellRedirect],
2200    cwd: &Path,
2201) -> Result<SingleCommandAnalysis> {
2202    let mut analysis = SingleCommandAnalysis::empty(CommandAdapterId::NonWatchable);
2203    apply_redirects(&mut analysis, redirects, cwd)?;
2204    Ok(analysis)
2205}
2206
2207fn analyze_generic_read_paths(
2208    argv: &[String],
2209    redirects: &[ShellRedirect],
2210    cwd: &Path,
2211) -> Result<SingleCommandAnalysis> {
2212    let mut inputs = Vec::new();
2213    let mut positional_only = false;
2214    let mut index = 1usize;
2215
2216    while index < argv.len() {
2217        let token = argv[index].as_str();
2218        if !positional_only && token == "--" {
2219            positional_only = true;
2220            index += 1;
2221            continue;
2222        }
2223        if !positional_only {
2224            if try_push_path_option_value(&mut inputs, argv, &mut index, cwd)? {
2225                continue;
2226            }
2227            if token.starts_with('-') {
2228                index += 1;
2229                continue;
2230            }
2231        }
2232        if token != "-" {
2233            push_inferred_input(&mut inputs, token, cwd)?;
2234        }
2235        index += 1;
2236    }
2237
2238    let mut analysis = SingleCommandAnalysis {
2239        inputs,
2240        adapter_ids: vec![CommandAdapterId::ReadPaths],
2241        fallback_used: false,
2242        default_watch_root_used: false,
2243        filtered_output_count: 0,
2244        side_effect_profile: SideEffectProfile::ReadOnly,
2245        status: CommandAnalysisStatus::NoInputs,
2246    };
2247    apply_redirects(&mut analysis, redirects, cwd)?;
2248    Ok(analysis)
2249}
2250
2251fn analyze_fallback(
2252    argv: &[String],
2253    redirects: &[ShellRedirect],
2254    cwd: &Path,
2255) -> Result<SingleCommandAnalysis> {
2256    let mut inputs = Vec::new();
2257    let mut ambiguous_missing = Vec::new();
2258    let mut positional_only = false;
2259    let mut index = 1usize;
2260
2261    while index < argv.len() {
2262        let token = argv[index].as_str();
2263        if !positional_only && token == "--" {
2264            positional_only = true;
2265            index += 1;
2266            continue;
2267        }
2268        if !positional_only {
2269            if try_push_path_option_value(&mut inputs, argv, &mut index, cwd)? {
2270                continue;
2271            }
2272            if token.starts_with('-') {
2273                index += 1;
2274                continue;
2275            }
2276        }
2277
2278        if should_ignore_fallback_token(token) {
2279            index += 1;
2280            continue;
2281        }
2282
2283        if has_glob_magic(token) || path_exists(token, cwd) {
2284            push_inferred_input(&mut inputs, token, cwd)?;
2285        } else if is_path_shaped(token) {
2286            ambiguous_missing.push(token.to_string());
2287        }
2288        index += 1;
2289    }
2290
2291    let mut analysis = SingleCommandAnalysis {
2292        inputs,
2293        adapter_ids: vec![CommandAdapterId::Fallback],
2294        fallback_used: true,
2295        default_watch_root_used: false,
2296        filtered_output_count: 0,
2297        side_effect_profile: SideEffectProfile::ReadOnly,
2298        status: CommandAnalysisStatus::NoInputs,
2299    };
2300
2301    if ambiguous_missing.len() > 1 {
2302        analysis.status = CommandAnalysisStatus::AmbiguousFallback;
2303    } else if let Some(token) = ambiguous_missing.first() {
2304        push_inferred_input(&mut analysis.inputs, token, cwd)?;
2305    }
2306
2307    apply_redirects(&mut analysis, redirects, cwd)?;
2308    Ok(analysis)
2309}
2310
2311fn apply_redirects(
2312    analysis: &mut SingleCommandAnalysis,
2313    redirects: &[ShellRedirect],
2314    cwd: &Path,
2315) -> Result<()> {
2316    for redirect in redirects {
2317        if is_dynamic_shell_token(redirect.target.as_str()) {
2318            continue;
2319        }
2320        if redirect.operator.reads_input() {
2321            push_inferred_input(&mut analysis.inputs, redirect.target.as_str(), cwd)?;
2322        } else if redirect.operator.writes_output()
2323            || matches!(redirect.operator, ShellRedirectOperator::Other(_))
2324        {
2325            analysis.filtered_output_count += 1;
2326        }
2327    }
2328    Ok(())
2329}
2330
2331fn try_push_path_option_value(
2332    inputs: &mut Vec<WatchInput>,
2333    argv: &[String],
2334    index: &mut usize,
2335    cwd: &Path,
2336) -> Result<bool> {
2337    let token = argv[*index].as_str();
2338    if let Some((option_name, value)) = split_long_option(token) {
2339        if is_path_option_name(option_name) {
2340            push_inferred_input(inputs, value, cwd)?;
2341            *index += 1;
2342            return Ok(true);
2343        }
2344        return Ok(false);
2345    }
2346
2347    if token.starts_with("--") && is_path_option_name(token) {
2348        if let Some(value) = argv.get(*index + 1) {
2349            push_inferred_input(inputs, value.as_str(), cwd)?;
2350        }
2351        *index += 2;
2352        return Ok(true);
2353    }
2354
2355    Ok(false)
2356}
2357
2358fn split_long_option(token: &str) -> Option<(&str, &str)> {
2359    if !token.starts_with("--") {
2360        return None;
2361    }
2362    let (name, value) = token.split_once('=')?;
2363    Some((name, value))
2364}
2365
2366fn is_path_option_name(option_name: &str) -> bool {
2367    matches!(
2368        option_name
2369            .trim_start_matches('-')
2370            .to_ascii_lowercase()
2371            .as_str(),
2372        "file"
2373            | "files"
2374            | "files0-from"
2375            | "path"
2376            | "paths"
2377            | "dir"
2378            | "directory"
2379            | "input"
2380            | "inputs"
2381            | "from"
2382            | "glob"
2383            | "arg-file"
2384            | "reference"
2385    )
2386}
2387
2388fn push_inferred_input(inputs: &mut Vec<WatchInput>, raw: &str, cwd: &Path) -> Result<()> {
2389    let trimmed = raw.trim();
2390    if trimmed.is_empty() || trimmed == "-" {
2391        return Ok(());
2392    }
2393
2394    let input = if has_glob_magic(trimmed) {
2395        WatchInput::glob(trimmed, cwd)?
2396    } else {
2397        WatchInput::path(trimmed, cwd, WatchInputKind::Inferred)?
2398    };
2399
2400    if !inputs.contains(&input) {
2401        inputs.push(input);
2402    }
2403
2404    Ok(())
2405}
2406
2407fn push_inferred_path_with_mode(
2408    inputs: &mut Vec<WatchInput>,
2409    raw: &str,
2410    cwd: &Path,
2411    snapshot_mode: PathSnapshotMode,
2412) -> Result<()> {
2413    let trimmed = raw.trim();
2414    if trimmed.is_empty() || trimmed == "-" {
2415        return Ok(());
2416    }
2417
2418    let input =
2419        WatchInput::path_with_snapshot_mode(trimmed, cwd, WatchInputKind::Inferred, snapshot_mode)?;
2420
2421    if !inputs.contains(&input) {
2422        inputs.push(input);
2423    }
2424
2425    Ok(())
2426}
2427
2428fn has_glob_magic(raw: &str) -> bool {
2429    raw.contains('*') || raw.contains('?') || raw.contains('[')
2430}
2431
2432fn path_exists(raw: &str, cwd: &Path) -> bool {
2433    absolutize(raw, cwd).exists()
2434}
2435
2436fn is_path_shaped(token: &str) -> bool {
2437    token.starts_with('/')
2438        || token.starts_with("./")
2439        || token.starts_with("../")
2440        || token.starts_with("~/")
2441        || token.contains('/')
2442        || token.contains('\\')
2443        || token.contains('.')
2444}
2445
2446fn looks_like_variable_assignment(token: &str) -> bool {
2447    let Some((name, _value)) = token.split_once('=') else {
2448        return false;
2449    };
2450    !name.is_empty()
2451        && name
2452            .chars()
2453            .all(|character| character == '_' || character.is_ascii_alphanumeric())
2454}
2455
2456fn is_signed_integer(token: &str) -> bool {
2457    let trimmed = token.trim();
2458    if trimmed.is_empty() {
2459        return false;
2460    }
2461    let rest = trimmed
2462        .strip_prefix('+')
2463        .or_else(|| trimmed.strip_prefix('-'))
2464        .unwrap_or(trimmed);
2465    !rest.is_empty() && rest.chars().all(|character| character.is_ascii_digit())
2466}
2467
2468fn is_find_expression_token(token: &str) -> bool {
2469    token == "!" || token == "(" || token == ")" || token.starts_with('-') || token.starts_with(',')
2470}
2471
2472fn should_ignore_fallback_token(token: &str) -> bool {
2473    token.is_empty()
2474        || is_signed_integer(token)
2475        || looks_like_variable_assignment(token)
2476        || is_dynamic_shell_token(token)
2477}
2478
2479fn is_dynamic_shell_token(token: &str) -> bool {
2480    token.starts_with("$(")
2481        || token.starts_with("`")
2482        || token.starts_with("<(")
2483        || token.starts_with(">(")
2484        || token.starts_with("${")
2485        || token == "$@"
2486        || token == "$*"
2487}
2488
2489#[cfg(test)]
2490mod tests {
2491    use std::{collections::BTreeSet, ffi::OsString, fs};
2492
2493    use super::{
2494        analyze_argv, analyze_shell_expression, help_inventory, render_after_long_help,
2495        CommandAdapterId, CommandAnalysisStatus, SideEffectProfile,
2496    };
2497    use crate::{
2498        parser::parse_shell_expression,
2499        snapshot::{PathSnapshotMode, WatchInput},
2500    };
2501
2502    #[test]
2503    fn cp_watches_only_sources() {
2504        let cwd = tempfile::tempdir().expect("create tempdir");
2505        let analysis = analyze_argv(
2506            &[
2507                OsString::from("cp"),
2508                OsString::from("src.txt"),
2509                OsString::from("dest.txt"),
2510            ],
2511            cwd.path(),
2512        )
2513        .expect("analyze");
2514
2515        assert_eq!(analysis.adapter_ids, vec![CommandAdapterId::CopyLike]);
2516        assert_eq!(analysis.inputs.len(), 1);
2517        assert_eq!(analysis.filtered_output_count, 1);
2518        assert_eq!(
2519            analysis.side_effect_profile,
2520            SideEffectProfile::WritesExcludedOutputs
2521        );
2522    }
2523
2524    #[test]
2525    fn mv_marks_watched_inputs_as_self_mutating() {
2526        let cwd = tempfile::tempdir().expect("create tempdir");
2527        let analysis = analyze_argv(
2528            &[
2529                OsString::from("mv"),
2530                OsString::from("src.txt"),
2531                OsString::from("dest.txt"),
2532            ],
2533            cwd.path(),
2534        )
2535        .expect("analyze");
2536
2537        assert_eq!(analysis.inputs.len(), 1);
2538        assert_eq!(
2539            analysis.side_effect_profile,
2540            SideEffectProfile::WritesWatchedInputs
2541        );
2542    }
2543
2544    #[test]
2545    fn grep_ignores_pattern_but_keeps_pattern_files() {
2546        let cwd = tempfile::tempdir().expect("create tempdir");
2547        let analysis = analyze_argv(
2548            &[
2549                OsString::from("grep"),
2550                OsString::from("hello"),
2551                OsString::from("file.txt"),
2552            ],
2553            cwd.path(),
2554        )
2555        .expect("analyze");
2556        assert_eq!(analysis.inputs.len(), 1);
2557
2558        let analysis = analyze_argv(
2559            &[
2560                OsString::from("grep"),
2561                OsString::from("-f"),
2562                OsString::from("patterns.txt"),
2563                OsString::from("file.txt"),
2564            ],
2565            cwd.path(),
2566        )
2567        .expect("analyze");
2568        assert_eq!(analysis.inputs.len(), 2);
2569
2570        let grouped = analyze_argv(
2571            &[
2572                OsString::from("grep"),
2573                OsString::from("-rf"),
2574                OsString::from("patterns.txt"),
2575                OsString::from("src"),
2576            ],
2577            cwd.path(),
2578        )
2579        .expect("analyze");
2580        assert_eq!(grouped.inputs.len(), 2);
2581    }
2582
2583    #[test]
2584    fn sed_and_awk_ignore_inline_scripts() {
2585        let cwd = tempfile::tempdir().expect("create tempdir");
2586        let sed = analyze_argv(
2587            &[
2588                OsString::from("sed"),
2589                OsString::from("-n"),
2590                OsString::from("1,2p"),
2591                OsString::from("file.txt"),
2592            ],
2593            cwd.path(),
2594        )
2595        .expect("analyze");
2596        assert_eq!(sed.inputs.len(), 1);
2597
2598        let awk = analyze_argv(
2599            &[
2600                OsString::from("awk"),
2601                OsString::from("{print $1}"),
2602                OsString::from("file.txt"),
2603            ],
2604            cwd.path(),
2605        )
2606        .expect("analyze");
2607        assert_eq!(awk.inputs.len(), 1);
2608    }
2609
2610    #[test]
2611    fn pathless_allowlist_defaults_to_current_directory() {
2612        let cwd = tempfile::tempdir().expect("create tempdir");
2613        let analysis = analyze_argv(&[OsString::from("ls"), OsString::from("-l")], cwd.path())
2614            .expect("analyze");
2615
2616        assert_eq!(analysis.inputs.len(), 1);
2617        assert!(analysis.default_watch_root_used);
2618        assert_path_snapshot_mode(
2619            &analysis.inputs[0],
2620            cwd.path(),
2621            PathSnapshotMode::MetadataChildren,
2622        );
2623
2624        let analysis = analyze_argv(&[OsString::from("find")], cwd.path()).expect("analyze");
2625        assert_eq!(analysis.inputs.len(), 1);
2626        assert!(analysis.default_watch_root_used);
2627
2628        let analysis = analyze_argv(
2629            &[
2630                OsString::from("find"),
2631                OsString::from("-D"),
2632                OsString::from("stat"),
2633                OsString::from("-name"),
2634                OsString::from("*.rs"),
2635            ],
2636            cwd.path(),
2637        )
2638        .expect("analyze");
2639        assert_eq!(analysis.inputs.len(), 1);
2640        assert!(analysis.default_watch_root_used);
2641
2642        let analysis = analyze_argv(
2643            &[
2644                OsString::from("find"),
2645                OsString::from("-O"),
2646                OsString::from("3"),
2647                OsString::from("-name"),
2648                OsString::from("*.rs"),
2649            ],
2650            cwd.path(),
2651        )
2652        .expect("analyze");
2653        assert_eq!(analysis.inputs.len(), 1);
2654        assert!(analysis.default_watch_root_used);
2655    }
2656
2657    #[test]
2658    fn ls_like_inputs_use_listing_snapshot_modes() {
2659        let cwd = tempfile::tempdir().expect("create tempdir");
2660        fs::create_dir_all(cwd.path().join("subdir").join("nested")).expect("create nested dir");
2661        fs::write(cwd.path().join("file.txt"), "alpha\n").expect("write file");
2662
2663        let default_ls = analyze_argv(&[OsString::from("ls")], cwd.path()).expect("analyze");
2664        assert_path_snapshot_mode(
2665            &default_ls.inputs[0],
2666            cwd.path(),
2667            PathSnapshotMode::MetadataChildren,
2668        );
2669
2670        let directory_ls = analyze_argv(
2671            &[OsString::from("ls"), OsString::from("subdir")],
2672            cwd.path(),
2673        )
2674        .expect("analyze");
2675        assert_path_snapshot_mode(
2676            &directory_ls.inputs[0],
2677            &cwd.path().join("subdir"),
2678            PathSnapshotMode::MetadataChildren,
2679        );
2680
2681        let recursive_ls = analyze_argv(
2682            &[
2683                OsString::from("ls"),
2684                OsString::from("-R"),
2685                OsString::from("subdir"),
2686            ],
2687            cwd.path(),
2688        )
2689        .expect("analyze");
2690        assert_path_snapshot_mode(
2691            &recursive_ls.inputs[0],
2692            &cwd.path().join("subdir"),
2693            PathSnapshotMode::MetadataTree,
2694        );
2695
2696        let directory_flag_ls = analyze_argv(
2697            &[
2698                OsString::from("ls"),
2699                OsString::from("-d"),
2700                OsString::from("subdir"),
2701            ],
2702            cwd.path(),
2703        )
2704        .expect("analyze");
2705        assert_path_snapshot_mode(
2706            &directory_flag_ls.inputs[0],
2707            &cwd.path().join("subdir"),
2708            PathSnapshotMode::MetadataPath,
2709        );
2710    }
2711
2712    #[test]
2713    fn tar_excludes_archive_outputs_for_create_and_reads_archives_for_extract() {
2714        let cwd = tempfile::tempdir().expect("create tempdir");
2715        let create = analyze_argv(
2716            &[
2717                OsString::from("tar"),
2718                OsString::from("-cf"),
2719                OsString::from("out.tar"),
2720                OsString::from("src"),
2721                OsString::from("dir"),
2722            ],
2723            cwd.path(),
2724        )
2725        .expect("analyze");
2726        assert_eq!(create.inputs.len(), 2);
2727        assert_eq!(create.filtered_output_count, 1);
2728
2729        let extract = analyze_argv(
2730            &[
2731                OsString::from("tar"),
2732                OsString::from("-xf"),
2733                OsString::from("archive.tar"),
2734            ],
2735            cwd.path(),
2736        )
2737        .expect("analyze");
2738        assert_eq!(extract.inputs.len(), 1);
2739    }
2740
2741    #[test]
2742    fn wrappers_unwrap_before_adapter_selection() {
2743        let cwd = tempfile::tempdir().expect("create tempdir");
2744        let analysis = analyze_argv(
2745            &[
2746                OsString::from("env"),
2747                OsString::from("FOO=bar"),
2748                OsString::from("grep"),
2749                OsString::from("hello"),
2750                OsString::from("file.txt"),
2751            ],
2752            cwd.path(),
2753        )
2754        .expect("analyze");
2755        assert_eq!(
2756            analysis.adapter_ids,
2757            vec![CommandAdapterId::WrapperEnv, CommandAdapterId::Grep]
2758        );
2759
2760        let analysis = analyze_argv(
2761            &[
2762                OsString::from("timeout"),
2763                OsString::from("5"),
2764                OsString::from("grep"),
2765                OsString::from("hello"),
2766                OsString::from("file.txt"),
2767            ],
2768            cwd.path(),
2769        )
2770        .expect("analyze");
2771        assert_eq!(
2772            analysis.adapter_ids,
2773            vec![CommandAdapterId::WrapperTimeout, CommandAdapterId::Grep]
2774        );
2775    }
2776
2777    #[test]
2778    fn unknown_command_fallback_ignores_opaque_words() {
2779        let cwd = tempfile::tempdir().expect("create tempdir");
2780        let analysis = analyze_argv(
2781            &[
2782                OsString::from("mystery"),
2783                OsString::from("hello"),
2784                OsString::from("1,2p"),
2785            ],
2786            cwd.path(),
2787        )
2788        .expect("analyze");
2789
2790        assert_eq!(analysis.adapter_ids, vec![CommandAdapterId::Fallback]);
2791        assert_eq!(analysis.status, CommandAnalysisStatus::NoInputs);
2792    }
2793
2794    #[test]
2795    fn help_inventory_preserves_source_order_and_grouping() {
2796        let inventory = help_inventory();
2797
2798        assert_eq!(
2799            inventory.wrapper_commands,
2800            vec!["env", "nice", "nohup", "stdbuf", "timeout"]
2801        );
2802        assert_eq!(
2803            inventory.safe_current_dir_defaults,
2804            vec!["find", "ls", "dir", "vdir", "du"]
2805        );
2806        assert!(inventory
2807            .dedicated_built_ins
2808            .starts_with(&["cp", "mv", "install"]));
2809        assert_eq!(
2810            inventory.non_watchable_commands,
2811            &[
2812                "echo", "printf", "seq", "yes", "sleep", "date", "uname", "pwd", "true", "false",
2813                "basename", "dirname", "nproc", "printenv", "whoami", "logname", "users", "hostid",
2814                "numfmt", "mktemp", "mkdir", "mkfifo", "mknod",
2815            ]
2816        );
2817
2818        let dedicated_set = inventory
2819            .dedicated_built_ins
2820            .iter()
2821            .copied()
2822            .collect::<BTreeSet<_>>();
2823        assert!(dedicated_set.contains("find"));
2824        assert!(dedicated_set.contains("grep"));
2825        assert!(dedicated_set.contains("fgrep"));
2826        assert!(dedicated_set.contains("chgrp"));
2827    }
2828
2829    #[test]
2830    fn long_help_appendix_renders_full_inventory_sections() {
2831        let help = render_after_long_help();
2832
2833        assert!(help.contains("Command modes:"));
2834        assert!(help.contains("Wrapper commands:"));
2835        assert!(help.contains("Dedicated built-in adapters and aliases:"));
2836        assert!(help.contains("Generic read-path commands:"));
2837        assert!(help.contains("Safe current-directory defaults:"));
2838        assert!(help.contains("Recognized but not auto-watchable commands:"));
2839        assert!(help.contains("exec --input escape hatch:"));
2840        assert!(help.contains("find, ls, dir, vdir, du"));
2841        assert!(help.contains("echo, printf, seq, yes, sleep"));
2842    }
2843
2844    #[test]
2845    fn shell_analysis_keeps_input_redirects_and_filters_outputs() {
2846        let cwd = tempfile::tempdir().expect("create tempdir");
2847        let parsed =
2848            parse_shell_expression("grep hello < input.txt > output.txt").expect("parse shell");
2849        let analysis = analyze_shell_expression(&parsed, cwd.path()).expect("analyze shell");
2850
2851        assert_eq!(analysis.inputs.len(), 1);
2852        assert_eq!(analysis.filtered_output_count, 1);
2853    }
2854
2855    fn assert_path_snapshot_mode(
2856        input: &WatchInput,
2857        expected_path: &std::path::Path,
2858        expected_snapshot_mode: PathSnapshotMode,
2859    ) {
2860        match input {
2861            WatchInput::Path {
2862                path,
2863                snapshot_mode,
2864                ..
2865            } => {
2866                assert_eq!(path, expected_path);
2867                assert_eq!(*snapshot_mode, expected_snapshot_mode);
2868            }
2869            other => panic!("unexpected watch input: {other:?}"),
2870        }
2871    }
2872}