Skip to main content

grex_cli/cli/
args.rs

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    /// Emit output as JSON.
16    #[arg(long, global = true, conflicts_with = "plain")]
17    pub json: bool,
18
19    /// Emit plain (non-color, non-table) output.
20    #[arg(long, global = true)]
21    pub plain: bool,
22
23    /// Show planned actions without executing them.
24    #[arg(long, global = true)]
25    pub dry_run: bool,
26
27    /// Filter packs by expression.
28    #[arg(long, global = true)]
29    pub filter: Option<String>,
30}
31
32#[derive(Subcommand, Debug)]
33pub enum Verb {
34    /// Initialize a grex workspace.
35    Init(InitArgs),
36    /// Register and clone a pack.
37    Add(AddArgs),
38    /// Teardown and remove a pack.
39    Rm(RmArgs),
40    /// List registered packs.
41    Ls(LsArgs),
42    /// Report drift vs lockfile.
43    Status(StatusArgs),
44    /// Git fetch and pull (recurse by default).
45    Sync(SyncArgs),
46    /// Sync plus re-run install on lock change.
47    Update(UpdateArgs),
48    /// Run integrity checks.
49    Doctor(DoctorArgs),
50    /// Start MCP stdio server.
51    Serve(ServeArgs),
52    /// Import legacy REPOS.json.
53    Import(ImportArgs),
54    /// Run a named action across packs.
55    Run(RunArgs),
56    /// Execute a shell command in pack context.
57    Exec(ExecArgs),
58    /// Tear down a pack tree (reverse of `sync`/`install`).
59    Teardown(TeardownArgs),
60}
61
62#[derive(Args, Debug)]
63pub struct InitArgs {}
64
65#[derive(Args, Debug)]
66pub struct AddArgs {
67    /// Git URL of the pack repo.
68    pub url: String,
69    /// Optional local path (defaults to repo name).
70    pub path: Option<String>,
71}
72
73#[derive(Args, Debug)]
74pub struct RmArgs {
75    /// Local path of the pack to remove.
76    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    /// Recurse into child packs.
88    #[arg(long, default_value_t = true)]
89    pub recursive: bool,
90
91    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
92    /// itself. When omitted, `sync` prints the legacy M1 stub and exits 0.
93    pub pack_root: Option<std::path::PathBuf>,
94
95    /// Workspace directory for cloned children. Defaults to
96    /// `<pack_root>/.grex/workspace`.
97    #[arg(long)]
98    pub workspace: Option<std::path::PathBuf>,
99
100    /// Plan actions without touching the filesystem.
101    #[arg(long, short = 'n')]
102    pub dry_run: bool,
103
104    /// Suppress per-action log lines.
105    #[arg(long, short = 'q')]
106    pub quiet: bool,
107
108    /// Skip plan-phase validators. Debug-only escape hatch.
109    #[arg(long)]
110    pub no_validate: bool,
111
112    /// Override the default ref for every pack in this sync invocation.
113    /// Accepts a branch, tag, or commit SHA. Empty strings are rejected.
114    #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
115    pub ref_override: Option<String>,
116
117    /// Restrict sync to packs whose workspace-relative path (or name)
118    /// matches the glob. Repeat the flag to OR-combine multiple patterns
119    /// (standard `*`/`**`/`?` semantics). Non-matching packs are skipped
120    /// entirely — no action execution, no lockfile write.
121    #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
122    pub only: Vec<String>,
123
124    /// Re-execute every pack even when its `actions_hash` is unchanged
125    /// from the prior lockfile. Overrides the M4-B skip-on-hash
126    /// short-circuit; dry-run semantics are unchanged.
127    #[arg(long)]
128    pub force: bool,
129
130    /// Max parallel pack ops during this sync run (feat-m6-1).
131    ///
132    /// Semantics:
133    /// * Absent → default `num_cpus::get()` resolved in `verbs::sync`.
134    /// * `0` → unbounded (`Semaphore::MAX_PERMITS`).
135    /// * `1` → serial fast-path (preserves pre-M6 wall-order).
136    /// * `2..=1024` → bounded parallel.
137    ///
138    /// Env fallback: `GREX_PARALLEL` is honoured only when the flag is
139    /// absent. Clap reads the env var automatically via `env`.
140    ///
141    /// Distinct from the global `--parallel` on [`GlobalFlags`]; that
142    /// knob is documented as the harness-level worker cap and rejects
143    /// `0`. Sync parallelism uses `0` as the "unbounded" sentinel per
144    /// `.omne/cfg/concurrency.md`.
145    #[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
153/// Clap `value_parser` that rejects empty or whitespace-only strings.
154/// Keeps `--ref ""`, `--ref " "`, `--only ""`, `--only "\t"` off the
155/// fast path. Whitespace-only values are rejected because they
156/// degrade silently inside the walker / globset layers rather than
157/// producing a useful error.
158fn 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    /// Optional pack path; if omitted, update all.
169    pub pack: Option<String>,
170}
171
172#[derive(Args, Debug)]
173pub struct DoctorArgs {
174    /// Heal gitignore drift by re-emitting the managed block. Safety:
175    /// NEVER touches the manifest or the filesystem on other checks.
176    #[arg(long)]
177    pub fix: bool,
178
179    /// Run the opt-in config-lint check (`openspec/config.yaml` +
180    /// `.omne/cfg/*.md`). Skipped by default.
181    #[arg(long = "lint-config")]
182    pub lint_config: bool,
183}
184
185#[derive(Args, Debug)]
186pub struct ServeArgs {
187    /// Path to the `grex.jsonl` event-log manifest. Captured at server
188    /// launch and immutable for the session (per spec §"Manifest binding").
189    /// Defaults to `<cwd>/grex.jsonl` when omitted.
190    #[arg(long, value_name = "PATH")]
191    pub manifest: Option<std::path::PathBuf>,
192
193    /// Workspace root the MCP server resolves relative paths against.
194    /// Defaults to the current working directory when omitted.
195    #[arg(long, value_name = "PATH")]
196    pub workspace: Option<std::path::PathBuf>,
197
198    /// Harness-level worker cap inherited by the MCP server's
199    /// `Scheduler` (feat-m7-1 stage 8.3). `1` = serial; range `1..=1024`.
200    /// Defaults to `std::thread::available_parallelism()` when omitted.
201    /// Distinct from `sync --parallel` which uses `0` = unbounded.
202    #[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    /// Path to a legacy REPOS.json file.
212    #[arg(long)]
213    pub from_repos_json: Option<std::path::PathBuf>,
214
215    /// Target manifest (`grex.jsonl`). Defaults to `<cwd>/grex.jsonl`.
216    #[arg(long, value_name = "PATH")]
217    pub manifest: Option<std::path::PathBuf>,
218
219    /// Verb-scoped dry-run. Alias for the global `--dry-run`; either
220    /// flag short-circuits before any manifest write.
221    #[arg(long = "dry-run", short = 'n')]
222    pub dry_run: bool,
223}
224
225#[derive(Args, Debug)]
226pub struct RunArgs {
227    /// Action name to run.
228    pub action: String,
229}
230
231#[derive(Args, Debug)]
232pub struct ExecArgs {
233    /// Shell command and args to execute.
234    #[arg(trailing_var_arg = true)]
235    pub cmd: Vec<String>,
236}
237
238#[derive(Args, Debug)]
239pub struct TeardownArgs {
240    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
241    /// itself. When omitted, `teardown` prints a usage stub and exits 0.
242    pub pack_root: Option<std::path::PathBuf>,
243
244    /// Workspace directory. Defaults to `<pack_root>/.grex/workspace`.
245    #[arg(long)]
246    pub workspace: Option<std::path::PathBuf>,
247
248    /// Suppress per-action log lines.
249    #[arg(long, short = 'q')]
250    pub quiet: bool,
251
252    /// Skip plan-phase validators. Debug-only escape hatch.
253    #[arg(long)]
254    pub no_validate: bool,
255}
256
257#[cfg(test)]
258mod tests {
259    //! Direct-parse unit tests. These bypass the spawned binary and hit
260    //! `Cli::try_parse_from` in-process — much faster than `assert_cmd`.
261    use super::*;
262    use clap::Parser;
263
264    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
265        // clap's `try_parse_from` expects argv[0] to be the binary name.
266        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        // `--json` and `--plain` are mutually exclusive, so split into two
344        // parses to exercise the remaining flags on both modes.
345        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        // feat-m6 B2 — `--parallel` is sync-scoped only; it must NOT
367        // be accepted as a global flag on verbs like `init`/`ls`.
368        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        // F8: `--ref " "` / `--only "\t"` must be rejected by the value
434        // parser, not silently threaded into the walker / globset layer
435        // where they degrade into useless errors.
436        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}