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}
71
72#[derive(Args, Debug)]
73pub struct InitArgs {}
74
75#[derive(Args, Debug)]
76pub struct AddArgs {
77 pub url: String,
79 pub path: Option<String>,
81}
82
83#[derive(Args, Debug)]
84pub struct RmArgs {
85 pub path: String,
87}
88
89#[derive(Args, Debug)]
90pub struct LsArgs {
91 pub pack_root: Option<std::path::PathBuf>,
94}
95
96#[derive(Args, Debug)]
97pub struct StatusArgs {}
98
99#[derive(Args, Debug)]
100pub struct SyncArgs {
101 #[arg(long, default_value_t = true)]
103 pub recursive: bool,
104
105 pub pack_root: Option<std::path::PathBuf>,
108
109 #[arg(long)]
112 pub workspace: Option<std::path::PathBuf>,
113
114 #[arg(long, short = 'n')]
116 pub dry_run: bool,
117
118 #[arg(long, short = 'q')]
120 pub quiet: bool,
121
122 #[arg(long)]
124 pub no_validate: bool,
125
126 #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
129 pub ref_override: Option<String>,
130
131 #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
136 pub only: Vec<String>,
137
138 #[arg(long)]
142 pub force: bool,
143
144 #[arg(long = "force-prune")]
150 pub force_prune: bool,
151
152 #[arg(long = "force-prune-with-ignored")]
157 pub force_prune_with_ignored: bool,
158
159 #[arg(
175 long = "parallel",
176 env = "GREX_PARALLEL",
177 value_parser = clap::value_parser!(u32).range(0..=1024),
178 )]
179 pub parallel: Option<u32>,
180}
181
182fn non_empty_string(s: &str) -> Result<String, String> {
188 if s.trim().is_empty() {
189 Err("value must not be empty or whitespace-only".to_string())
190 } else {
191 Ok(s.to_string())
192 }
193}
194
195#[derive(Args, Debug)]
196pub struct UpdateArgs {
197 pub pack: Option<String>,
199}
200
201#[derive(Args, Debug)]
202pub struct DoctorArgs {
203 #[arg(long)]
206 pub fix: bool,
207
208 #[arg(long = "lint-config")]
211 pub lint_config: bool,
212
213 #[arg(long = "shallow", value_name = "N")]
220 pub shallow: Option<usize>,
221}
222
223#[derive(Args, Debug)]
224pub struct ServeArgs {
225 #[arg(long, value_name = "PATH")]
232 pub manifest: Option<std::path::PathBuf>,
233
234 #[arg(long, value_name = "PATH")]
237 pub workspace: Option<std::path::PathBuf>,
238
239 #[arg(
244 long = "parallel",
245 value_parser = clap::value_parser!(u32).range(1..=1024),
246 )]
247 pub parallel: Option<u32>,
248}
249
250#[derive(Args, Debug)]
251pub struct ImportArgs {
252 #[arg(long)]
254 pub from_repos_json: Option<std::path::PathBuf>,
255
256 #[arg(long, value_name = "PATH")]
260 pub manifest: Option<std::path::PathBuf>,
261
262 #[arg(long = "dry-run", short = 'n')]
265 pub dry_run: bool,
266}
267
268#[derive(Args, Debug)]
269pub struct RunArgs {
270 pub action: String,
272}
273
274#[derive(Args, Debug)]
275pub struct ExecArgs {
276 #[arg(trailing_var_arg = true)]
278 pub cmd: Vec<String>,
279}
280
281#[derive(Args, Debug)]
282pub struct TeardownArgs {
283 pub pack_root: Option<std::path::PathBuf>,
286
287 #[arg(long)]
290 pub workspace: Option<std::path::PathBuf>,
291
292 #[arg(long, short = 'q')]
294 pub quiet: bool,
295
296 #[arg(long)]
298 pub no_validate: bool,
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
306 use clap::Parser;
307
308 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
309 let mut full = vec!["grex"];
311 full.extend_from_slice(args);
312 Cli::try_parse_from(full)
313 }
314
315 #[test]
316 fn init_parses_to_init_variant() {
317 let cli = parse(&["init"]).expect("init parses");
318 assert!(matches!(cli.verb, Verb::Init(_)));
319 }
320
321 #[test]
322 fn add_parses_url_and_optional_path() {
323 let cli = parse(&["add", "https://example.com/repo.git"]).expect("add url parses");
324 match cli.verb {
325 Verb::Add(a) => {
326 assert_eq!(a.url, "https://example.com/repo.git");
327 assert!(a.path.is_none());
328 }
329 _ => panic!("expected Add variant"),
330 }
331
332 let cli = parse(&["add", "https://example.com/repo.git", "local"])
333 .expect("add url + path parses");
334 match cli.verb {
335 Verb::Add(a) => {
336 assert_eq!(a.url, "https://example.com/repo.git");
337 assert_eq!(a.path.as_deref(), Some("local"));
338 }
339 _ => panic!("expected Add variant"),
340 }
341 }
342
343 #[test]
344 fn rm_parses_path() {
345 let cli = parse(&["rm", "pack-a"]).expect("rm parses");
346 match cli.verb {
347 Verb::Rm(a) => assert_eq!(a.path, "pack-a"),
348 _ => panic!("expected Rm variant"),
349 }
350 }
351
352 #[test]
353 fn sync_recursive_defaults_to_true() {
354 let cli = parse(&["sync"]).expect("sync parses");
355 match cli.verb {
356 Verb::Sync(a) => assert!(a.recursive, "sync should default to recursive=true"),
357 _ => panic!("expected Sync variant"),
358 }
359 }
360
361 #[test]
362 fn update_pack_is_optional() {
363 let cli = parse(&["update"]).expect("update parses bare");
364 match cli.verb {
365 Verb::Update(a) => assert!(a.pack.is_none()),
366 _ => panic!("expected Update variant"),
367 }
368
369 let cli = parse(&["update", "mypack"]).expect("update parses w/ pack");
370 match cli.verb {
371 Verb::Update(a) => assert_eq!(a.pack.as_deref(), Some("mypack")),
372 _ => panic!("expected Update variant"),
373 }
374 }
375
376 #[test]
377 fn exec_collects_trailing_args() {
378 let cli = parse(&["exec", "echo", "hi", "there"]).expect("exec parses");
379 match cli.verb {
380 Verb::Exec(a) => assert_eq!(a.cmd, vec!["echo", "hi", "there"]),
381 _ => panic!("expected Exec variant"),
382 }
383 }
384
385 #[test]
386 fn universal_flags_populate_on_any_verb() {
387 let cli = parse(&["ls", "--json", "--dry-run", "--filter", "kind=git"])
390 .expect("ls w/ json+dry-run+filter parses");
391 assert!(cli.global.json);
392 assert!(!cli.global.plain);
393 assert!(cli.global.dry_run);
394 assert_eq!(cli.global.filter.as_deref(), Some("kind=git"));
395
396 let cli = parse(&["ls", "--plain", "--dry-run"]).expect("ls w/ plain+dry-run parses");
397 assert!(!cli.global.json);
398 assert!(cli.global.plain);
399 }
400
401 #[test]
402 fn json_and_plain_conflict() {
403 let err =
404 parse(&["init", "--json", "--plain"]).expect_err("--json and --plain must conflict");
405 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
406 }
407
408 #[test]
409 fn parallel_not_global_rejected_on_non_sync_verb() {
410 let err =
413 parse(&["init", "--parallel", "1"]).expect_err("--parallel on non-sync verb must fail");
414 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
415 }
416
417 #[test]
418 fn sync_parallel_one_accepted() {
419 let cli = parse(&["sync", "--parallel", "1"]).expect("sync --parallel 1 parses");
420 match cli.verb {
421 Verb::Sync(a) => assert_eq!(a.parallel, Some(1)),
422 _ => panic!("expected Sync variant"),
423 }
424 }
425
426 #[test]
427 fn sync_parallel_max_accepted() {
428 let cli = parse(&["sync", "--parallel", "1024"]).expect("sync --parallel 1024 parses");
429 match cli.verb {
430 Verb::Sync(a) => assert_eq!(a.parallel, Some(1024)),
431 _ => panic!("expected Sync variant"),
432 }
433 }
434
435 #[test]
436 fn sync_parallel_over_max_rejected() {
437 let err =
438 parse(&["sync", "--parallel", "1025"]).expect_err("sync --parallel 1025 must fail");
439 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
440 }
441
442 #[test]
443 fn import_from_repos_json_parses_as_pathbuf() {
444 let cli =
445 parse(&["import", "--from-repos-json", "./REPOS.json"]).expect("import parses path");
446 match cli.verb {
447 Verb::Import(a) => {
448 assert_eq!(
449 a.from_repos_json.as_deref(),
450 Some(std::path::Path::new("./REPOS.json"))
451 );
452 }
453 _ => panic!("expected Import variant"),
454 }
455 }
456
457 #[test]
458 fn run_requires_action() {
459 let err = parse(&["run"]).expect_err("run w/o action must fail");
460 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
461 }
462
463 #[test]
464 fn unknown_verb_fails() {
465 let err = parse(&["nope"]).expect_err("unknown verb must fail");
466 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
467 }
468
469 #[test]
470 fn unknown_flag_fails() {
471 let err = parse(&["init", "--not-a-flag"]).expect_err("unknown flag must fail");
472 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
473 }
474
475 #[test]
476 fn test_cli_force_prune_flag_parsed() {
477 let cli = parse(&["sync", "."]).expect("sync . parses");
480 match cli.verb {
481 Verb::Sync(ref a) => {
482 assert!(!a.force_prune, "default --force-prune must be false");
483 assert!(
484 !a.force_prune_with_ignored,
485 "default --force-prune-with-ignored must be false"
486 );
487 }
488 _ => panic!("expected Sync variant"),
489 }
490 let cli = parse(&["sync", ".", "--force-prune"]).expect("sync --force-prune parses");
491 match cli.verb {
492 Verb::Sync(a) => {
493 assert!(a.force_prune, "--force-prune must set true");
494 assert!(
495 !a.force_prune_with_ignored,
496 "--force-prune-with-ignored stays default false"
497 );
498 }
499 _ => panic!("expected Sync variant"),
500 }
501 }
502
503 #[test]
504 fn test_cli_force_prune_with_ignored_flag_parsed() {
505 let cli = parse(&["sync", ".", "--force-prune-with-ignored"])
509 .expect("sync --force-prune-with-ignored parses");
510 match cli.verb {
511 Verb::Sync(a) => {
512 assert!(
513 !a.force_prune,
514 "--force-prune is independent of --force-prune-with-ignored at parse layer"
515 );
516 assert!(a.force_prune_with_ignored, "--force-prune-with-ignored must set true");
517 }
518 _ => panic!("expected Sync variant"),
519 }
520 let cli = parse(&["sync", ".", "--force-prune", "--force-prune-with-ignored"])
522 .expect("sync --force-prune --force-prune-with-ignored parses");
523 match cli.verb {
524 Verb::Sync(a) => {
525 assert!(a.force_prune);
526 assert!(a.force_prune_with_ignored);
527 }
528 _ => panic!("expected Sync variant"),
529 }
530 }
531
532 #[test]
533 fn cli_non_empty_string_rejects_whitespace() {
534 for bad in ["", " ", "\t", " ", "\n"] {
538 let err =
539 parse(&["sync", ".", "--ref", bad]).expect_err("whitespace --ref must be rejected");
540 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
541
542 let err = parse(&["sync", ".", "--only", bad])
543 .expect_err("whitespace --only must be rejected");
544 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
545 }
546 }
547}