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 #[arg(long, global = true)]
21 pub no_hash: bool,
22
23 #[arg(long, global = true)]
25 pub clear: bool,
26
27 #[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 Exec(ExecArgs),
39 #[command(external_subcommand)]
40 Passthrough(Vec<OsString>),
41}
42
43#[derive(Debug, Clone, Args)]
44pub struct ExecArgs {
45 #[arg(long = "input", value_name = "GLOB", required = true)]
47 pub input: Vec<String>,
48
49 #[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}