1use clap::{Args, Parser, Subcommand};
2
3#[derive(Parser, Debug)]
4#[command(name = "grex", version, about = "Pack-based dev-env orchestrator", long_about = None)]
5pub struct Cli {
6 #[command(flatten)]
7 pub global: GlobalFlags,
8
9 #[command(subcommand)]
10 pub verb: Verb,
11}
12
13#[derive(Args, Debug)]
14pub struct GlobalFlags {
15 #[arg(long, global = true, conflicts_with = "plain")]
17 pub json: bool,
18
19 #[arg(long, global = true)]
21 pub plain: bool,
22
23 #[arg(long, global = true)]
25 pub dry_run: bool,
26
27 #[arg(long, global = true)]
29 pub filter: Option<String>,
30}
31
32#[derive(Subcommand, Debug)]
33pub enum Verb {
34 Init(InitArgs),
36 Add(AddArgs),
38 Rm(RmArgs),
40 Ls(LsArgs),
42 Status(StatusArgs),
44 Sync(SyncArgs),
46 Update(UpdateArgs),
48 Doctor(DoctorArgs),
50 Serve(ServeArgs),
52 Import(ImportArgs),
54 Run(RunArgs),
56 Exec(ExecArgs),
58 Teardown(TeardownArgs),
60}
61
62#[derive(Args, Debug)]
63pub struct InitArgs {}
64
65#[derive(Args, Debug)]
66pub struct AddArgs {
67 pub url: String,
69 pub path: Option<String>,
71}
72
73#[derive(Args, Debug)]
74pub struct RmArgs {
75 pub path: String,
77}
78
79#[derive(Args, Debug)]
80pub struct LsArgs {}
81
82#[derive(Args, Debug)]
83pub struct StatusArgs {}
84
85#[derive(Args, Debug)]
86pub struct SyncArgs {
87 #[arg(long, default_value_t = true)]
89 pub recursive: bool,
90
91 pub pack_root: Option<std::path::PathBuf>,
94
95 #[arg(long)]
98 pub workspace: Option<std::path::PathBuf>,
99
100 #[arg(long, short = 'n')]
102 pub dry_run: bool,
103
104 #[arg(long, short = 'q')]
106 pub quiet: bool,
107
108 #[arg(long)]
110 pub no_validate: bool,
111
112 #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
115 pub ref_override: Option<String>,
116
117 #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
122 pub only: Vec<String>,
123
124 #[arg(long)]
128 pub force: bool,
129
130 #[arg(
146 long = "parallel",
147 env = "GREX_PARALLEL",
148 value_parser = clap::value_parser!(u32).range(0..=1024),
149 )]
150 pub parallel: Option<u32>,
151}
152
153fn non_empty_string(s: &str) -> Result<String, String> {
159 if s.trim().is_empty() {
160 Err("value must not be empty or whitespace-only".to_string())
161 } else {
162 Ok(s.to_string())
163 }
164}
165
166#[derive(Args, Debug)]
167pub struct UpdateArgs {
168 pub pack: Option<String>,
170}
171
172#[derive(Args, Debug)]
173pub struct DoctorArgs {
174 #[arg(long)]
177 pub fix: bool,
178
179 #[arg(long = "lint-config")]
182 pub lint_config: bool,
183}
184
185#[derive(Args, Debug)]
186pub struct ServeArgs {
187 #[arg(long, value_name = "PATH")]
191 pub manifest: Option<std::path::PathBuf>,
192
193 #[arg(long, value_name = "PATH")]
196 pub workspace: Option<std::path::PathBuf>,
197
198 #[arg(
203 long = "parallel",
204 value_parser = clap::value_parser!(u32).range(1..=1024),
205 )]
206 pub parallel: Option<u32>,
207}
208
209#[derive(Args, Debug)]
210pub struct ImportArgs {
211 #[arg(long)]
213 pub from_repos_json: Option<std::path::PathBuf>,
214
215 #[arg(long, value_name = "PATH")]
217 pub manifest: Option<std::path::PathBuf>,
218
219 #[arg(long = "dry-run", short = 'n')]
222 pub dry_run: bool,
223}
224
225#[derive(Args, Debug)]
226pub struct RunArgs {
227 pub action: String,
229}
230
231#[derive(Args, Debug)]
232pub struct ExecArgs {
233 #[arg(trailing_var_arg = true)]
235 pub cmd: Vec<String>,
236}
237
238#[derive(Args, Debug)]
239pub struct TeardownArgs {
240 pub pack_root: Option<std::path::PathBuf>,
243
244 #[arg(long)]
246 pub workspace: Option<std::path::PathBuf>,
247
248 #[arg(long, short = 'q')]
250 pub quiet: bool,
251
252 #[arg(long)]
254 pub no_validate: bool,
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
262 use clap::Parser;
263
264 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
265 let mut full = vec!["grex"];
267 full.extend_from_slice(args);
268 Cli::try_parse_from(full)
269 }
270
271 #[test]
272 fn init_parses_to_init_variant() {
273 let cli = parse(&["init"]).expect("init parses");
274 assert!(matches!(cli.verb, Verb::Init(_)));
275 }
276
277 #[test]
278 fn add_parses_url_and_optional_path() {
279 let cli = parse(&["add", "https://example.com/repo.git"]).expect("add url parses");
280 match cli.verb {
281 Verb::Add(a) => {
282 assert_eq!(a.url, "https://example.com/repo.git");
283 assert!(a.path.is_none());
284 }
285 _ => panic!("expected Add variant"),
286 }
287
288 let cli = parse(&["add", "https://example.com/repo.git", "local"])
289 .expect("add url + path parses");
290 match cli.verb {
291 Verb::Add(a) => {
292 assert_eq!(a.url, "https://example.com/repo.git");
293 assert_eq!(a.path.as_deref(), Some("local"));
294 }
295 _ => panic!("expected Add variant"),
296 }
297 }
298
299 #[test]
300 fn rm_parses_path() {
301 let cli = parse(&["rm", "pack-a"]).expect("rm parses");
302 match cli.verb {
303 Verb::Rm(a) => assert_eq!(a.path, "pack-a"),
304 _ => panic!("expected Rm variant"),
305 }
306 }
307
308 #[test]
309 fn sync_recursive_defaults_to_true() {
310 let cli = parse(&["sync"]).expect("sync parses");
311 match cli.verb {
312 Verb::Sync(a) => assert!(a.recursive, "sync should default to recursive=true"),
313 _ => panic!("expected Sync variant"),
314 }
315 }
316
317 #[test]
318 fn update_pack_is_optional() {
319 let cli = parse(&["update"]).expect("update parses bare");
320 match cli.verb {
321 Verb::Update(a) => assert!(a.pack.is_none()),
322 _ => panic!("expected Update variant"),
323 }
324
325 let cli = parse(&["update", "mypack"]).expect("update parses w/ pack");
326 match cli.verb {
327 Verb::Update(a) => assert_eq!(a.pack.as_deref(), Some("mypack")),
328 _ => panic!("expected Update variant"),
329 }
330 }
331
332 #[test]
333 fn exec_collects_trailing_args() {
334 let cli = parse(&["exec", "echo", "hi", "there"]).expect("exec parses");
335 match cli.verb {
336 Verb::Exec(a) => assert_eq!(a.cmd, vec!["echo", "hi", "there"]),
337 _ => panic!("expected Exec variant"),
338 }
339 }
340
341 #[test]
342 fn universal_flags_populate_on_any_verb() {
343 let cli = parse(&["ls", "--json", "--dry-run", "--filter", "kind=git"])
346 .expect("ls w/ json+dry-run+filter parses");
347 assert!(cli.global.json);
348 assert!(!cli.global.plain);
349 assert!(cli.global.dry_run);
350 assert_eq!(cli.global.filter.as_deref(), Some("kind=git"));
351
352 let cli = parse(&["ls", "--plain", "--dry-run"]).expect("ls w/ plain+dry-run parses");
353 assert!(!cli.global.json);
354 assert!(cli.global.plain);
355 }
356
357 #[test]
358 fn json_and_plain_conflict() {
359 let err =
360 parse(&["init", "--json", "--plain"]).expect_err("--json and --plain must conflict");
361 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
362 }
363
364 #[test]
365 fn parallel_not_global_rejected_on_non_sync_verb() {
366 let err =
369 parse(&["init", "--parallel", "1"]).expect_err("--parallel on non-sync verb must fail");
370 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
371 }
372
373 #[test]
374 fn sync_parallel_one_accepted() {
375 let cli = parse(&["sync", "--parallel", "1"]).expect("sync --parallel 1 parses");
376 match cli.verb {
377 Verb::Sync(a) => assert_eq!(a.parallel, Some(1)),
378 _ => panic!("expected Sync variant"),
379 }
380 }
381
382 #[test]
383 fn sync_parallel_max_accepted() {
384 let cli = parse(&["sync", "--parallel", "1024"]).expect("sync --parallel 1024 parses");
385 match cli.verb {
386 Verb::Sync(a) => assert_eq!(a.parallel, Some(1024)),
387 _ => panic!("expected Sync variant"),
388 }
389 }
390
391 #[test]
392 fn sync_parallel_over_max_rejected() {
393 let err =
394 parse(&["sync", "--parallel", "1025"]).expect_err("sync --parallel 1025 must fail");
395 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
396 }
397
398 #[test]
399 fn import_from_repos_json_parses_as_pathbuf() {
400 let cli =
401 parse(&["import", "--from-repos-json", "./REPOS.json"]).expect("import parses path");
402 match cli.verb {
403 Verb::Import(a) => {
404 assert_eq!(
405 a.from_repos_json.as_deref(),
406 Some(std::path::Path::new("./REPOS.json"))
407 );
408 }
409 _ => panic!("expected Import variant"),
410 }
411 }
412
413 #[test]
414 fn run_requires_action() {
415 let err = parse(&["run"]).expect_err("run w/o action must fail");
416 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
417 }
418
419 #[test]
420 fn unknown_verb_fails() {
421 let err = parse(&["nope"]).expect_err("unknown verb must fail");
422 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
423 }
424
425 #[test]
426 fn unknown_flag_fails() {
427 let err = parse(&["init", "--not-a-flag"]).expect_err("unknown flag must fail");
428 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
429 }
430
431 #[test]
432 fn cli_non_empty_string_rejects_whitespace() {
433 for bad in ["", " ", "\t", " ", "\n"] {
437 let err =
438 parse(&["sync", ".", "--ref", bad]).expect_err("whitespace --ref must be rejected");
439 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
440
441 let err = parse(&["sync", ".", "--only", bad])
442 .expect_err("whitespace --only must be rejected");
443 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
444 }
445 }
446}