1use clap::{Args, Parser, Subcommand};
2
3#[derive(Parser, Debug)]
4#[command(
5 name = "grex",
6 version,
7 about = "grex — nested meta-repo manager. Pack-based, agent-native, Rust-fast.",
8 long_about = "grex manages trees of git repositories as a single addressable graph. \
9 Each node is a \"pack\" — a plain git repo plus a `.grex/` contract — and every \
10 pack is a meta-pack by construction (zero children = leaf, N children = orchestrator \
11 of N more packs, recursively). One uniform command surface (`sync`, `add`, `rm`, \
12 `update`, `status`, `import`, `doctor`, `teardown`, `exec`, `run`, `serve`) operates \
13 over the whole graph regardless of depth."
14)]
15pub struct Cli {
16 #[command(flatten)]
17 pub global: GlobalFlags,
18
19 #[command(subcommand)]
20 pub verb: Verb,
21}
22
23#[derive(Args, Debug)]
24pub struct GlobalFlags {
25 #[arg(long, global = true, conflicts_with = "plain")]
27 pub json: bool,
28
29 #[arg(long, global = true)]
31 pub plain: bool,
32
33 #[arg(long, global = true)]
35 pub dry_run: bool,
36
37 #[arg(long, global = true)]
39 pub filter: Option<String>,
40}
41
42#[derive(Subcommand, Debug)]
43pub enum Verb {
44 Init(InitArgs),
46 Add(AddArgs),
48 Rm(RmArgs),
50 Ls(LsArgs),
52 Status(StatusArgs),
54 Sync(SyncArgs),
56 Update(UpdateArgs),
58 Doctor(DoctorArgs),
60 Serve(ServeArgs),
62 Import(ImportArgs),
64 Run(RunArgs),
66 Exec(ExecArgs),
68 Teardown(TeardownArgs),
70 #[command(name = "migrate-lockfile")]
74 MigrateLockfile(MigrateLockfileArgs),
75}
76
77#[derive(Args, Debug)]
78pub struct InitArgs {}
79
80#[derive(Args, Debug)]
81pub struct AddArgs {
82 pub url: String,
84 pub path: Option<String>,
86}
87
88#[derive(Args, Debug)]
89pub struct RmArgs {
90 pub path: String,
92}
93
94#[derive(Args, Debug)]
95pub struct LsArgs {
96 pub pack_root: Option<std::path::PathBuf>,
99}
100
101#[derive(Args, Debug)]
102pub struct StatusArgs {}
103
104#[derive(Args, Debug)]
105pub struct SyncArgs {
106 #[arg(long, default_value_t = true)]
108 pub recursive: bool,
109
110 pub pack_root: Option<std::path::PathBuf>,
113
114 #[arg(long)]
121 pub workspace: Option<std::path::PathBuf>,
122
123 #[arg(long, short = 'n')]
125 pub dry_run: bool,
126
127 #[arg(long, short = 'q')]
129 pub quiet: bool,
130
131 #[arg(long)]
133 pub no_validate: bool,
134
135 #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
138 pub ref_override: Option<String>,
139
140 #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
145 pub only: Vec<String>,
146
147 #[arg(long)]
151 pub force: bool,
152
153 #[arg(long = "force-prune")]
159 pub force_prune: bool,
160
161 #[arg(long = "force-prune-with-ignored")]
166 pub force_prune_with_ignored: bool,
167
168 #[arg(long = "quarantine")]
182 pub quarantine: bool,
183
184 #[arg(
200 long = "parallel",
201 env = "GREX_PARALLEL",
202 value_parser = clap::value_parser!(u32).range(0..=1024),
203 )]
204 pub parallel: Option<u32>,
205}
206
207fn non_empty_string(s: &str) -> Result<String, String> {
213 if s.trim().is_empty() {
214 Err("value must not be empty or whitespace-only".to_string())
215 } else {
216 Ok(s.to_string())
217 }
218}
219
220#[derive(Args, Debug)]
221pub struct UpdateArgs {
222 pub pack: Option<String>,
224}
225
226#[derive(Args, Debug)]
227pub struct DoctorArgs {
228 #[arg(long)]
231 pub fix: bool,
232
233 #[arg(long = "lint-config")]
236 pub lint_config: bool,
237
238 #[arg(long = "shallow", value_name = "N")]
245 pub shallow: Option<usize>,
246
247 #[arg(long = "scan-undeclared")]
253 pub scan_undeclared: bool,
254
255 #[arg(long = "depth", value_name = "N", requires = "scan_undeclared")]
262 pub depth: Option<usize>,
263}
264
265#[derive(Args, Debug)]
266pub struct ServeArgs {
267 #[arg(long, value_name = "PATH")]
274 pub manifest: Option<std::path::PathBuf>,
275
276 #[arg(long, value_name = "PATH")]
279 pub workspace: Option<std::path::PathBuf>,
280
281 #[arg(
286 long = "parallel",
287 value_parser = clap::value_parser!(u32).range(1..=1024),
288 )]
289 pub parallel: Option<u32>,
290}
291
292#[derive(Args, Debug)]
293pub struct ImportArgs {
294 #[arg(long)]
296 pub from_repos_json: Option<std::path::PathBuf>,
297
298 #[arg(long, value_name = "PATH")]
302 pub manifest: Option<std::path::PathBuf>,
303
304 #[arg(long = "dry-run", short = 'n')]
307 pub dry_run: bool,
308}
309
310#[derive(Args, Debug)]
311pub struct RunArgs {
312 pub action: String,
314}
315
316#[derive(Args, Debug)]
317pub struct ExecArgs {
318 #[arg(trailing_var_arg = true)]
320 pub cmd: Vec<String>,
321}
322
323#[derive(Args, Debug)]
324pub struct MigrateLockfileArgs {
325 #[arg(long, value_name = "PATH")]
328 pub workspace: Option<std::path::PathBuf>,
329
330 #[arg(long = "dry-run", short = 'n')]
333 pub dry_run: bool,
334}
335
336#[derive(Args, Debug)]
337pub struct TeardownArgs {
338 pub pack_root: Option<std::path::PathBuf>,
341
342 #[arg(long)]
349 pub workspace: Option<std::path::PathBuf>,
350
351 #[arg(long, short = 'q')]
353 pub quiet: bool,
354
355 #[arg(long)]
357 pub no_validate: bool,
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
365 use clap::Parser;
366
367 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
368 let mut full = vec!["grex"];
370 full.extend_from_slice(args);
371 Cli::try_parse_from(full)
372 }
373
374 #[test]
375 fn init_parses_to_init_variant() {
376 let cli = parse(&["init"]).expect("init parses");
377 assert!(matches!(cli.verb, Verb::Init(_)));
378 }
379
380 #[test]
381 fn add_parses_url_and_optional_path() {
382 let cli = parse(&["add", "https://example.com/repo.git"]).expect("add url parses");
383 match cli.verb {
384 Verb::Add(a) => {
385 assert_eq!(a.url, "https://example.com/repo.git");
386 assert!(a.path.is_none());
387 }
388 _ => panic!("expected Add variant"),
389 }
390
391 let cli = parse(&["add", "https://example.com/repo.git", "local"])
392 .expect("add url + path parses");
393 match cli.verb {
394 Verb::Add(a) => {
395 assert_eq!(a.url, "https://example.com/repo.git");
396 assert_eq!(a.path.as_deref(), Some("local"));
397 }
398 _ => panic!("expected Add variant"),
399 }
400 }
401
402 #[test]
403 fn rm_parses_path() {
404 let cli = parse(&["rm", "pack-a"]).expect("rm parses");
405 match cli.verb {
406 Verb::Rm(a) => assert_eq!(a.path, "pack-a"),
407 _ => panic!("expected Rm variant"),
408 }
409 }
410
411 #[test]
412 fn sync_recursive_defaults_to_true() {
413 let cli = parse(&["sync"]).expect("sync parses");
414 match cli.verb {
415 Verb::Sync(a) => assert!(a.recursive, "sync should default to recursive=true"),
416 _ => panic!("expected Sync variant"),
417 }
418 }
419
420 #[test]
421 fn update_pack_is_optional() {
422 let cli = parse(&["update"]).expect("update parses bare");
423 match cli.verb {
424 Verb::Update(a) => assert!(a.pack.is_none()),
425 _ => panic!("expected Update variant"),
426 }
427
428 let cli = parse(&["update", "mypack"]).expect("update parses w/ pack");
429 match cli.verb {
430 Verb::Update(a) => assert_eq!(a.pack.as_deref(), Some("mypack")),
431 _ => panic!("expected Update variant"),
432 }
433 }
434
435 #[test]
436 fn exec_collects_trailing_args() {
437 let cli = parse(&["exec", "echo", "hi", "there"]).expect("exec parses");
438 match cli.verb {
439 Verb::Exec(a) => assert_eq!(a.cmd, vec!["echo", "hi", "there"]),
440 _ => panic!("expected Exec variant"),
441 }
442 }
443
444 #[test]
445 fn universal_flags_populate_on_any_verb() {
446 let cli = parse(&["ls", "--json", "--dry-run", "--filter", "kind=git"])
449 .expect("ls w/ json+dry-run+filter parses");
450 assert!(cli.global.json);
451 assert!(!cli.global.plain);
452 assert!(cli.global.dry_run);
453 assert_eq!(cli.global.filter.as_deref(), Some("kind=git"));
454
455 let cli = parse(&["ls", "--plain", "--dry-run"]).expect("ls w/ plain+dry-run parses");
456 assert!(!cli.global.json);
457 assert!(cli.global.plain);
458 }
459
460 #[test]
461 fn json_and_plain_conflict() {
462 let err =
463 parse(&["init", "--json", "--plain"]).expect_err("--json and --plain must conflict");
464 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
465 }
466
467 #[test]
468 fn parallel_not_global_rejected_on_non_sync_verb() {
469 let err =
472 parse(&["init", "--parallel", "1"]).expect_err("--parallel on non-sync verb must fail");
473 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
474 }
475
476 #[test]
477 fn sync_parallel_one_accepted() {
478 let cli = parse(&["sync", "--parallel", "1"]).expect("sync --parallel 1 parses");
479 match cli.verb {
480 Verb::Sync(a) => assert_eq!(a.parallel, Some(1)),
481 _ => panic!("expected Sync variant"),
482 }
483 }
484
485 #[test]
486 fn sync_parallel_max_accepted() {
487 let cli = parse(&["sync", "--parallel", "1024"]).expect("sync --parallel 1024 parses");
488 match cli.verb {
489 Verb::Sync(a) => assert_eq!(a.parallel, Some(1024)),
490 _ => panic!("expected Sync variant"),
491 }
492 }
493
494 #[test]
495 fn sync_parallel_over_max_rejected() {
496 let err =
497 parse(&["sync", "--parallel", "1025"]).expect_err("sync --parallel 1025 must fail");
498 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
499 }
500
501 #[test]
502 fn import_from_repos_json_parses_as_pathbuf() {
503 let cli =
504 parse(&["import", "--from-repos-json", "./REPOS.json"]).expect("import parses path");
505 match cli.verb {
506 Verb::Import(a) => {
507 assert_eq!(
508 a.from_repos_json.as_deref(),
509 Some(std::path::Path::new("./REPOS.json"))
510 );
511 }
512 _ => panic!("expected Import variant"),
513 }
514 }
515
516 #[test]
517 fn run_requires_action() {
518 let err = parse(&["run"]).expect_err("run w/o action must fail");
519 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
520 }
521
522 #[test]
523 fn unknown_verb_fails() {
524 let err = parse(&["nope"]).expect_err("unknown verb must fail");
525 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
526 }
527
528 #[test]
529 fn unknown_flag_fails() {
530 let err = parse(&["init", "--not-a-flag"]).expect_err("unknown flag must fail");
531 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
532 }
533
534 #[test]
535 fn test_cli_force_prune_flag_parsed() {
536 let cli = parse(&["sync", "."]).expect("sync . parses");
539 match cli.verb {
540 Verb::Sync(ref a) => {
541 assert!(!a.force_prune, "default --force-prune must be false");
542 assert!(
543 !a.force_prune_with_ignored,
544 "default --force-prune-with-ignored must be false"
545 );
546 }
547 _ => panic!("expected Sync variant"),
548 }
549 let cli = parse(&["sync", ".", "--force-prune"]).expect("sync --force-prune parses");
550 match cli.verb {
551 Verb::Sync(a) => {
552 assert!(a.force_prune, "--force-prune must set true");
553 assert!(
554 !a.force_prune_with_ignored,
555 "--force-prune-with-ignored stays default false"
556 );
557 }
558 _ => panic!("expected Sync variant"),
559 }
560 }
561
562 #[test]
563 fn test_cli_force_prune_with_ignored_flag_parsed() {
564 let cli = parse(&["sync", ".", "--force-prune-with-ignored"])
568 .expect("sync --force-prune-with-ignored parses");
569 match cli.verb {
570 Verb::Sync(a) => {
571 assert!(
572 !a.force_prune,
573 "--force-prune is independent of --force-prune-with-ignored at parse layer"
574 );
575 assert!(a.force_prune_with_ignored, "--force-prune-with-ignored must set true");
576 }
577 _ => panic!("expected Sync variant"),
578 }
579 let cli = parse(&["sync", ".", "--force-prune", "--force-prune-with-ignored"])
581 .expect("sync --force-prune --force-prune-with-ignored parses");
582 match cli.verb {
583 Verb::Sync(a) => {
584 assert!(a.force_prune);
585 assert!(a.force_prune_with_ignored);
586 }
587 _ => panic!("expected Sync variant"),
588 }
589 }
590
591 #[test]
592 fn cli_non_empty_string_rejects_whitespace() {
593 for bad in ["", " ", "\t", " ", "\n"] {
597 let err =
598 parse(&["sync", ".", "--ref", bad]).expect_err("whitespace --ref must be rejected");
599 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
600
601 let err = parse(&["sync", ".", "--only", bad])
602 .expect_err("whitespace --only must be rejected");
603 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
604 }
605 }
606}