Skip to main content

with_watch/
cli.rs

1use std::ffi::OsString;
2
3use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand};
4
5use crate::{
6    analysis::render_after_long_help,
7    error::{Result, WithWatchError},
8    runner::OutputRefreshMode,
9    snapshot::ChangeDetectionMode,
10};
11
12#[derive(Debug, Parser)]
13#[command(
14    name = "with-watch",
15    version,
16    about = "Run commands again when their inputs change"
17)]
18pub struct Cli {
19    /// Disable content hashing and compare only file metadata.
20    #[arg(long, global = true)]
21    pub no_hash: bool,
22
23    /// Clear the terminal before the initial run and each rerun.
24    #[arg(long, global = true)]
25    pub clear: bool,
26
27    /// Run a quoted shell command line that may contain `&&`, `||`, or `|`.
28    #[arg(long, global = true, value_name = "EXPR")]
29    pub shell: Option<String>,
30
31    #[command(subcommand)]
32    pub command: Option<Command>,
33}
34
35#[derive(Debug, Subcommand)]
36pub enum Command {
37    /// Run an arbitrary command with explicit watched inputs.
38    Exec(ExecArgs),
39    #[command(external_subcommand)]
40    Passthrough(Vec<OsString>),
41}
42
43#[derive(Debug, Clone, Args)]
44pub struct ExecArgs {
45    /// Watched filesystem inputs expressed as repeatable glob or path values.
46    #[arg(long = "input", value_name = "GLOB", required = true)]
47    pub input: Vec<String>,
48
49    /// Command to execute after `--`.
50    #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
51    pub command: Vec<OsString>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum CommandMode {
56    Passthrough {
57        argv: Vec<OsString>,
58    },
59    Shell {
60        expression: String,
61    },
62    Exec {
63        inputs: Vec<String>,
64        argv: Vec<OsString>,
65    },
66}
67
68impl Cli {
69    pub fn command_with_inventory() -> clap::Command {
70        <Self as CommandFactory>::command().after_long_help(render_after_long_help())
71    }
72
73    pub fn parse_with_inventory() -> Self {
74        let matches = Self::command_with_inventory().get_matches();
75        Self::from_arg_matches(&matches).unwrap_or_else(|error| error.exit())
76    }
77
78    pub fn change_detection_mode(&self) -> ChangeDetectionMode {
79        if self.no_hash {
80            ChangeDetectionMode::MtimeOnly
81        } else {
82            ChangeDetectionMode::ContentHash
83        }
84    }
85
86    pub fn output_refresh_mode(&self) -> OutputRefreshMode {
87        if self.clear {
88            OutputRefreshMode::ClearTerminal
89        } else {
90            OutputRefreshMode::Preserve
91        }
92    }
93
94    pub fn command_mode(&self) -> Result<CommandMode> {
95        match (&self.shell, &self.command) {
96            (Some(_), Some(_)) => Err(WithWatchError::ConflictingModes),
97            (Some(expression), None) => {
98                let trimmed = expression.trim();
99                if trimmed.is_empty() {
100                    return Err(WithWatchError::EmptyShellExpression);
101                }
102                Ok(CommandMode::Shell {
103                    expression: trimmed.to_string(),
104                })
105            }
106            (None, Some(Command::Exec(exec))) => {
107                if exec.command.is_empty() {
108                    return Err(WithWatchError::MissingExecCommand);
109                }
110                Ok(CommandMode::Exec {
111                    inputs: exec.input.clone(),
112                    argv: exec.command.clone(),
113                })
114            }
115            (None, Some(Command::Passthrough(argv))) => {
116                if argv.is_empty() {
117                    return Err(WithWatchError::MissingCommand);
118                }
119                Ok(CommandMode::Passthrough { argv: argv.clone() })
120            }
121            (None, None) => Err(WithWatchError::MissingCommand),
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use clap::Parser;
129
130    use super::{Cli, CommandMode};
131    use crate::{error::WithWatchError, runner::OutputRefreshMode, snapshot::ChangeDetectionMode};
132
133    #[test]
134    fn passthrough_mode_preserves_external_subcommand_arguments() {
135        let cli = Cli::parse_from(["with-watch", "cp", "a", "b"]);
136        let mode = cli.command_mode().expect("command mode");
137
138        match mode {
139            CommandMode::Passthrough { argv } => {
140                assert_eq!(argv.len(), 3);
141                assert_eq!(argv[0].to_string_lossy(), "cp");
142                assert_eq!(argv[1].to_string_lossy(), "a");
143                assert_eq!(argv[2].to_string_lossy(), "b");
144            }
145            other => panic!("unexpected mode: {other:?}"),
146        }
147    }
148
149    #[test]
150    fn shell_mode_is_mutually_exclusive_with_subcommands() {
151        let error = Cli::parse_from(["with-watch", "--shell", "echo hi", "cp", "a", "b"])
152            .command_mode()
153            .expect_err("expected error");
154
155        assert!(matches!(error, WithWatchError::ConflictingModes));
156    }
157
158    #[test]
159    fn passthrough_mode_accepts_clear_flag() {
160        let cli = Cli::parse_from(["with-watch", "--clear", "cat", "input.txt"]);
161        let mode = cli.command_mode().expect("command mode");
162
163        assert_eq!(cli.output_refresh_mode(), OutputRefreshMode::ClearTerminal);
164        assert!(matches!(mode, CommandMode::Passthrough { .. }));
165    }
166
167    #[test]
168    fn shell_mode_accepts_clear_flag() {
169        let cli = Cli::parse_from(["with-watch", "--clear", "--shell", "cat input.txt"]);
170        let mode = cli.command_mode().expect("command mode");
171
172        assert_eq!(cli.output_refresh_mode(), OutputRefreshMode::ClearTerminal);
173        assert!(matches!(mode, CommandMode::Shell { .. }));
174    }
175
176    #[test]
177    fn exec_mode_accepts_clear_flag() {
178        let cli = Cli::parse_from([
179            "with-watch",
180            "exec",
181            "--clear",
182            "--input",
183            "src/**/*.rs",
184            "--",
185            "cargo",
186            "test",
187        ]);
188        let mode = cli.command_mode().expect("command mode");
189
190        assert_eq!(cli.output_refresh_mode(), OutputRefreshMode::ClearTerminal);
191        assert!(matches!(mode, CommandMode::Exec { .. }));
192    }
193
194    #[test]
195    fn exec_mode_uses_mtime_only_when_hashing_is_disabled() {
196        let cli = Cli::parse_from([
197            "with-watch",
198            "--no-hash",
199            "exec",
200            "--input",
201            "src/**/*.rs",
202            "--",
203            "cargo",
204            "test",
205        ]);
206
207        assert_eq!(cli.change_detection_mode(), ChangeDetectionMode::MtimeOnly);
208    }
209
210    #[test]
211    fn short_help_stays_compact_while_long_help_includes_inventory() {
212        let mut short_command = Cli::command_with_inventory();
213        let mut short_help = Vec::new();
214        short_command
215            .write_help(&mut short_help)
216            .expect("write short help");
217
218        let mut long_command = Cli::command_with_inventory();
219        let mut long_help = Vec::new();
220        long_command
221            .write_long_help(&mut long_help)
222            .expect("write long help");
223
224        let short_help = String::from_utf8(short_help).expect("short help utf8");
225        let long_help = String::from_utf8(long_help).expect("long help utf8");
226
227        assert!(!short_help.contains("Wrapper commands:"));
228        assert!(!short_help.contains("Recognized but not auto-watchable commands:"));
229        assert!(short_help.contains("--clear"));
230        assert!(long_help.contains("Wrapper commands:"));
231        assert!(long_help.contains("Recognized but not auto-watchable commands:"));
232        assert!(long_help.contains("--clear"));
233    }
234}