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