Skip to main content

grex_cli/cli/
args.rs

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    /// Emit output as JSON.
26    #[arg(long, global = true, conflicts_with = "plain")]
27    pub json: bool,
28
29    /// Emit plain (non-color, non-table) output.
30    #[arg(long, global = true)]
31    pub plain: bool,
32
33    /// Show planned actions without executing them.
34    #[arg(long, global = true)]
35    pub dry_run: bool,
36
37    /// Filter packs by expression.
38    #[arg(long, global = true)]
39    pub filter: Option<String>,
40}
41
42#[derive(Subcommand, Debug)]
43pub enum Verb {
44    /// Initialize a grex workspace.
45    Init(InitArgs),
46    /// Register and clone a pack.
47    Add(AddArgs),
48    /// Teardown and remove a pack.
49    Rm(RmArgs),
50    /// List registered packs.
51    Ls(LsArgs),
52    /// Report drift vs lockfile.
53    Status(StatusArgs),
54    /// Git fetch and pull (recurse by default).
55    Sync(SyncArgs),
56    /// Sync plus re-run install on lock change.
57    Update(UpdateArgs),
58    /// Run integrity checks.
59    Doctor(DoctorArgs),
60    /// Start MCP stdio server.
61    Serve(ServeArgs),
62    /// Import legacy REPOS.json.
63    Import(ImportArgs),
64    /// Run a named action across packs.
65    Run(RunArgs),
66    /// Execute a shell command in pack context.
67    Exec(ExecArgs),
68    /// Tear down a pack tree (reverse of `sync`/`install`).
69    Teardown(TeardownArgs),
70    /// Migrate a v1.1.x lockfile in place to the v1.2.0 schema (opt-in,
71    /// idempotent). Thin shim over the v1.2.0 Stage 1.h library
72    /// migrator (`grex_core::lockfile::migrate_v1_1_1`).
73    #[command(name = "migrate-lockfile")]
74    MigrateLockfile(MigrateLockfileArgs),
75}
76
77#[derive(Args, Debug)]
78pub struct InitArgs {
79    /// Workspace path to initialize. Defaults to the current working
80    /// directory. The directory is created if it does not exist. The
81    /// command refuses to overwrite an existing `.grex/pack.yaml` —
82    /// idempotent only on a fresh tree.
83    #[arg(value_parser = pack_path)]
84    pub path: Option<std::path::PathBuf>,
85}
86
87#[derive(Args, Debug)]
88pub struct AddArgs {
89    /// Git URL of the pack repo.
90    pub url: String,
91    /// Optional local path (defaults to repo name).
92    pub path: Option<String>,
93    /// v1.3.3 B10 — git ref to pin at add time. Accepts `<branch>`,
94    /// `<commit>` (7..40 hex chars), or `<branch>@<commit>` (single
95    /// flag, `@` delimiter). When omitted, defaults to the remote's
96    /// `main` branch HEAD per the 8-cell folder-FA design.
97    #[arg(long = "ref", value_name = "GIT-REF", value_parser = non_empty_string)]
98    pub git_ref: Option<String>,
99}
100
101#[derive(Args, Debug)]
102pub struct RmArgs {
103    /// Local path of the pack to remove.
104    pub path: String,
105
106    /// Remove a meta-pack even if it still has registered children.
107    /// Without this flag, `rm` refuses to delete a meta-pack with
108    /// non-empty `children:` to avoid orphaning sub-trees.
109    #[arg(long)]
110    pub force: bool,
111}
112
113#[derive(Args, Debug)]
114pub struct LsArgs {
115    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
116    /// itself. Defaults to the current working directory. Pass `.` to
117    /// substitute the current working directory explicitly (v1.3.3 B3).
118    #[arg(value_parser = pack_path)]
119    pub pack_root: Option<std::path::PathBuf>,
120}
121
122#[derive(Args, Debug)]
123pub struct StatusArgs {
124    /// Pack root to inspect. Directory holding `.grex/pack.yaml`, or
125    /// the YAML file itself. When omitted, defaults to the current
126    /// working directory (v1.3.1 B2 cwd-default).
127    #[arg(value_parser = pack_path)]
128    pub pack_root: Option<std::path::PathBuf>,
129}
130
131#[derive(Args, Debug)]
132pub struct SyncArgs {
133    /// Recurse into child packs.
134    #[arg(long, default_value_t = true)]
135    pub recursive: bool,
136
137    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
138    /// itself. When omitted, `sync` prints the legacy M1 stub and exits 0.
139    /// Pass `.` to substitute the current working directory explicitly
140    /// (v1.3.3 B3).
141    #[arg(value_parser = pack_path)]
142    pub pack_root: Option<std::path::PathBuf>,
143
144    /// Path to the pack root (formerly `--workspace`). Defaults to the
145    /// pack root directory (where `.grex/pack.yaml` lives). When set,
146    /// this path becomes the canonical meta directory: children resolve
147    /// parent-relatively as `<pack>/<child.path>`. The path MUST exist;
148    /// symlinks are resolved to their canonical inode (logged as
149    /// `pack: <input> → <canonical>` when it differs). The legacy
150    /// `--workspace` spelling is preserved as a deprecated alias and
151    /// emits a one-time warning per process; removal scheduled for
152    /// v2.0.0. Pass `.` to substitute the current working directory
153    /// explicitly (v1.3.3 B3).
154    #[arg(long = "pack", alias = "workspace", value_parser = pack_path)]
155    pub pack: Option<std::path::PathBuf>,
156
157    /// Plan actions without touching the filesystem.
158    #[arg(long, short = 'n')]
159    pub dry_run: bool,
160
161    /// Suppress per-action log lines.
162    #[arg(long, short = 'q')]
163    pub quiet: bool,
164
165    /// Skip plan-phase validators. Debug-only escape hatch.
166    #[arg(long)]
167    pub no_validate: bool,
168
169    /// Override the default ref for every pack in this sync invocation.
170    /// Accepts a branch, tag, or commit SHA. Empty strings are rejected.
171    #[arg(long = "ref", value_name = "REF", value_parser = non_empty_string)]
172    pub ref_override: Option<String>,
173
174    /// Restrict sync to packs whose workspace-relative path (or name)
175    /// matches the glob. Repeat the flag to OR-combine multiple patterns
176    /// (standard `*`/`**`/`?` semantics). Non-matching packs are skipped
177    /// entirely — no action execution, no lockfile write.
178    #[arg(long = "only", value_name = "GLOB", value_parser = non_empty_string)]
179    pub only: Vec<String>,
180
181    /// Re-execute every pack even when its `actions_hash` is unchanged
182    /// from the prior lockfile. Overrides the M4-B skip-on-hash
183    /// short-circuit; dry-run semantics are unchanged.
184    #[arg(long)]
185    pub force: bool,
186
187    /// v1.2.0 Stage 1.l — Override Phase 2 prune-safety refusal for
188    /// dirty (tracked or untracked-non-ignored) working trees. Still
189    /// refuses ignored content unless `--force-prune-with-ignored` is
190    /// also set. Never overrides `GitInProgress` (mid-rebase / merge /
191    /// cherry-pick / revert / bisect).
192    #[arg(long = "force-prune")]
193    pub force_prune: bool,
194
195    /// v1.2.0 Stage 1.l — Strongest prune override. Implies
196    /// `--force-prune` and additionally drops trees whose only dirt is
197    /// in `--ignored` paths (build artefacts, `target/`, `node_modules/`).
198    /// Never overrides `GitInProgress`.
199    #[arg(long = "force-prune-with-ignored")]
200    pub force_prune_with_ignored: bool,
201
202    /// v1.2.1 Item 5b — Recursively snapshot Phase 2 prune targets to
203    /// `<meta>/.grex/trash/<ISO8601>/<basename>/` BEFORE deletion.
204    /// Audit log entry (`QuarantineStart`) is appended + fsync'd
205    /// before any byte is copied; on snapshot failure the prune
206    /// aborts and the original dest is left intact for forensics.
207    /// Requires `--force-prune` or `--force-prune-with-ignored` —
208    /// quarantine only applies to overridden prunes; clean-consent
209    /// prunes still go through the direct-unlink fast path. The
210    /// "requires one of" check is enforced in the verb handler
211    /// (see `crates/grex/src/cli/verbs/sync.rs`) since clap's
212    /// `requires`/`required_unless_present_any` semantics don't
213    /// model "X requires (A or B)" cleanly without an `ArgGroup`.
214    /// Matches Lean theorem `quarantine_snapshot_precedes_delete`.
215    #[arg(long = "quarantine")]
216    pub quarantine: bool,
217
218    /// Max parallel pack ops during this sync run (feat-m6-1).
219    ///
220    /// Semantics:
221    /// * Absent → default `num_cpus::get()` resolved in `verbs::sync`.
222    /// * `0` → unbounded (`Semaphore::MAX_PERMITS`).
223    /// * `1` → serial fast-path (preserves pre-M6 wall-order).
224    /// * `2..=1024` → bounded parallel.
225    ///
226    /// Env fallback: `GREX_PARALLEL` is honoured only when the flag is
227    /// absent. Clap reads the env var automatically via `env`.
228    ///
229    /// Distinct from the global `--parallel` on [`GlobalFlags`]; that
230    /// knob is documented as the harness-level worker cap and rejects
231    /// `0`. Sync parallelism uses `0` as the "unbounded" sentinel per
232    /// `inst/concurrency.md`.
233    #[arg(
234        long = "parallel",
235        env = "GREX_PARALLEL",
236        value_parser = clap::value_parser!(u32).range(0..=1024),
237    )]
238    pub parallel: Option<u32>,
239
240    /// v1.2.5 — sweep `<meta>/.grex/trash/` of entries older than
241    /// `N` days at the start of every meta sync (best-effort). When
242    /// omitted, no GC fires (v1.2.1 indefinite-retention behavior is
243    /// preserved). When set, sweep failures log via tracing and do
244    /// NOT halt the sync.
245    #[arg(long = "retain-days", value_name = "N")]
246    pub retain_days: Option<u32>,
247}
248
249/// Clap `value_parser` that rejects empty or whitespace-only strings.
250/// Keeps `--ref ""`, `--ref " "`, `--only ""`, `--only "\t"` off the
251/// fast path. Whitespace-only values are rejected because they
252/// degrade silently inside the walker / globset layers rather than
253/// producing a useful error.
254fn non_empty_string(s: &str) -> Result<String, String> {
255    if s.trim().is_empty() {
256        Err("value must not be empty or whitespace-only".to_string())
257    } else {
258        Ok(s.to_string())
259    }
260}
261
262/// v1.3.3 B3 — Clap `value_parser` for `--pack` / `--workspace` / pack-root
263/// positional path args. Substitutes the literal `.` token with
264/// `std::env::current_dir()` at parse time so all downstream code sees a
265/// concrete cwd path. Per design.md § B3 (Q2): cwd-only resolution, **no
266/// walk-up**. Other relative path forms (`..`, `./subdir`, etc.) flow
267/// through unchanged.
268fn pack_path(s: &str) -> Result<std::path::PathBuf, String> {
269    if s == "." {
270        std::env::current_dir().map_err(|e| format!("`.` shorthand: cannot resolve cwd: {e}"))
271    } else {
272        Ok(std::path::PathBuf::from(s))
273    }
274}
275
276#[derive(Args, Debug)]
277pub struct UpdateArgs {
278    /// Optional pack path; if omitted, update all.
279    pub pack: Option<String>,
280}
281
282#[derive(Args, Debug)]
283pub struct DoctorArgs {
284    /// Heal gitignore drift by re-emitting the managed block. Safety:
285    /// NEVER touches the manifest or the filesystem on other checks.
286    #[arg(long)]
287    pub fix: bool,
288
289    /// Run the opt-in config-lint check (`openspec/config.yaml` +
290    /// `inst/cfg/*.md`). Skipped by default.
291    #[arg(long = "lint-config")]
292    pub lint_config: bool,
293
294    /// v1.2.0 Stage 1.j — bound the recursive ManifestTree walk.
295    /// Omitted: walk every nested meta exhaustively (default).
296    /// `--shallow 0`: root meta only.
297    /// `--shallow N`: recurse up to `N` levels of nesting (root is
298    /// depth 0; depth-`N` metas are visited but their children are
299    /// not). The walk is read-only at every frame.
300    #[arg(long = "shallow", value_name = "N")]
301    pub shallow: Option<usize>,
302
303    /// v1.2.1 item 4 — opt-in full-filesystem scan for `.git/`
304    /// directories that are not registered in the manifest tree.
305    /// Read-only audit; complements the manifest-driven default walk.
306    /// Composes with `--shallow` (which bounds the manifest walk).
307    /// Use `--depth N` to bound the filesystem scan independently.
308    #[arg(long = "scan-undeclared")]
309    pub scan_undeclared: bool,
310
311    /// v1.2.1 item 4 — bound the `--scan-undeclared` filesystem walk.
312    /// Omitted: scan every level under the workspace (default).
313    /// `--depth 0`: workspace root only.
314    /// `--depth N`: descend up to `N` directory levels below the
315    /// workspace root. Has no effect unless `--scan-undeclared` is
316    /// also set.
317    #[arg(long = "depth", value_name = "N", requires = "scan_undeclared")]
318    pub depth: Option<usize>,
319
320    /// v1.2.5 — sweep `<workspace>/.grex/trash/` of entries older than
321    /// the supplied retention window (in days). Pairs with the
322    /// canonical retention default surfaced by
323    /// [`grex_core::tree::DEFAULT_RETAIN_DAYS`] when the operator
324    /// omits a value. Best-effort: per-entry failures log via
325    /// `tracing::warn!` and do not halt the doctor run.
326    #[arg(long = "prune-quarantine")]
327    pub prune_quarantine: bool,
328
329    /// v1.2.5 — explicit retention window for `--prune-quarantine`
330    /// (and `grex sync`'s GC sweep). Defaults to
331    /// [`grex_core::tree::DEFAULT_RETAIN_DAYS`] when `--prune-quarantine`
332    /// is set without an explicit value. Has no effect unless
333    /// `--prune-quarantine` is also passed (or threaded into
334    /// `grex sync` via the matching flag there).
335    #[arg(long = "retain-days", value_name = "N")]
336    pub retain_days: Option<u32>,
337
338    /// v1.2.5 — restore the snapshot at
339    /// `<workspace>/.grex/trash/<TS>/<BASENAME>/` back into the
340    /// workspace. When BASENAME is omitted the `<TS>/` slot must hold
341    /// exactly one child entry (otherwise restore is refused as
342    /// ambiguous). Refuses to clobber an existing dest unless
343    /// `--force` is also passed.
344    #[arg(long = "restore-quarantine", value_name = "TS[:BASENAME]", num_args = 1)]
345    pub restore_quarantine: Option<String>,
346
347    /// v1.2.5 — paired with `--restore-quarantine`: when set, remove
348    /// the existing dest before the rename. Without this flag,
349    /// restore refuses to clobber an existing dest.
350    #[arg(long = "force", requires = "restore_quarantine")]
351    pub force: bool,
352}
353
354#[derive(Args, Debug)]
355pub struct ServeArgs {
356    /// Path to the `.grex/events.jsonl` event log. Captured at server
357    /// launch and immutable for the session (per spec §"Manifest binding").
358    /// Defaults to `<workspace>/.grex/events.jsonl` when omitted, where
359    /// `<workspace>` is resolved by walking up from cwd to the nearest
360    /// `.grex/` marker. v1.x `<workspace>/grex.jsonl` event logs are
361    /// auto-migrated to the canonical location on first access.
362    #[arg(long, value_name = "PATH")]
363    pub manifest: Option<std::path::PathBuf>,
364
365    /// Path to the pack root (formerly `--workspace`) the MCP server
366    /// resolves relative paths against. Defaults to the current working
367    /// directory when omitted. The legacy `--workspace` spelling is
368    /// preserved as a deprecated alias and emits a one-time warning per
369    /// process; removal scheduled for v2.0.0. Pass `.` to substitute
370    /// the current working directory explicitly (v1.3.3 B3).
371    #[arg(long = "pack", alias = "workspace", value_name = "PATH", value_parser = pack_path)]
372    pub pack: Option<std::path::PathBuf>,
373
374    /// Harness-level worker cap inherited by the MCP server's
375    /// `Scheduler` (feat-m7-1 stage 8.3). `1` = serial; range `1..=1024`.
376    /// Defaults to `std::thread::available_parallelism()` when omitted.
377    /// Distinct from `sync --parallel` which uses `0` = unbounded.
378    #[arg(
379        long = "parallel",
380        value_parser = clap::value_parser!(u32).range(1..=1024),
381    )]
382    pub parallel: Option<u32>,
383}
384
385#[derive(Args, Debug)]
386pub struct ImportArgs {
387    /// Path to a legacy REPOS.json file.
388    #[arg(long)]
389    pub from_repos_json: Option<std::path::PathBuf>,
390
391    /// Target event log (`.grex/events.jsonl`). Defaults to
392    /// `<workspace>/.grex/events.jsonl` where `<workspace>` is resolved
393    /// by walking up from cwd to the nearest `.grex/` marker.
394    #[arg(long, value_name = "PATH")]
395    pub manifest: Option<std::path::PathBuf>,
396
397    /// Verb-scoped dry-run. Alias for the global `--dry-run`; either
398    /// flag short-circuits before any manifest write.
399    #[arg(long = "dry-run", short = 'n')]
400    pub dry_run: bool,
401}
402
403#[derive(Args, Debug)]
404pub struct RunArgs {
405    /// Action name to run.
406    pub action: String,
407
408    /// Pack root to walk. Directory holding `.grex/pack.yaml`, or the
409    /// YAML file itself. When omitted, defaults to the current working
410    /// directory (v1.3.1 B2 cwd-default).
411    #[arg(value_parser = pack_path)]
412    pub pack_root: Option<std::path::PathBuf>,
413}
414
415#[derive(Args, Debug)]
416pub struct ExecArgs {
417    /// Pack root in which to run the command. Defaults to the current
418    /// working directory (v1.3.1 B2 cwd-default).
419    #[arg(long = "pack", value_parser = pack_path)]
420    pub pack: Option<std::path::PathBuf>,
421
422    /// Command + args to execute. The first element is the program
423    /// name; subsequent elements are passed verbatim. For shell
424    /// expansion use `sh -c '...'` or `pwsh -Command ...`.
425    #[arg(trailing_var_arg = true, required = true)]
426    pub cmd: Vec<String>,
427}
428
429#[derive(Args, Debug)]
430pub struct MigrateLockfileArgs {
431    /// Path to the pack root (formerly `--workspace`) — the meta whose
432    /// `.grex/grex.lock.jsonl` is migrated. Defaults to the current
433    /// working directory. The legacy `--workspace` spelling is preserved
434    /// as a deprecated alias and emits a one-time warning per process;
435    /// removal scheduled for v2.0.0. Pass `.` to substitute the current
436    /// working directory explicitly (v1.3.3 B3).
437    #[arg(long = "pack", alias = "workspace", value_name = "PATH", value_parser = pack_path)]
438    pub pack: Option<std::path::PathBuf>,
439
440    /// Inspect-only: detect schema version and report what would happen
441    /// without writing. Lockfile bytes are unchanged.
442    #[arg(long = "dry-run", short = 'n')]
443    pub dry_run: bool,
444}
445
446#[derive(Args, Debug)]
447pub struct TeardownArgs {
448    /// Pack root. Directory holding `.grex/pack.yaml`, or the YAML file
449    /// itself. When omitted, `teardown` prints a usage stub and exits 0.
450    /// Pass `.` to substitute the current working directory explicitly
451    /// (v1.3.3 B3).
452    #[arg(value_parser = pack_path)]
453    pub pack_root: Option<std::path::PathBuf>,
454
455    /// Path to the pack root (formerly `--workspace`). Defaults to the
456    /// pack root directory (where `.grex/pack.yaml` lives). When set,
457    /// this path becomes the canonical meta directory: children resolve
458    /// parent-relatively as `<pack>/<child.path>`. The path MUST exist;
459    /// symlinks are resolved to their canonical inode (logged as
460    /// `pack: <input> → <canonical>` when it differs). The legacy
461    /// `--workspace` spelling is preserved as a deprecated alias and
462    /// emits a one-time warning per process; removal scheduled for
463    /// v2.0.0. Pass `.` to substitute the current working directory
464    /// explicitly (v1.3.3 B3).
465    #[arg(long = "pack", alias = "workspace", value_parser = pack_path)]
466    pub pack: Option<std::path::PathBuf>,
467
468    /// Suppress per-action log lines.
469    #[arg(long, short = 'q')]
470    pub quiet: bool,
471
472    /// Skip plan-phase validators. Debug-only escape hatch.
473    #[arg(long)]
474    pub no_validate: bool,
475}
476
477#[cfg(test)]
478mod tests {
479    //! Direct-parse unit tests. These bypass the spawned binary and hit
480    //! `Cli::try_parse_from` in-process — much faster than `assert_cmd`.
481    use super::*;
482    use clap::Parser;
483
484    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
485        // clap's `try_parse_from` expects argv[0] to be the binary name.
486        let mut full = vec!["grex"];
487        full.extend_from_slice(args);
488        Cli::try_parse_from(full)
489    }
490
491    #[test]
492    fn init_parses_to_init_variant() {
493        let cli = parse(&["init"]).expect("init parses");
494        assert!(matches!(cli.verb, Verb::Init(_)));
495    }
496
497    #[test]
498    fn add_parses_url_and_optional_path() {
499        let cli = parse(&["add", "https://example.com/repo.git"]).expect("add url parses");
500        match cli.verb {
501            Verb::Add(a) => {
502                assert_eq!(a.url, "https://example.com/repo.git");
503                assert!(a.path.is_none());
504            }
505            _ => panic!("expected Add variant"),
506        }
507
508        let cli = parse(&["add", "https://example.com/repo.git", "local"])
509            .expect("add url + path parses");
510        match cli.verb {
511            Verb::Add(a) => {
512                assert_eq!(a.url, "https://example.com/repo.git");
513                assert_eq!(a.path.as_deref(), Some("local"));
514            }
515            _ => panic!("expected Add variant"),
516        }
517    }
518
519    #[test]
520    fn rm_parses_path() {
521        let cli = parse(&["rm", "pack-a"]).expect("rm parses");
522        match cli.verb {
523            Verb::Rm(a) => assert_eq!(a.path, "pack-a"),
524            _ => panic!("expected Rm variant"),
525        }
526    }
527
528    #[test]
529    fn sync_recursive_defaults_to_true() {
530        let cli = parse(&["sync"]).expect("sync parses");
531        match cli.verb {
532            Verb::Sync(a) => assert!(a.recursive, "sync should default to recursive=true"),
533            _ => panic!("expected Sync variant"),
534        }
535    }
536
537    #[test]
538    fn update_pack_is_optional() {
539        let cli = parse(&["update"]).expect("update parses bare");
540        match cli.verb {
541            Verb::Update(a) => assert!(a.pack.is_none()),
542            _ => panic!("expected Update variant"),
543        }
544
545        let cli = parse(&["update", "mypack"]).expect("update parses w/ pack");
546        match cli.verb {
547            Verb::Update(a) => assert_eq!(a.pack.as_deref(), Some("mypack")),
548            _ => panic!("expected Update variant"),
549        }
550    }
551
552    #[test]
553    fn exec_collects_trailing_args() {
554        let cli = parse(&["exec", "echo", "hi", "there"]).expect("exec parses");
555        match cli.verb {
556            Verb::Exec(a) => assert_eq!(a.cmd, vec!["echo", "hi", "there"]),
557            _ => panic!("expected Exec variant"),
558        }
559    }
560
561    #[test]
562    fn universal_flags_populate_on_any_verb() {
563        // `--json` and `--plain` are mutually exclusive, so split into two
564        // parses to exercise the remaining flags on both modes.
565        let cli = parse(&["ls", "--json", "--dry-run", "--filter", "kind=git"])
566            .expect("ls w/ json+dry-run+filter parses");
567        assert!(cli.global.json);
568        assert!(!cli.global.plain);
569        assert!(cli.global.dry_run);
570        assert_eq!(cli.global.filter.as_deref(), Some("kind=git"));
571
572        let cli = parse(&["ls", "--plain", "--dry-run"]).expect("ls w/ plain+dry-run parses");
573        assert!(!cli.global.json);
574        assert!(cli.global.plain);
575    }
576
577    #[test]
578    fn json_and_plain_conflict() {
579        let err =
580            parse(&["init", "--json", "--plain"]).expect_err("--json and --plain must conflict");
581        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
582    }
583
584    #[test]
585    fn parallel_not_global_rejected_on_non_sync_verb() {
586        // feat-m6 B2 — `--parallel` is sync-scoped only; it must NOT
587        // be accepted as a global flag on verbs like `init`/`ls`.
588        let err =
589            parse(&["init", "--parallel", "1"]).expect_err("--parallel on non-sync verb must fail");
590        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
591    }
592
593    #[test]
594    fn sync_parallel_one_accepted() {
595        let cli = parse(&["sync", "--parallel", "1"]).expect("sync --parallel 1 parses");
596        match cli.verb {
597            Verb::Sync(a) => assert_eq!(a.parallel, Some(1)),
598            _ => panic!("expected Sync variant"),
599        }
600    }
601
602    #[test]
603    fn sync_parallel_max_accepted() {
604        let cli = parse(&["sync", "--parallel", "1024"]).expect("sync --parallel 1024 parses");
605        match cli.verb {
606            Verb::Sync(a) => assert_eq!(a.parallel, Some(1024)),
607            _ => panic!("expected Sync variant"),
608        }
609    }
610
611    #[test]
612    fn sync_parallel_over_max_rejected() {
613        let err =
614            parse(&["sync", "--parallel", "1025"]).expect_err("sync --parallel 1025 must fail");
615        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
616    }
617
618    #[test]
619    fn import_from_repos_json_parses_as_pathbuf() {
620        let cli =
621            parse(&["import", "--from-repos-json", "./REPOS.json"]).expect("import parses path");
622        match cli.verb {
623            Verb::Import(a) => {
624                assert_eq!(
625                    a.from_repos_json.as_deref(),
626                    Some(std::path::Path::new("./REPOS.json"))
627                );
628            }
629            _ => panic!("expected Import variant"),
630        }
631    }
632
633    #[test]
634    fn run_requires_action() {
635        let err = parse(&["run"]).expect_err("run w/o action must fail");
636        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
637    }
638
639    #[test]
640    fn unknown_verb_fails() {
641        let err = parse(&["nope"]).expect_err("unknown verb must fail");
642        assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
643    }
644
645    #[test]
646    fn unknown_flag_fails() {
647        let err = parse(&["init", "--not-a-flag"]).expect_err("unknown flag must fail");
648        assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
649    }
650
651    #[test]
652    fn test_cli_force_prune_flag_parsed() {
653        // v1.2.0 Stage 1.l — `--force-prune` toggles the SyncArgs
654        // bool. Default is `false`.
655        let cli = parse(&["sync", "."]).expect("sync . parses");
656        match cli.verb {
657            Verb::Sync(ref a) => {
658                assert!(!a.force_prune, "default --force-prune must be false");
659                assert!(
660                    !a.force_prune_with_ignored,
661                    "default --force-prune-with-ignored must be false"
662                );
663            }
664            _ => panic!("expected Sync variant"),
665        }
666        let cli = parse(&["sync", ".", "--force-prune"]).expect("sync --force-prune parses");
667        match cli.verb {
668            Verb::Sync(a) => {
669                assert!(a.force_prune, "--force-prune must set true");
670                assert!(
671                    !a.force_prune_with_ignored,
672                    "--force-prune-with-ignored stays default false"
673                );
674            }
675            _ => panic!("expected Sync variant"),
676        }
677    }
678
679    #[test]
680    fn test_cli_force_prune_with_ignored_flag_parsed() {
681        // v1.2.0 Stage 1.l — `--force-prune-with-ignored` toggles
682        // independently of `--force-prune`. Walker layer interprets the
683        // matrix; CLI just parses the bools.
684        let cli = parse(&["sync", ".", "--force-prune-with-ignored"])
685            .expect("sync --force-prune-with-ignored parses");
686        match cli.verb {
687            Verb::Sync(a) => {
688                assert!(
689                    !a.force_prune,
690                    "--force-prune is independent of --force-prune-with-ignored at parse layer"
691                );
692                assert!(a.force_prune_with_ignored, "--force-prune-with-ignored must set true");
693            }
694            _ => panic!("expected Sync variant"),
695        }
696        // Both flags together: caller's documented "stronger" combo.
697        let cli = parse(&["sync", ".", "--force-prune", "--force-prune-with-ignored"])
698            .expect("sync --force-prune --force-prune-with-ignored parses");
699        match cli.verb {
700            Verb::Sync(a) => {
701                assert!(a.force_prune);
702                assert!(a.force_prune_with_ignored);
703            }
704            _ => panic!("expected Sync variant"),
705        }
706    }
707
708    #[test]
709    fn b3_dot_resolves_to_cwd_for_pack_flag_on_sync() {
710        // v1.3.3 B3 — `--pack .` substitutes process cwd at parse time.
711        let cwd = std::env::current_dir().expect("cwd available");
712        let cli = parse(&["sync", "--pack", "."]).expect("sync --pack . parses");
713        match cli.verb {
714            Verb::Sync(a) => assert_eq!(a.pack.as_deref(), Some(cwd.as_path())),
715            _ => panic!("expected Sync variant"),
716        }
717    }
718
719    #[test]
720    fn b3_dot_resolves_to_cwd_for_workspace_alias() {
721        // v1.3.3 B3 — legacy `--workspace .` spelling honors `.` shorthand
722        // identically to `--pack .` (the alias is the same flag).
723        let cwd = std::env::current_dir().expect("cwd available");
724        let cli = parse(&["sync", "--workspace", "."]).expect("sync --workspace . parses");
725        match cli.verb {
726            Verb::Sync(a) => assert_eq!(a.pack.as_deref(), Some(cwd.as_path())),
727            _ => panic!("expected Sync variant"),
728        }
729    }
730
731    #[test]
732    fn b3_dot_resolves_for_positional_pack_root() {
733        // v1.3.3 B3 — positional `<pack_root>` arg also honors `.` for
734        // sync / teardown / ls verbs.
735        let cwd = std::env::current_dir().expect("cwd available");
736        let cli = parse(&["sync", "."]).expect("sync . parses");
737        match cli.verb {
738            Verb::Sync(a) => assert_eq!(a.pack_root.as_deref(), Some(cwd.as_path())),
739            _ => panic!("expected Sync variant"),
740        }
741        let cli = parse(&["teardown", "."]).expect("teardown . parses");
742        match cli.verb {
743            Verb::Teardown(a) => assert_eq!(a.pack_root.as_deref(), Some(cwd.as_path())),
744            _ => panic!("expected Teardown variant"),
745        }
746        let cli = parse(&["ls", "."]).expect("ls . parses");
747        match cli.verb {
748            Verb::Ls(a) => assert_eq!(a.pack_root.as_deref(), Some(cwd.as_path())),
749            _ => panic!("expected Ls variant"),
750        }
751    }
752
753    #[test]
754    fn b3_dot_resolves_on_serve_and_migrate_lockfile() {
755        // v1.3.3 B3 — `--pack .` is uniform across every verb that
756        // accepts the flag. Cover the verbs not exercised above.
757        let cwd = std::env::current_dir().expect("cwd available");
758        let cli = parse(&["serve", "--pack", "."]).expect("serve --pack . parses");
759        match cli.verb {
760            Verb::Serve(a) => assert_eq!(a.pack.as_deref(), Some(cwd.as_path())),
761            _ => panic!("expected Serve variant"),
762        }
763        let cli =
764            parse(&["migrate-lockfile", "--pack", "."]).expect("migrate-lockfile --pack . parses");
765        match cli.verb {
766            Verb::MigrateLockfile(a) => assert_eq!(a.pack.as_deref(), Some(cwd.as_path())),
767            _ => panic!("expected MigrateLockfile variant"),
768        }
769    }
770
771    #[test]
772    fn b3_other_relative_paths_unchanged() {
773        // v1.3.3 B3 — only the literal `.` token is rewritten. Other
774        // relative path forms (`./subdir`, `..`, plain names) flow
775        // through unchanged so existing path handling stays intact.
776        let cli = parse(&["sync", "--pack", "./subdir"]).expect("sync --pack ./subdir parses");
777        match cli.verb {
778            Verb::Sync(a) => {
779                assert_eq!(a.pack.as_deref(), Some(std::path::Path::new("./subdir")));
780            }
781            _ => panic!("expected Sync variant"),
782        }
783        let cli = parse(&["sync", "--pack", ".."]).expect("sync --pack .. parses");
784        match cli.verb {
785            Verb::Sync(a) => {
786                assert_eq!(a.pack.as_deref(), Some(std::path::Path::new("..")));
787            }
788            _ => panic!("expected Sync variant"),
789        }
790        let cli = parse(&["sync", "--pack", "some/pack"]).expect("sync --pack some/pack parses");
791        match cli.verb {
792            Verb::Sync(a) => {
793                assert_eq!(a.pack.as_deref(), Some(std::path::Path::new("some/pack")));
794            }
795            _ => panic!("expected Sync variant"),
796        }
797    }
798
799    #[test]
800    fn b3_absolute_path_unchanged() {
801        // v1.3.3 B3 — absolute paths are passed through verbatim.
802        #[cfg(windows)]
803        let abs = "C:\\abs\\path";
804        #[cfg(not(windows))]
805        let abs = "/abs/path";
806        let cli = parse(&["sync", "--pack", abs]).expect("sync --pack <abs> parses");
807        match cli.verb {
808            Verb::Sync(a) => assert_eq!(a.pack.as_deref(), Some(std::path::Path::new(abs))),
809            _ => panic!("expected Sync variant"),
810        }
811    }
812
813    #[test]
814    fn b10_add_ref_flag_parses_bare_branch() {
815        // v1.3.3 B10 — `--ref main` populates `git_ref`.
816        let cli = parse(&["add", "https://example.com/repo.git", "--ref", "main"])
817            .expect("add --ref main parses");
818        match cli.verb {
819            Verb::Add(a) => assert_eq!(a.git_ref.as_deref(), Some("main")),
820            _ => panic!("expected Add variant"),
821        }
822    }
823
824    #[test]
825    fn b10_add_ref_flag_parses_branch_at_commit() {
826        // v1.3.3 B10 — `@`-delimited branch+commit pin parses verbatim;
827        // `parse_ref` (in grex-core) splits the components.
828        let cli = parse(&["add", "https://example.com/repo.git", "--ref", "main@a3f9c1d"])
829            .expect("add --ref main@a3f9c1d parses");
830        match cli.verb {
831            Verb::Add(a) => assert_eq!(a.git_ref.as_deref(), Some("main@a3f9c1d")),
832            _ => panic!("expected Add variant"),
833        }
834    }
835
836    #[test]
837    fn b10_add_ref_flag_parses_bare_commit() {
838        // v1.3.3 B10 — bare 7-char SHA token routes to `git_ref`.
839        let cli = parse(&["add", "https://example.com/repo.git", "--ref", "a3f9c1d"])
840            .expect("add --ref a3f9c1d parses");
841        match cli.verb {
842            Verb::Add(a) => assert_eq!(a.git_ref.as_deref(), Some("a3f9c1d")),
843            _ => panic!("expected Add variant"),
844        }
845    }
846
847    #[test]
848    fn b10_add_ref_flag_optional() {
849        // v1.3.3 B10 — `--ref` is opt-in; omitting it leaves `git_ref` None.
850        let cli =
851            parse(&["add", "https://example.com/repo.git"]).expect("add without --ref parses");
852        match cli.verb {
853            Verb::Add(a) => assert!(a.git_ref.is_none(), "default --ref must be None"),
854            _ => panic!("expected Add variant"),
855        }
856    }
857
858    #[test]
859    fn b10_add_ref_rejects_whitespace() {
860        // v1.3.3 B10 — `--ref ""` / `--ref " "` rejected by value parser.
861        for bad in ["", " ", "\t"] {
862            let err = parse(&["add", "https://example.com/repo.git", "--ref", bad])
863                .expect_err("whitespace --ref must be rejected");
864            assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
865        }
866    }
867
868    #[test]
869    fn cli_non_empty_string_rejects_whitespace() {
870        // F8: `--ref " "` / `--only "\t"` must be rejected by the value
871        // parser, not silently threaded into the walker / globset layer
872        // where they degrade into useless errors.
873        for bad in ["", " ", "\t", "  ", "\n"] {
874            let err =
875                parse(&["sync", ".", "--ref", bad]).expect_err("whitespace --ref must be rejected");
876            assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
877
878            let err = parse(&["sync", ".", "--only", bad])
879                .expect_err("whitespace --only must be rejected");
880            assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation, "for {bad:?}");
881        }
882    }
883}