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
92#[derive(Args, Debug)]
93pub struct StatusArgs {}
94
95#[derive(Args, Debug)]
96pub struct SyncArgs {
97 #[arg(long, default_value_t = true)]
99 pub recursive: bool,
100
101 pub pack_root: Option<std::path::PathBuf>,
104
105 #[arg(long)]
108 pub workspace: Option<std::path::PathBuf>,
109
110 #[arg(long, short = 'n')]
112 pub dry_run: bool,
113
114 #[arg(long, short = 'q')]
116 pub quiet: bool,
117
118 #[arg(long)]
120 pub no_validate: bool,
121
122 #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
125 pub ref_override: Option<String>,
126
127 #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
132 pub only: Vec<String>,
133
134 #[arg(long)]
138 pub force: bool,
139
140 #[arg(
156 long = "parallel",
157 env = "GREX_PARALLEL",
158 value_parser = clap::value_parser!(u32).range(0..=1024),
159 )]
160 pub parallel: Option<u32>,
161}
162
163fn non_empty_string(s: &str) -> Result<String, String> {
169 if s.trim().is_empty() {
170 Err("value must not be empty or whitespace-only".to_string())
171 } else {
172 Ok(s.to_string())
173 }
174}
175
176#[derive(Args, Debug)]
177pub struct UpdateArgs {
178 pub pack: Option<String>,
180}
181
182#[derive(Args, Debug)]
183pub struct DoctorArgs {
184 #[arg(long)]
187 pub fix: bool,
188
189 #[arg(long = "lint-config")]
192 pub lint_config: bool,
193}
194
195#[derive(Args, Debug)]
196pub struct ServeArgs {
197 #[arg(long, value_name = "PATH")]
201 pub manifest: Option<std::path::PathBuf>,
202
203 #[arg(long, value_name = "PATH")]
206 pub workspace: Option<std::path::PathBuf>,
207
208 #[arg(
213 long = "parallel",
214 value_parser = clap::value_parser!(u32).range(1..=1024),
215 )]
216 pub parallel: Option<u32>,
217}
218
219#[derive(Args, Debug)]
220pub struct ImportArgs {
221 #[arg(long)]
223 pub from_repos_json: Option<std::path::PathBuf>,
224
225 #[arg(long, value_name = "PATH")]
227 pub manifest: Option<std::path::PathBuf>,
228
229 #[arg(long = "dry-run", short = 'n')]
232 pub dry_run: bool,
233}
234
235#[derive(Args, Debug)]
236pub struct RunArgs {
237 pub action: String,
239}
240
241#[derive(Args, Debug)]
242pub struct ExecArgs {
243 #[arg(trailing_var_arg = true)]
245 pub cmd: Vec<String>,
246}
247
248#[derive(Args, Debug)]
249pub struct TeardownArgs {
250 pub pack_root: Option<std::path::PathBuf>,
253
254 #[arg(long)]
257 pub workspace: Option<std::path::PathBuf>,
258
259 #[arg(long, short = 'q')]
261 pub quiet: bool,
262
263 #[arg(long)]
265 pub no_validate: bool,
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
273 use clap::Parser;
274
275 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
276 let mut full = vec!["grex"];
278 full.extend_from_slice(args);
279 Cli::try_parse_from(full)
280 }
281
282 #[test]
283 fn init_parses_to_init_variant() {
284 let cli = parse(&["init"]).expect("init parses");
285 assert!(matches!(cli.verb, Verb::Init(_)));
286 }
287
288 #[test]
289 fn add_parses_url_and_optional_path() {
290 let cli = parse(&["add", "https://example.com/repo.git"]).expect("add url parses");
291 match cli.verb {
292 Verb::Add(a) => {
293 assert_eq!(a.url, "https://example.com/repo.git");
294 assert!(a.path.is_none());
295 }
296 _ => panic!("expected Add variant"),
297 }
298
299 let cli = parse(&["add", "https://example.com/repo.git", "local"])
300 .expect("add url + path parses");
301 match cli.verb {
302 Verb::Add(a) => {
303 assert_eq!(a.url, "https://example.com/repo.git");
304 assert_eq!(a.path.as_deref(), Some("local"));
305 }
306 _ => panic!("expected Add variant"),
307 }
308 }
309
310 #[test]
311 fn rm_parses_path() {
312 let cli = parse(&["rm", "pack-a"]).expect("rm parses");
313 match cli.verb {
314 Verb::Rm(a) => assert_eq!(a.path, "pack-a"),
315 _ => panic!("expected Rm variant"),
316 }
317 }
318
319 #[test]
320 fn sync_recursive_defaults_to_true() {
321 let cli = parse(&["sync"]).expect("sync parses");
322 match cli.verb {
323 Verb::Sync(a) => assert!(a.recursive, "sync should default to recursive=true"),
324 _ => panic!("expected Sync variant"),
325 }
326 }
327
328 #[test]
329 fn update_pack_is_optional() {
330 let cli = parse(&["update"]).expect("update parses bare");
331 match cli.verb {
332 Verb::Update(a) => assert!(a.pack.is_none()),
333 _ => panic!("expected Update variant"),
334 }
335
336 let cli = parse(&["update", "mypack"]).expect("update parses w/ pack");
337 match cli.verb {
338 Verb::Update(a) => assert_eq!(a.pack.as_deref(), Some("mypack")),
339 _ => panic!("expected Update variant"),
340 }
341 }
342
343 #[test]
344 fn exec_collects_trailing_args() {
345 let cli = parse(&["exec", "echo", "hi", "there"]).expect("exec parses");
346 match cli.verb {
347 Verb::Exec(a) => assert_eq!(a.cmd, vec!["echo", "hi", "there"]),
348 _ => panic!("expected Exec variant"),
349 }
350 }
351
352 #[test]
353 fn universal_flags_populate_on_any_verb() {
354 let cli = parse(&["ls", "--json", "--dry-run", "--filter", "kind=git"])
357 .expect("ls w/ json+dry-run+filter parses");
358 assert!(cli.global.json);
359 assert!(!cli.global.plain);
360 assert!(cli.global.dry_run);
361 assert_eq!(cli.global.filter.as_deref(), Some("kind=git"));
362
363 let cli = parse(&["ls", "--plain", "--dry-run"]).expect("ls w/ plain+dry-run parses");
364 assert!(!cli.global.json);
365 assert!(cli.global.plain);
366 }
367
368 #[test]
369 fn json_and_plain_conflict() {
370 let err =
371 parse(&["init", "--json", "--plain"]).expect_err("--json and --plain must conflict");
372 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
373 }
374
375 #[test]
376 fn parallel_not_global_rejected_on_non_sync_verb() {
377 let err =
380 parse(&["init", "--parallel", "1"]).expect_err("--parallel on non-sync verb must fail");
381 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
382 }
383
384 #[test]
385 fn sync_parallel_one_accepted() {
386 let cli = parse(&["sync", "--parallel", "1"]).expect("sync --parallel 1 parses");
387 match cli.verb {
388 Verb::Sync(a) => assert_eq!(a.parallel, Some(1)),
389 _ => panic!("expected Sync variant"),
390 }
391 }
392
393 #[test]
394 fn sync_parallel_max_accepted() {
395 let cli = parse(&["sync", "--parallel", "1024"]).expect("sync --parallel 1024 parses");
396 match cli.verb {
397 Verb::Sync(a) => assert_eq!(a.parallel, Some(1024)),
398 _ => panic!("expected Sync variant"),
399 }
400 }
401
402 #[test]
403 fn sync_parallel_over_max_rejected() {
404 let err =
405 parse(&["sync", "--parallel", "1025"]).expect_err("sync --parallel 1025 must fail");
406 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
407 }
408
409 #[test]
410 fn import_from_repos_json_parses_as_pathbuf() {
411 let cli =
412 parse(&["import", "--from-repos-json", "./REPOS.json"]).expect("import parses path");
413 match cli.verb {
414 Verb::Import(a) => {
415 assert_eq!(
416 a.from_repos_json.as_deref(),
417 Some(std::path::Path::new("./REPOS.json"))
418 );
419 }
420 _ => panic!("expected Import variant"),
421 }
422 }
423
424 #[test]
425 fn run_requires_action() {
426 let err = parse(&["run"]).expect_err("run w/o action must fail");
427 assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
428 }
429
430 #[test]
431 fn unknown_verb_fails() {
432 let err = parse(&["nope"]).expect_err("unknown verb must fail");
433 assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
434 }
435
436 #[test]
437 fn unknown_flag_fails() {
438 let err = parse(&["init", "--not-a-flag"]).expect_err("unknown flag must fail");
439 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
440 }
441
442 #[test]
443 fn cli_non_empty_string_rejects_whitespace() {
444 for bad in ["", " ", "\t", " ", "\n"] {
448 let err =
449 parse(&["sync", ".", "--ref", bad]).expect_err("whitespace --ref must be rejected");
450 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
451
452 let err = parse(&["sync", ".", "--only", bad])
453 .expect_err("whitespace --only must be rejected");
454 assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
455 }
456 }
457}