Skip to content

Commit c554209

Browse files
committed
Move default arg handling to cli module
1 parent 5ee35e9 commit c554209

File tree

2 files changed

+246
-252
lines changed

2 files changed

+246
-252
lines changed

rewatch/src/cli.rs

Lines changed: 245 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::{ffi::OsString, ops::Deref};
22

3-
use clap::{Args, Parser, Subcommand};
3+
use clap::{error::ErrorKind, Args, CommandFactory, Parser, Subcommand};
44
use clap_verbosity_flag::InfoLevel;
55
use regex::Regex;
66

@@ -44,6 +44,112 @@ pub struct Cli {
4444
pub command: Command,
4545
}
4646

47+
/// Parse argv while treating `build` as the implicit default subcommand when clap indicates the
48+
/// user omitted one. This keeps the top-level help compact while still supporting bare `rescript …`
49+
/// invocations that expect to run the build.
50+
pub fn parse_with_default(raw_args: &[OsString]) -> Result<Cli, clap::Error> {
51+
match Cli::try_parse_from(raw_args) {
52+
Ok(cli) => Ok(cli),
53+
Err(err) => {
54+
if should_default_to_build(&err, raw_args) {
55+
let fallback_args = build_default_args(raw_args);
56+
Cli::try_parse_from(&fallback_args)
57+
} else {
58+
Err(err)
59+
}
60+
}
61+
}
62+
}
63+
64+
fn should_default_to_build(err: &clap::Error, args: &[OsString]) -> bool {
65+
match err.kind() {
66+
ErrorKind::MissingSubcommand
67+
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
68+
| ErrorKind::UnknownArgument
69+
| ErrorKind::InvalidSubcommand => {
70+
let first_non_global = first_non_global_arg(args);
71+
match first_non_global {
72+
Some(arg) => !is_known_subcommand(arg),
73+
None => true,
74+
}
75+
}
76+
_ => false,
77+
}
78+
}
79+
80+
fn is_global_flag(arg: &OsString) -> bool {
81+
matches!(
82+
arg.to_str(),
83+
Some(
84+
"-v" | "-vv"
85+
| "-vvv"
86+
| "-vvvv"
87+
| "-q"
88+
| "-qq"
89+
| "-qqq"
90+
| "-qqqq"
91+
| "--verbose"
92+
| "--quiet"
93+
| "-h"
94+
| "--help"
95+
| "-V"
96+
| "--version"
97+
)
98+
)
99+
}
100+
101+
fn first_non_global_arg(args: &[OsString]) -> Option<&OsString> {
102+
args.iter().skip(1).find(|arg| !is_global_flag(arg))
103+
}
104+
105+
fn is_known_subcommand(arg: &OsString) -> bool {
106+
let Some(arg_str) = arg.to_str() else {
107+
return false;
108+
};
109+
110+
Cli::command().get_subcommands().any(|subcommand| {
111+
subcommand.get_name() == arg_str || subcommand.get_all_aliases().any(|alias| alias == arg_str)
112+
})
113+
}
114+
115+
fn build_default_args(raw_args: &[OsString]) -> Vec<OsString> {
116+
// Preserve clap's global flag handling semantics by keeping `-v/-q/-h/-V` in front of the
117+
// inserted `build` token while leaving the rest of the argv untouched. This mirrors clap's own
118+
// precedence rules so the second parse sees an argument layout it would have produced if the
119+
// user had typed `rescript build …` directly.
120+
let mut result = Vec::with_capacity(raw_args.len() + 1);
121+
if raw_args.is_empty() {
122+
return vec![OsString::from("build")];
123+
}
124+
125+
let mut globals = Vec::new();
126+
let mut others = Vec::new();
127+
let mut saw_double_dash = false;
128+
129+
for arg in raw_args.iter().skip(1) {
130+
if !saw_double_dash {
131+
if arg == "--" {
132+
saw_double_dash = true;
133+
others.push(arg.clone());
134+
continue;
135+
}
136+
137+
if is_global_flag(arg) {
138+
globals.push(arg.clone());
139+
continue;
140+
}
141+
}
142+
143+
others.push(arg.clone());
144+
}
145+
146+
result.push(raw_args[0].clone());
147+
result.extend(globals);
148+
result.push(OsString::from("build"));
149+
result.extend(others);
150+
result
151+
}
152+
47153
#[derive(Args, Debug, Clone)]
48154
pub struct FolderArg {
49155
/// The relative path to where the main rescript.json resides. IE - the root of your project.
@@ -139,6 +245,144 @@ pub struct BuildArgs {
139245
pub warn_error: Option<String>,
140246
}
141247

248+
#[cfg(test)]
249+
mod tests {
250+
use super::*;
251+
use clap::error::ErrorKind;
252+
use log::LevelFilter;
253+
254+
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
255+
let raw_args: Vec<OsString> = args.iter().map(OsString::from).collect();
256+
parse_with_default(&raw_args)
257+
}
258+
259+
// Default command behaviour.
260+
#[test]
261+
fn no_subcommand_defaults_to_build() {
262+
let cli = parse(&["rescript"]).expect("expected default build command");
263+
assert!(matches!(cli.command, Command::Build(_)));
264+
}
265+
266+
#[test]
267+
fn defaults_to_build_with_folder_shortcut() {
268+
let cli = parse(&["rescript", "someFolder"]).expect("expected build command");
269+
270+
match cli.command {
271+
Command::Build(build_args) => assert_eq!(build_args.folder.folder, "someFolder"),
272+
other => panic!("expected build command, got {other:?}"),
273+
}
274+
}
275+
276+
#[test]
277+
fn trailing_global_flag_is_treated_as_global() {
278+
let cli = parse(&["rescript", "my-project", "-v"]).expect("expected build command");
279+
280+
assert_eq!(cli.verbose.log_level_filter(), LevelFilter::Debug);
281+
match cli.command {
282+
Command::Build(build_args) => assert_eq!(build_args.folder.folder, "my-project"),
283+
other => panic!("expected build command, got {other:?}"),
284+
}
285+
}
286+
287+
#[test]
288+
fn double_dash_keeps_following_args_positional() {
289+
let cli = parse(&["rescript", "--", "-v"]).expect("expected build command");
290+
291+
assert_eq!(cli.verbose.log_level_filter(), LevelFilter::Info);
292+
match cli.command {
293+
Command::Build(build_args) => assert_eq!(build_args.folder.folder, "-v"),
294+
other => panic!("expected build command, got {other:?}"),
295+
}
296+
}
297+
298+
#[test]
299+
fn unknown_subcommand_help_uses_global_help() {
300+
let err = parse(&["rescript", "xxx", "--help"]).expect_err("expected global help");
301+
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
302+
}
303+
304+
// Build command specifics.
305+
#[test]
306+
fn build_help_shows_subcommand_help() {
307+
let err = parse(&["rescript", "build", "--help"]).expect_err("expected subcommand help");
308+
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
309+
let rendered = err.to_string();
310+
assert!(
311+
rendered.contains("Usage: rescript build"),
312+
"unexpected help: {rendered:?}"
313+
);
314+
assert!(!rendered.contains("Usage: rescript [OPTIONS] <COMMAND>"));
315+
}
316+
317+
#[test]
318+
fn build_allows_global_verbose_flag() {
319+
let cli = parse(&["rescript", "build", "-v"]).expect("expected build command");
320+
assert_eq!(cli.verbose.log_level_filter(), LevelFilter::Debug);
321+
assert!(matches!(cli.command, Command::Build(_)));
322+
}
323+
324+
#[test]
325+
fn build_option_is_parsed_normally() {
326+
let cli = parse(&["rescript", "build", "--no-timing"]).expect("expected build command");
327+
328+
match cli.command {
329+
Command::Build(build_args) => assert!(build_args.no_timing),
330+
other => panic!("expected build command, got {other:?}"),
331+
}
332+
}
333+
334+
// Subcommand flag handling.
335+
#[test]
336+
fn respects_global_flag_before_subcommand() {
337+
let cli = parse(&["rescript", "-v", "watch"]).expect("expected watch command");
338+
339+
assert!(matches!(cli.command, Command::Watch(_)));
340+
}
341+
342+
#[test]
343+
fn invalid_option_for_subcommand_does_not_fallback() {
344+
let err = parse(&["rescript", "watch", "--no-timing"]).expect_err("expected watch parse failure");
345+
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
346+
}
347+
348+
// Version/help flag handling.
349+
#[test]
350+
fn version_flag_before_subcommand_displays_version() {
351+
let err = parse(&["rescript", "-V", "build"]).expect_err("expected version display");
352+
assert_eq!(err.kind(), ErrorKind::DisplayVersion);
353+
}
354+
355+
#[test]
356+
fn version_flag_after_subcommand_is_rejected() {
357+
let err = parse(&["rescript", "build", "-V"]).expect_err("expected unexpected argument");
358+
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
359+
}
360+
361+
#[test]
362+
fn global_help_flag_shows_help() {
363+
let err = parse(&["rescript", "--help"]).expect_err("expected clap help error");
364+
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
365+
let rendered = err.to_string();
366+
assert!(rendered.contains("Usage: rescript [OPTIONS] <COMMAND>"));
367+
}
368+
369+
#[test]
370+
fn global_version_flag_shows_version() {
371+
let err = parse(&["rescript", "--version"]).expect_err("expected clap version error");
372+
assert_eq!(err.kind(), ErrorKind::DisplayVersion);
373+
}
374+
375+
#[cfg(unix)]
376+
#[test]
377+
fn non_utf_argument_returns_error() {
378+
use std::os::unix::ffi::OsStringExt;
379+
380+
let args = vec![OsString::from("rescript"), OsString::from_vec(vec![0xff])];
381+
let err = parse_with_default(&args).expect_err("expected clap to report invalid utf8");
382+
assert_eq!(err.kind(), ErrorKind::InvalidUtf8);
383+
}
384+
}
385+
142386
#[derive(Args, Clone, Debug)]
143387
pub struct WatchArgs {
144388
#[command(flatten)]

0 commit comments

Comments
 (0)